diff --git a/app/controllers/api/v1/review_mappings_controller.rb b/app/controllers/api/v1/review_mappings_controller.rb new file mode 100644 index 000000000..1d79979e7 --- /dev/null +++ b/app/controllers/api/v1/review_mappings_controller.rb @@ -0,0 +1,571 @@ +# app/controllers/api/v1/review_mappings_controller.rb + +module Api + module V1 + class ReviewMappingsController < ApplicationController + # Set up before actions for common operations + include ReviewMappingsHelper + + before_action :authorize_request + before_action :set_review_mapping, only: [:show, :update, :destroy] + before_action :validate_contributor_id, only: [:select_reviewer] + + # GET /api/v1/review_mappings + # Returns a list of all review mappings + def index + render json: { message: "Use /assignments/:assignment_id/review_mappings to list mappings." }, status: :ok + end + + # GET /api/v1/assignments/:assignment_id/review_mappings + # This action fetches all review mappings associated with a specific assignment. + # Optional query parameters (reviewer_id, reviewee_id, type) can be used to filter the results. + def list_mappings + # Whitelist and extract the relevant query parameters + params.permit(:assignment_id, :reviewer_id, :reviewee_id, :type) + + # Find the assignment by the provided assignment_id + assignment = Assignment.find_by(id: params[:assignment_id]) + + # Return 404 Not Found if the assignment doesn't exist + if assignment.nil? + render json: { error: 'Assignment not found' }, status: :not_found + return + end + + # Extract optional filtering parameters + filters = params.slice(:reviewer_id, :reviewee_id, :type) + + # Fetch the review mappings using the helper method with the given filters + mappings_data = fetch_review_mappings(assignment, filters) + + # Respond with the filtered review mappings in JSON format + render json: mappings_data, status: :ok + end + + + # GET /api/v1/review_mappings/:id + # Returns a specific review mapping by ID + def show + render json: @review_mapping + end + + # POST /api/v1/review_mappings + # Creates a new review mapping + def create + @review_mapping = ReviewMapping.new(review_mapping_params) + + if @review_mapping.save + render json: @review_mapping, status: :created + else + render json: @review_mapping.errors, status: :unprocessable_entity + end + end + + # PATCH/PUT /api/v1/review_mappings/:id + def update + if @review_mapping.update(review_mapping_params) + render json: @review_mapping + else + render json: @review_mapping.errors, status: :unprocessable_entity + end + end + + # DELETE /api/v1/review_mappings/:id + def destroy + @review_mapping.destroy + head :no_content + end + + + # POST /api/v1/review_mappings/add_calibration + # Creates a calibration review mapping between a team and an assignment + # This is used for calibration reviews where instructors review team submissions + # to establish grading standards + def add_calibration + result = ReviewMapping.create_calibration_review( + assignment_id: params.dig(:calibration, :assignment_id), + team_id: params.dig(:calibration, :team_id), + user_id: current_user.id + ) + + if result.success + render json: result.review_mapping, status: :created + else + render json: { error: result.error }, status: :unprocessable_entity + end + end + + # GET /api/v1/review_mappings/select_reviewer + # Selects a contributor for review mapping and stores it in the session + # This is used in the review assignment process to track the selected contributor + def select_reviewer + @contributor = Team.find(params[:contributor_id]) + session[:contributor] = @contributor + render json: @contributor, status: :ok + rescue ActiveRecord::RecordNotFound + render json: { error: "Contributor not found" }, status: :not_found + end + + # POST /api/v1/review_mappings/add_reviewer + # Adds a reviewer to a review mapping + # This endpoint handles the assignment of reviewers to teams for review purposes + def add_reviewer + Rails.logger.debug "Raw params: #{params.inspect}" + Rails.logger.debug "Request content type: #{request.content_type}" + Rails.logger.debug "Request body: #{request.body.read}" + + begin + result = ReviewMapping.add_reviewer( + assignment_id: params[:id], + team_id: params[:contributor_id], + user_name: params.dig(:user, :name) + ) + + if result.success? + render json: result.review_mapping, status: :created + else + render json: { error: result.error }, status: :unprocessable_entity + end + rescue => e + Rails.logger.error "Error in add_reviewer: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + render json: { error: e.message }, status: :bad_request + end + end + + # POST /api/v1/review_mappings/assign_reviewer_dynamically + # Assigns a reviewer dynamically to a team or topic + def assign_reviewer_dynamically + result = ReviewMapping.assign_reviewer_dynamically( + assignment_id: params[:assignment_id], + reviewer_id: params[:reviewer_id], + topic_id: params[:topic_id], + i_dont_care: params[:i_dont_care].present? + ) + + if result.success? + render json: result.review_mapping, status: :created + else + render json: { error: result.error }, status: :unprocessable_entity + end + end + + # GET /api/v1/review_mappings/review_allowed + # Checks if a reviewer can perform more reviews for an assignment + def review_allowed + result = ReviewResponseMap.review_allowed?(params[:assignment_id], params[:reviewer_id]) + + if result.success + render plain: result.allowed.to_s + else + render json: { error: result.error }, status: :unprocessable_entity + end + end + + # GET /api/v1/review_mappings/check_outstanding_reviews + # Checks if a reviewer has exceeded the maximum number of outstanding reviews + def check_outstanding_reviews + result = ReviewMapping.check_outstanding_reviews?( + Assignment.find(params[:assignment_id]), + User.find(params[:reviewer_id]) + ) + + if result.success + render plain: result.allowed.to_s + else + render json: { error: result.error }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotFound + render json: { error: "Assignment or Reviewer not found" }, status: :unprocessable_entity + end + + # POST /api/v1/review_mappings/assign_quiz_dynamically + # Assigns a quiz to a participant for a specific assignment + def assign_quiz_dynamically + result = QuizResponseMap.assign_quiz( + assignment_id: params[:assignment_id], + reviewer_id: params[:reviewer_id], + questionnaire_id: params[:questionnaire_id] + ) + + if result.success + render json: result.quiz_response_map, status: :created + else + render json: { error: result.error }, status: :unprocessable_entity + end + end + + # POST /api/v1/review_mappings/start_self_review + # Initiates a self-review process for a participant + def start_self_review + Rails.logger.debug "Starting self-review with params: #{params.inspect}" + + result = SelfReviewResponseMap.create_self_review( + assignment_id: params[:assignment_id], + reviewer_id: params[:reviewer_id], + reviewer_userid: params[:reviewer_userid] + ) + + Rails.logger.debug "Self-review result: #{result.inspect}" + + if result.success + render json: result.self_review_map, status: :created + else + error_message = result.error || "Unknown error occurred during self-review creation" + Rails.logger.error "Self-review error: #{error_message}" + render json: { error: error_message }, status: :unprocessable_entity + end + rescue => e + Rails.logger.error "Exception in start_self_review: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + render json: { error: e.message }, status: :unprocessable_entity + end + + # GET /api/v1/review_mappings/get_questionnaire_id + # Returns the questionnaire ID for a given assignment and reviewer + def get_questionnaire_id + assignment = Assignment.find(params[:assignment_id]) + reviewer = User.find(params[:reviewer_id]) + + # Get the review questionnaire for the assignment + questionnaire = assignment.review_questionnaire_id + + if questionnaire + render json: { questionnaire_id: questionnaire.id }, status: :ok + else + render json: { error: "No questionnaire found for this assignment" }, status: :not_found + end + rescue ActiveRecord::RecordNotFound + render json: { error: "Assignment or Reviewer not found" }, status: :not_found + end + + # POST /api/v1/assignments/:assignment_id/automatic_review_mapping + # Automatically generates reviewer-reviewee mappings for a given assignment. + def automatic_review_mapping + # Permit only the expected parameters from the request + params.permit(:assignment_id, :num_reviews_per_student, :num_of_reviewers, :strategy) + + # Find the assignment using the provided assignment_id + assignment = Assignment.find_by(id: params[:assignment_id]) + + # Return a 404 error if the assignment does not exist + if assignment.nil? + render json: { error: 'Assignment not found' }, status: :not_found + return + end + + # Delegate the mapping generation logic to the helper function + result = generate_automatic_review_mappings(assignment, params) + + # If successful, return a success message with status 200 + if result[:success] + render json: { message: result[:message] }, status: :ok + else + # If mapping fails, return an error message with status 422 + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + # POST /api/v1/assignments/:assignment_id/automatic_review_mapping_strategy + def automatic_review_mapping_strategy + # Allow only the permitted parameters from the incoming request. + # These include the assignment ID (from the URL), number of reviews per student, and the desired strategy. + params.permit(:assignment_id, :num_reviews_per_student, :strategy) + + # Attempt to find the Assignment record based on the provided assignment_id. + # If no such assignment exists, respond with a 404 Not Found error. + assignment = Assignment.find_by(id: params[:assignment_id]) + if assignment.nil? + render json: { error: 'Assignment not found' }, status: :not_found + return + end + + # Pass control to the helper method responsible for generating review mappings + # using the specified strategy. The helper handles logic variations based on the strategy value. + result = generate_review_mappings_with_strategy(assignment, params) + + # Check the result returned by the helper method. + # If successful, return a success message with HTTP 200 OK. + if result[:success] + render json: { message: result[:message] }, status: :ok + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + # POST /api/v1/assignments/:assignment_id/automatic_review_mapping_staggered + def automatic_review_mapping_staggered + # Permit only the required params for safety + params.permit(:assignment_id, :num_reviews_per_student, :strategy) + + # Find the assignment by ID + assignment = Assignment.find_by(id: params[:assignment_id]) + if assignment.nil? + render json: { error: 'Assignment not found' }, status: :not_found + return + end + + # Delegate to helper that supports both individual and team-based strategies + result = generate_staggered_review_mappings(assignment, params) + + # Render based on success/failure + if result[:success] + render json: { message: result[:message] }, status: :ok + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + # POST /api/v1/assignments/:assignment_id/assign_reviewers_for_team + # This endpoint assigns a fixed number of reviewers to a specific team within an assignment. + # It supports both team-based and individual assignments. + def assign_reviewers_for_team + # Allow only permitted parameters from the request + params.permit(:assignment_id, :team_id, :num_reviewers) + + # Locate the assignment using the provided ID + assignment = Assignment.find_by(id: params[:assignment_id]) + if assignment.nil? + # Return 404 if the assignment does not exist + render json: { error: 'Assignment not found' }, status: :not_found + return + end + + # Locate the target team using the provided ID + team = Team.find_by(id: params[:team_id]) + if team.nil? + # Return 404 if the team is not found + render json: { error: 'Team not found' }, status: :not_found + return + end + + # Set the number of reviewers, defaulting to 3 if not specified + num_reviewers = params[:num_reviewers]&.to_i || 3 + + # Delegate logic to helper method to perform the assignment + result = assign_reviewers_for_team_logic(assignment, team, num_reviewers) + + # Return the outcome based on the helper result + if result[:success] + render json: { message: result[:message] }, status: :ok + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + # POST /api/v1/assignments/:assignment_id/peer_review_strategy + def peer_review_strategy + # Permit and extract required parameters + params.permit(:assignment_id, :num_reviews_per_student, :strategy) + + # Find the assignment by ID + assignment = Assignment.find_by(id: params[:assignment_id]) + if assignment.nil? + render json: { error: 'Assignment not found' }, status: :not_found + return + end + + # Delegate core peer review logic to helper + result = generate_peer_review_strategy(assignment, params) + + if result[:success] + render json: { message: result[:message] }, status: :ok + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + # Controller action to save or update a review grade and comment from a reviewer to a reviewee + def save_grade_and_comment_for_reviewer + # Step 1: Fetch the assignment using the assignment_id passed in params + assignment = Assignment.find_by(id: params[:assignment_id]) + + # If the assignment doesn't exist, return a 404 error + return render json: { error: "Assignment not found." }, status: :not_found unless assignment + + # Step 2: Get the currently logged-in user (the reviewer) + reviewer = current_user + + # Step 3: Extract the necessary parameters from the request body + reviewee_id = params[:reviewee_id] # ID of the participant (or team) being reviewed + answers = params[:answers] # Array of answer hashes with item_id, score, and optional comment + overall_comment = params[:overall_comment] # General comment for the review + is_submitted = params[:is_submitted] # Boolean flag indicating if review is finalized + + # Step 4: Delegate the business logic to a helper method that handles validation and saving + result = ReviewMappingsHelper.save_review_data( + reviewer: reviewer, + assignment: assignment, + reviewee_id: reviewee_id, + answers: answers, + overall_comment: overall_comment, + is_submitted: is_submitted + ) + + # Step 5: Render appropriate JSON response based on success or failure + if result[:success] + render json: { message: result[:message] }, status: :ok + else + render json: { error: result[:error] }, status: :unprocessable_entity + end + end + + + # POST /api/v1/review_mappings/:id/select_metareviewer + # This action assigns a metareviewer to a specific review mapping. + def select_metareviewer + review_mapping = ResponseMap.find_by(id: params[:id]) + return render json: { error: 'Review mapping not found' }, status: :not_found unless review_mapping + + metareviewer_id = params[:metareviewer_id] + metareviewer = Participant.find_by(id: metareviewer_id) + return render json: { error: 'Metareviewer not found' }, status: :not_found unless metareviewer + + # Check if metareview already exists + existing_map = MetareviewResponseMap.find_by(reviewed_object_id: review_mapping.id, reviewer_id: metareviewer.id) + if existing_map + return render json: { message: 'Metareviewer already assigned' }, status: :ok + end + + MetareviewResponseMap.create!( + reviewed_object_id: review_mapping.id, + reviewer_id: metareviewer.id, + reviewee_id: review_mapping.reviewer_id + ) + + render json: { message: 'Metareviewer assigned successfully' }, status: :created + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_entity + end + + # POST /api/v1/review_mappings/:id/assign_metareviewer + def add_metareviewer + review_mapping = ResponseMap.find(params[:id]) + result = ReviewMappingsHelper.add_metareviewer(review_mapping) + + if result[:success] + render json: { success: true, message: result[:message] }, status: :ok + else + render json: { success: false, message: result[:message] }, status: :unprocessable_entity + end + end + + # POST /api/v1/review_mappings/:id/assign_metareviewer_dynamically + def assign_metareviewer_dynamically + review_mapping = ResponseMap.find_by(id: params[:id]) + return render json: { error: 'Review mapping not found' }, status: :not_found unless review_mapping + + assignment = Assignment.find_by(id: review_mapping.reviewed_object_id) + return render json: { error: 'Assignment not found' }, status: :not_found unless assignment + + metareviewer = ReviewMappingsHelper.find_available_metareviewer(review_mapping, assignment.id) + return render json: { error: 'No available metareviewer found' }, status: :unprocessable_entity unless metareviewer + + MetareviewResponseMap.create!( + reviewed_object_id: review_mapping.id, + reviewer_id: metareviewer.id, + reviewee_id: review_mapping.reviewer_id + ) + + render json: { message: 'Metareviewer dynamically assigned successfully', metareviewer_id: metareviewer.id }, status: :ok + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_entity + end + + # DELETE /api/v1/review_mappings/delete_outstanding_reviewers/:assignment_id + def delete_outstanding_reviewers + assignment = Assignment.find_by(id: params[:assignment_id]) + return render json: { error: 'Assignment not found' }, status: :not_found unless assignment + + mappings = ReviewResponseMap.where(reviewed_object_id: assignment.id) + deleted_count = 0 + + mappings.each do |map| + unless Response.exists?(map_id: map.id) + map.destroy + deleted_count += 1 + end + end + + render json: { message: "#{deleted_count} outstanding reviewers deleted." }, status: :ok + end + + # app/controllers/api/v1/review_mappings_controller.rb + def delete_all_metareviewers + assignment = Assignment.find_by(id: params[:assignment_id]) + return render json: { error: 'Assignment not found' }, status: :not_found unless assignment + + deleted_count = MetareviewResponseMap.where('reviewee_id IN (?)', Participant.where(assignment_id: assignment.id).pluck(:id)).delete_all + + render json: { message: "#{deleted_count} metareviewers deleted." }, status: :ok + end + + # DELETE /api/v1/review_mappings/:id/delete_reviewer + def delete_reviewer + review_mapping = ResponseMap.find_by(id: params[:id]) + return render json: { error: 'Review mapping not found' }, status: :not_found unless review_mapping + + review_mapping.destroy + render json: { message: 'Reviewer mapping deleted successfully' }, status: :ok + end + + # DELETE /review_mappings/:id/delete_metareviewer + def delete_metareviewer + metareview_mapping = MetareviewResponseMap.find_by(id: params[:id]) + return render json: { error: 'Metareview mapping not found' }, status: :not_found unless metareview_mapping + + metareview_mapping.destroy + render json: { message: 'Metareviewer mapping deleted successfully' }, status: :ok + end + + # DELETE /review_mappings/:id/delete_metareview + def delete_metareview + metareview = MetareviewResponseMap.find_by(id: params[:id]) + return render json: { error: 'Metareview mapping not found' }, status: :not_found unless metareview + + metareview.destroy + render json: { message: 'Metareview mapping deleted successfully' }, status: :ok + end + + # DELETE /api/v1/review_mappings/:id/unsubmit_review + def unsubmit_review + review_mapping = ResponseMap.find_by(id: params[:id]) + return render json: { error: 'Review mapping not found' }, status: :not_found unless review_mapping + + response = Response.where(map_id: review_mapping.id).order(created_at: :desc).first + return render json: { error: 'Response not found' }, status: :not_found unless response + + response.update!(is_submitted: false) + render json: { message: 'Review unsubmitted successfully' }, status: :ok + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_entity + end + + + + private + + # Sets the review mapping instance variable based on the ID parameter + # Used by show, update, and destroy actions + def set_review_mapping + @review_mapping = ReviewMapping.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: "ReviewMapping not found" }, status: :not_found + end + + # Validates that a contributor_id parameter is present in the request + # Used by the select_reviewer action + def validate_contributor_id + unless params[:contributor_id].present? + render json: { error: 'Contributor ID is required' }, status: :bad_request + end + end + + # Strong parameters for review mapping creation and updates + # Ensures only permitted attributes can be mass-assigned + def review_mapping_params + params.require(:review_mapping).permit(:reviewer_id, :reviewee_id, :review_type) + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 12ffcf261..8c2f33af7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,24 @@ +# app/controllers/application_controller.rb class ApplicationController < ActionController::API - include Authorization - include JwtToken - - before_action :authorize + before_action :authenticate_request! + private + + def authenticate_request! + @current_user = authorize_request + render json: { error: 'Not Authorized' }, status: 401 unless @current_user + end + + def authorize_request + header = request.headers['Authorization'] + token = header.split(' ').last if header + decoded = JsonWebToken.decode(token) + User.find(decoded[:id]) if decoded + rescue + nil + end + + def current_user + @current_user + end end diff --git a/app/controllers/authentication_controller.rb b/app/controllers/authentication_controller.rb index 32c96a909..37dd9760e 100644 --- a/app/controllers/authentication_controller.rb +++ b/app/controllers/authentication_controller.rb @@ -1,19 +1,28 @@ -# app/controllers/api/v1/authentication_controller.rb +# app/controllers/authentication_controller.rb require 'json_web_token' -class AuthenticationController < ApplicationController - skip_before_action :authenticate_request! - # POST /login - def login - user = User.find_by(name: params[:user_name]) || User.find_by(email: params[:user_name]) - if user&.authenticate(params[:password]) - payload = { id: user.id, name: user.name, full_name: user.full_name, role: user.role.name, - institution_id: user.institution.id } - token = JsonWebToken.encode(payload, 24.hours.from_now) - render json: { token: }, status: :ok - else - render json: { error: 'Invalid username / password' }, status: :unauthorized + class AuthenticationController < ApplicationController + skip_before_action :authenticate_request! + + # POST /login + def login + user = User.find_by(name: params[:user_name]) || User.find_by(email: params[:user_name]) + + if user&.authenticate(params[:password]) + payload = { + id: user.id, + name: user.name, + full_name: user.full_name, + role: user.role.name, + institution_id: user.institution&.id + } + + token = JsonWebToken.encode(payload, 24.hours.from_now) + + render json: { token: }, status: :ok + else + render json: { error: 'Invalid username or password' }, status: :unauthorized + end + end end - end -end diff --git a/app/helpers/review_mappings_helper.rb b/app/helpers/review_mappings_helper.rb new file mode 100644 index 000000000..8b1f1a4c7 --- /dev/null +++ b/app/helpers/review_mappings_helper.rb @@ -0,0 +1,493 @@ +# app/helpers/review_mappings_helper.rb +module ReviewMappingsHelper + # Fetches review mappings for a given assignment with optional filtering by reviewer_id, reviewee_id, and type. + # + # @param assignment [Assignment] the assignment for which review mappings are requested + # @param filters [Hash] optional filters for reviewer_id, reviewee_id, and type + # @return [Array] array of formatted review mapping hashes + def fetch_review_mappings(assignment, filters = {}) + # Start by fetching all response maps for the given assignment + mappings = ResponseMap.where(reviewed_object_id: assignment.id) + + # Apply optional filters if provided + mappings = mappings.where(reviewer_id: filters[:reviewer_id]) if filters[:reviewer_id].present? + mappings = mappings.where(reviewee_id: filters[:reviewee_id]) if filters[:reviewee_id].present? + + # Filter by STI 'type' column only if the 'type' column exists in the table + mappings = mappings.where(type: filters[:type]) if filters[:type].present? && ResponseMap.column_names.include?('type') + + # Eager load associated reviewer and reviewee user records to avoid N+1 query problems + mappings = mappings.includes(:reviewer, :reviewee) unless mappings.blank? + + # Format each mapping into a JSON-compatible hash + mappings.map do |mapping| + { + id: mapping.id, + assignment_id: mapping.reviewed_object_id, + reviewer_id: mapping.reviewer_id, + reviewer_name: mapping.reviewer.try(:name), + reviewee_id: mapping.reviewee_id, + reviewee_name: mapping.reviewee.try(:name), + type: mapping.try(:type) || mapping.class.name # fallback to class name if type is nil + } + end + end + + def generate_automatic_review_mappings(assignment, options = {}) + # Extract the number of reviews each student should perform. Default is 3 if not provided. + num_reviews_per_student = options[:num_reviews_per_student]&.to_i || 3 + + # Optional parameter: number of reviewers to consider β€” currently not used in the logic. + num_of_reviewers = options[:num_of_reviewers]&.to_i + + # Strategy for assignment (e.g., 'default', 'topic-based', etc.). Currently only 'default' is implemented. + strategy = options[:strategy] || "default" + + # Get all participants in the assignment and shuffle them to ensure random reviewer-reviewee combinations. + participants = assignment.participants.to_a.shuffle + + # Check if there are at least 2 participants. Otherwise, review mappings cannot be generated. + if participants.size < 2 + return { success: false, message: "Not enough participants to assign reviews." } + end + + # Counter to keep track of how many new mappings are created + created_count = 0 + + # Loop through each participant to assign them as a reviewer + participants.each do |reviewer| + # Exclude the reviewer from the list of potential reviewees to avoid self-review + potential_reviewees = participants.reject { |p| p.id == reviewer.id } + + # Randomly select N reviewees (as per num_reviews_per_student) from the list of potential reviewees + reviewees_to_assign = potential_reviewees.sample(num_reviews_per_student) + + # For each selected reviewee, attempt to create a review mapping + reviewees_to_assign.each do |reviewee| + # Skip creation if this reviewer-reviewee mapping already exists to avoid duplicates + next if ResponseMap.exists?( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee.id + ) + + # Create a new review mapping. Here, the 'type' is explicitly specified for STI compatibility. + ResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + type: "ResponseMap" + ) + + # Increment the number of mappings created + created_count += 1 + end + end + + # Return success along with a message indicating how many mappings were created and which strategy was used + { + success: true, + message: "Successfully created #{created_count} review mappings using strategy '#{strategy}'." + } + end + + # Generates automatic review mappings for a given assignment based on the selected strategy. + # Supports multiple strategies like "default", "round_robin", etc. + def generate_review_mappings_with_strategy(assignment, options = {}) + # Extract the number of reviews per student from options, defaulting to 3 if not provided + num_reviews = options[:num_reviews_per_student]&.to_i || 3 + + # Extract the chosen strategy, defaulting to "default" + strategy = options[:strategy] || "default" + + # Retrieve and shuffle all participants to randomize assignment + participants = assignment.participants.to_a.shuffle + + # Ensure there are at least two participants for mapping + return { success: false, message: "Not enough participants." } if participants.size < 2 + + created_count = 0 + + case strategy + when "default" + # Default strategy: randomly assign `num_reviews` reviewees to each reviewer, + # ensuring no self-review and avoiding duplicate mappings + participants.each do |reviewer| + potential_reviewees = participants.reject { |p| p.id == reviewer.id } + reviewees = potential_reviewees.sample(num_reviews) + + reviewees.each do |reviewee| + # Create a review mapping only if one doesn't already exist + unless ResponseMap.exists?(reviewed_object_id: assignment.id, reviewer_id: reviewer.id, reviewee_id: reviewee.id) + ResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + type: "ResponseMap" + ) + created_count += 1 + end + end + end + + when "round_robin" + # Round-robin strategy: Assigns reviewees in a circular fashion, + # skipping self-reviews and wrapping around the list + participants.each_with_index do |reviewer, i| + reviewees = [] + offset = 1 + + # Select the next `num_reviews` participants after the reviewer in circular order + while reviewees.size < num_reviews + reviewee = participants[(i + offset) % participants.size] + reviewees << reviewee unless reviewee.id == reviewer.id + offset += 1 + end + + reviewees.each do |reviewee| + # Avoid creating duplicate mappings + unless ResponseMap.exists?(reviewed_object_id: assignment.id, reviewer_id: reviewer.id, reviewee_id: reviewee.id) + ResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + type: "ResponseMap" + ) + created_count += 1 + end + end + end + + else + # Return an error if the strategy is not recognized + return { success: false, message: "Unsupported strategy: #{strategy}" } + end + + # Return a success message with the number of mappings created + { + success: true, + message: "Created #{created_count} review mappings using strategy '#{strategy}'." + } + end + + # Generates staggered reviewer-reviewee mappings for an assignment. + # Supports both team-based and individual participant assignments. + # Returns a success message or an error if conditions aren't met. + def generate_staggered_review_mappings(assignment, options = {}) + # Extract configuration options from input or assign defaults + num_reviews = options[:num_reviews_per_student]&.to_i || 3 + strategy = options[:strategy] || "default" + + # Select reviewers based on assignment type (teams or individuals) + if assignment.has_teams + reviewers = assignment.teams.to_a.shuffle # Randomize teams as reviewers + mapping_type = "ResponseMap" # Type used in STI (can be customized) + else + reviewers = assignment.participants.to_a.shuffle # Randomize individual participants + mapping_type = "ResponseMap" + end + + # Fail fast if there are not enough reviewers to proceed + return { success: false, message: "Not enough reviewers to create mappings." } if reviewers.size < 2 + + created_count = 0 + + # Loop over each reviewer and assign them staggered reviewees + reviewers.each_with_index do |reviewer, index| + reviewees = [] + offset = 1 + + # Select num_reviews unique reviewees in staggered (offset-based) order + while reviewees.size < num_reviews + reviewee = reviewers[(index + offset) % reviewers.size] + offset += 1 + + # Skip self-review and duplicates + next if reviewer == reviewee || reviewees.include?(reviewee) + + # Prevent self-assignment based on team or individual context + if assignment.has_teams + next if reviewer.id == reviewee.id + else + next if reviewer.user_id == reviewee.user_id + end + + reviewees << reviewee + end + + # Create response mapping records if they don't already exist + reviewees.each do |reviewee| + exists = ResponseMap.exists?( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + type: mapping_type + ) + + unless exists + ResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + type: mapping_type + ) + created_count += 1 + end + end + end + + # Return a success response with mapping count + { + success: true, + message: "Successfully created #{created_count} staggered review mappings using strategy '#{strategy}'." + } + end + + + # Helper method to assign reviewers to a given team for an assignment + # Supports both team-based and individual assignments + def assign_reviewers_for_team_logic(assignment, team, num_reviewers) + # Fetch all participants in the assignment + participants = assignment.participants.to_a + + # Determine the list of eligible reviewers based on whether the assignment has teams + eligible_reviewers = if assignment.has_teams + # Select teams excluding the current one, then collect their participants + other_teams = assignment.teams.where.not(id: team.id) + other_teams.flat_map(&:participants) + else + # For individual assignments, select participants not in the target team + participants.reject { |p| p.team_id == team.id } + end.shuffle + + # Return error if not enough reviewers + if eligible_reviewers.size < num_reviewers + return { + success: false, + message: "Not enough eligible reviewers (found #{eligible_reviewers.size}, need #{num_reviewers})." + } + end + + # Randomly select reviewers + selected_reviewers = eligible_reviewers.first(num_reviewers) + + # Determine mapping type (STI) + mapping_type = "ResponseMap" # This can be extended to TeamReviewResponseMap later if needed + + # Track number of mappings created + created_count = 0 + + # Assign selected reviewers to the team + selected_reviewers.each do |reviewer| + # Prevent duplicate mappings + exists = ResponseMap.exists?( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: team.id, + type: mapping_type + ) + + next if exists + + # Create the mapping + ResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: team.id, + type: mapping_type + ) + created_count += 1 + end + + # Return success message + { + success: true, + message: "Assigned #{created_count} reviewers to team ##{team.id}." + } + end + + # Generates peer review mappings in a circular staggered fashion for both individual and team-based assignments + def generate_peer_review_strategy(assignment, options = {}) + # Retrieve the number of reviews each student/team should perform (default: 3) + num_reviews = options[:num_reviews_per_student]&.to_i || 3 + + # Optional strategy param, useful for logging or response messages + strategy = options[:strategy] || 'peer_review' + + # Choose reviewer pool and mapping type based on assignment configuration + if assignment.has_teams + # If the assignment uses teams, fetch all teams as reviewers + reviewers = assignment.teams.to_a.shuffle + + # Use STI type for team-based reviews + mapping_type = 'ResponseMap' + else + # If the assignment is individual, fetch all participants as reviewers + reviewers = assignment.participants.to_a.shuffle + + # Use base mapping type for individual review mappings + mapping_type = 'ResponseMap' + end + + # Prevent mapping generation if there are fewer than 2 reviewers + return { success: false, message: 'Not enough reviewers to generate peer review mappings.' } if reviewers.size < 2 + + # Counter to track how many review mappings were created + created_count = 0 + + # Core peer review logic: circular staggered assignment + # For each reviewer (team or participant), assign the next N peers as reviewees + reviewers.each_with_index do |reviewer, index| + assigned_reviewees = [] # Tracks reviewees assigned to this reviewer + offset = 1 # Start from the next element in the circle + + while assigned_reviewees.size < num_reviews + # Use modular arithmetic to wrap around the list circularly + reviewee = reviewers[(index + offset) % reviewers.size] + offset += 1 + + # Skip self-review scenarios: + # - For teams: ensure a team does not review itself + # - For individuals: ensure a student doesn't review their own work + if assignment.has_teams + next if reviewer.id == reviewee.id + else + next if reviewer.user_id == reviewee.user_id + end + + # Avoid assigning the same reviewee twice to the same reviewer + next if assigned_reviewees.include?(reviewee) + + # Add to current list of reviewees + assigned_reviewees << reviewee + end + + # Create review mappings for each valid reviewer-reviewee pair + assigned_reviewees.each do |reviewee| + # Check if the mapping already exists to prevent duplicates + already_exists = ResponseMap.exists?( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + type: mapping_type + ) + + # Create new mapping if it does not already exist + unless already_exists + ResponseMap.create!( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + type: mapping_type + ) + created_count += 1 + end + end + end + + # Return a success message along with the count of mappings created + { + success: true, + message: "Created #{created_count} peer review mappings using strategy '#{strategy}'." + } + end + + + def self.save_review_data(reviewer:, assignment:, reviewee_id:, answers:, overall_comment:, is_submitted:) + # Step 1: Validate reviewer + # Ensure the reviewer is actually a participant in the assignment. + reviewer_participant = Participant.find_by(user_id: reviewer.id, assignment_id: assignment.id) + return { success: false, error: "Reviewer not a participant in the assignment." } unless reviewer_participant + + # Step 2: Identify the reviewee + # Depending on whether the assignment is team-based or individual, locate the appropriate entity. + reviewee = assignment.has_teams ? Team.find_by(id: reviewee_id) : Participant.find_by(id: reviewee_id) + + # Handle missing reviewee + return { success: false, error: "Reviewee not found." } unless reviewee + + # For individual assignments, make sure the reviewee belongs to the given assignment. + if !assignment.has_teams && reviewee.assignment_id != assignment.id + return { success: false, error: "Reviewee not part of the assignment." } + end + + # Step 3: Create or find a ResponseMap linking the reviewer and reviewee + # This establishes the mapping record needed for tracking the review. + map = ResponseMap.find_or_create_by!( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee.id, + reviewed_object_id: assignment.id, + type: 'ReviewResponseMap' + ) + + # Step 4: Create or update the associated Response + # This represents the overall review entry, which includes additional comments. + response = Response.find_or_initialize_by(map_id: map.id) + response.additional_comment = overall_comment if overall_comment.present? + response.is_submitted = is_submitted == true + + # Save the response if it is new or modified + response.save! + + # Step 5: Process each answer provided by the reviewer + # This is where each individual rubric item or comment is saved. + Answer.transaction do + answers.each do |ans| + # Find the corresponding item (rubric/question) + item = Item.find_by(id: ans[:item_id]) + + # Ensure the item exists and is associated with the assignment's questionnaire + unless item && assignment.questionnaires.exists?(id: item.questionnaire_id) + raise ActiveRecord::RecordInvalid.new(Answer.new), "Invalid item #{ans[:item_id]}" + end + + # Find or create an Answer for the item within the current response + answer = Answer.find_or_initialize_by(response_id: response.id, question_id: item.id) + + # Populate score and comments if provided + answer.answer = ans[:score].to_i if ans[:score] + answer.comments = ans[:comment] if ans[:comment] + + # Validate score range if applicable + if item.questionnaire&.max_question_score && answer.answer + max = item.questionnaire.max_question_score + min = item.questionnaire.min_question_score || 0 + unless (min..max).include?(answer.answer) + return { success: false, error: "Score must be between #{min} and #{max}" } + end + end + + # Save the individual answer + answer.save! + end + end + + # If all saves were successful, return success message + { success: true, message: "Grade and comment saved successfully." } + + rescue => e + # Catch any exceptions and return the error message for debugging or display + { success: false, error: e.message } + end + + + + + + def self.find_available_metareviewer(review_mapping, assignment_id) + assignment = Assignment.find(review_mapping.reviewed_object_id) + all_participants = Participant.where(assignment_id: assignment.id) + + all_participants.find do |participant| + # Avoid self-review and already assigned metareview + participant.id != review_mapping.reviewer_id && + !MetareviewResponseMap.exists?( + reviewed_object_id: review_mapping.id, + reviewer_id: participant.id + ) + end + end + + + end + \ No newline at end of file diff --git a/app/models/answer.rb b/app/models/answer.rb index 2b55378fd..b4645d9f0 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -1,4 +1,4 @@ class Answer < ApplicationRecord belongs_to :response - belongs_to :item + # belongs_to :item, class_name: 'Item', foreign_key: 'question_id' end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 6ba38d889..9cd368511 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -1,5 +1,11 @@ class Assignment < ApplicationRecord include MetricHelper + # Maximum number of outstanding reviews allowed per reviewer + MAX_OUTSTANDING_REVIEWS = 2 + + # Set default value for num_reviews_allowed + attribute :num_reviews_allowed, :integer, default: 3 + has_many :participants, class_name: 'AssignmentParticipant', foreign_key: 'assignment_id', dependent: :destroy has_many :users, through: :participants, inverse_of: :assignment has_many :teams, class_name: 'Team', foreign_key: 'assignment_id', dependent: :destroy, inverse_of: :assignment diff --git a/app/models/calibration_mapping.rb b/app/models/calibration_mapping.rb new file mode 100644 index 000000000..0919c1fa7 --- /dev/null +++ b/app/models/calibration_mapping.rb @@ -0,0 +1,4 @@ +class CalibrationMapping < ApplicationRecord + belongs_to :assignment + belongs_to :team +end diff --git a/app/models/metareview_response_map.rb b/app/models/metareview_response_map.rb new file mode 100644 index 000000000..14011b75f --- /dev/null +++ b/app/models/metareview_response_map.rb @@ -0,0 +1,2 @@ +class MetareviewResponseMap < ResponseMap +end diff --git a/app/models/participant.rb b/app/models/participant.rb index cce037f0b..20807d7fb 100644 --- a/app/models/participant.rb +++ b/app/models/participant.rb @@ -15,4 +15,12 @@ class Participant < ApplicationRecord def fullname user.fullname end + + # Check if the participant can review in this assignment + # @return [Boolean] true if the participant can review, false otherwise + def can_review? + # For now, all participants can review + # This can be extended with more complex logic if needed + true + end end diff --git a/app/models/quiz_response_map.rb b/app/models/quiz_response_map.rb new file mode 100644 index 000000000..f9a3021a2 --- /dev/null +++ b/app/models/quiz_response_map.rb @@ -0,0 +1,39 @@ +class QuizResponseMap < ResponseMap + # Assigns a quiz to a participant for a specific assignment + # + # @param assignment_id [Integer] The ID of the assignment + # @param reviewer_id [Integer] The ID of the reviewer (user) + # @param questionnaire_id [Integer] The ID of the questionnaire + # @return [OpenStruct] An object containing: + # - success [Boolean] Whether the assignment was successful + # - quiz_response_map [QuizResponseMap] The created mapping if successful + # - error [String] Error message if any + def self.assign_quiz(assignment_id:, reviewer_id:, questionnaire_id:) + # Find the participant for this assignment + participant = AssignmentParticipant.find_by(user_id: reviewer_id, parent_id: assignment_id) + return OpenStruct.new(success: false, error: 'Participant not registered for this assignment') unless participant + + # Check if quiz already taken + if exists?(reviewer_id: participant.id, reviewed_object_id: questionnaire_id) + return OpenStruct.new(success: false, error: 'You have already taken this quiz') + end + + # Find the questionnaire + questionnaire = Questionnaire.find(questionnaire_id) + + # Create the quiz response mapping + quiz_response_map = create!( + reviewee_id: questionnaire.instructor_id, + reviewer_id: participant.id, + reviewed_object_id: questionnaire.id + ) + + OpenStruct.new(success: true, quiz_response_map: quiz_response_map) + rescue ActiveRecord::RecordNotFound => e + OpenStruct.new(success: false, error: 'Questionnaire not found') + rescue ActiveRecord::RecordInvalid => e + OpenStruct.new(success: false, error: e.message) + rescue StandardError => e + OpenStruct.new(success: false, error: e.message) + end +end \ No newline at end of file diff --git a/app/models/review_mapping.rb b/app/models/review_mapping.rb new file mode 100644 index 000000000..49103416d --- /dev/null +++ b/app/models/review_mapping.rb @@ -0,0 +1,285 @@ +class ReviewMapping < ApplicationRecord + # Associations + # A review mapping belongs to a reviewer (User) who will perform the review + belongs_to :reviewer, class_name: 'User' + # A review mapping belongs to a reviewee (User) whose work will be reviewed + belongs_to :reviewee, class_name: 'User' + # A review mapping belongs to an assignment + belongs_to :assignment + + # Validations + # Ensure all required fields are present + validates :reviewer_id, presence: true + validates :reviewee_id, presence: true + validates :assignment_id, presence: true + validates :review_type, presence: true + + # Creates a new review mapping between a reviewer and a team + # Handles the business logic for assigning reviewers to teams + # + # @param assignment_id [Integer] The ID of the assignment + # @param team_id [Integer] The ID of the team to be reviewed + # @param user_name [String] The name of the user to assign as reviewer + # @return [OpenStruct] An object containing success status and either the review mapping or error message + def self.add_reviewer(assignment_id:, team_id:, user_name:) + # Find the required records + assignment = Assignment.find(assignment_id) + team = Team.find(team_id) + user = User.find_by(name: user_name) + + # Validate user exists + unless user + return OpenStruct.new(success?: false, error: "User '#{user_name}' not found") + end + + # Validate user is a participant in the assignment + participant = Participant.find_by(user_id: user.id, assignment_id: assignment.id) + unless participant + return OpenStruct.new(success?: false, error: "User '#{user_name}' is not a participant in this assignment") + end + + # Validate user can review + unless participant.can_review? + return OpenStruct.new(success?: false, error: "User '#{user_name}' cannot review in this assignment") + end + + # Find a user from the team to be the reviewee + team_user = TeamsUser.find_by(team_id: team.id) + unless team_user + return OpenStruct.new(success?: false, error: "No users found in team #{team_id}") + end + reviewee = User.find(team_user.user_id) + + # Check if user is already assigned to review this team + if ReviewMapping.exists?(reviewer_id: user.id, reviewee_id: reviewee.id, assignment_id: assignment.id) + return OpenStruct.new(success?: false, error: "User '#{user_name}' is already assigned to review this team") + end + + # Create the review mapping + review_mapping = ReviewMapping.create!( + reviewer_id: user.id, + reviewee_id: reviewee.id, + assignment_id: assignment.id, + review_type: 'Review' + ) + + OpenStruct.new(success?: true, review_mapping: review_mapping) + end + + # Creates a calibration review mapping between a team and an assignment + # This is used by instructors to review team submissions for calibration purposes + # + # @param assignment_id [Integer] The ID of the assignment + # @param team_id [Integer] The ID of the team to be reviewed + # @param user_id [Integer] The ID of the user creating the review + # @return [OpenStruct] An object containing success status and either the review mapping or error message + def self.create_calibration_review(assignment_id:, team_id:, user_id:) + assignment = Assignment.find(assignment_id) + team = Team.find(team_id) + user = User.find(user_id) + + unless user.can_create_calibration_review?(assignment) + return OpenStruct.new(success?: false, error: 'User does not have permission to create calibration reviews') + end + + # Find a user from the team to be the reviewee + team_user = TeamsUser.find_by(team_id: team.id) + unless team_user + return OpenStruct.new(success?: false, error: "No users found in team #{team_id}") + end + reviewee = User.find(team_user.user_id) + + # Check if a calibration review already exists for this team + if ReviewMapping.exists?(assignment_id: assignment_id, reviewee_id: reviewee.id, review_type: 'Calibration') + return OpenStruct.new(success?: false, error: 'Team has already been assigned for calibration') + end + + review_mapping = ReviewMapping.new( + reviewer_id: user_id, + reviewee_id: reviewee.id, + assignment_id: assignment_id, + review_type: 'Calibration' + ) + + if review_mapping.save + OpenStruct.new(success?: true, review_mapping: review_mapping) + else + OpenStruct.new(success?: false, error: review_mapping.errors.full_messages.join(', ')) + end + end + + # Assigns a reviewer dynamically to a team or topic + # + # @param assignment_id [Integer] The ID of the assignment + # @param reviewer_id [Integer] The ID of the reviewer + # @param topic_id [Integer] The ID of the topic (optional) + # @param i_dont_care [Boolean] Whether the reviewer doesn't care about topic selection + # @return [OpenStruct] An object containing success status and either the review mapping or error message + def self.assign_reviewer_dynamically(assignment_id:, reviewer_id:, topic_id: nil, i_dont_care: false) + assignment = Assignment.find(assignment_id) + participant = Participant.find_by(user_id: reviewer_id, assignment_id: assignment.id) + reviewer = User.find(reviewer_id) + + return OpenStruct.new(success?: false, error: 'Reviewer not found') unless reviewer + + # Validate review limits + unless can_review?(assignment, reviewer) + return OpenStruct.new(success?: false, error: "You cannot do more than #{assignment.num_reviews_allowed} reviews based on assignment policy") + end + + # Check outstanding reviews + if has_outstanding_reviews?(assignment, reviewer) + return OpenStruct.new(success?: false, error: "You cannot do more reviews when you have #{Assignment::MAX_OUTSTANDING_REVIEWS} reviews to do") + end + + # Handle topic-based assignments + if assignment.topics? + return handle_topic_based_assignment(assignment, reviewer, topic_id, i_dont_care) + else + return handle_non_topic_assignment(assignment, reviewer) + end + end + + # Checks if a reviewer has exceeded the maximum number of outstanding (incomplete) reviews + # for a given assignment. + # + # @param assignment [Assignment] The assignment to check reviews for + # @param reviewer [User] The reviewer to check + # @return [OpenStruct] An object containing: + # - success [Boolean] Whether the check was successful + # - allowed [Boolean] Whether the reviewer can perform more reviews + # - error [String] Error message if any + def self.check_outstanding_reviews?(assignment, reviewer) + # Find all review response maps for this assignment and reviewer + review_mappings = ReviewResponseMap.where(reviewed_object_id: assignment.id, reviewer_id: reviewer.id) + + # Count completed reviews (where response exists) + completed_reviews = review_mappings.joins(:response).count + + # Count total assigned reviews + total_reviews = review_mappings.count + + # Calculate outstanding reviews + outstanding_reviews = total_reviews - completed_reviews + + # Get the maximum allowed outstanding reviews (default to 2 if not set) + max_outstanding = 2 + + OpenStruct.new( + success: true, + allowed: outstanding_reviews < max_outstanding, + error: outstanding_reviews >= max_outstanding ? + "You cannot do more reviews when you have #{outstanding_reviews} reviews to do" : nil + ) + rescue StandardError => e + OpenStruct.new( + success: false, + allowed: false, + error: "Error checking outstanding reviews: #{e.message}" + ) + end + + private_class_method + + # Checks if the reviewer can perform more reviews based on assignment policy + def self.can_review?(assignment, reviewer) + current_reviews = where(reviewer_id: reviewer.id, assignment_id: assignment.id).count + # If num_reviews_allowed is nil, use the default value from the Assignment model + max_reviews = assignment.num_reviews_allowed || 3 + current_reviews < max_reviews + end + + # Checks if the reviewer has outstanding reviews + def self.has_outstanding_reviews?(assignment, reviewer) + return false if assignment.nil? + return false if reviewer.nil? + + # Find all review mappings for this assignment and reviewer + review_mappings = where(reviewer_id: reviewer.id, assignment_id: assignment.id) + return false if review_mappings.empty? + + # Count completed reviews (where response exists) + completed_reviews = review_mappings.joins(:response).count + + # Count total reviews + total_reviews = review_mappings.count + + # Calculate outstanding reviews + outstanding_reviews = total_reviews - completed_reviews + + outstanding_reviews >= Assignment::MAX_OUTSTANDING_REVIEWS + end + + # Handles assignment with topics + def self.handle_topic_based_assignment(assignment, reviewer, topic_id, i_dont_care) + unless i_dont_care || topic_id || !assignment.can_choose_topic_to_review? + return OpenStruct.new(success?: false, error: 'No topic is selected. Please go back and select a topic.') + end + + topic = if topic_id + SignUpTopic.find(topic_id) + else + assignment.candidate_topics_to_review(reviewer).to_a.sample + end + + if topic.nil? + OpenStruct.new(success?: false, error: 'No topics are available to review at this time. Please try later.') + else + create_review_mapping(assignment, reviewer, topic) + end + end + + # Handles assignment without topics + def self.handle_non_topic_assignment(assignment, reviewer) + assignment_teams = assignment.candidate_assignment_teams_to_review(reviewer) + assignment_team = assignment_teams.to_a.sample + + if assignment_team.nil? + OpenStruct.new(success?: false, error: 'No artifacts are available to review at this time. Please try later.') + else + create_review_mapping_no_topic(assignment, reviewer, assignment_team) + end + end + + # Creates a review mapping for topic-based assignments + def self.create_review_mapping(assignment, reviewer, topic) + # Find the team signed up for this topic + signed_up_team = SignedUpTeam.find_by(sign_up_topic_id: topic.id) + return OpenStruct.new(success?: false, error: 'No team signed up for this topic') unless signed_up_team + + # Find a user from the team + team_user = TeamsUser.find_by(team_id: signed_up_team.team_id) + return OpenStruct.new(success?: false, error: 'No users found in team') unless team_user + + reviewee = User.find(team_user.user_id) + + review_mapping = ReviewMapping.create!( + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + assignment_id: assignment.id, + review_type: 'Review' + ) + OpenStruct.new(success?: true, review_mapping: review_mapping) + rescue StandardError => e + OpenStruct.new(success?: false, error: e.message) + end + + # Creates a review mapping for non-topic assignments + def self.create_review_mapping_no_topic(assignment, reviewer, assignment_team) + # Find a user from the team + team_user = TeamsUser.find_by(team_id: assignment_team.id) + return OpenStruct.new(success?: false, error: 'No users found in team') unless team_user + + reviewee = User.find(team_user.user_id) + + review_mapping = ReviewMapping.create!( + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + assignment_id: assignment.id, + review_type: 'Review' + ) + OpenStruct.new(success?: true, review_mapping: review_mapping) + rescue StandardError => e + OpenStruct.new(success?: false, error: e.message) + end +end \ No newline at end of file diff --git a/app/models/review_response_map.rb b/app/models/review_response_map.rb index c4f852657..f161bab1d 100644 --- a/app/models/review_response_map.rb +++ b/app/models/review_response_map.rb @@ -7,4 +7,50 @@ class ReviewResponseMap < ResponseMap def response_assignment return assignment end + + # Checks if a reviewer can perform more reviews for an assignment + # @param assignment_id [Integer] The ID of the assignment to check + # @param reviewer_id [Integer] The ID of the reviewer to check + # @return [OpenStruct] Contains success status, allowed boolean, and error message if any + def self.review_allowed?(assignment_id, reviewer_id) + # Validate parameters + if assignment_id.blank? || reviewer_id.blank? + return OpenStruct.new( + success: false, + error: 'Assignment ID and Reviewer ID are required' + ) + end + + # Find the assignment and reviewer + assignment = Assignment.find_by(id: assignment_id) + reviewer = User.find_by(id: reviewer_id) + + # Check if assignment and reviewer exist + unless assignment && reviewer + return OpenStruct.new( + success: false, + error: 'Assignment or Reviewer not found' + ) + end + + # Get the number of reviews already assigned to this reviewer + current_reviews_count = where( + reviewer_id: reviewer.id, + reviewed_object_id: assignment.id + ).count + + # Check if the reviewer has not exceeded the maximum allowed reviews + allowed = current_reviews_count < assignment.num_reviews_allowed + + # Return structured response + OpenStruct.new( + success: true, + allowed: allowed + ) + rescue StandardError => e + OpenStruct.new( + success: false, + error: e.message + ) + end end diff --git a/app/models/role.rb b/app/models/role.rb index 4941e300d..58fbf3592 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -3,36 +3,26 @@ class Role < ApplicationRecord belongs_to :parent, class_name: 'Role', optional: true has_many :users, dependent: :nullify - if Role.table_exists? - STUDENT = find_by_name('Student') - INSTRUCTOR = find_by_name('Instructor') - ADMINISTRATOR = find_by_name('Administrator') - TEACHING_ASSISTANT = find_by_name('Teaching Assistant') - SUPER_ADMINISTRATOR = find_by_name('Super Administrator') - end - - def super_administrator? - name['Super Administrator'] + name == 'Super Administrator' end def administrator? - name['Administrator'] || super_administrator? + name == 'Administrator' || super_administrator? end def instructor? - name['Instructor'] + name == 'Instructor' end def ta? - name['Teaching Assistant'] + name == 'Teaching Assistant' end def student? - name['Student'] + name == 'Student' end - # returns an array of ids of all roles that are below the current role def subordinate_roles role = Role.find_by(parent_id: id) return [] unless role @@ -40,13 +30,13 @@ def subordinate_roles [role] + role.subordinate_roles end - # returns an array of ids of all roles that are below the current role and includes the current role def subordinate_roles_and_self [self] + subordinate_roles end - # checks if the current role has all the privileges of the target role def all_privileges_of?(target_role) + return false if target_role.nil? + privileges = { 'Student' => 1, 'Teaching Assistant' => 2, @@ -55,16 +45,20 @@ def all_privileges_of?(target_role) 'Super Administrator' => 5 } - privileges[name] >= privileges[target_role.name] + current_level = privileges[name] + target_level = privileges[target_role.name] + + return false if current_level.nil? || target_level.nil? + + current_level >= target_level end - # return list of all roles other than the current role def other_roles - Role.where.not(id:) + Role.where.not(id: id) end def as_json(options = nil) - options = options || {} # Ensure options is a hash + options ||= {} super(options.merge({ only: %i[id name parent_id] })) end end diff --git a/app/models/self_review_response_map.rb b/app/models/self_review_response_map.rb new file mode 100644 index 000000000..e2e1470f8 --- /dev/null +++ b/app/models/self_review_response_map.rb @@ -0,0 +1,102 @@ +class SelfReviewResponseMap < ResponseMap + belongs_to :reviewee, class_name: 'Participant', foreign_key: 'reviewee_id' + belongs_to :assignment, class_name: 'Assignment', foreign_key: 'reviewed_object_id' + belongs_to :reviewer, class_name: 'Participant', foreign_key: 'reviewer_id' + + # Find a review questionnaire associated with this self-review response map's assignment + def questionnaire(round_number = nil, topic_id = nil) + Questionnaire.find(assignment.review_questionnaire_id(round_number, topic_id)) + end + + # This method helps to find contributor - here Team ID + def contributor + Team.find_by(id: reviewee.team_id) + end + + # This method returns 'Title' of type of review (used to manipulate headings accordingly) + def get_title + 'Self Review' + end + + # do not send any reminder for self review received. + def email(defn, participant, assignment); end + + # Creates a self review mapping if one doesn't already exist + # @param assignment_id [Integer] The ID of the assignment + # @param reviewer_id [Integer] The ID of the reviewer (participant) + # @param reviewer_userid [Integer] The user ID of the reviewer + # @return [OpenStruct] An object containing: + # - success [Boolean] Whether the self-review was created successfully + # - self_review_map [SelfReviewResponseMap] The created mapping if successful + # - error [String] Error message if any + def self.create_self_review(assignment_id:, reviewer_id:, reviewer_userid:) + Rails.logger.debug "Creating self-review with assignment_id: #{assignment_id}, reviewer_id: #{reviewer_id}, reviewer_userid: #{reviewer_userid}" + + # Find the assignment + assignment = Assignment.find_by(id: assignment_id) + unless assignment + Rails.logger.error "Assignment not found with ID: #{assignment_id}" + return OpenStruct.new(success: false, error: 'Assignment not found') + end + Rails.logger.debug "Found assignment: #{assignment.inspect}" + + # Find the reviewer participant + reviewer = Participant.find_by(id: reviewer_id, user_id: reviewer_userid, assignment_id: assignment_id) + unless reviewer + Rails.logger.error "Reviewer participant not found with ID: #{reviewer_id}, user_id: #{reviewer_userid}, assignment_id: #{assignment_id}" + return OpenStruct.new(success: false, error: 'Reviewer participant not found') + end + Rails.logger.debug "Found reviewer: #{reviewer.inspect}" + + # Find the team through the participant + team = reviewer.team + unless team + Rails.logger.error "No team found for reviewer: #{reviewer.inspect}" + return OpenStruct.new(success: false, error: 'No team found for this participant') + end + Rails.logger.debug "Found team: #{team.inspect}" + + # Find all participants in the team + team_participants = Participant.where(team_id: team.id, assignment_id: assignment_id) + if team_participants.empty? + Rails.logger.error "No participants found in team: #{team.inspect}" + return OpenStruct.new(success: false, error: 'No participants found in team') + end + Rails.logger.debug "Found team participants: #{team_participants.inspect}" + + # Use the first team participant as the reviewee + reviewee = team_participants.first + Rails.logger.debug "Using reviewee: #{reviewee.inspect}" + + # Check if self-review already exists + if exists?(reviewee_id: reviewee.id, reviewer_id: reviewer_id) + Rails.logger.error "Self review already exists for reviewee: #{reviewee.id}, reviewer: #{reviewer_id}" + return OpenStruct.new(success: false, error: 'Self review already assigned') + end + + # Create the self-review mapping + begin + Rails.logger.debug "Creating self-review mapping with: reviewee_id: #{reviewee.id}, reviewer_id: #{reviewer_id}, reviewed_object_id: #{assignment_id}" + + # Try using a different approach to create the record + self_review_map = new( + reviewee_id: reviewee.id, + reviewer_id: reviewer_id, + reviewed_object_id: assignment_id, + type: 'SelfReviewResponseMap' + ) + + if self_review_map.save + Rails.logger.debug "Created self-review mapping: #{self_review_map.inspect}" + OpenStruct.new(success: true, self_review_map: self_review_map) + else + Rails.logger.error "Failed to save self-review mapping: #{self_review_map.errors.full_messages.join(', ')}" + OpenStruct.new(success: false, error: "Validation error: #{self_review_map.errors.full_messages.join(', ')}") + end + rescue => e + Rails.logger.error "Unexpected error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + OpenStruct.new(success: false, error: "Unexpected error: #{e.message}") + end + end +end \ No newline at end of file diff --git a/app/models/sign_up_sheet.rb b/app/models/sign_up_sheet.rb new file mode 100644 index 000000000..a4673dff2 --- /dev/null +++ b/app/models/sign_up_sheet.rb @@ -0,0 +1,8 @@ +# app/models/sign_up_sheet.rb +class SignUpSheet + def self.signup_team(assignment_id, user_id, topic_id) + # TEMP MOCK: You can log or skip logic for now + Rails.logger.info "⚠️ Called SignUpSheet.signup_team(#{assignment_id}, #{user_id}, #{topic_id})" + end + end + \ No newline at end of file diff --git a/app/models/sign_up_topic.rb b/app/models/sign_up_topic.rb index 65ed670e9..3d883277e 100644 --- a/app/models/sign_up_topic.rb +++ b/app/models/sign_up_topic.rb @@ -1,7 +1,7 @@ class SignUpTopic < ApplicationRecord - has_many :signed_up_teams, foreign_key: 'topic_id', dependent: :destroy - has_many :teams, through: :signed_up_teams # list all teams choose this topic, no matter in waitlist or not + has_many :signed_up_teams, foreign_key: 'sign_up_topic_id', dependent: :destroy + has_many :teams, through: :signed_up_teams has_many :assignment_questionnaires, class_name: 'AssignmentQuestionnaire', foreign_key: 'topic_id', dependent: :destroy - has_many :due_dates, as: :parent,class_name: 'DueDate', dependent: :destroy, as: :parent + has_many :due_dates, as: :parent, class_name: 'DueDate', dependent: :destroy belongs_to :assignment end diff --git a/app/models/team.rb b/app/models/team.rb index afb8ac66f..dfad402f5 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -6,10 +6,18 @@ class Team < ApplicationRecord belongs_to :assignment attr_accessor :max_participants + # Find all teams that a user belongs to for a specific assignment + # @param assignment_id [Integer] The ID of the assignment + # @param user_id [Integer] The ID of the user + # @return [Array] Array of teams the user belongs to in the assignment + def self.find_team_for_assignment_and_user(assignment_id, user_id) + joins(:teams_users) + .where(assignment_id: assignment_id, teams_users: { user_id: user_id }) + end + # TODO Team implementing Teams controller and model should implement this method better. # TODO partial implementation here just for the functionality needed for join_team_tequests controller def full? - max_participants ||= 3 if participants.count >= max_participants true @@ -17,4 +25,10 @@ def full? false end end + + # Returns the name of the team, which is a combination of the team ID and the names of its members + def name + member_names = users.map(&:name).join(', ') + "Team #{id} (#{member_names})" + end end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index ebcdf9ed0..7c60a5730 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -114,4 +114,10 @@ def set_defaults self.etc_icons_on_homepage ||= true end + # Check if user can create calibration reviews + def can_create_calibration_review?(assignment) + # Only instructors and TAs can create calibration reviews + role.name == 'Instructor' || role.name == 'Teaching Assistant' + end + end diff --git a/config/application.rb b/config/application.rb index 4bd4ca23e..70c67e0c0 100644 --- a/config/application.rb +++ b/config/application.rb @@ -30,5 +30,8 @@ class Application < Rails::Application # Skip views, helpers and assets when generating a new resource. config.api_only = true config.cache_store = :redis_store, ENV['CACHE_STORE'], { expires_in: 3.days, raise_errors: false } + + # Enable session middleware for API-only application + config.middleware.use ActionDispatch::Session::CookieStore, key: '_reimplementation_session' end end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index a3c0a6327..557dec50a 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -RRAzGrMfVQyDFeSpVSZp7G9RJNa9DijjGV7WeSxfQt4QFoEFpghyblXMYzSw8pAYt01PaI8ErJvE2na9m/rBLA1YpP+jmr/VAZDjUbdDORUIfQvF51i0Q3gO1U0CMB88cAbC4dEybWkSnn9h6WHeo3JyV4QbgtFVxpfFhmMWJLyVmKVTis3jrlxbr1upkec3IsisQGDmZWxfvJbOlKnDZvmRtB6VAZwjN0zmGC8SnQhA4LWhl5kj7/PSTDtBXGpZUtd7K1qpZ8G5kzyHp+Xc5VMfBFhf+ottPJNfc2dwXzM8l2lIFIyXyKswJ0HagSjc8OtVUokYdkwoEwBCAh6BngngxfxeUPmUn8RkYZ4MABCXTlhY5/DJLJ9JymNtvEJlmVTdk8uHZr8rXJNyCodVFZ4S8M6zvxum7LG1--h7kTgG2d1frGqHt+--a6U2muVgvPAIoc854h7rlQ== \ No newline at end of file +psNK4fs7Lu37SfnUF/6SzPujHUwTCSve4PFGME3eJ3BI7ae2+Q4dm6jrc1rzOl2N5o6j8VwWx1GdPOrDP1BlOQOara0I2C3tVxLWL3TmqNbXPti3k7RaLC0MtWHMxGdLnhpoYCACdPPOK3bCHXRxOJ8C02SEcuK+8FN0GUAb6Lbxn9TkFveJo8mRdM2pPW2WPkjSjIKDMrrU4mXJT01LU/Qw4JUjI2A6xdry5xcwWXqQalrRmrp2amS74xcArZV9DcbG8w3wVtii3P7zVhOE4tgsWUZbfsDWP8/45eNdi4P7IYEofyUaLGzDzWCdVIgCq6AYyYrXrai0B/zTjrzE7Ers8S7MYRAW8Swi0EZaq+fWmrgrGf4tx9LfLQGJD45vj7Z4QarsNxzOO97sozqewAgvhhwHaUG/RWTQErVTWYWrrRrY0uizKqep+8Gv6xMbfqrz42Him/qAckwgzMlv6yMoGUSKWN0OhU8c/hQ/yhuwX7e/9RfNw1Tv--45rgRe2vloBbgdz4--Z9J6mZfWBjkSEI8dIILJwg== \ No newline at end of file diff --git a/config/credentials.yml.enc.bak b/config/credentials.yml.enc.bak new file mode 100644 index 000000000..a3c0a6327 --- /dev/null +++ b/config/credentials.yml.enc.bak @@ -0,0 +1 @@ +RRAzGrMfVQyDFeSpVSZp7G9RJNa9DijjGV7WeSxfQt4QFoEFpghyblXMYzSw8pAYt01PaI8ErJvE2na9m/rBLA1YpP+jmr/VAZDjUbdDORUIfQvF51i0Q3gO1U0CMB88cAbC4dEybWkSnn9h6WHeo3JyV4QbgtFVxpfFhmMWJLyVmKVTis3jrlxbr1upkec3IsisQGDmZWxfvJbOlKnDZvmRtB6VAZwjN0zmGC8SnQhA4LWhl5kj7/PSTDtBXGpZUtd7K1qpZ8G5kzyHp+Xc5VMfBFhf+ottPJNfc2dwXzM8l2lIFIyXyKswJ0HagSjc8OtVUokYdkwoEwBCAh6BngngxfxeUPmUn8RkYZ4MABCXTlhY5/DJLJ9JymNtvEJlmVTdk8uHZr8rXJNyCodVFZ4S8M6zvxum7LG1--h7kTgG2d1frGqHt+--a6U2muVgvPAIoc854h7rlQ== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index b9f5aa055..af2aa5584 100644 --- a/config/database.yml +++ b/config/database.yml @@ -2,17 +2,21 @@ default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + username: root + password: password # or your actual password + host: 127.0.0.1 port: 3306 - socket: /var/run/mysqld/mysqld.sock development: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_development?') %> + database: reimplementation_development test: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> + database: reimplementation_test production: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> \ No newline at end of file + database: reimplementation_production + username: <%= ENV['DB_USERNAME'] %> + password: <%= ENV['DB_PASSWORD'] %> diff --git a/config/master.key.bak b/config/master.key.bak new file mode 100644 index 000000000..c92841370 --- /dev/null +++ b/config/master.key.bak @@ -0,0 +1 @@ +01234567890123456789012345678901 \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 33df803e7..a8c0c33fa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,4 @@ 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 @@ -25,22 +24,39 @@ 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/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/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 + post '/:assignment_id/create_node', action: :create_node + # Route to trigger strategy-based review mapping for an assignment + post '/:assignment_id/automatic_review_mapping_strategy', + to: 'review_mappings#automatic_review_mapping_strategy' + # Defines a POST route for triggering staggered automatic review mappings + post '/:assignment_id/automatic_review_mapping_staggered', + to: 'review_mappings#automatic_review_mapping_staggered' + # Route to assign reviewers for a specific team within an assignment + post '/:assignment_id/assign_reviewers_for_team', to: 'review_mappings#assign_reviewers_for_team' + # Route to trigger peer review mapping logic + post '/:assignment_id/peer_review_strategy', to: 'review_mappings#peer_review_strategy' end end - resources :bookmarks, except: [:new, :edit] do + # Route for triggering automatic review mapping for a given assignment. + # Accepts POST requests with assignment_id in the path and options in the JSON body + post 'assignments/:assignment_id/automatic_review_mapping', to: 'review_mappings#automatic_review_mapping' + post 'review_mappings/save_grade_and_comment', to: 'review_mappings#save_grade_and_comment_for_reviewer' + + post 'review_mappings/:id/select_metareviewer', to: 'review_mappings#select_metareviewer' + + resources :bookmarks, except: %i[new edit] do member do get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' @@ -53,6 +69,43 @@ end end + # route added for review_mapping + resources :review_mappings do + collection do + post :add_calibration + get :select_reviewer + post :add_reviewer + post :assign_reviewer_dynamically + get :review_allowed + get :check_outstanding_reviews + post :assign_quiz_dynamically + post :start_self_review + # Additional routes for review mapping operations + get 'valid_reviewers', action: :valid_reviewers + get 'review_mappings_count', action: :review_mappings_count + get 'reviewer/:reviewer_id', action: :reviewer_mappings + get 'reviewee/:reviewee_id', action: :reviewee_mappings + post 'assign_meta_reviewer', action: :assign_meta_reviewer + delete 'remove_reviewer/:reviewer_id', action: :remove_reviewer + get 'review_mapping_types', action: :review_mapping_types + get 'review_mapping_strategy', action: :review_mapping_strategy + post 'update_review_mapping_strategy', action: :update_review_mapping_strategy + end + end + resources :review_mappings, only: %i[index show create update destroy] + # route added for review_mapping + resources :review_mappings, only: %i[index show create update destroy] + get 'assignments/:assignment_id/review_mappings', to: 'review_mappings#list_mappings' + post 'review_mappings/:id/add_metareviewer', to: 'review_mappings#add_metareviewer' + post 'review_mappings/:id/assign_metareviewer_dynamically', to: 'review_mappings#assign_metareviewer_dynamically' + delete '/review_mappings/delete_outstanding_reviewers/:assignment_id', + to: 'review_mappings#delete_outstanding_reviewers' + delete '/review_mappings/delete_all_metareviewers/:assignment_id', to: 'review_mappings#delete_all_metareviewers' + delete '/review_mappings/:id/delete_reviewer', to: 'review_mappings#delete_reviewer' + delete 'review_mappings/:id/delete_metareviewer', to: 'review_mappings#delete_metareviewer' + delete '/review_mappings/:id/delete_metareview', to: 'review_mappings#delete_metareview' + delete '/review_mappings/:id/unsubmit_review', to: 'review_mappings#unsubmit_review' + resources :courses do collection do get ':id/add_ta/:ta_id', action: :add_ta @@ -72,8 +125,8 @@ 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' + 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 @@ -86,12 +139,10 @@ resources :join_team_requests do collection do - post 'decline/:id', to:'join_team_requests#decline' + post 'decline/:id', to: 'join_team_requests#decline' end end - - resources :sign_up_topics do collection do get :filter @@ -122,4 +173,4 @@ end end end -end \ No newline at end of file +end diff --git a/db/migrate/20240416000000_add_parent_id_to_participants.rb b/db/migrate/20240416000000_add_parent_id_to_participants.rb new file mode 100644 index 000000000..3d0d7d7f0 --- /dev/null +++ b/db/migrate/20240416000000_add_parent_id_to_participants.rb @@ -0,0 +1,6 @@ +class AddParentIdToParticipants < ActiveRecord::Migration[7.0] + def change + add_column :participants, :parent_id, :integer + add_index :participants, :parent_id + end +end \ No newline at end of file diff --git a/db/migrate/20250420041508_add_sign_up_topic_id_to_assignment_questionnaires.rb b/db/migrate/20250420041508_add_sign_up_topic_id_to_assignment_questionnaires.rb new file mode 100644 index 000000000..a0a9e049b --- /dev/null +++ b/db/migrate/20250420041508_add_sign_up_topic_id_to_assignment_questionnaires.rb @@ -0,0 +1,5 @@ +class AddSignUpTopicIdToAssignmentQuestionnaires < ActiveRecord::Migration[8.0] + def change + add_column :assignment_questionnaires, :sign_up_topic_id, :integer + end +end diff --git a/db/migrate/20250420183550_update_review_mappings_reviewee_foreign_key.rb b/db/migrate/20250420183550_update_review_mappings_reviewee_foreign_key.rb new file mode 100644 index 000000000..66e950500 --- /dev/null +++ b/db/migrate/20250420183550_update_review_mappings_reviewee_foreign_key.rb @@ -0,0 +1,4 @@ +class UpdateReviewMappingsRevieweeForeignKey < ActiveRecord::Migration[8.0] + def change + end +end diff --git a/db/schema.rb b/db/schema.rb index 7db16863e..ba867398a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_02_16_020117) do +ActiveRecord::Schema[8.0].define(version: 2025_04_20_183550) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -43,6 +43,8 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "used_in_round" + t.integer "sign_up_topic_id" + t.integer "topic_id" t.index ["assignment_id"], name: "fk_aq_assignments_id" t.index ["questionnaire_id"], name: "fk_aq_questionnaire_id" end @@ -124,6 +126,15 @@ t.datetime "updated_at", null: false end + create_table "calibration_mappings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "assignment_id", null: false + t.bigint "team_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "index_calibration_mappings_on_assignment_id" + t.index ["team_id"], name: "index_calibration_mappings_on_team_id" + end + create_table "courses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.string "directory_path" @@ -231,8 +242,10 @@ t.boolean "can_take_quiz" t.boolean "can_mentor" t.string "authorization" + t.integer "parent_id" t.index ["assignment_id"], name: "index_participants_on_assignment_id" t.index ["join_team_request_id"], name: "index_participants_on_join_team_request_id" + t.index ["parent_id"], name: "index_participants_on_parent_id" t.index ["team_id"], name: "index_participants_on_team_id" t.index ["user_id"], name: "fk_participant_users" t.index ["user_id"], name: "index_participants_on_user_id" @@ -286,6 +299,7 @@ t.integer "reviewee_id", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "type" t.index ["reviewer_id"], name: "fk_response_map_reviewer" end @@ -298,6 +312,19 @@ t.index ["map_id"], name: "fk_response_response_map" end + create_table "review_mappings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "reviewer_id" + t.bigint "reviewee_id" + t.bigint "assignment_id" + t.string "review_type" + t.string "status" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "index_review_mappings_on_assignment_id" + t.index ["reviewee_id"], name: "index_review_mappings_on_reviewee_id" + t.index ["reviewer_id"], name: "index_review_mappings_on_reviewer_id" + end + create_table "roles", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.bigint "parent_id" @@ -391,6 +418,8 @@ add_foreign_key "account_requests", "roles" add_foreign_key "assignments", "courses" add_foreign_key "assignments", "users", column: "instructor_id" + add_foreign_key "calibration_mappings", "assignments" + add_foreign_key "calibration_mappings", "teams" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" add_foreign_key "items", "questionnaires" @@ -399,6 +428,9 @@ add_foreign_key "participants", "teams" add_foreign_key "participants", "users" add_foreign_key "question_advices", "items", column: "question_id" + add_foreign_key "review_mappings", "assignments" + add_foreign_key "review_mappings", "users", column: "reviewee_id" + add_foreign_key "review_mappings", "users", column: "reviewer_id" add_foreign_key "roles", "roles", column: "parent_id", on_delete: :cascade add_foreign_key "sign_up_topics", "assignments" add_foreign_key "signed_up_teams", "sign_up_topics" diff --git a/db/seeds.rb b/db/seeds.rb index b6de376f2..423831aad 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,127 +1,560 @@ -begin - #Create an instritution - inst_id = Institution.create!( - name: 'North Carolina State University', - ).id - - # 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 +require 'faker' - 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 "🌱 Seeding database..." - 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 +ActiveRecord::Base.transaction do + # Destroy dependent data first to avoid foreign key constraint errors + CalibrationMapping.delete_all + ReviewResponseMap.delete_all + ResponseMap.delete_all + ReviewMapping.delete_all + SignedUpTeam.delete_all + SignUpTopic.delete_all + Participant.delete_all + TeamsUser.delete_all + Team.delete_all + Assignment.delete_all + Course.delete_all + Questionnaire.delete_all + User.delete_all + Role.delete_all + Institution.delete_all +end +puts"βœ… Cleanup complete" - puts "creating teams" - team_ids = [] - num_teams.times do |i| - team_ids << Team.create( - assignment_id: assignment_ids[i%num_assignments] - ).id - end +# Institution +institution = Institution.find_or_create_by!(name: "North Carolina State University") +puts "βœ… Created Institution" - 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, - ).id - end +# Roles +roles = ["Student", "Teaching Assistant", "Instructor", "Administrator", "Super Administrator"] +role_ids = {} - 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 +roles.each do |role_name| + role = Role.find_or_create_by!(name: role_name) + role_ids[role_name] = role.id +end +puts "βœ… Created Roles: #{role_ids.inspect}" + +# Verify Super Administrator exists +super_admin_id = role_ids["Super Administrator"] +raise "❌ Super Administrator role not found!" if super_admin_id.nil? + +# Admin User +admin = User.find_or_create_by!(email: "admin@example.com") do |user| + user.name = "admin" + user.password = "password123" + user.full_name = "Admin User" + user.institution_id = institution.id + user.role_id = super_admin_id +end +puts "βœ… Created Admin User" + +# Instructors +instructors = 2.times.map do + loop do + name = Faker::Internet.unique.username + break User.find_or_create_by!(name: name) do |user| + user.email = Faker::Internet.unique.email + user.password = "password" + user.full_name = Faker::Name.name + user.institution_id = institution.id + user.role_id = role_ids["Instructor"] end + end +end +puts "βœ… Created Instructors" - puts "assigning participant to students, teams, courses, and assignments" - participant_ids = [] - num_students.times do |i| - participant_ids << Participant.create( - user_id: student_user_ids[i], - assignment_id: assignment_ids[i%num_assignments], - team_id: team_ids[i%num_teams], - ).id +# Students +students = 48.times.map do + loop do + name = Faker::Internet.unique.username + break User.find_or_create_by!(name: name) do |user| + user.email = Faker::Internet.unique.email + user.password = "password" + user.full_name = Faker::Name.name + user.institution_id = institution.id + user.role_id = role_ids["Student"] end + end +end +puts "βœ… Created Students" +# Courses +courses = instructors.map do |instructor| + Course.create!( + name: Faker::Educator.course_name, + directory_path: Faker::File.dir(segment_count: 2), + info: "Sample Course Info", + instructor_id: instructor.id, + institution_id: institution.id + ) +end +puts "βœ… Created Courses" +# Assignments +assignments = courses.map do |course| + Assignment.create!( + name: Faker::Educator.subject, + instructor_id: course.instructor_id, + course_id: course.id, + has_teams: true, + has_topics: true, + private: false + ) +end +puts "βœ… Created Assignments" +# Teams +teams = assignments.flat_map do |assignment| + 8.times.map do + Team.create!( + assignment_id: assignment.id + ) + end +end +puts "βœ… Created Teams" +# TeamsUsers +teams.each_with_index do |team, idx| + student1 = students[(2 * idx) % students.length] + student2 = students[(2 * idx + 1) % students.length] + TeamsUser.create!(team_id: team.id, user_id: student1.id) + TeamsUser.create!(team_id: team.id, user_id: student2.id) +end +puts "βœ… Assigned Students to Teams" +# Participants +students.each_with_index do |student, idx| + Participant.create!( + user_id: student.id, + assignment_id: assignments[idx % assignments.length].id, + team_id: teams[idx % teams.length].id + ) +end +puts "βœ… Created Participants" -rescue ActiveRecord::RecordInvalid => e - puts 'The db has already been seeded' +# SignUpTopics +signup_topics = assignments.flat_map do |assignment| + 5.times.map do |i| + SignUpTopic.create!( + topic_name: Faker::Company.catch_phrase, + assignment_id: assignment.id, + max_choosers: 2, + category: "Default", + topic_identifier: Faker::Alphanumeric.alpha(number: 8).upcase, + description: Faker::Lorem.sentence + ) + end +end +puts "βœ… Created SignUpTopics" + +# SignedUpTeams +teams.each_with_index do |team, idx| + SignedUpTeam.create!( + sign_up_topic_id: signup_topics[idx % signup_topics.length].id, + team_id: team.id, + is_waitlisted: false, + preference_priority_number: 1 + ) end +puts "βœ… Created SignedUpTeams" + +puts "βœ… Creating Review Mappings..." + +assignments.each do |assignment| + participants = assignment.participants.to_a + next if participants.count < 2 + + reviewers = participants.sample(3) + reviewees = participants.sample(3) + + reviewers.zip(reviewees).each do |reviewer, reviewee| + next if reviewer.id == reviewee.id + ResponseMap.find_or_create_by!( + reviewed_object_id: assignment.id, + reviewer_id: reviewer.id, + reviewee_id: reviewee.id, + type: "ResponseMap" + ) + end +end + +puts "βœ… Created Review Mappings" + +# Add a clean assignment for testing automatic_review_mapping_strategy +test_assignment = Assignment.create!( + name: "GSoC Strategy Test", + instructor_id: instructors.first.id, + course_id: courses.first.id, + has_teams: false, + has_topics: false, + private: false +) + +# Add 10 participants to this assignment +10.times do |i| + Participant.create!( + user_id: students[i].id, + assignment_id: test_assignment.id, + team_id: nil + ) +end + +puts "βœ… Created test assignment with 10 participants for strategy testing" + +staggered_assignment = Assignment.create!( + name: "Staggered Mapping Test", + instructor_id: instructors.last.id, + course_id: courses.last.id, + has_teams: true, + has_topics: false, + private: false +) + +# Create 6 teams and assign each 2 students +6.times do |t| + team = Team.create!(assignment_id: staggered_assignment.id) + user1 = students.sample + user2 = students.reject { |u| u == user1 }.sample + + TeamsUser.create!(team_id: team.id, user_id: user1.id) + TeamsUser.create!(team_id: team.id, user_id: user2.id) + + Participant.create!(user_id: user1.id, assignment_id: staggered_assignment.id, team_id: team.id) + Participant.create!(user_id: user2.id, assignment_id: staggered_assignment.id, team_id: team.id) +end + +puts "βœ… Created staggered mapping assignment with 6 teams" + +# Assignment for testing assign_reviewers_for_team +assign_team_reviewers_assignment = Assignment.create!( + name: "Team Reviewer Assignment Test", + instructor_id: instructors.first.id, + course_id: courses.first.id, + has_teams: true, + has_topics: false, + private: false +) + +# Create 3 reviewee teams with 2 members each +reviewee_teams = 3.times.map do + team = Team.create!(assignment_id: assign_team_reviewers_assignment.id) + user1, user2 = students.sample(2) + + TeamsUser.create!(team_id: team.id, user_id: user1.id) + TeamsUser.create!(team_id: team.id, user_id: user2.id) + + Participant.create!(user_id: user1.id, assignment_id: assign_team_reviewers_assignment.id, team_id: team.id) + Participant.create!(user_id: user2.id, assignment_id: assign_team_reviewers_assignment.id, team_id: team.id) + + team +end + +# Create 5 standalone students as potential reviewers +5.times do + reviewer = students.reject { |s| Participant.exists?(user_id: s.id, assignment_id: assign_team_reviewers_assignment.id) }.sample + + Participant.create!( + user_id: reviewer.id, + assignment_id: assign_team_reviewers_assignment.id, + team_id: nil # Not part of a team + ) +end + +puts "βœ… Created assignment for assign_reviewers_for_team with 3 teams and 5 reviewers" + +# Assignment for testing peer_review_strategy +peer_review_assignment = Assignment.create!( + name: "Peer Review Strategy Test", + instructor_id: instructors.first.id, + course_id: courses.first.id, + has_teams: false, # Set to true if you want to test team-based mapping + has_topics: false, + private: false +) + +# Add 8 participants (individuals) for the peer review strategy test +8.times do |i| + Participant.create!( + user_id: students[i + 20].id, + assignment_id: peer_review_assignment.id, + team_id: nil # No team assignment needed for individual + ) +end + +puts "βœ… Created peer_review_strategy test assignment with 8 participants" + +# Create specific test users for review mappings +puts "πŸ“ Creating specific test users..." +test_users = { + instructor: User.find_or_create_by!(email: "instructor@example.com") do |user| + user.name = "test_instructor" + user.password = "password" + user.full_name = "Test Instructor" + user.institution_id = institution.id + user.role_id = role_ids["Instructor"] + end, + ta: User.find_or_create_by!(email: "ta@example.com") do |user| + user.name = "test_ta" + user.password = "password" + user.full_name = "Test TA" + user.institution_id = institution.id + user.role_id = role_ids["Teaching Assistant"] + end, + student1: User.find_or_create_by!(email: "student1@example.com") do |user| + user.name = "student1" + user.password = "password" + user.full_name = "Test Student 1" + user.institution_id = institution.id + user.role_id = role_ids["Student"] + end, + student2: User.find_or_create_by!(email: "student2@example.com") do |user| + user.name = "student2" + user.password = "password" + user.full_name = "Test Student 2" + user.institution_id = institution.id + user.role_id = role_ids["Student"] + end, + student3: User.find_or_create_by!(email: "student3@example.com") do |user| + user.name = "student3" + user.password = "password" + user.full_name = "Test Student 3" + user.institution_id = institution.id + user.role_id = role_ids["Student"] + end +} +puts "βœ… Created specific test users" + +# 1. Test data for add_reviewer +add_reviewer_assignment = Assignment.create!( + name: "Add Reviewer Test", + instructor_id: test_users[:instructor].id, + course_id: courses.first.id, + has_teams: true, + has_topics: true, + private: false +) + +# Create teams for add_reviewer +add_reviewer_teams = 2.times.map do + Team.create!(assignment_id: add_reviewer_assignment.id) +end + +# Assign students to teams +TeamsUser.create!(team_id: add_reviewer_teams[0].id, user_id: test_users[:student1].id) +TeamsUser.create!(team_id: add_reviewer_teams[0].id, user_id: test_users[:student2].id) + +# Create participants +Participant.create!(user_id: test_users[:student1].id, assignment_id: add_reviewer_assignment.id, team_id: add_reviewer_teams[0].id) +Participant.create!(user_id: test_users[:student2].id, assignment_id: add_reviewer_assignment.id, team_id: add_reviewer_teams[0].id) +Participant.create!(user_id: test_users[:student3].id, assignment_id: add_reviewer_assignment.id, team_id: nil) + +# Create topics for add_reviewer +add_reviewer_topics = 2.times.map do |i| + SignUpTopic.create!( + topic_name: "Add Reviewer Topic #{i + 1}", + assignment_id: add_reviewer_assignment.id, + max_choosers: 2, + category: "Default", + topic_identifier: "ART#{i + 1}", + description: "Test topic for add_reviewer" + ) +end + +# Assign team to topic +SignedUpTeam.create!( + sign_up_topic_id: add_reviewer_topics[0].id, + team_id: add_reviewer_teams[0].id, + is_waitlisted: false, + preference_priority_number: 1 +) + +# 2. Test data for assign_reviewer_dynamically +dynamic_reviewer_assignment = Assignment.create!( + name: "Dynamic Reviewer Test", + instructor_id: test_users[:instructor].id, + course_id: courses.first.id, + has_teams: true, + has_topics: true, + private: false +) + +# Create teams for dynamic reviewer +dynamic_teams = 2.times.map do + Team.create!(assignment_id: dynamic_reviewer_assignment.id) +end + +# Assign students to teams +TeamsUser.create!(team_id: dynamic_teams[0].id, user_id: test_users[:student1].id) +TeamsUser.create!(team_id: dynamic_teams[1].id, user_id: test_users[:student2].id) + +# Create participants +Participant.create!(user_id: test_users[:student1].id, assignment_id: dynamic_reviewer_assignment.id, team_id: dynamic_teams[0].id) +Participant.create!(user_id: test_users[:student2].id, assignment_id: dynamic_reviewer_assignment.id, team_id: dynamic_teams[1].id) +Participant.create!(user_id: test_users[:student3].id, assignment_id: dynamic_reviewer_assignment.id, team_id: nil) + +# Create topics for dynamic reviewer +dynamic_topics = 2.times.map do |i| + SignUpTopic.create!( + topic_name: "Dynamic Topic #{i + 1}", + assignment_id: dynamic_reviewer_assignment.id, + max_choosers: 2, + category: "Default", + topic_identifier: "DT#{i + 1}", + description: "Test topic for dynamic reviewer" + ) +end + +# 3. Test data for review_allowed and check_outstanding_reviews +review_state_assignment = Assignment.create!( + name: "Review State Test", + instructor_id: test_users[:instructor].id, + course_id: courses.first.id, + has_teams: true, + has_topics: false, + private: false +) + +# Create teams for review state +review_state_teams = 2.times.map do + Team.create!(assignment_id: review_state_assignment.id) +end + +# Assign students to teams +TeamsUser.create!(team_id: review_state_teams[0].id, user_id: test_users[:student1].id) +TeamsUser.create!(team_id: review_state_teams[1].id, user_id: test_users[:student2].id) + +# Create participants +Participant.create!(user_id: test_users[:student1].id, assignment_id: review_state_assignment.id, team_id: review_state_teams[0].id) +Participant.create!(user_id: test_users[:student2].id, assignment_id: review_state_assignment.id, team_id: review_state_teams[1].id) + +# Create review mappings with different states +reviewer_participant = Participant.find_by!(user_id: test_users[:student1].id, assignment_id: review_state_assignment.id) + +ReviewResponseMap.create!( + reviewer_id: reviewer_participant.id, + reviewee_id: review_state_teams[1].id, + reviewed_object_id: review_state_assignment.id +) + + +# 4. Test data for assign_quiz_dynamically +quiz_assignment = Assignment.create!( + name: "Quiz Assignment Test", + instructor_id: test_users[:instructor].id, + course_id: courses.first.id, + has_teams: true, + has_topics: false, + private: false +) + +# Create quiz questionnaire +quiz_questionnaire = Questionnaire.create!( + name: "Test Quiz", + instructor_id: test_users[:instructor].id, + private: false, + min_question_score: 0, + max_question_score: 5, + questionnaire_type: "QuizQuestionnaire" +) + +# Create teams for quiz +quiz_teams = 2.times.map do + Team.create!(assignment_id: quiz_assignment.id) +end + +# Assign students to teams +TeamsUser.create!(team_id: quiz_teams[0].id, user_id: test_users[:student1].id) +TeamsUser.create!(team_id: quiz_teams[1].id, user_id: test_users[:student2].id) + +# Create participants +Participant.create!(user_id: test_users[:student1].id, assignment_id: quiz_assignment.id, team_id: quiz_teams[0].id) +Participant.create!(user_id: test_users[:student2].id, assignment_id: quiz_assignment.id, team_id: quiz_teams[1].id) + +# 5. Test data for start_self_review +self_review_assignment = Assignment.create!( + name: "Self Review Test", + instructor_id: test_users[:instructor].id, + course_id: courses.first.id, + has_teams: true, + has_topics: false, + private: false +) + +# Create teams for self review +self_review_teams = 2.times.map do + Team.create!(assignment_id: self_review_assignment.id) +end + +# Assign students to teams +TeamsUser.create!(team_id: self_review_teams[0].id, user_id: test_users[:student1].id) +TeamsUser.create!(team_id: self_review_teams[1].id, user_id: test_users[:student2].id) + +# Create participants +Participant.create!(user_id: test_users[:student1].id, assignment_id: self_review_assignment.id, team_id: self_review_teams[0].id) +Participant.create!(user_id: test_users[:student2].id, assignment_id: self_review_assignment.id, team_id: self_review_teams[1].id) + +puts "βœ… Created organized test data for all review mappings functions" + +# 6. Test data for add_calibration +calibration_assignment = Assignment.create!( + name: "Calibration Test", + instructor_id: test_users[:instructor].id, + course_id: courses.first.id, + has_teams: true, + has_topics: false, + private: false +) + +# Create teams for calibration +calibration_teams = 2.times.map do + Team.create!(assignment_id: calibration_assignment.id) +end + +# Assign students to teams +TeamsUser.create!(team_id: calibration_teams[0].id, user_id: test_users[:student1].id) +TeamsUser.create!(team_id: calibration_teams[1].id, user_id: test_users[:student2].id) + +# Create participants +Participant.create!(user_id: test_users[:student1].id, assignment_id: calibration_assignment.id, team_id: calibration_teams[0].id) +Participant.create!(user_id: test_users[:student2].id, assignment_id: calibration_assignment.id, team_id: calibration_teams[1].id) + +# Create calibration mappings +CalibrationMapping.create!( + assignment_id: calibration_assignment.id, + team_id: calibration_teams[0].id +) + +puts "βœ… Created test data for calibration functions" + +# Create a new assignment with questionnaire +review_assignment = Assignment.create!( + name: "Review Assignment with Questionnaire", + instructor_id: test_users[:instructor].id, + course_id: courses.first.id, + has_teams: true, + has_topics: false, + private: false +) + +questionnaire = Questionnaire.create!( + name: "Review Questionnaire", + instructor_id: test_users[:instructor].id, + private: false, + min_question_score: 0, + max_question_score: 10, + questionnaire_type: "ReviewQuestionnaire" +) + +AssignmentQuestionnaire.create!( + assignment_id: review_assignment.id, + questionnaire_id: questionnaire.id, + used_in_round: 1 +) +puts "βœ… Created review assignment with questionnaire (ID: #{review_assignment.id})" + +puts "πŸŽ‰ Seeding Complete!" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 85edfda55..4bfad926f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.1' +#version: '3.1' services: app: diff --git a/spec/factories/assignment_teams.rb b/spec/factories/assignment_teams.rb new file mode 100644 index 000000000..87a3c76f9 --- /dev/null +++ b/spec/factories/assignment_teams.rb @@ -0,0 +1,11 @@ +# Factory for creating AssignmentTeam test instances +# AssignmentTeam represents a team of students working on an assignment +FactoryBot.define do + factory :assignment_team do + # Generate a random team name using Faker + name { Faker::Team.name } + # Create an associated assignment for the team + # This ensures each team is properly associated with an assignment + association :assignment + end +end \ No newline at end of file diff --git a/spec/models/review_response_map_spec.rb b/spec/models/review_response_map_spec.rb new file mode 100644 index 000000000..b3a97fd47 --- /dev/null +++ b/spec/models/review_response_map_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' + +RSpec.describe ReviewResponseMap, type: :model do + describe '.review_allowed?' do + let(:assignment) { create(:assignment, num_reviews_allowed: 3) } + let(:reviewer) { create(:user) } + let(:team) { create(:assignment_team, assignment: assignment) } + + context 'when reviewer has not reached review limit' do + before do + # Create 2 reviews for the reviewer (below the limit of 3) + 2.times do + create(:review_response_map, + reviewer_id: reviewer.id, + reviewed_object_id: assignment.id, + reviewee_id: team.id) + end + end + + it 'returns success with allowed true' do + result = ReviewResponseMap.review_allowed?(assignment.id, reviewer.id) + expect(result.success).to be true + expect(result.allowed).to be true + end + end + + context 'when reviewer has reached review limit' do + before do + # Create 3 reviews for the reviewer (at the limit) + 3.times do + create(:review_response_map, + reviewer_id: reviewer.id, + reviewed_object_id: assignment.id, + reviewee_id: team.id) + end + end + + it 'returns success with allowed false' do + result = ReviewResponseMap.review_allowed?(assignment.id, reviewer.id) + expect(result.success).to be true + expect(result.allowed).to be false + end + end + + context 'when parameters are missing' do + it 'returns error for missing assignment_id' do + result = ReviewResponseMap.review_allowed?(nil, reviewer.id) + expect(result.success).to be false + expect(result.error).to eq('Assignment ID and Reviewer ID are required') + end + + it 'returns error for missing reviewer_id' do + result = ReviewResponseMap.review_allowed?(assignment.id, nil) + expect(result.success).to be false + expect(result.error).to eq('Assignment ID and Reviewer ID are required') + end + end + + context 'when resources are not found' do + it 'returns error for non-existent assignment' do + result = ReviewResponseMap.review_allowed?(99999, reviewer.id) + expect(result.success).to be false + expect(result.error).to eq('Assignment or Reviewer not found') + end + + it 'returns error for non-existent reviewer' do + result = ReviewResponseMap.review_allowed?(assignment.id, 99999) + expect(result.success).to be false + expect(result.error).to eq('Assignment or Reviewer not found') + end + end + + context 'when an error occurs' do + before do + allow(ReviewResponseMap).to receive(:where).and_raise(StandardError.new('Database error')) + end + + it 'returns failure with error message' do + result = ReviewResponseMap.review_allowed?(assignment.id, reviewer.id) + expect(result.success).to be false + expect(result.error).to eq('Database error') + end + end + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/review_mappings_spec.rb b/spec/requests/api/v1/review_mappings_spec.rb new file mode 100644 index 000000000..48410dcc1 --- /dev/null +++ b/spec/requests/api/v1/review_mappings_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "Api::V1::ReviewMappings", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end \ No newline at end of file diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index de8081625..da304a0de 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -9,6 +9,7 @@ components: type: http scheme: bearer bearerFormat: JWT + description: "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"" security: - bearerAuth: [] paths: @@ -1326,8 +1327,1041 @@ paths: responses: '200': description: A specific student task + "/api/v1/review_mappings/add_reviewer": + post: + tags: + - Review Mappings + summary: Add a reviewer to a review mapping + description: Assigns a reviewer to a team for review purposes + operationId: addReviewer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + - contributor_id + - user + properties: + id: + type: integer + description: ID of the assignment + contributor_id: + type: integer + description: ID of the team to be reviewed + user: + type: object + required: + - name + properties: + name: + type: string + description: Name of the user to assign as reviewer + responses: + '200': + description: Review mapping created successfully + content: + application/json: + schema: + type: object + properties: + reviewer_id: + type: integer + reviewee_id: + type: integer + assignment_id: + type: integer + review_type: + type: string + '422': + description: Unprocessable Entity + content: + application/json: + schema: + type: object + properties: + error: + type: string + + "/api/v1/review_mappings/add_calibration": + post: + tags: + - Review Mappings + summary: Add a calibration review + description: Creates a calibration review mapping between a team and an assignment + operationId: addCalibration + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - calibration + properties: + calibration: + type: object + required: + - assignment_id + - team_id + properties: + assignment_id: + type: integer + description: ID of the assignment + team_id: + type: integer + description: ID of the team + responses: + '200': + description: Calibration review mapping created successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + review_mapping: + type: object + response_url: + type: string + '401': + description: Unauthorized + '422': + description: Unprocessable Entity + content: + application/json: + schema: + type: object + properties: + error: + type: string + + "/api/v1/review_mappings/select_reviewer": + get: + tags: + - Review Mappings + summary: Select a reviewer + description: Selects a contributor for review mapping and stores it in the session + operationId: selectReviewer + parameters: + - name: contributor_id + in: query + required: true + schema: + type: integer + description: ID of the contributor team + responses: + '200': + description: Contributor selected successfully + '400': + description: Bad Request - Missing contributor_id + content: + application/json: + schema: + type: object + properties: + error: + type: string + '404': + description: Contributor not found + + "/api/v1/review_mappings/assign_reviewer_dynamically": + post: + tags: + - Review Mappings + summary: Assign a reviewer dynamically + description: Assigns a reviewer to a topic dynamically + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - assignment_id + - reviewer_id + - topic_id + properties: + assignment_id: + type: integer + description: ID of the assignment + reviewer_id: + type: integer + description: ID of the reviewer/participant + topic_id: + type: integer + description: ID of the topic + i_dont_care: + type: boolean + description: Whether to ignore existing assignments + responses: + '200': + description: Reviewer assigned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ReviewMapping' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + "/api/v1/review_mappings/review_allowed": + get: + tags: + - Review Mappings + summary: Check if review is allowed + description: Checks if a reviewer is allowed to review + parameters: + - name: assignment_id + in: query + required: true + schema: + type: integer + description: ID of the assignment + - name: reviewer_id + in: query + required: true + schema: + type: integer + description: ID of the reviewer + responses: + '200': + description: Review permission status retrieved successfully + content: + text/plain: + schema: + type: string + description: Whether review is allowed ('true' or 'false') + '422': + description: Unprocessable Entity + content: + application/json: + schema: + type: object + properties: + error: + type: string + + "/api/v1/review_mappings/check_outstanding_reviews": + get: + tags: + - Review Mappings + summary: Check outstanding reviews + description: Checks for outstanding reviews for a reviewer + parameters: + - name: assignment_id + in: query + required: true + schema: + type: integer + description: ID of the assignment + - name: reviewer_id + in: query + required: true + schema: + type: integer + description: ID of the reviewer + responses: + '200': + description: Outstanding reviews retrieved successfully + content: + text/plain: + schema: + type: string + description: Whether the reviewer has outstanding reviews ('true' or 'false') + '422': + description: Unprocessable Entity + content: + application/json: + schema: + type: object + properties: + error: + type: string + + "/api/v1/review_mappings/assign_quiz_dynamically": + post: + tags: + - Review Mappings + summary: Assign a quiz dynamically + description: Assigns a quiz to a reviewer dynamically + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - assignment_id + - reviewer_id + - questionnaire_id + properties: + assignment_id: + type: integer + description: ID of the assignment + reviewer_id: + type: integer + description: ID of the reviewer/participant + questionnaire_id: + type: integer + description: ID of the questionnaire to be assigned + responses: + '200': + description: Quiz assigned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/QuizResponseMap' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "/api/v1/review_mappings/start_self_review": + post: + tags: + - Review Mappings + summary: Start a self-review + description: Initiates a self-review process for a participant + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - assignment_id + - reviewer_id + - reviewer_userid + properties: + assignment_id: + type: integer + description: ID of the assignment + reviewer_id: + type: integer + description: ID of the reviewer (participant) + reviewer_userid: + type: integer + description: User ID of the reviewer + responses: + '200': + description: Self-review started successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfReviewResponseMap' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "/api/v1/assignments/{assignment_id}/review_mappings": + get: + summary: "List review mappings for an assignment" + description: "Retrieves all review mappings (reviewer-reviewee assignments) for a given assignment. Optional query parameters can filter by reviewer, reviewee, or mapping type." + tags: + - Review Mappings + security: + - bearerAuth: [] + parameters: + - name: assignment_id + in: path + required: true + schema: + type: integer + - name: reviewer_id + in: query + schema: + type: integer + - name: reviewee_id + in: query + schema: + type: integer + - name: type + in: query + schema: + type: string + responses: + '200': + description: OK + '404': + description: Assignment not found + "/api/v1/assignments/{assignment_id}/automatic_review_mapping": + post: + summary: "Automatically assign reviewers for an assignment" + description: "Generates review mappings for the specified assignment based on the provided strategy and number of reviews per student." + tags: + - Review Mappings + security: + - bearerAuth: [] + parameters: + - name: assignment_id + in: path + required: true + description: ID of the assignment + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + num_reviews_per_student: + type: integer + description: Number of reviews each student should perform + example: 3 + strategy: + type: string + description: Strategy to use for mapping (e.g., "default") + example: default + responses: + '200': + description: Review mappings successfully generated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + '404': + description: Assignment not found + '400': + description: Invalid input or insufficient participants + /api/v1/assignments/{assignment_id}/automatic_review_mapping_strategy: + post: + summary: Automatically generate review mappings using a specified strategy + description: | + Automatically assigns reviewers to reviewees for the given assignment using the specified strategy. + Strategies can include "default", "round_robin", etc. + tags: + - Review Mappings + security: + - bearerAuth: [] + parameters: + - name: assignment_id + in: path + required: true + description: ID of the assignment + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + strategy: + type: string + example: "round_robin" + description: Strategy for assigning reviewers + num_reviews_per_student: + type: integer + example: 2 + description: Number of reviews each student should perform + required: + - strategy + responses: + '200': + description: Mappings created successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + '404': + description: Assignment not found + '422': + description: Strategy not supported or invalid input + /api/v1/assignments/{assignment_id}/automatic_review_mapping_staggered: + post: + summary: Automatically assign reviewers in a staggered manner + description: | + Generates reviewer-reviewee mappings using a staggered algorithm. + Works for both team-based and individual assignments. + tags: + - Review Mappings + security: + - bearerAuth: [] + parameters: + - name: assignment_id + in: path + required: true + description: ID of the assignment + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + num_reviews_per_student: + type: integer + example: 2 + description: Number of reviews each student or team should perform + required: + - num_reviews_per_student + responses: + '200': + description: Review mappings created successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + '400': + description: Invalid input or insufficient participants + '404': + description: Assignment not found + /api/v1/assignments/{assignment_id}/assign_reviewers_for_team: + post: + summary: Assign reviewers for a specific team + description: > + Automatically assigns a specified number of reviewers to a given team within an assignment. + Supports both team-based and individual assignments. + tags: + - Review Mappings + parameters: + - in: path + name: assignment_id + required: true + schema: + type: integer + description: ID of the assignment + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - team_id + - num_reviewers + properties: + team_id: + type: integer + description: ID of the team to assign reviewers to + num_reviewers: + type: integer + description: Number of reviewers to assign + responses: + '200': + description: Reviewers successfully assigned + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Assigned 3 reviewers to team 45" + '400': + description: Invalid input or insufficient participants + content: + application/json: + schema: + type: object + properties: + error: + type: string + '404': + description: Assignment or team not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + /api/v1/assignments/{assignment_id}/peer_review_strategy: + post: + summary: Automatically assign peer review mappings in a circular staggered manner + tags: + - Review Mappings + description: > + Assigns each participant (or team) to review a fixed number of peers using a staggered peer-review strategy. + Avoids self-reviews and supports both individual and team-based assignments. + parameters: + - in: path + name: assignment_id + schema: + type: integer + required: true + description: ID of the assignment + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + num_reviews_per_student: + type: integer + example: 3 + responses: + '200': + description: Peer review mappings created successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + '400': + description: Invalid input or not enough reviewers + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + '404': + description: Assignment not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + /api/v1/review_mappings/save_grade_and_comment: + post: + summary: Save grade and comment for reviewer + description: Saves or updates a review grade and comment for a reviewer evaluating a participant. + tags: + - Review Mappings + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + participant_id: + type: integer + description: ID of the participant being reviewed + example: 1 + assignment_id: + type: integer + description: ID of the assignment for which the review is being graded + example: 2 + grade: + type: number + format: float + description: Numeric grade given to the participant + example: 9.5 + comment: + type: string + description: Reviewer’s written feedback + example: "Excellent work and teamwork." + required: + - participant_id + - assignment_id + - grade + responses: + '200': + description: Grade and comment saved successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Grade and comment saved successfully. + review_grade: + type: object + properties: + grade: + type: string + example: "9.5" + comment: + type: string + example: "Excellent work and teamwork." + response_id: + type: integer + example: 12 + '422': + description: Validation failed or record could not be saved + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: No question item found for assignment + /api/v1/review_mappings/{id}/select_metareviewer: + post: + summary: Select a metareviewer for the specified review mapping + tags: + - Review Mappings + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: ID of the review mapping to assign a metareviewer to + - name: metareviewer_id + in: query + required: true + schema: + type: integer + description: ID of the metareviewer (Participant) + responses: + '200': + description: Metareviewer selected successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + '404': + description: Review mapping not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + '422': + description: Failed to assign metareviewer + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + message: + type: string + + /api/v1/review_mappings/{id}/add_metareviewer: + post: + summary: Add a metareviewer to a review mapping + tags: + - Review Mappings + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: The ID of the review mapping + - name: metareviewer_id + in: query + required: true + schema: + type: integer + description: The ID of the participant to assign as metareviewer + responses: + '200': + description: Metareviewer added successfully + '404': + description: Review mapping not found + /api/v1/review_mappings/{id}/assign_metareviewer_dynamically: + post: + summary: Dynamically assign an available metareviewer to the review mapping + tags: + - Review Mappings + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: The ID of the review mapping + responses: + '200': + description: Metareviewer dynamically assigned successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + metareviewer_id: + type: integer + '404': + description: Review mapping or assignment not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + '422': + description: No available metareviewer found + content: + application/json: + schema: + type: object + properties: + error: + type: string + /api/v1/review_mappings/delete_outstanding_reviewers/{assignment_id}: + delete: + summary: Delete all outstanding reviewers (those who haven't submitted reviews) for an assignment + tags: + - Review Mappings + parameters: + - in: path + name: assignment_id + required: true + schema: + type: integer + description: ID of the assignment + responses: + '200': + description: Review mappings deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Assignment not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + /api/v1/review_mappings/delete_all_metareviewers/{assignment_id}: + delete: + summary: Delete all metareviewers for the specified assignment + tags: + - Review Mappings + parameters: + - name: assignment_id + in: path + required: true + schema: + type: integer + description: ID of the assignment + responses: + '200': + description: Metareviewers deleted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Assignment not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + /api/v1/review_mappings/{id}/delete_reviewer: + delete: + summary: Delete a reviewer from the specified review mapping + tags: + - Review Mappings + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: ID of the review mapping to delete + responses: + '200': + description: Reviewer mapping deleted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Review mapping not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + /api/v1/review_mappings/{id}/delete_metareviewer: + delete: + summary: Delete a metareviewer mapping by ID + tags: + - Review Mappings + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: ID of the metareview mapping + responses: + '200': + description: Metareviewer mapping deleted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Mapping not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + /api/v1/review_mappings/{id}/delete_metareview: + delete: + summary: Delete a specific metareview mapping + tags: + - Review Mappings + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: ID of the metareview mapping to delete + responses: + '200': + description: Metareview mapping deleted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Metareview mapping not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + /api/v1/review_mappings/{id}/unsubmit_review: + delete: + summary: Unsubmit a review response by ID + tags: + - Review Mappings + parameters: + - name: id + in: path + required: true + schema: + type: integer + description: ID of the review mapping + responses: + '200': + description: Review unsubmitted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Review mapping or response not found + content: + application/json: + schema: + type: object + properties: + error: + type: string + '422': + description: Update failed + content: + application/json: + schema: + type: object + properties: + error: + type: string