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 @@
-