Skip to content
158 changes: 158 additions & 0 deletions app/controllers/course_reports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# frozen_string_literal: true

class CourseReportsController < ApplicationController
class FinalDueDateNotReviewDeadlineError < StandardError; end

# only restruct for course staff (TA + instr)
def action_allowed?
case params[:action]
when 'index'
course = Course.find_by(id: params[:course_id])
return current_user_has_instructor_privileges? || current_user_has_ta_privileges? if course.nil?

current_user_teaching_staff_of_course?(course)
else
false
end
end

# GET /course_reports
# Returns a table for all assignments in the given course,
# with students as rows and assignments as horizontal columns.
def index
course = Course.find_by(id: params[:course_id])
return render json: { error: 'Course not found' }, status: :not_found unless course

assignments = assignments_ordered_by(course, :final_review_due_date)
assignment_ids = assignments.map(&:id)
participants = AssignmentParticipant
.includes(:user, :assignment)
.where(parent_id: assignment_ids)

student_rows = participants
.group_by(&:user_id)
.values
.map { |student_participants| build_student_row(assignments, student_participants) }
.sort_by { |row| row[:user_name].downcase }

render json: course_report_response(course, assignments, student_rows), status: :ok
rescue FinalDueDateNotReviewDeadlineError => e
render json: { error: e.message }, status: :internal_server_error
end

private

def current_user_teaching_staff_of_course?(course)
user_logged_in? && (
course.instructor_id == current_user.id ||
TaMapping.exists?(user_id: current_user.id, course_id: course.id)
)
end

# metadata col
# indicates whether an assignment has optional columns eg topics.
def assignment_column(assignment)
{
assignment_id: assignment.id,
assignment_name: assignment.name,
has_topics: !!assignment.has_topics
}
end

def assignments_ordered_by(course, field)
course.assignments.includes(:due_dates).sort_by do |assignment|
[assignment_sort_value(assignment, field), assignment.id]
end
end

def assignment_sort_value(assignment, field)
case field
when :final_review_due_date
final_review_due_date_for(assignment)
else
assignment.public_send(field)
end
end

# function to retrieve final review due date from the db:
# Raises FinalDueDateNotReviewDeadline as error
def final_review_due_date_for(assignment)
final_due_date = assignment.due_dates.max_by(&:due_at)
return final_due_date.due_at if final_due_date&.deadline_type_id == DueDate::REVIEW_DEADLINE_TYPE_ID

# At this point of the project, all assignments are peer review assignments,
# so the final deadline is bound to be a review deadline, hence this guard
#
#Replace this with code in the incident that non peer review assignments are introduced
raise FinalDueDateNotReviewDeadlineError,
"Final due date for assignment #{assignment.id} is not a review deadline"
Comment on lines +79 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter by review-deadline type before picking the “final” review deadline.

At Line 72, max_by(&:due_at) runs across all due dates. If a later non-review deadline exists, Line 73 fails the type check and the endpoint returns 500 even when valid review deadlines are present.

Proposed fix
 def final_review_due_date_for(assignment)
-  final_due_date = assignment.due_dates.max_by(&:due_at)
-  return final_due_date.due_at if final_due_date&.deadline_type_id == DueDate::REVIEW_DEADLINE_TYPE_ID
+  final_review_due_date = assignment.due_dates
+                                    .select { |d| d.deadline_type_id == DueDate::REVIEW_DEADLINE_TYPE_ID }
+                                    .max_by(&:due_at)
+  return final_review_due_date.due_at if final_review_due_date
 
   # At this point of the project, all assignments are peer review assignments,
   # so the final deadline is bound to be a review deadline, hence this guard
   #
   `#Replace` this with code in the incident that non peer review assignments are introduced
   raise FinalDueDateNotReviewDeadlineError,
         "Final due date for assignment #{assignment.id} is not a review deadline"
 end
🧰 Tools
🪛 RuboCop (1.86.1)

[convention] 77-77: Trailing whitespace detected.

(Layout/TrailingWhitespace)


[convention] 78-78: Missing space after #.

(Layout/LeadingCommentSpace)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/course_reports_controller.rb` around lines 71 - 80,
final_review_due_date_for currently picks the latest due date across all types
then checks its type, causing a 500 if a later non-review deadline exists;
update final_review_due_date_for to first filter assignment.due_dates for
entries where deadline_type_id == DueDate::REVIEW_DEADLINE_TYPE_ID (e.g., using
select or where on the association), then call max_by(&:due_at) on that filtered
collection, return the due_at if found, and only raise
FinalDueDateNotReviewDeadlineError when no review-type due dates are present;
reference the method final_review_due_date_for, the
DueDate::REVIEW_DEADLINE_TYPE_ID constant, and the raise site to implement this
change.

end

# function to build full response, along with the metadata + student rows.
def course_report_response(course, assignments, student_rows)
{
course_id: course.id,
course_name: course.name,
assignments: assignments.map { |assignment| assignment_column(assignment) },
students: student_rows
}
end

# function to build each row of students (students x assignment matrix)
def build_student_row(assignments, student_participants)
first_participant = student_participants.first
participant_by_assignment = student_participants.index_by(&:parent_id)

{
user_id: first_participant.user_id,
user_name: first_participant.user_name,
assignments: assignment_cells_for_student(assignments, participant_by_assignment)
}
end

# per user assignment stats, combines assignment cells (next call )with student row
def assignment_cells_for_student(assignments, participant_by_assignment)
assignments.to_h do |assignment|
participant = participant_by_assignment[assignment.id]
[assignment.id.to_s, participant ? build_assignment_cell(assignment, participant) : nil]
end
end

# building per-assignment cell. each cell corresponds to each assignment in a single student row.
def build_assignment_cell(assignment, participant)
team = participant.team

{
participant_id: participant.id,
peer_grade: team&.aggregate_review_grade,
instructor_grade: team&.grade_for_submission,
avg_teammate_score: participant.aggregate_teammate_review_grade(teammate_review_maps_for(assignment, participant)),
avg_author_feedback_score: participant.aggregate_teammate_review_grade(author_feedback_maps_for(assignment, participant))
}.tap do |cell| # optional topic col
cell[:topic] = topic_name_for(assignment, participant) if assignment.has_topics
end
end

# get topic name if exists
def topic_name_for(assignment, participant)
return unless assignment.has_topics

team_id = TeamsParticipant.find_by(participant_id: participant.id)&.team_id
return unless team_id

SignedUpTeam.find_by(team_id: team_id)&.project_topic&.topic_name
end

# response maps for teammate review.

def teammate_review_maps_for(assignment, participant)
TeammateReviewResponseMap.where(reviewed_object_id: assignment.id, reviewee_id: participant.id)
end

# response maps for auth feedback
def author_feedback_maps_for(assignment, participant)
review_maps = ReviewResponseMap.where(reviewed_object_id: assignment.id, reviewer_id: participant.id)

FeedbackResponseMap.where(reviewed_object_id: review_maps.select(:id), reviewee_id: participant.id)
end
Comment on lines +122 to +157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This report builder currently has an N+1 query cascade.

At Line 100-Line 131, each assignment cell triggers additional DB calls (participant.team, teammate maps, author feedback maps, topic lookup). On real courses, this will degrade badly.

Refactor direction (preload once, then lookup in-memory)
 def index
   course = Course.find_by(id: params[:course_id])
   return render json: { error: 'Course not found' }, status: :not_found unless course
 
   assignments = assignments_ordered_by_final_review_due_date(course)
   assignment_ids = assignments.map(&:id)
-  participants = AssignmentParticipant
-    .includes(:user, :assignment)
+  participants = AssignmentParticipant
+    .includes(:user, :assignment)
     .where(parent_id: assignment_ids)
+
+  participant_ids = participants.map(&:id)
+  teammate_maps = TeammateReviewResponseMap.where(reviewed_object_id: assignment_ids, reviewee_id: participant_ids)
+                                           .group_by { |m| [m.reviewed_object_id, m.reviewee_id] }
+  review_maps = ReviewResponseMap.where(reviewed_object_id: assignment_ids, reviewer_id: participant_ids)
+  feedback_maps = FeedbackResponseMap.where(reviewed_object_id: review_maps.select(:id), reviewee_id: participant_ids)
+                                     .group_by(&:reviewee_id)
 
   student_rows = participants
     .group_by(&:user_id)
     .values
-    .map { |student_participants| build_student_row(assignments, student_participants) }
+    .map { |student_participants| build_student_row(assignments, student_participants, teammate_maps, feedback_maps) }
     .sort_by { |row| row[:user_name].downcase }
🧰 Tools
🪛 RuboCop (1.86.1)

[convention] 107-107: Line is too long. [121/120]

(Layout/LineLength)


[convention] 108-108: Line is too long. [127/120]

(Layout/LineLength)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/course_reports_controller.rb` around lines 100 - 131,
build_assignment_cell performs per-row DB calls (participant.team,
teammate_review_maps_for, author_feedback_maps_for, topic_name_for) causing
N+1s; preload all needed data once and replace those lookups with in-memory
lookups. Specifically, before iterating assignments/participants load
participants' teams and teams_participants, SignedUpTeam records,
TeammateReviewResponseMap rows (filter by assignment.id and participant.ids),
ReviewResponseMap rows (filter by assignment.id and reviewer_ids), and
FeedbackResponseMap rows (filter by review_map ids), then update
build_assignment_cell to read from these preloaded hashes/relations instead of
calling participant.team, TeamsParticipant.find_by,
TeammateReviewResponseMap.where, ReviewResponseMap.where and
FeedbackResponseMap.where so that topic_name_for, teammate_review_maps_for and
author_feedback_maps_for use the in-memory collections.

end
28 changes: 15 additions & 13 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,23 @@
end
end

resources :participants do
collection do
get '/user/:user_id', to: 'participants#list_user_participants'
get '/assignment/:assignment_id', to: 'participants#list_assignment_participants'
get '/:id', to: 'participants#show'
resources :participants do
collection do
get '/user/:user_id', to: 'participants#list_user_participants'
get '/assignment/:assignment_id', to: 'participants#list_assignment_participants'
get '/:id', to: 'participants#show'
post '/:authorization', to: 'participants#add'
patch '/:id/:authorization', to: 'participants#update_authorization'
delete '/:id', to: 'participants#destroy'
end
end

resources :student_teams, only: %i[create update] do
collection do
get :view
get :mentor
delete '/:id', to: 'participants#destroy'
end
end
Comment on lines +157 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix indentation/trailing whitespace in the new route blocks.

Line 157-Line 166, Line 168, and Line 172 currently trigger RuboCop layout cops; this can fail lint gates.

Suggested cleanup
-      resources :participants do
-        collection do
-          get '/user/:user_id', to: 'participants#list_user_participants'
-          get '/assignment/:assignment_id', to: 'participants#list_assignment_participants'
-          get '/:id', to: 'participants#show'
+  resources :participants do
+    collection do
+      get '/user/:user_id', to: 'participants#list_user_participants'
+      get '/assignment/:assignment_id', to: 'participants#list_assignment_participants'
+      get '/:id', to: 'participants#show'
           post '/:authorization', to: 'participants#add'
           patch '/:id/:authorization', to: 'participants#update_authorization'
-          delete '/:id', to: 'participants#destroy'
-        end
-      end
+      delete '/:id', to: 'participants#destroy'
+    end
+  end
 
-      resources :course_reports, only: [:index]
+  resources :course_reports, only: [:index]
 
-      resources :student_teams, only: %i[create update] do
-        collection do
-          get :view          
-          get :mentor
+  resources :student_teams, only: %i[create update] do
+    collection do
+      get :view
+      get :mentor

Also applies to: 170-173

🧰 Tools
🪛 RuboCop (1.86.1)

[convention] 157-166: Inconsistent indentation detected.

(Layout/IndentationConsistency)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/routes.rb` around lines 157 - 166, Fix the layout issues in the
participants routes block by normalizing indentation and removing trailing
whitespace: align the nested collection do ... end block consistently with the
resources :participants line and ensure each route line (e.g., get
'/user/:user_id' -> 'participants#list_user_participants', get
'/assignment/:assignment_id' -> 'participants#list_assignment_participants', get
'/:id' -> 'participants#show', post '/:authorization' -> 'participants#add',
patch '/:id/:authorization' -> 'participants#update_authorization', delete
'/:id' -> 'participants#destroy') has no trailing spaces and uses two-space
indentation (or the project's standard) so RuboCop layout cops no longer fail.


resources :course_reports, only: [:index]

resources :student_teams, only: %i[create update] do
collection do
get :view
get :mentor
get :remove_participant
put '/leave', to: 'student_teams#leave_team'
end
Expand Down
Loading
Loading