diff --git a/Gemfile b/Gemfile index 020dbe491..d3d733e54 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ ruby '3.4.5' gem 'mysql2', '~> 0.5.7' gem 'sqlite3', '~> 1.4' # Alternative for development -gem 'puma', '~> 5.0' +gem 'puma', '~> 6.0' gem 'rails', '~> 8.0', '>= 8.0.1' gem 'mini_portile2', '~> 2.8' # Helps with native gem compilation gem 'observer' # Required for Ruby 3.4.5 compatibility with Rails 8.0 diff --git a/Gemfile.lock b/Gemfile.lock index 5e7341cb0..6fdf41173 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,7 @@ GEM term-ansicolor thor crass (1.0.6) + csv (3.3.5) danger (9.5.3) base64 (~> 0.2) claide (~> 1.0) @@ -128,6 +129,7 @@ GEM debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) + delegate (0.4.0) diff-lcs (1.6.2) docile (1.4.1) domain_name (0.6.20240107) @@ -149,8 +151,11 @@ GEM faraday (>= 0.8) faraday-net_http (3.4.1) net-http (>= 0.5.0) + faraday-retry (2.3.2) + faraday (~> 2.0) find_with_order (1.3.1) activerecord (>= 3) + forwardable (1.3.3) git (2.3.3) activesupport (>= 5.0) addressable (~> 2.8) @@ -197,10 +202,13 @@ GEM mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (3.2025.0924) mini_mime (1.1.5) + mini_portile2 (2.8.9) minitest (5.25.5) mize (0.6.1) + monitor (0.2.0) msgpack (1.8.0) multi_json (1.17.0) + mutex_m (0.3.0) mysql2 (0.5.7) bigdecimal nap (1.1.0) @@ -217,7 +225,7 @@ GEM net-protocol netrc (0.11.0) nio4r (2.5.9) - nokogiri (1.15.2-aarch64-linux) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) @@ -230,6 +238,7 @@ GEM faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) @@ -350,6 +359,7 @@ GEM addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) + set (1.1.2) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) simplecov (0.22.0) @@ -358,7 +368,10 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + singleton (0.3.0) spring (4.4.0) + sqlite3 (1.7.3) + mini_portile2 (~> 2.8.0) stringio (3.1.7) sync (0.5.0) term-ansicolor (1.11.3) @@ -398,18 +411,30 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10.0) bcrypt (~> 3.1.7) + bigdecimal bootsnap (>= 1.18.4) coveralls + csv danger database_cleaner-active_record + date debug + delegate factory_bot_rails faker + faraday-retry find_with_order + forwardable jwt (~> 2.7, >= 2.7.1) lingua - mysql2 (~> 0.5.5) + logger + mini_portile2 (~> 2.8) + monitor + mutex_m + mysql2 (~> 0.5.7) observer + ostruct + psych (~> 5.2) puma (~> 6.0) rack-cors rails (~> 8.0, >= 8.0.1) @@ -418,14 +443,19 @@ DEPENDENCIES rswag-specs rswag-ui rubocop + set shoulda-matchers simplecov simplecov_json_formatter + singleton spring + sqlite3 (~> 1.4) + timeout tzinfo-data + uri RUBY VERSION - ruby 3.2.7p253 + ruby 3.4.5p51 BUNDLED WITH 2.4.14 diff --git a/app/controllers/review_mappings_controller.rb b/app/controllers/review_mappings_controller.rb new file mode 100644 index 000000000..486840270 --- /dev/null +++ b/app/controllers/review_mappings_controller.rb @@ -0,0 +1,93 @@ +class ReviewMappingsController < ApplicationController + include Authorization + before_action :set_assignment + before_action :authorize + + # ===== STATIC ASSIGNMENT ===== + def assign_round_robin + handler = ReviewMappingHandler.new(@assignment) + handler.assign_statically(ReviewMappingStrategies::RoundRobinStrategy) + render json: { status: "ok", message: "Round-robin assignments created" } + end + + def assign_from_csv + csv_text = params[:csv].read # file upload + handler = ReviewMappingHandler.new(@assignment) + handler.assign_from_csv(csv_text) + render json: { status: "ok", message: "CSV-based assignments created" } + end + + # POST /assignments/:assignment_id/review_mappings/random + def assign_random + handler = ReviewMappingHandler.new(@assignment) + handler.assign_random + render json: { status: "ok", message: "Random assignments created" } + end + + + # ===== DYNAMIC ASSIGNMENT ===== + def request_review_fewest + reviewer = AssignmentParticipant.find(params[:reviewer_id]) + handler = ReviewMappingHandler.new(@assignment) + + mapping = handler.assign_dynamically( + ReviewMappingStrategies::LeastReviewedSubmissionStrategy, + reviewer + ) + + if mapping + render json: { status: "ok", mapping_id: mapping.id } + else + render json: { status: "error", message: "No team available or limit reached" }, status: :unprocessable_entity + end + end + + def request_review_topic_balance + reviewer = AssignmentParticipant.find(params[:reviewer_id]) + handler = ReviewMappingHandler.new(@assignment) + + mapping = handler.assign_dynamic_topic_fairly(reviewer, k: params[:k].to_i) + + if mapping + render json: { status: "ok", mapping_id: mapping.id } + else + render json: { status: "error", message: "No eligible topic/team available" }, status: :unprocessable_entity + end + end + + # ===== CALIBRATION ===== + def assign_calibration_artifacts + handler = ReviewMappingHandler.new(@assignment) + handler.assign_calibration_reviews_round_robin + render json: { status: "ok", message: "Calibration reviews assigned to all reviewers" } + end + + # ===== DELETE ===== + def destroy + handler = ReviewMappingHandler.new(@assignment) + handler.delete_review_mapping(params[:id]) + render json: { status: "ok", message: "Mapping deleted" } + end + + def delete_all_for_reviewer + reviewer = AssignmentParticipant.find(params[:reviewer_id]) + handler = ReviewMappingHandler.new(@assignment) + handler.delete_all_reviews_for(reviewer) + render json: { status: "ok", message: "All mappings for reviewer deleted" } + end + + # ===== INSTRUCTOR GRADING ===== + def grade_review + mapping = ReviewResponseMap.find(params[:mapping_id]) + handler = ReviewMappingHandler.new(@assignment) + + handler.grade_review(mapping, grade: params[:grade], comment: params[:comment]) + render json: { status: "ok", message: "Review graded" } + end + + private + + def set_assignment + @assignment = Assignment.find(params[:assignment_id]) + end +end diff --git a/app/models/review_mapping_handler.rb b/app/models/review_mapping_handler.rb new file mode 100644 index 000000000..11a5a5f2d --- /dev/null +++ b/app/models/review_mapping_handler.rb @@ -0,0 +1,118 @@ +class ReviewMappingHandler + DEFAULT_OUTSTANDING_LIMIT = 2 + + def initialize(assignment) + @assignment = assignment + end + + # ===== STATIC ASSIGNMENT ===== + # assign reviews statically using the given strategy e.g. Round Robin Strategy, CSV Import Strategy + def assign_statically(strategy_class) + strategy = strategy_class.new(@assignment) + strategy.each_review_pair do |reviewer, team| + create_mapping(reviewer, team) + end + end + + def assign_from_csv(csv_text) + strategy = ReviewMappingStrategies::CsvImportStrategy.new(@assignment, csv_text) + strategy.each_review_pair do |reviewer, team| + create_mapping(reviewer, team) + end + end + + + def assign_random + strategy = ReviewMappingStrategies::RandomStaticStrategy.new(@assignment) + strategy.each_review_pair do |reviewer, team| + create_mapping(reviewer, team) + end + end + + + # ===== DYNAMIC ASSIGNMENT ===== + def assign_dynamically(strategy_class, reviewer, k: DEFAULT_OUTSTANDING_LIMIT) + return nil unless can_accept_more_reviews?(reviewer, k: k) + + strategy = strategy_class.new(@assignment) + team = strategy.assign_one(reviewer) + return nil unless team + + create_mapping(reviewer, team) + end + + def assign_dynamic_topic_fairly(reviewer, k: 1) + return nil unless can_accept_more_reviews?(reviewer, k: DEFAULT_OUTSTANDING_LIMIT) + + strategy = ReviewMappingStrategies::LeastReviewedTopicStrategy.new(@assignment) + team = strategy.assign_one(reviewer, k: k) + return nil unless team + + create_mapping(reviewer, team) + end + + # ===== CALIBRATION ===== + # calibration for bit turned on + # everyone gets assigned 2 calibration reviews along with the other reviwes + # assign calibration reviews done by instructor in round robin to students + def assign_calibration_reviews_round_robin + # Get all participants (students) + reviewers = AssignmentParticipant.where(parent_id: @assignment.id) + + # Get all instructor calibration teams/submissions + calibration_teams = AssignmentTeam.where(parent_id: @assignment.id, is_calibration: true) + return if calibration_teams.empty? + + # Assign in round robin: each reviewer gets 2 calibration reviews + reviewers.each_with_index do |reviewer, index| + 2.times do |i| + team = calibration_teams[(index + i) % calibration_teams.size] + ReviewResponseMap.find_or_create_by!( + reviewer: reviewer, + reviewee: team, + reviewed_object_id: @assignment.id, + calibration: true + ) + end + end + end + + + def calibration_reviews_for(reviewer) + ReviewResponseMap.where(reviewer: reviewer, calibration: true) + end + + # ===== OUTSTANDING REVIEWS ===== + def can_accept_more_reviews?(reviewer, k: DEFAULT_OUTSTANDING_LIMIT) + outstanding = ReviewResponseMap.where( + reviewer: reviewer, + reviewed_object_id: @assignment.id, + submitted: false + ).count + outstanding < k + end + + # ===== DELETE ===== + def delete_review_mapping(mapping_id) + ReviewResponseMap.find(mapping_id).destroy + end + + def delete_all_reviews_for(reviewer) + ReviewResponseMap.where(reviewer: reviewer).destroy_all + end + + # ===== INSTRUCTOR GRADING ===== + def grade_review(mapping, grade:, comment:) + mapping.update!(instructor_grade: grade, instructor_comment: comment) + end + + private + + def create_mapping(reviewer, team) + ReviewResponseMap.create!( + reviewer: reviewer, + reviewee: team, + reviewed_object_id: @assignment.id + ) + end +end diff --git a/app/models/review_mapping_strategies/base_strategy.rb b/app/models/review_mapping_strategies/base_strategy.rb new file mode 100644 index 000000000..73bf69606 --- /dev/null +++ b/app/models/review_mapping_strategies/base_strategy.rb @@ -0,0 +1,15 @@ +module ReviewMappingStrategies + class BaseStrategy + def initialize(assignment) + @assignment = assignment + end + + def each_review_pair + raise NotImplementedError, 'Static strategies must implement each_pair' + end + + def assign_one(reviewer) + raise NotImplementedError, 'Dynamic strategies must implement assign_one' + end + end +end diff --git a/app/models/review_mapping_strategies/csv_import_strategy.rb b/app/models/review_mapping_strategies/csv_import_strategy.rb new file mode 100644 index 000000000..f42c2c395 --- /dev/null +++ b/app/models/review_mapping_strategies/csv_import_strategy.rb @@ -0,0 +1,34 @@ +require_relative 'base_strategy' +require 'csv' + +module ReviewMappingStrategies + class CsvImportStrategy < BaseStrategy + def initialize(assignment, csv_text) + super(assignment) + @csv_text = csv_text + end + + # Yields reviewer–team pairs based on CSV file + # CSV expected format: reviewer_email, team_name + def each_review_pair + return enum_for(:each_pair) unless block_given? + + CSV.parse(@csv_text, headers: true) do |row| + reviewer = find_participant_by_email(row['reviewer_email']) + team = find_team_by_name(row['team_name']) + yield reviewer, team if reviewer && team + end + end + + private + + def find_participant_by_email(email) + user = User.find_by(email: email) + AssignmentParticipant.find_by(user: user, parent_id: @assignment.id) + end + + def find_team_by_name(name) + @assignment.teams.find_by(name: name) + end + end +end diff --git a/app/models/review_mapping_strategies/least_reviewed_submission_strategy.rb b/app/models/review_mapping_strategies/least_reviewed_submission_strategy.rb new file mode 100644 index 000000000..2bb1d68ce --- /dev/null +++ b/app/models/review_mapping_strategies/least_reviewed_submission_strategy.rb @@ -0,0 +1,34 @@ +require_relative 'base_strategy' + +module ReviewMappingStrategies + class LeastReviewedSubmissionStrategy < BaseStrategy + # Returns the team with the fewest reviews so far, + # skipping self-review and duplicate assignments + def assign_one(reviewer) + counts = ReviewResponseMap.where(reviewed_object_id: @assignment.id) + .group(:reviewee_id) + .count + + eligible_teams = teams_eligible_for_review(reviewer) + + eligible_teams.min_by { |t| counts[t.id] || 0 } + end + + private + + # Excludes the team reviewer is on aand the teams + # that the reviewer has already reviewed + def teams_eligible_for_review(reviewer) + @assignment.teams.reject do |team| + + (team.participants.include?(reviewer)) || + # Skip duplicate reviews + ReviewResponseMap.exists?( + reviewer_id: reviewer.id, + reviewee_id: team.id, + reviewed_object_id: @assignment.id + ) + end + end + end +end diff --git a/app/models/review_mapping_strategies/least_reviewed_topic_strategy.rb b/app/models/review_mapping_strategies/least_reviewed_topic_strategy.rb new file mode 100644 index 000000000..22bc9acde --- /dev/null +++ b/app/models/review_mapping_strategies/least_reviewed_topic_strategy.rb @@ -0,0 +1,44 @@ +require_relative 'base_strategy' + +module ReviewMappingStrategies + class LeastReviewedTopicStrategy < BaseStrategy + # Assign reviewer to a team in a topic where review counts + # are within k fairness threshold + def assign_one(reviewer, k: 1) + counts = ReviewResponseMap.where(reviewed_object_id: @assignment.id) + .joins(reviewee: :topic) + .group('topics.id') + .count + + min_count = counts.values.min || 0 + + # Eligible topics within threshold + eligible_topics = counts.select { |_topic_id, count| count <= min_count + k }.keys + + # Choose the first eligible topic deterministically + topic_id = eligible_topics.first + topic = SignUpTopic.find(topic_id) + + # From this topic, pick a valid team + team = teams_eligible_for_review(reviewer, topic).min_by do |t| + counts[t.id] || 0 + end + + team + end + + private + + # Only allow teams in the topic that are valid for this reviewer + def teams_eligible_for_review(reviewer, topic) + topic.assignment_teams.reject do |team| + (team.participants.include?(reviewer)) || + ReviewResponseMap.exists?( + reviewer_id: reviewer.id, + reviewee_id: team.id, + reviewed_object_id: @assignment.id + ) + end + end + end +end diff --git a/app/models/review_mapping_strategies/random_static_strategy.rb b/app/models/review_mapping_strategies/random_static_strategy.rb new file mode 100644 index 000000000..6aec5c7f4 --- /dev/null +++ b/app/models/review_mapping_strategies/random_static_strategy.rb @@ -0,0 +1,30 @@ +require_relative 'base_strategy' + +module ReviewMappingStrategies + class RandomStaticStrategy < BaseStrategy + # Yields (reviewer, team) pairs with random assignment + def each_review_pair + # shuffle so that the students can't predict who they will review + reviewers = @assignment.participants.select(&:can_review?).shuffle + teams = @assignment.teams.to_a.shuffle + + return enum_for(:each_review_pair) if reviewers.empty? || teams.empty? + + # Example usage: + # 1) With a block: iterate directly + # strategy.each_review_pair { |reviewer, team| puts "#{reviewer.name} -> #{team.name}" } + + # 2) Without a block: get Enumerator first, then iterate + # enum = strategy.each_review_pair + # enum.each { |reviewer, team| ... } + if block_given? + teams.each do |team| + reviewer = reviewers.sample + yield reviewer, team + end + else + enum_for(:each_review_pair) + end + end + end +end \ No newline at end of file diff --git a/app/models/review_mapping_strategies/review_mapping_factory.rb b/app/models/review_mapping_strategies/review_mapping_factory.rb new file mode 100644 index 000000000..2add46c9f --- /dev/null +++ b/app/models/review_mapping_strategies/review_mapping_factory.rb @@ -0,0 +1,20 @@ +module ReviewMappingStrategies + class ReviewMappingFactory + def self.build(strategy_type, assignment, **options) + case strategy_type.to_sym + when :round_robin + RoundRobinStrategy.new(assignment) + when :random + RandomStaticStrategy.new(assignment) + when :fewest_reviews + LeastReviewedSubmissionStrategy.new(assignment) + when :topic_fairness + LeastReviewedTopicStrategy.new(assignment) + when :csv + CsvImportStrategy.new(assignment, options[:csv_text]) + else + raise ArgumentError, "Unknown strategy type: #{strategy_type}" + end + end + end +end diff --git a/app/models/review_mapping_strategies/round_robin_strategy.rb b/app/models/review_mapping_strategies/round_robin_strategy.rb new file mode 100644 index 000000000..748360cb3 --- /dev/null +++ b/app/models/review_mapping_strategies/round_robin_strategy.rb @@ -0,0 +1,20 @@ +require_relative 'base_strategy' + +module ReviewMappingStrategies + class RoundRobinStrategy < BaseStrategy + # Yields (reviewer, reviewee team) pairs in round-robin order + def each_review_pair + reviewers = @assignment.participants.select(&:can_review?) + teams = @assignment.teams.to_a + + return enum_for(:each_review_pair) if reviewers.empty? || teams.empty? + reviewers_cycle = reviewers.cycle + + if block_given? + teams.each { |team| yield reviewers_cycle.next, team } + else + enum_for(:each_review_pair) + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index b77a95f63..a7bb8d8ce 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,6 +99,20 @@ end end + resources :assignments do + resources :review_mappings, except: [:index, :show, :new, :edit, :create, :update] do + collection do + post 'assign_round_robin' + post 'assign_random' + post 'assign_from_csv' + post 'request_review_fewest' + post 'set_calibration_artifact' + delete 'delete_all_for_reviewer/:reviewer_id', action: :delete_all_for_reviewer + patch 'grade_review/:mapping_id', action: :grade_review + end + end + end + resources :invitations do get 'user/:user_id/assignment/:assignment_id/', on: :collection, action: :invitations_for_user_assignment end @@ -141,4 +155,24 @@ delete :delete_participants end end + + resources :review_mappings, only: [] do + collection do + post :assign_round_robin + post :assign_random + post :assign_from_csv + post :request_review_fewest + post :assign_calibration + post :assign_quiz + delete :delete_all_for_reviewer + end + + member do + patch :submit_review + patch :unsubmit_review + patch :grade_review + delete :delete_mapping + end + end + end diff --git a/spec/controllers/review_mappings_controller_spec.rb b/spec/controllers/review_mappings_controller_spec.rb new file mode 100644 index 000000000..029770010 --- /dev/null +++ b/spec/controllers/review_mappings_controller_spec.rb @@ -0,0 +1,152 @@ +require 'rails_helper' + +RSpec.describe ReviewMappingsController, type: :controller do + controller do + include JwtToken + include Authorization + + skip_before_action :authorize + skip_before_action :set_assignment + skip_before_action :authenticate_request! + end + + before do + @routes = Rails.application.routes + Rails.application.routes.draw do + resources :assignments do + resources :review_mappings, except: [:index, :show, :new, :edit, :create, :update] do + collection do + post 'assign_round_robin' + post 'assign_random' + post 'assign_from_csv' + post 'request_review_fewest' + post 'set_calibration_artifact' + delete 'delete_all_for_reviewer/:reviewer_id', action: :delete_all_for_reviewer + patch 'grade_review/:mapping_id', action: :grade_review + end + end + end + end + end + + let(:assignment) { create(:assignment) } + let(:reviewer) { create(:assignment_participant, assignment: assignment) } + let(:team) { create(:team, assignment: assignment) } + let!(:response_map) do + ReviewResponseMap.create!( + reviewer: reviewer, + reviewee: team, + reviewed_object_id: assignment.id + ) + end + + let(:handler) { instance_double(ReviewMappingHandler) } + + before do + allow(ReviewMappingHandler).to receive(:new).and_return(handler) + allow_any_instance_of(Authorization).to receive(:authorize).and_return(true) + # Stub current_user and auth_token + allow(controller).to receive(:authenticate_request!).and_return(true) + allow(controller).to receive(:current_user).and_return(reviewer.user) + allow(controller).to receive(:auth_token).and_return({ id: reviewer.user_id }) + # Stub set_assignment to set the assignment instance variable + controller.instance_variable_set(:@assignment, assignment) + end + + after do + Rails.application.reload_routes! + end + + describe 'POST #assign_round_robin' do + it 'creates round-robin assignments' do + expect(handler).to receive(:assign_statically).with(ReviewMappingStrategies::RoundRobinStrategy) + post :assign_round_robin, params: { assignment_id: assignment.id }, format: :json + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ + 'status' => 'ok', + 'message' => 'Round-robin assignments created' + }) + end + end + + describe 'POST #assign_random' do + it 'creates random assignments' do + expect(handler).to receive(:assign_random) + post :assign_random, params: { assignment_id: assignment.id }, format: :json + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ + 'status' => 'ok', + 'message' => 'Random assignments created' + }) + end + end + + describe 'POST #assign_from_csv' do + it 'creates assignments from CSV' do + csv_path = Rails.root.join('spec/fixtures/files/sample_assignments.csv') + csv_file = fixture_file_upload(csv_path, 'text/csv') + expect(handler).to receive(:assign_from_csv).with(instance_of(String)) + post :assign_from_csv, params: { assignment_id: assignment.id, csv: csv_file }, format: :json + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ + 'status' => 'ok', + 'message' => 'CSV-based assignments created' + }) + end + end + + describe 'POST #request_review_fewest' do + it 'assigns dynamic review to reviewer' do + mapping = double('ReviewResponseMap', id: 42) + expect(handler).to receive(:assign_dynamically) + .with(ReviewMappingStrategies::LeastReviewedSubmissionStrategy, reviewer) + .and_return(mapping) + + post :request_review_fewest, params: { assignment_id: assignment.id, reviewer_id: reviewer.id }, format: :json + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ + 'status' => 'ok', + 'mapping_id' => 42 + }) + end + end + + describe 'DELETE #destroy' do + it 'deletes a mapping' do + allow_any_instance_of(Authorization).to receive(:authorize).and_return(true) + expect(handler).to receive(:delete_review_mapping).with(response_map.id.to_s) + delete :destroy, params: { assignment_id: assignment.id, id: response_map.id }, format: :json + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ + 'status' => 'ok', + 'message' => 'Mapping deleted' + }) + end + end + + describe 'DELETE #delete_all_for_reviewer' do + it 'deletes all mappings for a reviewer' do + allow_any_instance_of(Authorization).to receive(:authorize).and_return(true) + expect(handler).to receive(:delete_all_reviews_for).with(reviewer) + delete :delete_all_for_reviewer, params: { assignment_id: assignment.id, reviewer_id: reviewer.id }, format: :json + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ + 'status' => 'ok', + 'message' => 'All mappings for reviewer deleted' + }) + end + end + + describe 'PATCH #grade_review' do + it 'grades a review' do + allow_any_instance_of(Authorization).to receive(:authorize).and_return(true) + expect(handler).to receive(:grade_review).with(response_map, grade: '95', comment: 'Good work') + patch :grade_review, params: { assignment_id: assignment.id, mapping_id: response_map.id, grade: 95, comment: 'Good work' }, format: :json + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq({ + 'status' => 'ok', + 'message' => 'Review graded' + }) + end + end +end diff --git a/spec/factories/review_response_maps.rb b/spec/factories/review_response_maps.rb new file mode 100644 index 000000000..1cee9aace --- /dev/null +++ b/spec/factories/review_response_maps.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :review_response_map do + association :assignment + association :reviewer, factory: :user + association :reviewee, factory: :team + reviewed_object_id { 1 } + end +end \ No newline at end of file diff --git a/spec/factories/submissions.rb b/spec/factories/submissions.rb new file mode 100644 index 000000000..7920129fe --- /dev/null +++ b/spec/factories/submissions.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :submission do + association :team + association :assignment + content { "Sample submission text" } + end +end diff --git a/spec/fixtures/files/sample_assignments.csv b/spec/fixtures/files/sample_assignments.csv new file mode 100644 index 000000000..6790672a6 --- /dev/null +++ b/spec/fixtures/files/sample_assignments.csv @@ -0,0 +1,3 @@ +reviewer,team +student1,team1 +student2,team2 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 9d528540b..31104cf95 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -77,7 +77,7 @@ end RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = Rails.root.join('spec/fixtures') + # config.fixture_path = Rails.root.join('spec/fixtures') # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false