Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
93 changes: 93 additions & 0 deletions app/controllers/review_mappings_controller.rb
Original file line number Diff line number Diff line change
@@ -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
118 changes: 118 additions & 0 deletions app/models/review_mapping_handler.rb
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions app/models/review_mapping_strategies/base_strategy.rb
Original file line number Diff line number Diff line change
@@ -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
Loading