diff --git a/Dockerfile b/Dockerfile index ff57d5dd5..687c70771 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ RUN bundle install EXPOSE 3002 # Set the entry point -ENTRYPOINT ["/app/setup.sh"] +ENTRYPOINT ["/app/setup.sh"] \ No newline at end of file diff --git a/app/controllers/bookmarks_controller.rb b/app/controllers/bookmarks_controller.rb index 0d769f2d7..59fa3efda 100644 --- a/app/controllers/bookmarks_controller.rb +++ b/app/controllers/bookmarks_controller.rb @@ -2,7 +2,7 @@ class BookmarksController < ApplicationController rescue_from ActiveRecord::RecordNotFound, with: :not_found def action_allowed? - has_privileges_of?('Student') + current_user_has_student_privileges? end # Index method returns the list of JSON objects of the bookmark # GET on /bookmarks diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index e4b7c333a..e03a644b4 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -17,7 +17,7 @@ def authorize # Check if all actions are allowed def all_actions_allowed? - return true if has_privileges_of?('Super Administrator') + return true if current_user_has_super_admin_privileges? action_allowed? end @@ -27,25 +27,13 @@ def action_allowed? true end - # Checks if current user has the required role or higher privileges - # @param required_role [Role, String] The minimum role required (can be Role object or role name) - # @return [Boolean] true if user has required role or higher privileges - # @example - # has_privileges_of?('Administrator') # checks if user is an admin or higher - # has_privileges_of?(Role::INSTRUCTOR) # checks if user is an instructor or higher - def has_privileges_of?(required_role) - required_role = Role.find_by_name(required_role) if required_role.is_a?(String) - current_user&.role&.all_privileges_of?(required_role) || false - end - - # Unlike has_privileges_of? which checks for role hierarchy and privilege levels, # this method checks if the user has exactly the specified role # @param role_name [String, Role] The exact role to check for # @return [Boolean] true if user has exactly this role, false otherwise # @example - # has_role?('Student') # true only if user is exactly a student - # has_role?(Role::INSTRUCTOR) # true only if user is exactly an instructor - def has_role?(required_role) + # current_user_has_role?('Student') # true only if user is exactly a student + # current_user_has_role?(Role::INSTRUCTOR) # true only if user is exactly an instructor + def current_user_has_role?(required_role) required_role = required_role.name if required_role.is_a?(Role) current_user&.role&.name == required_role end @@ -233,6 +221,21 @@ def current_user_has_all_heatgrid_data_privileges?(assignment) false end + # responding to an invitation i.e. accepting/declining the invitation is authorized only if they are recipient of that invitation + def current_user_can_respond_to_invitation?(invitation) + user_logged_in? && invitation.to_participant.user == current_user #to_participant refers to the participant class + end + + # retracting an invitation is authorized only if they are sender of that invitation + def current_user_can_retract_invitation?(invitation) + user_logged_in? && invitation.from_participant.user == current_user #from_participant refers to the participant class + end + + # only sender or teammates of the sender can view the invitations + def current_user_can_view_invitation?(invitation) + user_logged_in? && TeamsParticipant.where(team: invitation.from_team).pluck(:user_id).include?(current_user.id) + end + # PRIVATE METHODS private @@ -240,9 +243,6 @@ def current_user_has_all_heatgrid_data_privileges?(assignment) # Let the Role model define this logic for the sake of DRY # If there is no currently logged-in user simply return false def current_user_has_privileges_of?(role_name) - # puts current_user_and_role_exist? - # puts current_user - # puts current_user.role.all_privileges_of?(Role.find_by(name: role_name)) current_user_and_role_exist? && current_user.role.all_privileges_of?(Role.find_by(name: role_name)) end diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index f8d4ad65e..d414d5d25 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -4,7 +4,7 @@ class CoursesController < ApplicationController rescue_from ActionController::ParameterMissing, with: :parameter_missing def action_allowed? - has_privileges_of?('Instructor') + current_user_has_instructor_privileges? end # GET /courses @@ -51,7 +51,6 @@ def destroy # Adds a Teaching Assistant to the course def add_ta user_id = params[:ta_id] # Use user_id from the request - print(user_id) user = User.find_by(id: user_id) course_id = params[:id] diff --git a/app/controllers/grades_controller.rb b/app/controllers/grades_controller.rb index 287a64996..501942f06 100644 --- a/app/controllers/grades_controller.rb +++ b/app/controllers/grades_controller.rb @@ -16,7 +16,7 @@ def action_allowed? end end - # index (GET /api/v1/grades/:assignment_id/view_all_scores) + # index (GET /grades/:assignment_id/view_all_scores) # returns all review scores and computed heatmap data for the given assignment (instructor/TA view). def view_all_scores @assignment = Assignment.find(params[:assignment_id]) @@ -38,7 +38,7 @@ def view_all_scores end - # view_our_scores (GET /api/v1/grades/:assignment_id/view_our_scores) + # view_our_scores (GET /grades/:assignment_id/view_our_scores) # similar to view but scoped to the requesting student’s own team. # It returns the same heatmap data with reviewer identities removed, plus the list of review items. # renders JSON with scores, assignment, averages. @@ -47,14 +47,14 @@ def view_our_scores render json: get_our_scores_data(@team) end - # (GET /api/v1/grades/:assignment_id/view_my_scores) + # (GET /grades/:assignment_id/view_my_scores) # similar to view but scoped to the requesting student’s own scores given by its teammates and also . def view_my_scores render json: get_my_scores_data(@participant) end - # edit (GET /api/v1/grades/:participant_id/edit) + # edit (GET /grades/:participant_id/edit) # provides data for the grade-assignment interface. # Given an AssignmentParticipant ID, it looks up the participant and its assignment, gathers the full list of items # (via a helper like list_questions(assignment)), and computes existing peer-review scores for those items. @@ -74,7 +74,7 @@ def edit end - # assign_grade (PATCH /api/v1/grades/:participant_id/assign_grade) + # assign_grade (PATCH /grades/:participant_id/assign_grade) # saves an instructor’s grade and feedback for a team submission. # The method sets team.grade_for_submission and team.comment_for_submission. # This implements “assign score & give feedback” functionality for instructor. @@ -90,7 +90,7 @@ def assign_grade end - # instructor_review (GET /api/v1/grades/:participant_id/instructor_review) + # instructor_review (GET /grades/:participant_id/instructor_review) # helps the instructor begin grading or re-grading a submission. # It finds or creates the appropriate review mapping for the given participant and returns JSON indicating whether to go to # Response#new (no review exists yet) or Response#edit (review already exists). @@ -116,7 +116,7 @@ def instructor_review private - # helper method used when participant_id is passed as a paramater. this will be helpful in case of instructor/TA view + # helper method used when participant_id is passed as a parameter. this will be helpful in case of instructor/TA view # as they need participant id to view their scores or assign grade. It will take the participant id (i.e. AssignmentParticipant ID) to set # the team and assignment variables which are used inside other methods like edit, update, assign_grade def set_team_and_assignment_via_participant @@ -131,7 +131,7 @@ def set_team_and_assignment_via_participant @assignment = @participant.assignment end - # helper method used when participant_id is passed as a paramater. this will be helpful in case of student view + # helper method used when participant_id is passed as a parameter. this will be helpful in case of student view # It will take the assignment id and the current user's id to set the participant and team variables which are used inside other methods # like view_our_scores and view_my_scores def set_participant_and_team_via_assignment diff --git a/app/controllers/institutions_controller.rb b/app/controllers/institutions_controller.rb index 1eaa1f431..57a90d973 100644 --- a/app/controllers/institutions_controller.rb +++ b/app/controllers/institutions_controller.rb @@ -1,7 +1,7 @@ class InstitutionsController < ApplicationController rescue_from ActiveRecord::RecordNotFound, with: :institution_not_found def action_allowed? - has_role?('Instructor') + current_user_has_role?('Instructor') end # GET /institutions def index diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 6d3cb3738..b0949621d 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -1,87 +1,160 @@ -class InvitationsController < ApplicationController - rescue_from ActiveRecord::RecordNotFound, with: :invite_not_found - - # GET /invitations - def index - @invitations = Invitation.all - render json: @invitations, status: :ok - end - - # POST /invitations/ - def create - params[:invitation][:reply_status] ||= InvitationValidator::WAITING_STATUS - @invitation = Invitation.invitation_factory(invite_params) - if @invitation.save - @invitation.send_invite_email - render json: @invitation, status: :created - else - render json: { error: @invitation.errors }, status: :unprocessable_entity - end - end - - # GET /invitations/:id - def show - @invitation = Invitation.find(params[:id]) - render json: @invitation, status: :ok - end - - # PATCH /invitations/:id - def update - @invitation = Invitation.find(params[:id]) - case params[:reply_status] - when InvitationValidator::ACCEPT_STATUS - @invitation.accept_invitation(nil) - render json: @invitation, status: :ok - when InvitationValidator::REJECT_STATUS - @invitation.decline_invitation(nil) - render json: @invitation, status: :ok - else - render json: @invitation.errors, status: :unprocessable_entity - end - - end - - # DELETE /invitations/:id - def destroy - @invitation = Invitation.find(params[:id]) - @invitation.retract_invitation(nil) - render nothing: true, status: :no_content - end - - # GET /invitations/:user_id/:assignment_id - def invitations_for_user_assignment - begin - @user = User.find(params[:user_id]) - rescue ActiveRecord::RecordNotFound => e - render json: { error: e.message }, status: :not_found - return - end - - begin - @assignment = Assignment.find(params[:assignment_id]) - rescue ActiveRecord::RecordNotFound => e - render json: { error: e.message }, status: :not_found - return - end - - @invitations = Invitation.where(to_id: @user.id).where(assignment_id: @assignment.id) - render json: @invitations, status: :ok - end - - private - - # This method will check if the invited user is a participant in the assignment. - # Currently there is no association between assignment and users therefore this method is not implemented yet. - def check_participant_before_invitation; end - - # only allow a list of valid invite params - def invite_params - params.require(:invitation).permit(:id, :assignment_id, :from_id, :to_id, :reply_status) - end - - # helper method used when invite is not found - def invite_not_found - render json: { error: "Invitation with id #{params[:id]} not found" }, status: :not_found - end - -end +class InvitationsController < ApplicationController + rescue_from ActiveRecord::RecordNotFound, with: :invite_not_found + before_action :set_invitation, only: %i[show update destroy] + before_action :invitee_participant, only: %i[create] + + def action_allowed? + case params[:action] + when 'index', 'destroy' + current_user_has_ta_privileges? + when 'show' + set_invitation + current_user_can_view_invitation?(@invitation) + when 'invitations_sent_to_participant', 'invitations_sent_by_participant' + @participant = AssignmentParticipant.find_by(id:params[:participant_id]) + if @participant.nil? || !current_user_has_id?(@participant.user_id) + return render_forbidden + end + return true + when 'invitations_sent_by_team' + @participant = AssignmentParticipant.find_by(team_id: params[:team_id]) + if @participant.nil? || !current_user_has_id?(@participant.user_id) + return render_forbidden + end + return true + else + return true + end + end + + # GET /invitations + def index + @invitations = Invitation.all + render json: @invitations, status: :ok + end + + # POST /invitations/ + def create + @invitation = Invitation.invitation_factory(invite_params) + if @invitation.save + @invitation.send_invite_email + render json: { success: true, message: "Invitation successfully sent to #{params[:username]}", invitation: @invitation}, status: :created + else + render json: { error: @invitation.errors[:base].first}, status: :unprocessable_entity + end + end + + # GET /invitations/:id + def show + render json: @invitation, status: :ok + end + + # PATCH /invitations/:id + def update + case params[:reply_status] + when InvitationValidator::ACCEPT_STATUS + # accepting or declining the invitation is allowed only by the recipient of the invitation + unless current_user_can_respond_to_invitation?(@invitation) + return render_forbidden + end + # if the current invitation status is either accepted/rejected/retracted then the invitation is no longer valid. + unless @invitation.reply_status.eql?(InvitationValidator::WAITING_STATUS) + render json: { error: "Sorry, the invitation is no longer valid" }, status: :unprocessable_entity + return + end + result = @invitation.accept_invitation + if result[:success] + render json: { success: true, message: result[:message], invitation: @invitation}, status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + when InvitationValidator::DECLINED_STATUS + unless current_user_can_respond_to_invitation?(@invitation) + return render_forbidden + end + @invitation.decline_invitation + render json: { success: true, message: "Invitation rejected successfully", invitation: @invitation}, status: :ok + when InvitationValidator::RETRACT_STATUS + unless current_user_can_retract_invitation?(@invitation) + return render_forbidden + end + @invitation.retract_invitation + render json: { success: true, message: "Invitation retracted successfully", invitation: @invitation}, status: :ok + else + render json: @invitation.errors, status: :unprocessable_entity + end + end + + # DELETE /invitations/:id + def destroy + @invitation.destroy! + render json: { success:true, message: "Invitation deleted successfully." }, status: :ok + rescue ActiveRecord::RecordNotDestroyed => e + render json: { error: "Failed to retract invitation: #{e.record.errors.full_messages.to_sentence}" }, status: :unprocessable_entity + rescue => e + render json: { error: "Unexpected error: #{e.message}" }, status: :internal_server_error + end + + def invitations_sent_to_participant + @invitations = Invitation.where(to_id: @participant.id, assignment_id: @participant.parent_id) + render json: @invitations, status: :ok + end + + def invitations_sent_by_team + team = AssignmentTeam.find(params[:team_id]) + @invitations = Invitation.where(from_id: team.id, assignment_id: team.parent_id) + render json: @invitations, status: :ok + end + + def invitations_sent_by_participant + participant = AssignmentParticipant.find(params[:participant_id]) + @invitations = Invitation.where(participant_id: participant.id, assignment_id: participant.parent_id) + render json: @invitations, status: :ok + end + + + private + + # only allow a list of valid invite params + def invite_params + params.require(:invitation).permit(:id, :assignment_id, :reply_status).merge(from_team: inviter_team, to_participant: invitee_participant, from_participant: inviter_participant) + end + + # helper method used when invite is not found + def invite_not_found + render json: { error: "Invitation not found" }, status: :not_found + end + + # helper method current user is forbidden to perform certain actions + def render_forbidden(message = "You do not have permission to perform this action.") + render json: { error: message }, status: :forbidden + end + + # helper method used to fetch invitation from its id + def set_invitation + @invitation = Invitation.find(params[:id]) + end + + def inviter_participant + AssignmentParticipant.find_by(user: current_user) + end + + # the team of the inviter at the time of sending invitation + def inviter_team + inviter_participant.team + end + + def invitee_participant + invitee_user = User.find_by(name: params[:username])|| User.find_by(email: params[:username]) + unless invitee_user + render json: { error: "Participant with username #{params[:username]} not found" }, status: :not_found + return + end + invitee = AssignmentParticipant.find_by(parent_id: params[:assignment_id], user: invitee_user) + unless invitee + render json: { error: "Participant with username #{params[:username]} not found for this assignment" }, status: :not_found + return + end + invitee + end +end \ No newline at end of file diff --git a/app/controllers/join_team_requests_controller.rb b/app/controllers/join_team_requests_controller.rb index 2179e6b2c..933656c0f 100644 --- a/app/controllers/join_team_requests_controller.rb +++ b/app/controllers/join_team_requests_controller.rb @@ -15,7 +15,7 @@ def action_allowed? @current_user.student? end - # GET api/v1/join_team_requests + # GET /join_team_requests # gets a list of all the join team requests def index unless @current_user.administrator? @@ -25,13 +25,13 @@ def index render json: join_team_requests, status: :ok end - # GET api/v1join_team_requests/1 + # GET /join_team_requests/1 # show the join team request that is passed into the route def show render json: @join_team_request, status: :ok end - # POST api/v1/join_team_requests + # POST /join_team_requests # Creates a new join team request def create join_team_request = JoinTeamRequest.new @@ -55,7 +55,7 @@ def create end end - # PATCH/PUT api/v1/join_team_requests/1 + # PATCH/PUT /join_team_requests/1 # Updates a join team request def update if @join_team_request.update(join_team_request_params) @@ -65,7 +65,7 @@ def update end end - # DELETE api/v1/join_team_requests/1 + # DELETE /join_team_requests/1 # delete a join team request def destroy if @join_team_request.destroy diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb index 8baec1f7c..f49157861 100644 --- a/app/controllers/questions_controller.rb +++ b/app/controllers/questions_controller.rb @@ -3,7 +3,7 @@ class QuestionsController < ApplicationController # GET /questions def action_allowed? - has_role?('Instructor') + current_user_has_role?('Instructor') end # Index method returns the list of questions JSON object # GET on /questions diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index 6e1ab69c3..843fec9c1 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -3,7 +3,7 @@ class RolesController < ApplicationController rescue_from ActionController::ParameterMissing, with: :parameter_missing def action_allowed? - has_privileges_of?('Administrator') + current_user_has_admin_privileges? end # GET /roles diff --git a/app/controllers/student_tasks_controller.rb b/app/controllers/student_tasks_controller.rb index 859dc32bd..ffb6097a5 100644 --- a/app/controllers/student_tasks_controller.rb +++ b/app/controllers/student_tasks_controller.rb @@ -2,7 +2,7 @@ class StudentTasksController < ApplicationController # List retrieves all student tasks associated with the current logged-in user. def action_allowed? - has_privileges_of?('Student') + current_user_has_student_privileges? end def list # Retrieves all tasks that belong to the current user. diff --git a/app/controllers/student_teams_controller.rb b/app/controllers/student_teams_controller.rb new file mode 100644 index 000000000..43845b12f --- /dev/null +++ b/app/controllers/student_teams_controller.rb @@ -0,0 +1,100 @@ +class StudentTeamsController < ApplicationController + + # team is gaining or losing a member + def team + @team ||= if params[:student_id].present? && student.present? + TeamsParticipant.find_by(participant_id:student.id)&.team + elsif params[:team_id].present? + AssignmentTeam.find_by(id:params[:team_id]) + end + end + + attr_writer :team + + # student is someone who is joining or leaving a team + def student + @student ||= AssignmentParticipant.find_by(id: params[:student_id]) + end + + attr_writer :student + + before_action :team, :student, only: %i[view update leave_team] + + def action_allowed? + # this can be accessed only by the student and so someone with at least TA privileges won't be able to access this controller + # also the current logged in user can view only its relevant team and not other student teams. + if current_user_has_ta_privileges? || student.nil? || !current_user_has_id?(student.user_id) + render json: { error: "You do not have permission to perform this action." }, status: :forbidden + end + return true + end + + # GET /student_teams/view?student_id=${studentId}` + # Returns details of the team that the current student belongs to. + def view + if @team.nil? + render json: { assignment: AssignmentSerializer.new(student.assignment), team: nil, message: "You are not part of any team currently."}, status: :ok + else + render json: {assignment: AssignmentSerializer.new(student.assignment), team: TeamSerializer.new(@team)}, status: :ok + end + end + + # POST /student_teams/` + def create + # Checks for duplicate team names within the same assignment (by parent_id). + matching_teams = AssignmentTeam.where(name: params[:team][:name], parent_id: params[:assignment_id]) + + # no team with that name found - goes ahead and creates the team + if matching_teams.empty? + team = AssignmentTeam.new({ name: params[:team][:name], parent_id: params[:assignment_id]}) + if team.save + # adding the student as the participant for the student_team just created + team.add_participant(student) + serialized_team = ActiveModelSerializers::SerializableResource.new(team, serializer: TeamSerializer).as_json + render json: serialized_team.merge({ message: "Team created successfully", success: true }), status: :ok + else + render json: { error: team.errors.full_messages }, status: :unprocessable_entity + end + + else + # Returns an error if another team with the same name already exists. + render json: { error: "#{params[:team][:name]} is already in use." }, status: :unprocessable_entity + end + end + + # Updates the name of the student's team. + def update + # Checks for duplicate team names within the same assignment (by parent_id). + matching_teams = AssignmentTeam.where(name: params[:team][:name], parent_id: team.parent_id) + + # no team with that name found - goes ahead and saves the new name + if matching_teams.empty? + if team.update(name: params[:team][:name]) + serialized_team = ActiveModelSerializers::SerializableResource.new(team, serializer: TeamSerializer).as_json + render json: serialized_team.merge({ message: "Team updated successfully", success: true }), status: :ok + else + render json: { error: team.errors.full_messages }, status: :unprocessable_entity + end + + else + # Returns an error if another team with the same name already exists. + render json: { error: "#{params[:team][:name]} is already in use." }, status: :unprocessable_entity + end + end + + # method to remove the student from the current team. + # PUT /student_teams/leave?student_id=${studentId} + def leave_team + @team.remove_participant(@student) + render json: { message: "Left the team successfully", success: true }, status: :ok + end + + # used to check student team requirements + def student_team_requirements_met? + # checks if the student has a team + return false if @student.team.nil? + # checks that the student's team has a topic + return false if @student.team.topic.nil? + true + end +end \ No newline at end of file diff --git a/app/controllers/teams_participants_controller.rb b/app/controllers/teams_participants_controller.rb index 4479562ab..c34e3f34d 100644 --- a/app/controllers/teams_participants_controller.rb +++ b/app/controllers/teams_participants_controller.rb @@ -3,9 +3,9 @@ class TeamsParticipantsController < ApplicationController def action_allowed? case params[:action] when 'update_duty' - has_privileges_of?('Student') + current_user_has_student_privileges? else - has_privileges_of?('Teaching Assistant') + current_user_has_ta_privileges? end end diff --git a/app/helpers/grades_helper.rb b/app/helpers/grades_helper.rb index 45d2a784f..e09343efb 100644 --- a/app/helpers/grades_helper.rb +++ b/app/helpers/grades_helper.rb @@ -154,19 +154,19 @@ def find_assignment(assignment_id) # Checks if the student has the necessary permissions and authorizations to proceed. def student_with_permissions? - has_role?('Student') && + current_user_has_role?('Student') && self_review_finished?(current_user.id) && are_needed_authorizations_present?(current_user.id, 'reader', 'reviewer') end # Checks if the user is either a student viewing their own team or has Teaching Assistant privileges. def student_or_ta? - student_viewing_own_team? || has_privileges_of?('Teaching Assistant') + student_viewing_own_team? || current_user_has_ta_privileges? end # This method checks if the current user, who must have the 'Student' role, is viewing their own team. def student_viewing_own_team? - return false unless has_role?('Student') + return false unless current_user_has_role?('Student') participant = AssignmentParticipant.find_by(id: params[:id]) participant && current_user_is_assignment_participant?(participant.assignment.id) @@ -178,7 +178,6 @@ def self_review_finished?(id) assignment = participant.try(:assignment) self_review_enabled = assignment.try(:is_selfreview_enabled) not_submitted = ResponseMap.self_review_pending?(participant.try(:id)) - puts self_review_enabled if self_review_enabled !not_submitted else @@ -295,7 +294,6 @@ def is_team_assignment?(participant) # Redirects the user if they are not on the correct team that provided the feedback. def redirect_if_not_on_correct_team(participant) team = participant.team - puts team.attributes if team.nil? || !team.user?(session[:user]) flash[:error] = 'You are not on the team that wrote this feedback' redirect_to '/' diff --git a/app/mailers/invitation_mailer.rb b/app/mailers/invitation_mailer.rb new file mode 100644 index 000000000..005888e5f --- /dev/null +++ b/app/mailers/invitation_mailer.rb @@ -0,0 +1,9 @@ +class InvitationMailer < ApplicationMailer + default from: 'from@example.com' + def send_invitation_email + @invitation = params[:invitation] + @to_participant = Participant.find(@invitation.to_id) + @from_team = AssignmentTeam.find(@invitation.from_id) + mail(to: @to_participant.user.email, subject: 'You have a new invitation from Expertiza') + end +end \ No newline at end of file diff --git a/app/mailers/invitation_sent_mailer.rb b/app/mailers/invitation_sent_mailer.rb deleted file mode 100644 index 0044c1aad..000000000 --- a/app/mailers/invitation_sent_mailer.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class InvitationSentMailer < ApplicationMailer - default from: 'from@example.com' - def send_invitation_email - @invitation = params[:invitation] - @to_user = User.find(@invitation.to_id) - @from_user = User.find(@invitation.from_id) - mail(to: @to_user.email, subject: 'You have a new invitation from Expertiza') - end -end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 45ac849fb..d151d96d0 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -12,7 +12,6 @@ class Assignment < ApplicationRecord has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :sign_up_topics , class_name: 'SignUpTopic', foreign_key: 'assignment_id', dependent: :destroy has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy - has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy belongs_to :course, optional: true belongs_to :instructor, class_name: 'User', inverse_of: :assignments diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index d232413b1..412e2bf87 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -1,22 +1,27 @@ -# frozen_string_literal: true - -class AssignmentParticipant < Participant - include ReviewAggregator - belongs_to :user - validates :handle, presence: true - - def set_handle - self.handle = if user.handle.nil? || (user.handle == '') - user.name - elsif Participant.exists?(assignment_id: assignment.id, handle: user.handle) - user.name - else - user.handle - end - self.save - end - - def aggregate_teammate_review_grade(teammate_review_mappings) - compute_average_review_score(teammate_review_mappings) - end +# frozen_string_literal: true + +class AssignmentParticipant < Participant + include ReviewAggregator + has_many :sent_invitations, class_name: 'Invitation', foreign_key: 'participant_id' + belongs_to :user + validates :handle, presence: true + + def retract_sent_invitations + sent_invitations.each(&:retract_invitation) + end + + def set_handle + self.handle = if user.handle.nil? || (user.handle == '') + user.name + elsif Participant.exists?(assignment_id: assignment.id, handle: user.handle) + user.name + else + user.handle + end + self.save + end + + def aggregate_teammate_review_grade(teammate_review_mappings) + compute_average_review_score(teammate_review_mappings) + end end \ No newline at end of file diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index 95ad1fe14..4414831cb 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -25,8 +25,8 @@ def copy_to_course_team(course) end end course_team # Returns the newly created course team object - end - + end + # Get the review response map def review_map_type 'ReviewResponseMap' @@ -55,9 +55,45 @@ def aggregate_review_grade compute_average_review_score(review_mappings) end + # Adds a participant to this team. + # - Update the participant's team_id (so their direct reference is consistent) + # - Ensure there is a TeamsParticipant join record connecting the participant and this team + def add_participant(participant) + # need to have a check if the team is full then it can not add participant to the team + raise TeamFullError, "Team is full." if full? + + # Update the participant's team_id column - will remove the team reference inside participants table later. keeping it for now + participant.update!(team_id: id) + + # Create or reuse the join record to maintain the association + TeamsParticipant.find_or_create_by!(participant_id: participant.id, team_id: id, user_id: participant.user_id) + end + + # Removes a participant from this team. + # - Delete the TeamsParticipant join record + # - if the participant sent any invitations while being on the team, they all need to be retracted + # - If the team has no remaining members, destroy the team itself + def remove_participant(participant) + # retract all the invitations the participant sent (if any) while being on the this team + participant.retract_sent_invitations + + # Remove the join record if it exists + tp = TeamsParticipant.find_by(team_id: id, participant_id: participant.id) + tp&.destroy + + # Update the participant's team_id column - will remove the team reference inside participants table later. keeping it for now + # this will remove the reference only if the participant's current team is the same team removing the participant + if participant.team_id==id + participant.update!(team_id: nil) + end + + # If no participants remain after removal, delete the team + destroy if participants.empty? + end + protected - # Validates if a user is eligible to join the team + # Validates if a user is eligible to join the team # - Checks whether the user is a participant of the associated assignment def validate_membership(user) # Ensure user is enrolled in the assignment by checking AssignmentParticipant @@ -71,6 +107,7 @@ def validate_assignment_team_type unless self.kind_of?(AssignmentTeam) errors.add(:type, 'must be an AssignmentTeam or its subclass') end - end + end +end -end \ No newline at end of file +class TeamFullError < StandardError; end diff --git a/app/models/instructor.rb b/app/models/instructor.rb index 91ed5307c..bf0418f25 100644 --- a/app/models/instructor.rb +++ b/app/models/instructor.rb @@ -8,4 +8,4 @@ def managed_users end -end +end \ No newline at end of file diff --git a/app/models/invitation.rb b/app/models/invitation.rb index 8c86d9cfd..44053dbc6 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -1,76 +1,102 @@ -# frozen_string_literal: true - -class Invitation < ApplicationRecord - after_initialize :set_defaults - - belongs_to :to_user, class_name: 'User', foreign_key: 'to_id', inverse_of: false - belongs_to :from_user, class_name: 'User', foreign_key: 'from_id', inverse_of: false - belongs_to :assignment, class_name: 'Assignment', foreign_key: 'assignment_id' - - validates_with InvitationValidator - - # Return a new invitation - # params = :assignment_id, :to_id, :from_id, :reply_status - def self.invitation_factory(params) - Invitation.new(params) - end - - # check if the user is invited - def self.invited?(from_id, to_id, assignment_id) - conditions = { - to_id:, - from_id:, - assignment_id:, - reply_status: InvitationValidator::WAITING_STATUS - } - @invitations_exist = Invitation.where(conditions).exists? - end - - # send invite email - def send_invite_email - InvitationSentMailer.with(invitation: self) - .send_invitation_email - .deliver_later - end - - # After a users accepts an invite, the teams_users table needs to be updated. - # NOTE: Depends on TeamUser model, which is not implemented yet. - def update_users_topic_after_invite_accept(_inviter_user_id, _invited_user_id, _assignment_id); end - - # This method handles all that needs to be done upon a user accepting an invitation. - # Expected functionality: First the users previous team is deleted if they were the only member of that - # team and topics that the old team signed up for will be deleted. - # Then invites the user that accepted the invite sent will be removed. - # Lastly the users team entry will be added to the TeamsUser table and their assigned topic is updated. - # NOTE: For now this method simply updates the invitation's reply_status. - def accept_invitation(_logged_in_user) - update(reply_status: InvitationValidator::ACCEPT_STATUS) - end - - # This method handles all that needs to be done upon an user declining an invitation. - def decline_invitation(_logged_in_user) - update(reply_status: InvitationValidator::REJECT_STATUS) - end - - # This method handles all that need to be done upon an invitation retraction. - def retract_invitation(_logged_in_user) - destroy - end - - # This will override the default as_json method in the ApplicationRecord class and specify - def as_json(options = {}) - super(options.merge({ - only: %i[id reply_status created_at updated_at], - include: { - assignment: { only: %i[id name] }, - from_user: { only: %i[id name fullname email] }, - to_user: { only: %i[id name fullname email] } - } - })).tap do |hash| - end - end - - def set_defaults - self.reply_status ||= InvitationValidator::WAITING_STATUS - end -end +# frozen_string_literal: true + +class Invitation < ApplicationRecord + after_initialize :set_defaults + + belongs_to :to_participant, class_name: 'AssignmentParticipant', foreign_key: 'to_id', inverse_of: false + belongs_to :from_team, class_name: 'AssignmentTeam', foreign_key: 'from_id', inverse_of: false + belongs_to :assignment, class_name: 'Assignment', foreign_key: 'assignment_id' + belongs_to :from_participant, class_name: 'AssignmentParticipant', foreign_key: 'participant_id' + + validates_with InvitationValidator + + # Return a new invitation + # params = :assignment_id, :to_id, :from_id, :reply_status + def self.invitation_factory(params) + Invitation.new(params) + end + + # check if the participant is invited + def self.invited?(from_id, to_id, assignment_id) + conditions = { + to_id:, + from_id:, + assignment_id:, + reply_status: InvitationValidator::WAITING_STATUS + } + @invitations_exist = Invitation.where(conditions).exists? + end + + # send invite email + def send_invite_email + InvitationMailer.with(invitation: self) + .send_invitation_email + .deliver_later + end + + # This method handles all that needs to be done upon a user accepting an invitation. + def accept_invitation + inviter_team = from_team + invitee_team = to_participant.team + + # Wrap in transaction to prevent partial updates and concurrency + ActiveRecord::Base.transaction do + # 1. Add the invitee to the inviter's team + inviter_team.add_participant(to_participant) + + # if participant is member of an existing team then only step 2 and 3 makes sense. otherwise just need to add the participant to the inviter team + if invitee_team.present? + # 2. Update the participant’s and team's assigned topic + inviter_signed_up_team = SignedUpTeam.find_by(team_id: inviter_team.id) + invitee_signed_up_team = SignedUpTeam.find_by(team_id: invitee_team.id) + + SignedUpTeam.update_topic_after_invite_accept(inviter_signed_up_team,invitee_signed_up_team) + + # 3. Remove participant from their old team + invitee_team.remove_participant(to_participant) + end + + # 4. Mark this invitation as accepted + update!(reply_status: InvitationValidator::ACCEPT_STATUS) + end + + { success: true, message: "Invitation accepted successfully." } + + rescue TeamFullError => e + { success: false, error: e.message } + rescue => e + { success: false, error: "Unexpected error: #{e.message}" } + end + + + # This method handles all that needs to be done upon an user declining an invitation. + def decline_invitation + update(reply_status: InvitationValidator::DECLINED_STATUS) + end + + # This method handles all that need to be done upon an invitation retraction. + def retract_invitation + update(reply_status: InvitationValidator::RETRACT_STATUS) + end + + # This will override the default as_json method in the ApplicationRecord class and specify + def as_json(options = {}) + super(options.merge({ + only: %i[id reply_status created_at updated_at], + include: { + assignment: { only: %i[id name] }, + from_team: { only: %i[id name] }, + to_participant: { + only: [:id], + include: { + user: { only: %i[id name full_name email] } + }} + } + })).tap do |hash| + end + end + + def set_defaults + self.reply_status ||= InvitationValidator::WAITING_STATUS + end +end \ No newline at end of file diff --git a/app/models/questionnaire.rb b/app/models/questionnaire.rb index 82c950ca2..fdb606823 100644 --- a/app/models/questionnaire.rb +++ b/app/models/questionnaire.rb @@ -1,85 +1,142 @@ -# frozen_string_literal: true - -class Questionnaire < ApplicationRecord - belongs_to :instructor - has_many :items, class_name: "Item", foreign_key: "questionnaire_id", dependent: :destroy # the collection of questions associated with this Questionnaire - before_destroy :check_for_question_associations - - validate :validate_questionnaire - validates :name, presence: true - validates :max_question_score, :min_question_score, numericality: true - - - # after_initialize :post_initialization - # @print_name = 'Review Rubric' - - # class << self - # attr_reader :print_name - # end - - # def post_initialization - # self.display_type = 'Review' - # end - - def symbol - 'review'.to_sym - end - - def get_assessments_for(participant) - participant.reviews - end - - # validate the entries for this questionnaire - def validate_questionnaire - errors.add(:max_question_score, 'The maximum item score must be a positive integer.') if max_question_score < 1 - errors.add(:min_question_score, 'The minimum item score must be a positive integer.') if min_question_score < 0 - errors.add(:min_question_score, 'The minimum item score must be less than the maximum.') if min_question_score >= max_question_score - results = Questionnaire.where('id <> ? and name = ? and instructor_id = ?', id, name, instructor_id) - errors.add(:name, 'Questionnaire names must be unique.') if results.present? - end - - # clones the contents of a questionnaire, including the questions and associated advice - def self.copy_questionnaire_details(params) - orig_questionnaire = Questionnaire.find(params[:id]) - questions = Item.where(questionnaire_id: params[:id]) - questionnaire = orig_questionnaire.dup - questionnaire.instructor_id = params[:instructor_id] - questionnaire.name = 'Copy of ' + orig_questionnaire.name - questionnaire.created_at = Time.zone.now - questionnaire.save! - questions.each do |question| - new_question = question.dup - new_question.questionnaire_id = questionnaire.id - new_question.size = '50,3' if (new_question.is_a?(Criterion) || new_question.is_a?(TextResponse)) && new_question.size.nil? - new_question.save! - advices = QuestionAdvice.where(question_id: question.id) - next if advices.empty? - - advices.each do |advice| - new_advice = advice.dup - new_advice.question_id = new_question.id - new_advice.save! - end - end - questionnaire - end - - # Check_for_question_associations checks if questionnaire has associated questions or not - def check_for_question_associations - if questions.any? - raise ActiveRecord::DeleteRestrictionError.new( "Cannot delete record because dependent questions exist") - end - end - - def as_json(options = {}) - super(options.merge({ - only: %i[id name private min_question_score max_question_score created_at updated_at questionnaire_type instructor_id], - include: { - instructor: { only: %i[name email fullname password role] - } - } - })).tap do |hash| - hash['instructor'] ||= { id: nil, name: nil } - end - end -end \ No newline at end of file +# frozen_string_literal: true + +class Questionnaire < ApplicationRecord + belongs_to :instructor + has_many :items, class_name: "Item", foreign_key: "questionnaire_id", dependent: :destroy # the collection of items associated with this Questionnaire + before_destroy :check_for_question_associations + + validate :validate_questionnaire + validates :name, presence: true + validates :max_question_score, :min_question_score, numericality: true + + + # after_initialize :post_initialization + # @print_name = 'Review Rubric' + + # class << self + # attr_reader :print_name + # end + + # def post_initialization + # self.display_type = 'Review' + # end + + def symbol + 'review'.to_sym + end + + def get_assessments_for(participant) + participant.reviews + end + + # validate the entries for this questionnaire + def validate_questionnaire + errors.add(:max_question_score, 'The maximum item score must be a positive integer.') if max_question_score < 1 + errors.add(:min_question_score, 'The minimum item score must be a positive integer.') if min_question_score < 0 + errors.add(:min_question_score, 'The minimum item score must be less than the maximum.') if min_question_score >= max_question_score + results = Questionnaire.where('id <> ? and name = ? and instructor_id = ?', id, name, instructor_id) + errors.add(:name, 'Questionnaire names must be unique.') if results.present? + end + + # clones the contents of a questionnaire, including the items and associated advice + def self.copy_questionnaire_details(params) + orig_questionnaire = Questionnaire.find(params[:id]) + items = Item.where(questionnaire_id: params[:id]) + questionnaire = orig_questionnaire.dup + questionnaire.instructor_id = params[:instructor_id] + questionnaire.name = 'Copy of ' + orig_questionnaire.name + questionnaire.created_at = Time.zone.now + questionnaire.save! + items.each do |question| + new_question = question.dup + new_question.questionnaire_id = questionnaire.id + new_question.size = '50,3' if (new_question.is_a?(Criterion) || new_question.is_a?(TextResponse)) && new_question.size.nil? + new_question.save! + advice = QuestionAdvice.where(question_id: question.id) + next if advice.empty? + + advice.each do |advice| + new_advice = advice.dup + new_advice.question_id = new_question.id + new_advice.save! + end + end + questionnaire + end + + # Check_for_question_associations checks if questionnaire has associated items or not + def check_for_question_associations + if items.any? + raise ActiveRecord::DeleteRestrictionError.new( "Cannot delete record because dependent items exist") + end + end + + def as_json(options = {}) + super(options.merge({ + only: %i[id name private min_question_score max_question_score created_at updated_at questionnaire_type instructor_id], + include: { + instructor: { only: %i[name email fullname password role] + } + } + })).tap do |hash| + hash['instructor'] ||= { id: nil, name: nil } + end + end + + DEFAULT_MIN_QUESTION_SCORE = 0 # The lowest score that a reviewer can assign to any questionnaire question + DEFAULT_MAX_QUESTION_SCORE = 5 # The highest score that a reviewer can assign to any questionnaire question + DEFAULT_QUESTIONNAIRE_URL = 'http://www.courses.ncsu.edu/csc517'.freeze + QUESTIONNAIRE_TYPES = ['ReviewQuestionnaire', + 'MetareviewQuestionnaire', + 'Author FeedbackQuestionnaire', + 'AuthorFeedbackQuestionnaire', + 'Teammate ReviewQuestionnaire', + 'TeammateReviewQuestionnaire', + 'SurveyQuestionnaire', + 'AssignmentSurveyQuestionnaire', + 'Assignment SurveyQuestionnaire', + 'Global SurveyQuestionnaire', + 'GlobalSurveyQuestionnaire', + 'Course SurveyQuestionnaire', + 'CourseSurveyQuestionnaire', + 'Bookmark RatingQuestionnaire', + 'BookmarkRatingQuestionnaire', + 'QuizQuestionnaire'].freeze + # has_paper_trail + + def get_weighted_score(assignment, scores) + # create symbol for "varying rubrics" feature -Yang + round = AssignmentQuestionnaire.find_by(assignment_id: assignment.id, questionnaire_id: id).used_in_round + questionnaire_symbol = if round.nil? + symbol + else + (symbol.to_s + round.to_s).to_sym + end + compute_weighted_score(questionnaire_symbol, assignment, scores) + end + + def compute_weighted_score(symbol, assignment, scores) + # aq = assignment_questionnaires.find_by(assignment_id: assignment.id) + aq = AssignmentQuestionnaire.find_by(assignment_id: assignment.id) + + if scores[symbol][:scores][:avg].nil? + 0 + else + scores[symbol][:scores][:avg] * aq.questionnaire_weight / 100.0 + end + end + + # Does this questionnaire contain true/false items? + def true_false_items? + items.each { |question| return true if question.type == 'Checkbox' } + false + end + + def max_possible_score + results = Questionnaire.joins('INNER JOIN items ON items.questionnaire_id = questionnaires.id') + .select('SUM(items.weight) * questionnaires.max_question_score as max_score') + .where('questionnaires.id = ?', id) + results[0].max_score + end + + end \ No newline at end of file diff --git a/app/models/response_map.rb b/app/models/response_map.rb index 5657741cf..aeccf8ee3 100644 --- a/app/models/response_map.rb +++ b/app/models/response_map.rb @@ -46,8 +46,13 @@ def self.assessments_for(team) responses end + # Check to see if this response map is a survey. Default is false, and some subclasses will overwrite to true. + def survey? + false + end + # Computes the average score (as a fraction between 0 and 1) across the latest submitted responses - # from each round for this ReviewResponseMap. + # from each round for corresponding ResponseMap. def aggregate_reviewers_score # Return nil if there are no responses for this map return nil if responses.empty? diff --git a/app/models/review_response_map.rb b/app/models/review_response_map.rb index c00955bcc..1888d1d83 100644 --- a/app/models/review_response_map.rb +++ b/app/models/review_response_map.rb @@ -1,19 +1,23 @@ -# frozen_string_literal: true - -class ReviewResponseMap < ResponseMap - include ResponseMapSubclassTitles - belongs_to :reviewee, class_name: 'Team', foreign_key: 'reviewee_id', inverse_of: false - - # returns the assignment related to the response map - def response_assignment - return assignment - end - - def questionnaire_type - 'Review' - end - - def get_title - REVIEW_RESPONSE_MAP_TITLE - end -end +# frozen_string_literal: true +class ReviewResponseMap < ResponseMap + include ResponseMapSubclassTitles + belongs_to :reviewee, class_name: 'Team', foreign_key: 'reviewee_id', inverse_of: false + + # returns the assignment related to the response map + def response_assignment + return assignment + end + + def questionnaire_type + 'Review' + end + + def get_title + REVIEW_RESPONSE_MAP_TITLE + end + + # Get the review response map + def review_map_type + 'ReviewResponseMap' + end +end diff --git a/app/models/signed_up_team.rb b/app/models/signed_up_team.rb index 5d1a47f19..8f59156e6 100644 --- a/app/models/signed_up_team.rb +++ b/app/models/signed_up_team.rb @@ -1,6 +1,25 @@ -# frozen_string_literal: true - -class SignedUpTeam < ApplicationRecord - belongs_to :sign_up_topic - belongs_to :team -end +# frozen_string_literal: true + +class SignedUpTeam < ApplicationRecord + belongs_to :sign_up_topic + belongs_to :team + + # Case 1: If participant joins a team without a topic and participant has a topic, the team gets participant's topic. + # Case 2: If participant joins a team with a topic and participant doesn’t have a topic, participant get the team’s topic. + # Case 3: If participant joins a team with a topic and participant has a topic, participant is warned & participant lose its topic and get the team’s topic. + def self.update_topic_after_invite_accept(inviter_signed_up_team, invitee_signed_up_team) + return unless inviter_signed_up_team && invitee_signed_up_team + + ActiveRecord::Base.transaction do + inviter_topic = inviter_signed_up_team.sign_up_topic + invitee_topic = invitee_signed_up_team.sign_up_topic + + # Case 1: inviter team has no topic, take invitee participant's topic + if inviter_topic.nil? && invitee_topic.present? + inviter_signed_up_team.update!(sign_up_topic_id: invitee_topic.id) + end + # For all cases, the invitee signed up team record need to be removed + invitee_signed_up_team.destroy + end + end +end \ No newline at end of file diff --git a/app/serializers/assignment_serializer.rb b/app/serializers/assignment_serializer.rb new file mode 100644 index 000000000..30d2d75e5 --- /dev/null +++ b/app/serializers/assignment_serializer.rb @@ -0,0 +1,3 @@ +class AssignmentSerializer < ActiveModel::Serializer + attributes :id, :name, :max_team_size, :course_id +end \ No newline at end of file diff --git a/app/serializers/participant_serializer.rb b/app/serializers/participant_serializer.rb new file mode 100644 index 000000000..95813df80 --- /dev/null +++ b/app/serializers/participant_serializer.rb @@ -0,0 +1,12 @@ +class ParticipantSerializer < ActiveModel::Serializer + attributes :id, :user, :parent_id, :user_id, :authorization + + def user + { + id: object.user.id, + username: object.user.name, + email: object.user.email, + fullName: object.user.full_name + } + end +end \ No newline at end of file diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index eece9d3bc..f8eced314 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -1,20 +1,17 @@ # frozen_string_literal: true class TeamSerializer < ActiveModel::Serializer - attributes :id, :name, :type, :team_size, :assignment_id + attributes :id, :name, :type, :team_size + has_many :members, serializer: ParticipantSerializer has_many :users, serializer: UserSerializer - def users - # Use teams_participants association to get users - object.teams_participants.includes(:user).map(&:user) + def members + # Use teams_participants association to get participants + object.teams_participants.includes(:participant).map(&:participant) end def team_size object.teams_participants.count end - def assignment_id - # Return parent_id for AssignmentTeam, nil for CourseTeam - object.is_a?(AssignmentTeam) ? object.parent_id : nil - end -end +end \ No newline at end of file diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 287456b04..a1a7e28ee 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,5 +1,13 @@ # frozen_string_literal: true class UserSerializer < ActiveModel::Serializer - attributes :id, :name, :email, :full_name + attributes :id, :username, :email, :fullName + + def username + object.name + end + + def fullName + object.full_name + end end diff --git a/app/validators/invitation_validator.rb b/app/validators/invitation_validator.rb index fa5aa6b95..3bdecee23 100644 --- a/app/validators/invitation_validator.rb +++ b/app/validators/invitation_validator.rb @@ -1,52 +1,64 @@ -# frozen_string_literal: true - -# app/validators/invitation_validator.rb -class InvitationValidator < ActiveModel::Validator - ACCEPT_STATUS = 'A'.freeze - REJECT_STATUS = 'R'.freeze - WAITING_STATUS = 'W'.freeze - - DUPLICATE_INVITATION_ERROR_MSG = 'You cannot have duplicate invitations'.freeze - TO_FROM_SAME_ERROR_MSG = 'to and from users should be different'.freeze - REPLY_STATUS_ERROR_MSG = 'must be present and have a maximum length of 1'.freeze - REPLY_STATUS_INCLUSION_ERROR_MSG = "must be one of #{[ACCEPT_STATUS, REJECT_STATUS, WAITING_STATUS].to_sentence}".freeze - - def validate(record) - validate_reply_status(record) - validate_reply_status_inclusion(record) - validate_duplicate_invitation(record) - validate_to_from_different(record) - end - - private - - def validate_reply_status(record) - unless record.reply_status.present? && record.reply_status.length <= 1 - record.errors.add(:reply_status, REPLY_STATUS_ERROR_MSG) - end - end - - def validate_reply_status_inclusion(record) - unless [ACCEPT_STATUS, REJECT_STATUS, WAITING_STATUS].include?(record.reply_status) - record.errors.add(:reply_status, REPLY_STATUS_INCLUSION_ERROR_MSG) - end - end - - def validate_duplicate_invitation(record) - conditions = { - to_id: record.to_id, - from_id: record.from_id, - assignment_id: record.assignment_id, - reply_status: record.reply_status - } - if Invitation.where(conditions).exists? - record.errors[:base] << DUPLICATE_INVITATION_ERROR_MSG - end - end - - def validate_to_from_different(record) - if record.from_id == record.to_id - record.errors.add(:from_id, TO_FROM_SAME_ERROR_MSG) - end - end +# frozen_string_literal: true + +# app/validators/invitation_validator.rb +class InvitationValidator < ActiveModel::Validator + ACCEPT_STATUS = 'A'.freeze + DECLINED_STATUS = 'D'.freeze + WAITING_STATUS = 'W'.freeze + RETRACT_STATUS = 'R'.freeze + + DUPLICATE_INVITATION_ERROR_MSG = 'You cannot have duplicate invitations'.freeze + TO_FROM_SAME_ERROR_MSG = 'to and from participants should be different'.freeze + REPLY_STATUS_ERROR_MSG = 'must be present and have a maximum length of 1'.freeze + DIFFERENT_ASSIGNMENT_PARTICIPANT_MSG = "the participant is not part of this assignment".freeze + REPLY_STATUS_INCLUSION_ERROR_MSG = "must be one of #{[ACCEPT_STATUS, DECLINED_STATUS, WAITING_STATUS, RETRACT_STATUS].to_sentence}".freeze + + def validate(record) + validate_invitee(record) + validate_reply_status(record) + validate_reply_status_inclusion(record) + validate_duplicate_invitation(record) + validate_to_from_different(record) + end + + private + + # validates if the invitee is participant of the assignment or not + def validate_invitee(record) + participant = AssignmentParticipant.find_by(id: record.to_id, parent_id: record.assignment_id) + unless participant.present? + record.errors.add(:base, DIFFERENT_ASSIGNMENT_PARTICIPANT_MSG) + end + end + + def validate_reply_status(record) + unless record.reply_status.present? && record.reply_status.length <= 1 + record.errors.add(:base, REPLY_STATUS_ERROR_MSG) + end + end + + def validate_reply_status_inclusion(record) + unless [ACCEPT_STATUS, DECLINED_STATUS, WAITING_STATUS, RETRACT_STATUS].include?(record.reply_status) + record.errors.add(:base, REPLY_STATUS_INCLUSION_ERROR_MSG) + end + end + + def validate_duplicate_invitation(record) + conditions = { + # id: record&.id, + to_id: record.to_id, + from_id: record.from_id, + assignment_id: record.assignment_id, + reply_status: record.reply_status + } + if Invitation.where(conditions).exists? + record.errors.add(:base, DUPLICATE_INVITATION_ERROR_MSG) + end + end + + def validate_to_from_different(record) + if record.from_participant.id == record.to_id + record.errors.add(:base, TO_FROM_SAME_ERROR_MSG) + end + end end \ No newline at end of file diff --git a/app/views/invitation_sent_mailer/send_invitation_email.html.erb b/app/views/invitation_mailer/send_invitation_email.html.erb similarity index 52% rename from app/views/invitation_sent_mailer/send_invitation_email.html.erb rename to app/views/invitation_mailer/send_invitation_email.html.erb index bfe05d5f8..d98bfe7e9 100644 --- a/app/views/invitation_sent_mailer/send_invitation_email.html.erb +++ b/app/views/invitation_mailer/send_invitation_email.html.erb @@ -4,7 +4,7 @@ -

You have been invited to join the team by <%= @to_user.fullname %>

+

You have been invited to join <%= @from_team %>

diff --git a/config/routes.rb b/config/routes.rb index 25642363c..55eb8edba 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,152 +1,166 @@ -# frozen_string_literal: true - -Rails.application.routes.draw do - - mount Rswag::Api::Engine => 'api-docs' - mount Rswag::Ui::Engine => 'api-docs' - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - - # Defines the root path route ("/") - # root "articles#index" - post '/login', to: 'authentication#login' - resources :institutions - resources :roles do - collection do - # Get all roles that are subordinate to a role of a logged in user - get 'subordinate_roles', action: :subordinate_roles - end - end - resources :users do - collection do - get 'institution/:id', action: :institution_users - get ':id/managed', action: :managed_users - get 'role/:name', action: :role_users - end - end - resources :assignments do - collection do - post '/:assignment_id/add_participant/:user_id',action: :add_participant - delete '/:assignment_id/remove_participant/:user_id',action: :remove_participant - patch '/:assignment_id/remove_assignment_from_course',action: :remove_assignment_from_course - patch '/:assignment_id/assign_course/:course_id',action: :assign_course - post '/:assignment_id/copy_assignment', action: :copy_assignment - get '/:assignment_id/has_topics',action: :has_topics - get '/:assignment_id/show_assignment_details',action: :show_assignment_details - get '/:assignment_id/team_assignment', action: :team_assignment - get '/:assignment_id/has_teams', action: :has_teams - get '/:assignment_id/valid_num_review/:review_type', action: :valid_num_review - get '/:assignment_id/varying_rubrics_by_round', action: :varying_rubrics_by_round? - post '/:assignment_id/create_node',action: :create_node - end - end - - resources :bookmarks, except: [:new, :edit] do - member do - get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' - post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' - end - end - resources :student_tasks do - collection do - get :list, action: :list - get :view - end - end - - resources :courses do - collection do - get ':id/add_ta/:ta_id', action: :add_ta - get ':id/tas', action: :view_tas - get ':id/remove_ta/:ta_id', action: :remove_ta - get ':id/copy', action: :copy - end - end - - resources :questionnaires do - collection do - post 'copy/:id', to: 'questionnaires#copy', as: 'copy' - get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' - end - end - - resources :questions do - collection do - get :types - get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all' - delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' - end - end - - resources :signed_up_teams do - collection do - post '/sign_up', to: 'signed_up_teams#sign_up' - post '/sign_up_student', to: 'signed_up_teams#sign_up_student' - end - end - - resources :join_team_requests do - collection do - post 'decline/:id', to:'join_team_requests#decline' - end - end - - resources :sign_up_topics do - collection do - get :filter - delete '/', to: 'sign_up_topics#destroy' - end - end - - resources :invitations do - get 'user/:user_id/assignment/:assignment_id/', on: :collection, action: :invitations_for_user_assignment - end - - resources :account_requests do - collection do - get :pending, action: :pending_requests - get :processed, action: :processed_requests - end - end - - resources :participants do - collection do - get '/user/:user_id', to: 'participants#list_user_participants' - get '/assignment/:assignment_id', to: 'participants#list_assignment_participants' - get '/:id', to: 'participants#show' - post '/:authorization', to: 'participants#add' - patch '/:id/:authorization', to: 'participants#update_authorization' - delete '/:id', to: 'participants#destroy' - end - end - resources :teams do - member do - get 'members' - post 'members', to: 'teams#add_member' - delete 'members/:user_id', to: 'teams#remove_member' - - get 'join_requests' - post 'join_requests', to: 'teams#create_join_request' - put 'join_requests/:join_request_id', to: 'teams#update_join_request' - end - end - resources :teams_participants, only: [] do - collection do - put :update_duty - end - member do - get :list_participants - post :add_participant - delete :delete_participants - end - end - resources :grades do - collection do - get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' - patch '/:participant_id/assign_grade', to: 'grades#assign_grade' - get '/:participant_id/edit', to: 'grades#edit' - get '/:assignment_id/view_our_scores', to: 'grades#view_our_scores' - get '/:assignment_id/view_my_scores', to: 'grades#view_my_scores' - get '/:participant_id/instructor_review', to: 'grades#instructor_review' - end - end +# frozen_string_literal: true + +Rails.application.routes.draw do + + mount Rswag::Api::Engine => 'api-docs' + mount Rswag::Ui::Engine => 'api-docs' + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Defines the root path route ("/") + # root "articles#index" + post '/login', to: 'authentication#login' + resources :institutions + resources :roles do + collection do + # Get all roles that are subordinate to a role of a logged in user + get 'subordinate_roles', action: :subordinate_roles + end + end + resources :users do + collection do + get 'institution/:id', action: :institution_users + get ':id/managed', action: :managed_users + get 'role/:name', action: :role_users + end + end + resources :assignments do + collection do + post '/:assignment_id/add_participant/:user_id',action: :add_participant + delete '/:assignment_id/remove_participant/:user_id',action: :remove_participant + patch '/:assignment_id/remove_assignment_from_course',action: :remove_assignment_from_course + patch '/:assignment_id/assign_course/:course_id',action: :assign_course + post '/:assignment_id/copy_assignment', action: :copy_assignment + get '/:assignment_id/has_topics',action: :has_topics + get '/:assignment_id/show_assignment_details',action: :show_assignment_details + get '/:assignment_id/team_assignment', action: :team_assignment + get '/:assignment_id/has_teams', action: :has_teams + get '/:assignment_id/valid_num_review/:review_type', action: :valid_num_review + get '/:assignment_id/varying_rubrics_by_round', action: :varying_rubrics_by_round? + post '/:assignment_id/create_node',action: :create_node + end + end + + resources :bookmarks, except: [:new, :edit] do + member do + get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' + post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' + end + end + resources :student_tasks do + collection do + get :list, action: :list + get :view + end + end + + resources :courses do + collection do + get ':id/add_ta/:ta_id', action: :add_ta + get ':id/tas', action: :view_tas + get ':id/remove_ta/:ta_id', action: :remove_ta + get ':id/copy', action: :copy + end + end + + resources :questionnaires do + collection do + post 'copy/:id', to: 'questionnaires#copy', as: 'copy' + get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' + end + end + + resources :questions do + collection do + get :types + get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all' + delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' + end + end + + resources :signed_up_teams do + collection do + post '/sign_up', to: 'signed_up_teams#sign_up' + post '/sign_up_student', to: 'signed_up_teams#sign_up_student' + end + end + + resources :join_team_requests do + collection do + post 'decline/:id', to:'join_team_requests#decline' + end + end + + resources :sign_up_topics do + collection do + get :filter + delete '/', to: 'sign_up_topics#destroy' + end + end + + resources :invitations do + collection do + get '/sent_by/team/:team_id', to: 'invitations_sent_by_team' + get '/sent_by/participant/:participant_id', to: 'invitations_sent_by_participant' + get '/sent_to/:participant_id', to: 'invitations_sent_to_participant' + end + end + + resources :account_requests do + collection do + get :pending, action: :pending_requests + get :processed, action: :processed_requests + end + end + + resources :participants do + collection do + get '/user/:user_id', to: 'participants#list_user_participants' + get '/assignment/:assignment_id', to: 'participants#list_assignment_participants' + get '/:id', to: 'participants#show' + post '/:authorization', to: 'participants#add' + patch '/:id/:authorization', to: 'participants#update_authorization' + delete '/:id', to: 'participants#destroy' + end + end + + resources :student_teams, only: %i[create update] do + collection do + get :view + get :mentor + get :remove_participant + put '/leave', to: 'student_teams#leave_team' + end + end + + resources :teams do + member do + get 'members' + post 'members', to: 'teams#add_member' + delete 'members/:user_id', to: 'teams#remove_member' + + get 'join_requests' + post 'join_requests', to: 'teams#create_join_request' + put 'join_requests/:join_request_id', to: 'teams#update_join_request' + end + end + resources :teams_participants, only: [] do + collection do + put :update_duty + end + member do + get :list_participants + post :add_participant + delete :delete_participants + end + end + resources :grades do + collection do + get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' + patch '/:participant_id/assign_grade', to: 'grades#assign_grade' + get '/:participant_id/edit', to: 'grades#edit' + get '/:assignment_id/view_our_scores', to: 'grades#view_our_scores' + get '/:assignment_id/view_my_scores', to: 'grades#view_my_scores' + get '/:participant_id/instructor_review', to: 'grades#instructor_review' + end + end end \ No newline at end of file diff --git a/db/migrate/20231102173152_create_teams.rb b/db/migrate/20231102173152_create_teams.rb index 699e0c56e..91d2c2422 100644 --- a/db/migrate/20231102173152_create_teams.rb +++ b/db/migrate/20231102173152_create_teams.rb @@ -6,7 +6,6 @@ def change t.string :name, null: false t.integer :parent_id, index: true t.string :type, null: false - t.boolean :advertise_for_partner, null: false, default: false t.text :submitted_hyperlinks t.integer :directory_num diff --git a/db/migrate/20250805174104_rename_status_to_reply_status_in_in_join_team_requests.rb b/db/migrate/20250805174104_rename_status_to_reply_status_in_in_join_team_requests.rb new file mode 100644 index 000000000..fecb0ae22 --- /dev/null +++ b/db/migrate/20250805174104_rename_status_to_reply_status_in_in_join_team_requests.rb @@ -0,0 +1,5 @@ +class RenameStatusToReplyStatusInInJoinTeamRequests < ActiveRecord::Migration[8.0] + def change + rename_column :join_team_requests, :status, :reply_status + end +end diff --git a/db/migrate/20251021165336_add_participant_ref_to_invitations.rb b/db/migrate/20251021165336_add_participant_ref_to_invitations.rb new file mode 100644 index 000000000..470d22b40 --- /dev/null +++ b/db/migrate/20251021165336_add_participant_ref_to_invitations.rb @@ -0,0 +1,5 @@ +class AddParticipantRefToInvitations < ActiveRecord::Migration[8.0] + def change + add_reference :invitations, :participant, null: false, foreign_key: true + end +end \ No newline at end of file diff --git a/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb b/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb new file mode 100644 index 000000000..9f5ab7f09 --- /dev/null +++ b/db/migrate/20251022160053_change_invitation_from_id_foreign_key.rb @@ -0,0 +1,9 @@ +class ChangeInvitationFromIdForeignKey < ActiveRecord::Migration[7.0] + def change + # Remove old foreign key to participants + remove_foreign_key :invitations, column: :from_id + + # Add new foreign key to teams + add_foreign_key :invitations, :teams, column: :from_id + end +end \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index d3a15fcfa..002aefb54 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -422,8 +422,9 @@ add_foreign_key "assignments", "users", column: "instructor_id" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" - add_foreign_key "invitations", "participants", column: "from_id" + add_foreign_key "invitations", "participants" add_foreign_key "invitations", "participants", column: "to_id" + add_foreign_key "invitations", "teams", column: "from_id" add_foreign_key "items", "questionnaires" add_foreign_key "participants", "join_team_requests" add_foreign_key "participants", "teams" diff --git a/db/seeds.rb b/db/seeds.rb index 8c11894f0..66573371c 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,128 +1,128 @@ -# frozen_string_literal: true - -begin - # Create an instritution - inst_id = Institution.create!( - name: 'North Carolina State University' - ).id - - Role.create!(id: 1, name: 'Super Administrator') - Role.create!(id: 2, name: 'Administrator') - Role.create!(id: 3, name: 'Instructor') - Role.create!(id: 4, name: 'Teaching Assistant') - Role.create!(id: 5, name: 'Student') - - # Create an admin user - User.create!( - name: 'admin', - email: 'admin2@example.com', - password: 'password123', - full_name: 'admin admin', - institution_id: 1, - role_id: 1 - ) - - # Generate Random Users - num_students = 48 - num_assignments = 8 - num_teams = 16 - num_courses = 2 - num_instructors = 2 - - puts "creating instructors" - instructor_user_ids = [] - num_instructors.times do - instructor_user_ids << User.create( - name: Faker::Internet.unique.username, - email: Faker::Internet.unique.email, - password: "password", - full_name: Faker::Name.name, - institution_id: 1, - role_id: 3 - ).id - end - - puts "creating courses" - course_ids = [] - num_courses.times do |i| - course_ids << Course.create( - instructor_id: instructor_user_ids[i], - institution_id: inst_id, - directory_path: Faker::File.dir(segment_count: 2), - name: Faker::Company.industry, - info: "A fake class", - private: false - ).id - end - - puts "creating assignments" - assignment_ids = [] - num_assignments.times do |i| - assignment_ids << Assignment.create( - name: Faker::Verb.base, - instructor_id: instructor_user_ids[i % num_instructors], - course_id: course_ids[i % num_courses], - has_teams: true, - private: false - ).id - end - - puts "creating teams" - team_ids = [] - num_teams.times do |i| - team_ids << AssignmentTeam.create( - name: "Team #{i + 1}", - parent_id: assignment_ids[i % num_assignments] - ).id - end - - puts "creating students" - student_user_ids = [] - num_students.times do - student_user_ids << User.create( - name: Faker::Internet.unique.username, - email: Faker::Internet.unique.email, - password: "password", - full_name: Faker::Name.name, - institution_id: 1, - role_id: 5, - parent_id: [nil, *instructor_user_ids].sample - ).id - end - - puts "assigning students to teams" - teams_users_ids = [] - # num_students.times do |i| - # teams_users_ids << TeamsUser.create( - # team_id: team_ids[i%num_teams], - # user_id: student_user_ids[i] - # ).id - # end - - num_students.times do |i| - puts "Creating TeamsUser with team_id: #{team_ids[i % num_teams]}, user_id: #{student_user_ids[i]}" - teams_user = TeamsUser.create( - team_id: team_ids[i % num_teams], - user_id: student_user_ids[i] - ) - if teams_user.persisted? - teams_users_ids << teams_user.id - puts "Created TeamsUser with ID: #{teams_user.id}" - else - puts "Failed to create TeamsUser: #{teams_user.errors.full_messages.join(', ')}" - end - end - - puts "assigning participant to students, teams, courses, and assignments" - participant_ids = [] - num_students.times do |i| - participant_ids << AssignmentParticipant.create( - user_id: student_user_ids[i], - parent_id: assignment_ids[i%num_assignments], - team_id: team_ids[i%num_teams], - ).id - end - -rescue ActiveRecord::RecordInvalid => e - puts e, 'The db has already been seeded' -end +# frozen_string_literal: true + +begin + # Create an instritution + inst_id = Institution.create!( + name: 'North Carolina State University' + ).id + + Role.create!(id: 1, name: 'Super Administrator') + Role.create!(id: 2, name: 'Administrator') + Role.create!(id: 3, name: 'Instructor') + Role.create!(id: 4, name: 'Teaching Assistant') + Role.create!(id: 5, name: 'Student') + + # Create an admin user + User.create!( + name: 'admin', + email: 'admin2@example.com', + password: 'password123', + full_name: 'admin admin', + institution_id: 1, + role_id: 1 + ) + + # Generate Random Users + num_students = 48 + num_assignments = 8 + num_teams = 16 + num_courses = 2 + num_instructors = 2 + + puts "creating instructors" + instructor_user_ids = [] + num_instructors.times do + instructor_user_ids << User.create( + name: Faker::Internet.unique.username, + email: Faker::Internet.unique.email, + password: "password", + full_name: Faker::Name.name, + institution_id: 1, + role_id: 3 + ).id + end + + puts "creating courses" + course_ids = [] + num_courses.times do |i| + course_ids << Course.create( + instructor_id: instructor_user_ids[i], + institution_id: inst_id, + directory_path: Faker::File.dir(segment_count: 2), + name: Faker::Company.industry, + info: "A fake class", + private: false + ).id + end + + puts "creating assignments" + assignment_ids = [] + num_assignments.times do |i| + assignment_ids << Assignment.create( + name: Faker::Verb.base, + instructor_id: instructor_user_ids[i % num_instructors], + course_id: course_ids[i % num_courses], + has_teams: true, + private: false + ).id + end + + puts "creating teams" + team_ids = [] + num_teams.times do |i| + team_ids << AssignmentTeam.create( + name: "Team #{i + 1}", + parent_id: assignment_ids[i % num_assignments] + ).id + end + + puts "creating students" + student_user_ids = [] + num_students.times do + student_user_ids << User.create( + name: Faker::Internet.unique.username, + email: Faker::Internet.unique.email, + password: "password", + full_name: Faker::Name.name, + institution_id: 1, + role_id: 5, + parent_id: [nil, *instructor_user_ids].sample + ).id + end + + puts "assigning students to teams" + teams_users_ids = [] + # num_students.times do |i| + # teams_users_ids << TeamsUser.create( + # team_id: team_ids[i%num_teams], + # user_id: student_user_ids[i] + # ).id + # end + + num_students.times do |i| + puts "Creating TeamsUser with team_id: #{team_ids[i % num_teams]}, user_id: #{student_user_ids[i]}" + teams_user = TeamsUser.create( + team_id: team_ids[i % num_teams], + user_id: student_user_ids[i] + ) + if teams_user.persisted? + teams_users_ids << teams_user.id + puts "Created TeamsUser with ID: #{teams_user.id}" + else + puts "Failed to create TeamsUser: #{teams_user.errors.full_messages.join(', ')}" + end + end + + puts "assigning participant to students, teams, courses, and assignments" + participant_ids = [] + num_students.times do |i| + participant_ids << AssignmentParticipant.create( + user_id: student_user_ids[i], + parent_id: assignment_ids[i%num_assignments], + team_id: team_ids[i%num_teams], + ).id + end + +rescue ActiveRecord::RecordInvalid => e + puts e, 'The db has already been seeded' +end \ No newline at end of file diff --git a/spec/controllers/concerns/authorization_spec.rb b/spec/controllers/concerns/authorization_spec.rb index fd199e56a..09bdc9da3 100644 --- a/spec/controllers/concerns/authorization_spec.rb +++ b/spec/controllers/concerns/authorization_spec.rb @@ -17,31 +17,22 @@ end ########################################## - # Tests for has_privileges_of? method + # Tests for current_user_has_privileges_of? method ########################################## - describe '#has_privileges_of?' do + describe '#current_user_has_privileges_of?' do describe 'role validation' do context 'when required_role is a string' do let(:admin_role) { instance_double('Role') } before do - allow(Role).to receive(:find_by_name).with('Administrator').and_return(admin_role) + allow(Role).to receive(:find_by).with(name:'Administrator').and_return(admin_role) end it 'finds the role and checks privileges' do expect(role).to receive(:all_privileges_of?).with(admin_role).and_return(true) - expect(controller.has_privileges_of?('Administrator')).to be true + expect(controller.current_user_has_admin_privileges?).to be true end - end - - context 'when required_role is a Role object' do - let(:instructor_role) { instance_double('Role') } - - it 'directly checks privileges' do - expect(role).to receive(:all_privileges_of?).with(instructor_role).and_return(false) - expect(controller.has_privileges_of?(instructor_role)).to be false - end - end + end end describe 'edge cases' do @@ -51,7 +42,7 @@ end it 'returns false' do - expect(controller.has_privileges_of?('Administrator')).to be false + expect(controller.current_user_has_admin_privileges?).to be false end end @@ -61,16 +52,16 @@ end it 'returns false' do - expect(controller.has_privileges_of?('Administrator')).to be false + expect(controller.current_user_has_admin_privileges?).to be false end end end end ########################################## - # Tests for has_role? method + # Tests for current_user_has_role? method ########################################## - describe '#has_role?' do + describe '#current_user_has_role?' do describe 'role matching' do context 'when role_name is a string' do before do @@ -78,11 +69,11 @@ end it 'returns true when roles match' do - expect(controller.has_role?('Student')).to be true + expect(controller.current_user_has_role?('Student')).to be true end it 'returns false when roles do not match' do - expect(controller.has_role?('Instructor')).to be false + expect(controller.current_user_has_role?('Instructor')).to be false end end @@ -96,7 +87,7 @@ end it 'compares using the role name' do - expect(controller.has_role?(role_object)).to be true + expect(controller.current_user_has_role?(role_object)).to be true end end end @@ -108,7 +99,7 @@ end it 'returns false' do - expect(controller.has_role?('Student')).to be false + expect(controller.current_user_has_role?('Student')).to be false end end @@ -118,7 +109,7 @@ end it 'returns false' do - expect(controller.has_role?('Student')).to be false + expect(controller.current_user_has_role?('Student')).to be false end end end @@ -130,7 +121,7 @@ describe '#all_actions_allowed?' do context 'when the user has the Super Administrator role' do before do - allow(controller).to receive(:has_privileges_of?).with('Super Administrator').and_return(true) + allow(controller).to receive(:current_user_has_privileges_of?).with('Super Administrator').and_return(true) end it 'returns true' do @@ -140,7 +131,7 @@ context 'when the user does not have the Super Administrator role' do before do - allow(controller).to receive(:has_privileges_of?).with('Super Administrator').and_return(false) + allow(controller).to receive(:current_user_has_privileges_of?).with('Super Administrator').and_return(false) allow(controller).to receive(:action_allowed?).and_return(false) end @@ -151,7 +142,7 @@ context 'when action_allowed? returns true' do before do - allow(controller).to receive(:has_privileges_of?).with('Super Administrator').and_return(false) + allow(controller).to receive(:current_user_has_privileges_of?).with('Super Administrator').and_return(false) allow(controller).to receive(:action_allowed?).and_return(true) end diff --git a/spec/mailers/invitation_sent_spec.rb b/spec/mailers/invitation_sent_spec.rb deleted file mode 100644 index a95054448..000000000 --- a/spec/mailers/invitation_sent_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe InvitationSentMailer, type: :mailer do - pending "add some examples to (or delete) #{__FILE__}" -end diff --git a/spec/mailers/previews/invitation_sent_preview.rb b/spec/mailers/previews/invitation_sent_preview.rb deleted file mode 100644 index 201ba9b77..000000000 --- a/spec/mailers/previews/invitation_sent_preview.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -# Preview all emails at http://localhost:3000/rails/mailers/invitation_sent -class InvitationSentPreview < ActionMailer::Preview - -end diff --git a/spec/models/assignment_spec.rb b/spec/models/assignment_spec.rb index 58fd6a45f..5fa30b8aa 100644 --- a/spec/models/assignment_spec.rb +++ b/spec/models/assignment_spec.rb @@ -1,6 +1,4 @@ # frozen_string_literal: true - - require 'rails_helper' RSpec.describe Assignment, type: :model do @@ -35,4 +33,4 @@ expect(assignment.volume_of_review_comments(1)).to eq([1, 2, 2, 0]) end end -end +end \ No newline at end of file diff --git a/spec/models/invitation_spec.rb b/spec/models/invitation_spec.rb new file mode 100644 index 000000000..a1aa9160d --- /dev/null +++ b/spec/models/invitation_spec.rb @@ -0,0 +1,123 @@ +require 'rails_helper' +require 'swagger_helper' + +RSpec.describe Invitation, type: :model do + include ActiveJob::TestHelper + before(:all) do + @roles = create_roles_hierarchy + end + + let(:instructor) { create(:user, role_id: @roles[:instructor].id, name: "profa", full_name: "Prof A", email: "profa@example.com")} + let(:user1) do + User.create!( name: "student", password_digest: "password",role_id: @roles[:student].id, full_name: "Student Name",email: "student@example.com") + end + + let(:user2) do + User.create!( + name: "student2", password_digest: "password", role_id: @roles[:student].id, full_name: "Student Two", email: "student2@example.com") + end + let(:assignment) { Assignment.create!(name: "Test Assignment", instructor_id: instructor.id) } + let(:team1) { AssignmentTeam.create!(name: "Team1", parent_id: assignment.id) } + let(:team2) { AssignmentTeam.create!(name: "Team2", parent_id: assignment.id) } + + let(:participant1) { AssignmentParticipant.create!(user: user1, parent_id: assignment.id, handle: 'user1_handle') } + let(:participant2) { AssignmentParticipant.create!(user: user2, parent_id: assignment.id, handle: 'user2_handle') } + let(:invalid_user) { build :user, name: 'INVALID' } + + before(:each) do + ActiveJob::Base.queue_adapter = :test + end + + after(:each) do + clear_enqueued_jobs + end + + + it 'is invitation_factory returning new Invitation' do + invitation = Invitation.invitation_factory(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, participant_id: participant2.id) + expect(invitation).to be_valid + end + + it 'sends an invitation email' do + invitation = Invitation.create(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, participant_id: participant2.id) + expect do + invitation.send_invite_email + end.to have_enqueued_job.on_queue('default').exactly(:once) + end + + it 'accepts invitation and change reply_status to Accept(A)' do + invitation = Invitation.create(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, participant_id: participant2.id) + invitation.accept_invitation + expect(invitation.reply_status).to eq(InvitationValidator::ACCEPT_STATUS) + end + + it 'rejects invitation and change reply_status to Decline(D)' do + invitation = Invitation.create(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, participant_id: participant2.id) + invitation.decline_invitation + expect(invitation.reply_status).to eq(InvitationValidator::DECLINED_STATUS) + end + + it 'retracts invitation' do + invitation = Invitation.create(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, participant_id: participant2.id) + invitation.retract_invitation + expect(invitation.reply_status).to eq(InvitationValidator::RETRACT_STATUS) + end + + it 'as_json works as expected' do + invitation = Invitation.create(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, participant_id: participant2.id) + expect(invitation.as_json).to include('to_participant', 'from_team', 'assignment', 'reply_status', 'id') + end + + it 'is invited? false' do + invitation = Invitation.create(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, participant_id: participant2.id) + truth = Invitation.invited?(participant1.id, team2.id, assignment.id) + expect(truth).to eq(false) + end + + it 'is invited? true' do + invitation = Invitation.create(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, participant_id: participant2.id) + truth = Invitation.invited?(team2.id, participant1.id, assignment.id) + expect(truth).to eq(true) + end + + it 'is default reply_status set to WAITING' do + invitation = Invitation.new(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, participant_id: participant2.id) + expect(invitation.reply_status).to eq('W') + end + + it 'is valid with valid attributes' do + invitation = Invitation.new(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, + reply_status: InvitationValidator::WAITING_STATUS, participant_id: participant2.id) + expect(invitation).to be_valid + end + + it 'is invalid with same from and to attribute' do + invitation = Invitation.new(to_id: participant1.id, participant_id: participant1.id, assignment_id: assignment.id, + reply_status: InvitationValidator::WAITING_STATUS) + expect(invitation).to_not be_valid + end + + it 'is invalid with invalid to user attribute' do + invitation = Invitation.new(to_id: 'INVALID', from_id: team2.id, assignment_id: assignment.id, + reply_status: InvitationValidator::WAITING_STATUS, participant_id: participant2.id) + expect(invitation).to_not be_valid + end + + it 'is invalid with invalid from user attribute' do + invitation = Invitation.new(to_id: participant1.id, from_id: 'INVALID', assignment_id: assignment.id, + reply_status: InvitationValidator::WAITING_STATUS, participant_id: participant2.id) + expect(invitation).to_not be_valid + end + + it 'is invalid with invalid assignment attribute' do + invitation = Invitation.new(to_id: participant1.id, from_id: team2.id, assignment_id: 'INVALID', + reply_status: InvitationValidator::WAITING_STATUS, participant_id: participant2.id) + expect(invitation).to_not be_valid + end + + it 'is invalid with invalid reply_status attribute' do + invitation = Invitation.new(to_id: participant1.id, from_id: team2.id, assignment_id: assignment.id, + reply_status: 'X', participant_id: participant2.id) + expect(invitation).to_not be_valid + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/invitation_controller_spec.rb b/spec/requests/api/v1/invitation_controller_spec.rb new file mode 100644 index 000000000..9f1e52ea2 --- /dev/null +++ b/spec/requests/api/v1/invitation_controller_spec.rb @@ -0,0 +1,343 @@ +require 'swagger_helper' +require 'rails_helper' +require 'json_web_token' + +RSpec.describe 'Invitations API', type: :request do + before(:all) do + @roles = create_roles_hierarchy + end + + # + # --- USERS --- + # + + let(:ta) do + User.create!( + name: "ta", + password_digest: "password", + role_id: @roles[:ta].id, + full_name: "Teaching Assistant", + email: "ta@example.com" + ) + end + + let(:user1) do + User.create!( + name: "student", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student Name", + email: "student@example.com" + ) + end + + let(:user2) do + User.create!( + name: "student2", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student Two", + email: "student2@example.com" + ) + end + + let(:prof) { + create(:user, + role_id: @roles[:instructor].id, + name: "profa", + full_name: "Prof A", + email: "profa@example.com") + } + + # + # --- ASSIGNMENT + PARTICIPANTS + TEAMS --- + # + let(:assignment) { Assignment.create!(name: "Test Assignment", instructor_id: prof.id) } + + let(:token) { JsonWebToken.encode({ id: user1.id }) } + let(:Authorization) { "Bearer #{token}" } + + let(:ta_token) { JsonWebToken.encode({ id: ta.id }) } + + let(:team1) { AssignmentTeam.create!(name: "Team1", parent_id: assignment.id) } + let(:team2) { AssignmentTeam.create!(name: "Team2", parent_id: assignment.id) } + + let(:participant1) { AssignmentParticipant.create!(user: user1, parent_id: assignment.id, handle: 'user1_handle') } + let(:participant2) { AssignmentParticipant.create!(user: user2, parent_id: assignment.id, handle: 'user2_handle') } + + + before do + # assign participants to teams + team1.add_participant(participant1) + team2.add_participant(participant2) + end + + # + # Existing invitation instance + # + let(:invitation) { + Invitation.create!( + from_team: team1, + from_participant: participant1, + to_participant: participant2, + assignment: assignment + ) + } + + let(:invitation2) { + Invitation.create!( + from_team: team2, + from_participant: participant2, + to_participant: participant1, + assignment: assignment + ) + } + + # + # --- TESTS --- + # + path "/invitations" do + get("list invitations") do + tags "Invitations" + produces "application/json" + parameter name: 'Authorization', in: :header, type: :string, required: true + let(:Authorization) { "Bearer #{ta_token}" } + response(200, "Success") do + run_test! + end + end + end + + path "/invitations" do + # + # POST /invitations + # + post("create invitation") do + tags "Invitations" + consumes "application/json" + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + parameter name: :invitation, in: :body, schema: { + type: :object, + properties: { + assignment_id: { type: :integer }, + username: { type: :string }, + }, + required: %w[assignment_id username] + } + + # + # SUCCESS CASE + # + response(201, "Create successful") do + let(:invitation) { + { + assignment_id: assignment.id, + username: user2.name + } + } + + run_test! + end + + # + # Invalid — user not found + # + response(404, "User not found") do + let(:invitation) { + { + assignment_id: assignment.id, + username: "UNKNOWN_USER" + } + } + + run_test! + end + + # + # Invalid — user exists but not participant + # + response(404, "Participant not found") do + let(:non_participant_user) { create(:user, name: "randomuser") } + + let(:invitation) { + { + assignment_id: assignment.id, + username: non_participant_user.name + } + } + + run_test! + end + end + end + + # + # CRUD Operation on /invitations/:id + # + path "/invitations/{id}" do + parameter name: "id", in: :path, type: :integer + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + # GET /invitations/:id + get("show invitation") do + tags "Invitations" + response(200, "Show invitation") do + let(:id) { invitation.id } + run_test! + end + + response(403, "Cannot see other's invitations") do + let(:id) { invitation2.id } + run_test! + end + end + + # PATCH /invitations/:id + patch("update invitation") do + tags "Invitations" + consumes "application/json" + + parameter name: :invitation_status, in: :body, schema: { + type: :object, + properties: { + reply_status: { type: :string } + } + } + + # Accept invitation + response(200, "Acceptance successful") do + let(:id) { invitation2.id } + let(:invitation_status) { { reply_status: InvitationValidator::ACCEPT_STATUS } } + run_test! + end + + response(403, "Cannot accept other's invitations") do + let(:id) { invitation.id } + let(:invitation_status) { { reply_status: InvitationValidator::ACCEPT_STATUS } } + run_test! + end + + # Decline invitation + response(200, "Decline successful") do + let(:id) { invitation2.id } + let(:invitation_status) { { reply_status: InvitationValidator::DECLINED_STATUS } } + run_test! + end + + response(403, "Cannot decline other's invitations") do + let(:id) { invitation.id } + let(:invitation_status) { { reply_status: InvitationValidator::DECLINED_STATUS } } + run_test! + end + + # Retract invitation + response(200, "Retraction successful") do + let(:id) { invitation.id } + let(:invitation_status) { { reply_status: InvitationValidator::RETRACT_STATUS } } + run_test! + end + + response(403, "Cannot retract other's invitations") do + let(:id) { invitation2.id } + let(:invitation_status) { { reply_status: InvitationValidator::RETRACT_STATUS } } + run_test! + end + + # Invalid status + response(422, "Invalid request") do + let(:id) { invitation.id } + let(:invitation_status) { { reply_status: "Z" } } + run_test! + end + end + + # DELETE /invitations/:id + delete("Delete invitation") do + tags "Invitations" + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + response(403, "Student cannot delete invitations") do + let(:id) { invitation2.id } + run_test! + end + end + + delete("Delete invitation") do + tags "Invitations" + parameter name: 'Authorization', in: :header, type: :string, required: true + let(:Authorization) { "Bearer #{ta_token}" } + + response(200, "Delete successful") do + let(:id) { invitation.id } + run_test! + end + end + end + + # + # GET invitations sent by a team + # + path "/invitations/sent_by/team/{team_id}" do + parameter name: "team_id", in: :path, type: :integer + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + get("Show all invitations sent by team") do + tags "Invitations" + + response(200, "OK") do + let(:team_id) { team1.id } + run_test! + end + + response(403, "Not allowed") do + let(:team_id) {team2.id} + run_test! + end + end + end + + # + # GET invitations sent by a participant + # + path "/invitations/sent_by/participant/{participant_id}" do + parameter name: "participant_id", in: :path, type: :integer + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + get("Show all invitations sent by participant") do + tags "Invitations" + + response(200, "OK") do + let(:participant_id) { participant1.id } + run_test! + end + + response(403, "Not allowed") do + let(:participant_id) {participant2.id} + run_test! + end + end + end + + # + # GET invitations sent to a participant + # + path "/invitations/sent_to/{participant_id}" do + parameter name: "participant_id", in: :path, type: :integer + parameter name: 'Authorization', in: :header, type: :string, required: true, description: 'Bearer token' + + get("Show all invitations sent to participant") do + tags "Invitations" + + response(200, "OK") do + let(:participant_id) { participant1.id } + run_test! + end + + response(403, "Not allowed") do + let(:participant_id) {participant2.id} + run_test! + end + end + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/participants_controller_spec.rb b/spec/requests/api/v1/participants_controller_spec.rb index 5b26e377d..a519e36ad 100644 --- a/spec/requests/api/v1/participants_controller_spec.rb +++ b/spec/requests/api/v1/participants_controller_spec.rb @@ -283,7 +283,7 @@ run_test! do |response| data = JSON.parse(response.body) - expect(data['user_id']).to eq(studentb.id) + expect(data['user']['id']).to eq(studentb.id) expect(data['parent_id']).to eq(assignment2.id) expect(data['authorization']).to eq('mentor') end diff --git a/spec/validators/invitation_validator_spec.rb b/spec/validators/invitation_validator_spec.rb new file mode 100644 index 000000000..e3c3d8fb7 --- /dev/null +++ b/spec/validators/invitation_validator_spec.rb @@ -0,0 +1,89 @@ +require 'rails_helper' +require 'swagger_helper' + +RSpec.describe InvitationValidator do + before(:all) do + @roles = create_roles_hierarchy + end + + let(:instructor) { create(:user, role_id: @roles[:instructor].id, name: "profa", full_name: "Prof A", email: "profa@example.com")} + let(:user1) do + User.create!( name: "student", password_digest: "password",role_id: @roles[:student].id, full_name: "Student Name",email: "student@example.com") + end + + let(:user2) do + User.create!( name: "student2", password_digest: "password", role_id: @roles[:student].id, full_name: "Student Two", email: "student2@example.com") + end + let(:assignment) { Assignment.create!(name: "Test Assignment", instructor_id: instructor.id) } + let(:team1) { AssignmentTeam.create!(name: "Team1", parent_id: assignment.id) } + let(:team2) { AssignmentTeam.create!(name: "Team2", parent_id: assignment.id) } + + let(:participant1) { AssignmentParticipant.create!(user: user1, parent_id: assignment.id, handle: 'user1_handle') } + let(:participant2) { AssignmentParticipant.create!(user: user2, parent_id: assignment.id, handle: 'user2_handle') } + + let(:valid_attributes) do + { + from_participant: participant1, + to_id: participant2.id, + from_team: team1, + assignment_id: assignment.id, + reply_status: 'W' + } + end + + subject { Invitation.new(valid_attributes) } + + describe 'validations' do + it 'is valid with correct attributes' do + expect(subject).to be_valid + end + + context 'invitee validation' do + it 'adds error if invitee is not part of assignment' do + subject.to_id = 0 # non-existent participant + subject.validate + expect(subject.errors[:base]).to include("the participant is not part of this assignment") + end + end + + context 'reply status validation' do + it 'adds error if reply_status is missing' do + subject.reply_status = nil + subject.validate + expect(subject.errors[:base]).to include("must be present and have a maximum length of 1") + end + + it 'adds error if reply_status is too long' do + subject.reply_status = 'AB' + subject.validate + expect(subject.errors[:base]).to include("must be present and have a maximum length of 1") + end + + it 'adds error if reply_status is not included in allowed statuses' do + subject.reply_status = 'X' + subject.validate + expect(subject.errors[:base]).to include("must be one of A, D, W, and R") + end + end + + context 'duplicate invitation validation' do + before do + Invitation.create!(valid_attributes) + end + + it 'adds error if duplicate invitation exists' do + duplicate_invitation = Invitation.new(valid_attributes) + duplicate_invitation.validate + expect(duplicate_invitation.errors[:base]).to include("You cannot have duplicate invitations") + end + end + + context 'to/from participant difference validation' do + it 'adds error if to and from are same participant' do + subject.to_id = participant1.id + subject.validate + expect(subject.errors[:base]).to include("to and from participants should be different") + end + end + end +end \ No newline at end of file