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
222 changes: 154 additions & 68 deletions app/controllers/grades_controller.rb

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/controllers/participants_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def list_user_participants
if participants.nil?
render json: participants.errors, status: :unprocessable_entity
else
render json: participants, status: :ok
render json: participants.map(&:attributes), status: :ok
end
end

Expand Down
34 changes: 23 additions & 11 deletions app/controllers/student_tasks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,35 @@ def action_allowed?
current_user_has_student_privileges?
end
def list
# Retrieves all tasks that belong to the current user.
@student_tasks = StudentTask.from_user(current_user)
# Render the list of student tasks as JSON.
@student_tasks = StudentTask.tasks(current_user)
render json: @student_tasks, status: :ok
end

def show
render json: @student_task, status: :ok
# GET /student_tasks/teammates
def team
render json: StudentTask.all_teammates(current_user), status: :ok
end

# The view function retrieves a student task based on a participant's ID.
# It is meant to provide an endpoint where tasks can be queried based on participant ID.
def view
# Retrieves the student task where the participant's ID matches the provided parameter.
# This function will be used for clicking on a specific student task to "view" its details.
# Retrieves a StudentTask by AssignmentParticipant ID.
# Delegates lookup and preloading to from_participant_id so the find_by +
# nil-guard + create_from_participant logic is not duplicated here.
def show
@student_task = StudentTask.from_participant_id(params[:id])
# Render the found student task as JSON.

if @student_task.nil?
render json: { error: "Participant not found" }, status: :not_found
return
end

if @student_task.participant.user_id != current_user.id
render json: { error: "Unauthorized access to participant's task" }, status: :forbidden
return
end

@student_task.due_dates = StudentTask.get_events_for_assignment(
@student_task.participant.assignment,
@student_task.participant
)
render json: @student_task, status: :ok
end

Expand Down
18 changes: 18 additions & 0 deletions app/controllers/teams_participants_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ def action_allowed?
case params[:action]
when 'update_duty'
current_user_has_student_privileges?
Comment thread
coderabbitai[bot] marked this conversation as resolved.
when 'list_participants'
# TA+ can always enumerate any team.
# Students may only view their own team roster — enforced inside the action itself
# via an explicit membership check so IDs cannot be enumerated by guessing.
current_user_has_ta_privileges? || current_user_has_student_privileges?
else
current_user_has_ta_privileges?
end
Expand Down Expand Up @@ -38,6 +43,19 @@ def list_participants
render json: { error: "Couldn't find Team" }, status: :not_found and return
end

# Students may only view rosters for teams they belong to.
# Without this check any authenticated student can enumerate arbitrary team memberships
# by guessing team IDs, since action_allowed? only requires student privileges.
# TA+ skip this guard — they have legitimate cross-team visibility.
unless current_user_has_ta_privileges?
# exists? fires a single SELECT 1 and avoids materialising the full user_id list.
# user_id is NOT NULL (enforced by DB constraint and model validation), so the
# column is safe to match against directly without a pluck intermediate.
unless TeamsParticipant.exists?(team_id: current_team.id, user_id: current_user.id)
render json: { error: 'You are not authorized to view this team' }, status: :forbidden and return
end
end

# Fetch all team participant records associated with the current team.
team_participants = TeamsParticipant.where(team_id: current_team.id)

Expand Down
3 changes: 1 addition & 2 deletions app/models/assignment_participant.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

class AssignmentParticipant < Participant
include ReviewAggregator
has_many :sent_invitations, class_name: 'Invitation', foreign_key: 'from_id'
has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id'
has_many :response_maps, foreign_key: 'reviewee_id'
Expand Down Expand Up @@ -51,6 +50,6 @@ def retract_sent_invitations
end

def aggregate_teammate_review_grade(teammate_review_mappings)
compute_average_review_score(teammate_review_mappings)
ResponseMap.compute_average_reviewer_score(teammate_review_mappings)
end
end
13 changes: 13 additions & 0 deletions app/models/assignment_questionnaire.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,17 @@
class AssignmentQuestionnaire < ApplicationRecord
belongs_to :assignment
belongs_to :questionnaire

validate :weight_must_be_zero_if_no_scored_questions

# If the linked questionnaire has no scored questions (i.e. only SectionHeaders),
# questionnaire_weight must be 0 — a non-zero weight would produce meaningless grades.
def weight_must_be_zero_if_no_scored_questions
return if questionnaire.nil? || questionnaire_weight.nil? || questionnaire_weight.zero?

has_scored = questionnaire.items.where.not(question_type: 'SectionHeader').exists?
unless has_scored
errors.add(:questionnaire_weight, 'must be 0 when the rubric contains no scored questions')
end
end
end
28 changes: 15 additions & 13 deletions app/models/assignment_team.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

class AssignmentTeam < Team
include Analytic::AssignmentTeamAnalytic
include ReviewAggregator
# Each AssignmentTeam must belong to a specific assignment
belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id'
has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewee_id'
Expand Down Expand Up @@ -107,17 +106,26 @@ def assign_reviewer(reviewer)
ReviewResponseMap.create(reviewee_id: id, reviewer_id: reviewer.get_reviewer.id, reviewed_object_id: assignment.id, team_reviewing_enabled: assignment.team_reviewing_enabled)
end

# Returns submitted files for this team.
# File storage is not yet implemented in the reimplementation — returns empty array as stub.
def submitted_files
[]
end

# Whether the team has submitted work or not
def has_submissions?
submitted_files.any? || submitted_hyperlinks.present?
end

# Computes the average review grade for an assignment team.
# This method aggregates scores from all ReviewResponseMaps (i.e., all reviewers of the team).
def aggregate_review_grade
compute_average_review_score(review_mappings)
# Computes the weighted average peer review grade for this team.
# Scopes maps to this assignment then delegates to ResponseMap.compute_average_reviewer_score.
def aggregate_reviewer_score
maps = review_mappings
.where(reviewed_object_id: parent_id)
.includes(responses: { scores: :item })
ResponseMap.compute_average_reviewer_score(maps)
end

# Adds a participant to this team.
# - Update the participant's team_id (so their direct reference is consistent)
# - Ensure there is a TeamsParticipant join record connecting the participant and this team
Expand All @@ -143,7 +151,7 @@ def remove_participant(participant)
# Remove the join record if it exists
tp = TeamsParticipant.find_by(team_id: id, participant_id: participant.id)
tp&.destroy

# Update the participant's team_id column - will remove the team reference inside participants table later. keeping it for now
# participant.update!(team_id: nil)

Expand Down Expand Up @@ -199,12 +207,6 @@ def has_submissions?
submitted_files.any? || submitted_hyperlinks.present?
end

# Computes the average review grade for an assignment team.
# This method aggregates scores from all ReviewResponseMaps (i.e., all reviewers of the team).
def aggregate_review_grade
compute_average_review_score(review_mappings)
end

protected

# Validates if a user is eligible to join the team
Expand Down
19 changes: 19 additions & 0 deletions app/models/concerns/expertiza_constants.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
# frozen_string_literal: true

module ExpertizaConstants
module DeadlineTypes
SUBMISSION = 1
REVIEW = 2
QUIZ = 6
DROP_TOPIC = 7
SIGNUP = 8
TEAM_FORMATION = 9

# Maps deadline_type_id to stage name, mirroring the old DeadlineType table
NAMES = {
SUBMISSION => 'submission',
REVIEW => 'review',
QUIZ => 'quiz',
DROP_TOPIC => 'drop_topic',
SIGNUP => 'signup',
TEAM_FORMATION => 'team_formation'
}.freeze
end

module ResponseMapTitles
ASSIGNMENT_SURVEY_RESPONSE_MAP_TITLE = 'Assignment Survey'
BOOKMARK_RATING_RESPONSE_MAP_TITLE = 'Bookmark Review'
Expand Down
22 changes: 0 additions & 22 deletions app/models/concerns/review_aggregator.rb

This file was deleted.

10 changes: 9 additions & 1 deletion app/models/due_date.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
class DueDate < ApplicationRecord
include Comparable
# Any due date with deadline_type_id == DueDate::REVIEW_DEADLINE_TYPE_ID (constant = 2) is treated as a review deadline and counts as a review round
REVIEW_DEADLINE_TYPE_ID = 2
REVIEW_DEADLINE_TYPE_ID = 2
# Named constants for teammate review statuses
ALLOWED = 3
LATE_ALLOWED = 2
Expand All @@ -15,6 +15,14 @@ class DueDate < ApplicationRecord

attr_accessor :teammate_review_allowed, :submission_allowed, :review_allowed

# Returns the current stage name for an assignment based on its next upcoming due date
def self.current_stage_for(assignment)
next_due = next_due_date(assignment)
return 'Finished' unless next_due

ExpertizaConstants::DeadlineTypes::NAMES[next_due.deadline_type_id] || 'Unknown'
end

def due_at_is_valid_datetime
errors.add(:due_at, 'must be a valid datetime') unless due_at.is_a?(Time)
end
Expand Down
8 changes: 7 additions & 1 deletion app/models/questionnaire.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,13 @@ def true_false_items?
false
end

def max_possible_score
# Pre-calculates the sum of weights for all scored items (excludes SectionHeaders).
# Used by ResponseMap#review_grade to normalize scores per round without repeating the query.
def total_item_weight
items.reject { |i| i.question_type == 'SectionHeader' }.sum(&:weight)
end

def max_possible_score
results = Questionnaire.joins('INNER JOIN items ON items.questionnaire_id = questionnaires.id')
.select('SUM(items.weight) * questionnaires.max_question_score as max_score')
.where('questionnaires.id = ?', id)
Expand Down
16 changes: 13 additions & 3 deletions app/models/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,19 @@ class Response < ApplicationRecord
alias map response_map
delegate :reviewer_assignment, :response_assignment, :reviewee, :reviewer, to: :map

# return the questionnaire that belongs to the response
# return the questionnaire that belongs to the response.
# For varying-round assignments, used_in_round matches response.round (1 or 2).
# For single-round assignments, used_in_round is nil in the DB while response.round is 1,
# so we fall back to the first AssignmentQuestionnaire when the round-specific lookup fails.
def questionnaire
reviewer_assignment.assignment_questionnaires.find_by(used_in_round: self.round).questionnaire
assignment_questionnaires = reviewer_assignment.assignment_questionnaires
# Primary lookup: find the AQ whose round matches this response's round.
aq = assignment_questionnaires.find_by(used_in_round: round)
# Fallback: single-round assignments store their AQ with used_in_round: nil.
# Falling back to nil-round (not .first) keeps the resolution deterministic —
# .first is arbitrary when multiple AQs exist for the same assignment.
aq ||= assignment_questionnaires.find_by(used_in_round: nil)
aq&.questionnaire
end

# Backward-compatible wrapper around ResponseMap#response_map_label.
Expand All @@ -30,7 +40,7 @@ def rubric_label

# Returns true if this response's score differs from peers by more than the assignment notification limit
# This comparison is response-specific (uses per-response max score and questionnaire settings),
# so it stays on Response instead of the generic ReviewAggregator mixin.
# so it stays on Response instead of ResponseMap.compute_average_reviewer_score.
def reportable_difference?
map_class = map.class
# gets all responses made by a reviewee
Expand Down
64 changes: 53 additions & 11 deletions app/models/response_map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,32 +81,74 @@ def survey?
false
end

# Computes the average score (as a fraction between 0 and 1) across the latest submitted responses
# from each round for corresponding ResponseMap.
def aggregate_reviewers_score
# Return nil if there are no responses for this map
# Computes the normalized score (0–1) for this map, weighted by each round's
# questionnaire_weight from assignment_questionnaires.
# Takes the latest submitted response per round, normalizes it (score/max),
# multiplies by that round's weight, and returns Σ(normalized × weight) / Σ(weight).
def review_grade
return nil if responses.empty?

# Group all responses by round, then select the latest one per round based on the most recent revision in that round.
latest_responses_by_round = responses
.group_by(&:round)
.transform_values { |resps| resps.max_by(&:updated_at) }

response_score = 0.0
total_score = 0.0
weighted_score = 0.0
total_weight = 0.0
submitted_found = false

latest_responses_by_round.each_value do |response|
next unless response.is_submitted

submitted_found = true
response_score += response.aggregate_questionnaire_score
total_score += response.maximum_score
max = response.maximum_score
next if max.nil? || max.zero?

aq = assignment.assignment_questionnaires.find_by(used_in_round: response.round)
aq ||= assignment.assignment_questionnaires.find_by(used_in_round: nil)
weight = aq&.questionnaire_weight&.to_f || 1.0

submitted_found = true
weighted_score += (response.aggregate_questionnaire_score.to_f / max) * weight
total_weight += weight
end

return nil unless submitted_found
return 0 if total_weight.zero?

weighted_score / total_weight
end

# All response map types expose a common aggregate_response_score interface.
# Subclasses (e.g. QuizResponseMap) may override if their scoring differs.
alias aggregate_response_score review_grade

# Computes a weighted average reviewer score from a collection of ResponseMaps.
# Each map contributes: review_grade × reviewer_reputation.
# reviewer_reputation defaults to 1.0 until Uchswas/Hamer integration is added.
# Used by AssignmentTeam#aggregate_reviewer_score and AssignmentParticipant#aggregate_teammate_review_grade.
def self.compute_average_reviewer_score(maps)
return nil if maps.blank?

weighted_sum = 0.0
total_weight = 0.0

maps.each do |map|
grade = map.review_grade
next if grade.nil?

reputation = reviewer_reputation_for(map)
weighted_sum += grade * reputation
total_weight += reputation
end

return nil if total_weight.zero?

(weighted_sum / total_weight * 100).round(2)
end

total_score.positive? ? (response_score.to_f / total_score) : 0
# Returns the reviewer's reputation weight for this map.
# Placeholder — replace with Uchswas review-grader lookup or Hamer score.
def self.reviewer_reputation_for(_map)
1.0
end

# Best-effort timestamp of when the reviewee (or their team) last touched the work.
Expand Down
Loading
Loading