Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6f32448
Add FeedbackReport pipeline and ReportsController dispatch
Apr 27, 2026
533cab0
Remove unused feedback_response_report model method
Apr 27, 2026
8fd2d66
Fix missing trailing newline in feedback_response_map.rb
Apr 27, 2026
b9332a6
Refactor AnswerTaggingReport to follow BaseReport pipeline pattern
May 24, 2026
3e2529e
Extract model scopes and fix indentation in answer tagging report
May 24, 2026
49871f8
Refactor ReviewReport pipelines for clarity and deduplication
May 24, 2026
b8f3ebc
Extract model scopes for ReviewReport pipeline queries
May 24, 2026
a118c02
Test commit to verify GitHub contribution tracking for aanand-1706
aanand-1706 May 24, 2026
06e3dc9
Refactor AnswerTaggingReport into two streaming pipelines
aanand-1706 May 24, 2026
36d78be
Simplify AnswerTaggingReport to match tags by answer_id instead of re…
aanand-1706 May 24, 2026
ea432c5
Fix ReviewReport bug and precompute max_q_scores once for both pipelines
aanand-1706 May 24, 2026
94cac89
Expand BaseReport comments to document grouper and accumulate roles
aanand-1706 May 24, 2026
1d96437
Rename grouper to state_key_for and deduplicate review responses by l…
aanand-1706 May 25, 2026
eeaa067
Update TeammateReviewReport to accumulate reviewees per reviewer
aanand-1706 May 25, 2026
10a6a4c
Document that BaseReport does not enforce state_key_for key usage in …
aanand-1706 May 25, 2026
52a6acb
Add shared state support to BaseReport and refactor TeammateReviewReport
aanand-1706 May 25, 2026
f270833
Refactor ReviewReport to use shared state and simplify score computation
aanand-1706 May 25, 2026
4871ec9
Fix stale comments in base_report and teammate_review_report
aanand-1706 May 25, 2026
ec2614a
Add answer_tagging report and use for_assignment factory in ReportsCo…
aanand-1706 May 25, 2026
0e5a061
Simplify ReportsController: POST-only, rename to fetch_response_report
aanand-1706 May 25, 2026
afb74c5
Merge branch 'main' into report-gen
aanand-1706 May 25, 2026
bbeb1f1
Add submit and unsubmit member routes for responses
aanand-1706 May 25, 2026
acdbeb3
Remove trailing whitespace in routes.rb
aanand-1706 May 25, 2026
c4274fc
Removing other parts not needed for review report
aanand-1706 May 25, 2026
2c1bfd8
Remove state_key_for from BaseReport and all reducers
aanand-1706 May 26, 2026
2ff9d8a
Clean up ReviewReport: remove full_name, inline ScoresReducer source,…
aanand-1706 May 26, 2026
41f3944
Tighten ReportsController: assignment-level auth and rename action to…
aanand-1706 May 28, 2026
8f2fa18
Fix wording: reduce-based -> reduction-based in BaseReport comment
aanand-1706 May 28, 2026
db61e26
Improve comments in ReviewReport and BaseReport
aanand-1706 May 28, 2026
a3e205d
Fix whitespace-only issues in routes.rb
aanand-1706 May 28, 2026
7c641e6
Rename BaseReport to BaseReducer for semantic correctness
aanand-1706 Jun 3, 2026
33cf6c1
Add BasicReport β€” minimal assignment metadata fallback report
aanand-1706 Jun 3, 2026
b53c92a
Simplify ReviewReport: replace ReviewersReducer with AR query + as_json
aanand-1706 Jun 11, 2026
cde422b
Fix ReviewReport scores, remove Reports namespace, add review seed data
aanand-1706 Jun 15, 2026
e8b6bc3
Restore module Reports namespace for Zeitwerk autoloading compatibility
aanand-1706 Jun 15, 2026
0a5e6e1
Improve ReviewReport comments to accurately describe each data source
aanand-1706 Jun 15, 2026
f69797b
Refactor ScoresReducer to stream ResponseMap rows with eager loading
aanand-1706 Jun 15, 2026
ca39294
Clarify ReviewReport output JSON structure in comments
aanand-1706 Jun 15, 2026
91d2fef
Revert schema.rb to main branch state
aanand-1706 Jun 15, 2026
73cbbdf
Revert seeds.rb to main branch state
aanand-1706 Jun 15, 2026
55c60c0
Remove extra blank lines in Response model
aanand-1706 Jun 15, 2026
284246f
Rename output keys: scores→reviewer_scores, avg_and_ranges→team_averages
aanand-1706 Jun 15, 2026
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
42 changes: 42 additions & 0 deletions app/controllers/reports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

class ReportsController < ApplicationController
REPORT_CLASSES = {
'basic' => Reports::BasicReport,
'review_response_map' => Reports::ReviewReport
}.freeze
Comment thread
coderabbitai[bot] marked this conversation as resolved.

before_action :set_assignment
before_action :authorize

# Only teaching staff (instructor or TA) of the specific assignment may view reports.
def action_allowed?
current_user_teaching_staff_of_assignment?(@assignment.id)
end

# POST /reports/fetch_report
# Returns the requested report as JSON.
def fetch_report
type = params[:type] || 'basic'

report_class = REPORT_CLASSES[type]
unless report_class

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I question whether this clause is useful. It seems to be testing whether the app is working correctly. That kind of check should be done by automated tests, not application code within the app.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The check isn't testing internal app correctness - type comes from user-supplied POST params, which means an unknown value is a realistic scenario. Without the guard, passing an invalid type would hit nil.for_assignment and raise a NoMethodError.

return render json: {
error: "Unknown report type: #{type}. Valid types: #{REPORT_CLASSES.keys.join(', ')}"
}, status: :unprocessable_entity
end

data = report_class.for_assignment(@assignment).run
render json: { type: type, assignment_id: @assignment.id }.merge(data)
rescue StandardError => e
render json: { error: e.message }, status: :internal_server_error
Comment on lines +31 to +32

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

Do not expose raw exception messages in API responses.

Line 37 returns e.message directly, which can leak internal details (SQL/error internals) to clients. Return a generic error payload and log the exception server-side.

Proposed fix
   rescue StandardError => e
-    render json: { error: e.message }, status: :internal_server_error
+    Rails.logger.error("ReportsController#fetch_response_report failed: #{e.class} - #{e.message}")
+    render json: { error: 'Internal server error' }, status: :internal_server_error
   end

end

private

def set_assignment
@assignment = Assignment.find(params[:assignment_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Assignment not found' }, status: :not_found
end
end
2 changes: 1 addition & 1 deletion app/models/Item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class Item < ApplicationRecord
belongs_to :questionnaire # each item belongs to a specific questionnaire
has_many :answers, dependent: :destroy, foreign_key: 'item_id'
attr_accessor :choice_strategy

validates :seq, presence: true, numericality: true # sequence must be numeric
validates :txt, length: { minimum: 0, allow_nil: false, message: "can't be nil" } # text content must be provided
validates :question_type, presence: true # user must define the item type
Expand Down
78 changes: 78 additions & 0 deletions app/models/reports/base_reducer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# frozen_string_literal: true

module Reports
# Base class for streaming reducers.
#
# Design rationale (addresses two anti-patterns from the naive approach):
#
# Anti-pattern 1 β€” "fetch_responses": loading all records into an unnamed
# ad-hoc array before processing wastes memory and forces the entire result
# set into Ruby-land. Instead, #run streams the source relation via
# find_each so memory usage scales with the number of *groups*, not rows.
#
# Anti-pattern 2 β€” "default metrics in base": encoding avg_score or any
# domain metric in the base class ties every reducer to one shape of math.
# This class contains *only* the reducer scaffold; each subclass owns its
# accumulate/finalize logic entirely.
#
# Subclasses must implement (private):
# source β†’ AR relation (consumed via find_each)
# initial_state β†’ empty accumulator value
# accumulate(state, row) β†’ mutates state in place; all grouping and domain
# math lives here, not in the base class.
#
# Subclasses may override (private):
# finalize(state) β†’ transforms finished state into the output hash
# (default: returns state unchanged)
class BaseReducer
# Factory method for assignment-scoped reports.
def self.for_assignment(assignment)
new(assignment)
end

# Factory method for course-scoped reports.
def self.for_course(course)
new(course)
end

# @param reportable [Assignment, Course] the object the report is scoped to.
# Subclasses reference @reportable instead of a type-specific variable so
# the same reducer works for any reportable entity.
def initialize(reportable)
@reportable = reportable
end

# Runs the reducer: stream β†’ accumulate β†’ finalize.
#
# Accepts an optional shared_state so that multiple reducers can write
# into the same hash without a merge loop. When shared_state is provided,
# initial_state is ignored β€” the coordinator owns state initialization.
# finalize is always called, even when shared_state is provided.
#
# Benefits of this structure over writing reducer code directly:
# 1. Memory safety β€” find_each streams in batches of 500 rather than
# loading the entire relation into Ruby. Every report gets this for free.
# 2. New reports are just data β€” subclasses define source/initial_state/
# accumulate/finalize; the reducer wiring is not their concern.
# 3. Single place for cross-cutting concerns β€” logging, timing, or error
# handling can be added here once and applies to every report.
def run(shared_state = nil)
state = shared_state || initial_state
source.find_each(batch_size: 500) do |row|
accumulate(state, row)
end
finalize(state)
end

private

def source = raise NotImplementedError, "#{self.class}#source"
def initial_state = raise NotImplementedError, "#{self.class}#initial_state"

def accumulate(_state, _row)
raise NotImplementedError, "#{self.class}#accumulate"
end

def finalize(state) = state
end
end
34 changes: 34 additions & 0 deletions app/models/reports/basic_report.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module Reports
# Basic report: minimal reportable metadata.
# Used as a fallback when no specific report type is requested.
# No streaming needed β€” all data comes from the already-loaded reportable.
class BasicReport
# Factory method for assignment-scoped reports.
def self.for_assignment(assignment)
new(assignment)
end

# Factory method for course-scoped reports.
def self.for_course(course)
new(course)
end

# @param reportable [Assignment, Course] the object the report is scoped to.
def initialize(reportable)
@reportable = reportable
end

def run
{
reportable: {
id: @reportable.id,
name: @reportable.name,
num_review_rounds: @reportable.num_review_rounds,
varying_rubrics_by_round: @reportable.varying_rubrics_by_round?
}
}
end
end
end
160 changes: 160 additions & 0 deletions app/models/reports/review_report.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# frozen_string_literal: true

module Reports
# Peer-review report: returns raw review rows (reviewer, reviewee, responses,
# scores) plus computed score percentages per reviewer and per-team averages.
#
# Three data sources:
#
# Review rows β€” loads all ReviewResponseMaps for the assignment with reviewer,
# reviewee, responses, and scores eagerly loaded, then serializes via as_json.
#
# ScoresReducer β€” streams ReviewResponseMap rows for the assignment. For each
# map, picks the latest submitted response per round (via
# latest_submitted_response_by_round) and computes a score percentage from
# the response's answers relative to the questionnaire's max score.
# Output: { reviewer_id => { reviewee_id => { round => score_pct } } }
#
# AvgRangesReducer β€” streams AssignmentTeam rows. For each team, the average
# review score is computed from its eagerly-loaded review_mappings and their
# nested responses and scores (via aggregate_review_grade).
# Output: { team_id => avg_score }
#
# Output:
# {
# reviews: [
# {
# id: Integer, # ReviewResponseMap id
# reviewer: {
# id: Integer, # AssignmentParticipant id
# user: { id: Integer, name: String }
# },
# reviewee: { id: Integer }, # AssignmentTeam id
# responses: [
# {
# id: Integer,
# round: Integer, # review round number (1-based)
# is_submitted: Boolean,
# additional_comment: String,
# scores: [
# {
# id: Integer,
# answer: Integer, # raw score value
# comments: String,
# item: { id: Integer, txt: String, weight: Integer }
# }
# ]
# }
# ]
# }
# ],
# reviewer_scores: {
# reviewer_id => {
# reviewee_id => {
# round => Float # score as a percentage (0–100)
# }
# }
# },
# team_averages: {
# team_id => Float # average review score across all reviewers, as a percentage (0–100)
# }
# }
class ReviewReport
# Whitelist for as_json β€” includes only fields the frontend needs,
# excluding internal columns (raw FKs, timestamps, STI type, etc.).
MAP_JSON_OPTIONS = {
only: [:id],
include: {
reviewer: {
only: [:id],
include: { user: { only: %i[id name] } }
},
reviewee: {
only: [:id],
include: { user: { only: %i[id name] } }
},
responses: {
only: %i[id round additional_comment is_submitted],
include: {
scores: {
only: %i[id answer comments],
include: { item: { only: %i[id txt weight] } }
}
}
}
}
}.freeze

def self.for_assignment(assignment)
new(assignment)
end

def initialize(reportable)
@reportable = reportable
end

def run

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Needs a method comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated

maps = ReviewResponseMap
.for_assignment(@reportable.id)
.includes(reviewer: :user, reviewee: :user, responses: { scores: :item })

{
reviews: maps.as_json(MAP_JSON_OPTIONS),
reviewer_scores: ScoresReducer.new(@reportable).run,
team_averages: AvgRangesReducer.new(@reportable).run
}
end

# -----------------------------------------------------------------------
# Reducer 1 β€” per-reviewer Γ— reviewee Γ— round score percentages.
# Streams ReviewResponseMap rows with responses and scores eagerly loaded.
# For each map, delegates to latest_submitted_response_by_round to pick
# the most recent submitted response per round without N+1 queries.
# Output: { reviewer_id => { reviewee_id => { round => score_pct } } }
# -----------------------------------------------------------------------
class ScoresReducer < BaseReducer
def source
ReviewResponseMap
.for_assignment(@reportable.id)
.includes(responses: { scores: :item })
end

def initial_state
Hash.new do |state, reviewer_id|
state[reviewer_id] = Hash.new { |by_reviewee, reviewee_id| by_reviewee[reviewee_id] = {} }
end
end

def accumulate(state, map)
map.latest_submitted_response_by_round.each do |round, response|
next if response.maximum_score.zero?

score_pct = (response.aggregate_questionnaire_score.to_f / response.maximum_score * 100).round(2)
state[map.reviewer_id][map.reviewee_id][round] = score_pct
end
end
end

# -----------------------------------------------------------------------
# Reducer 2 β€” per-team average review score.
# Streams AssignmentTeam rows with review_mappings, responses, and scores
# eagerly loaded to avoid N+1. Delegates score computation to
# aggregate_review_grade which picks the latest submitted response per
# round per map and normalizes the score.
# Output: { team_id => avg_score }
# -----------------------------------------------------------------------
class AvgRangesReducer < BaseReducer
def source
AssignmentTeam
.where(parent_id: @reportable.id)
.includes(review_mappings: { responses: { scores: :item } })
end

def initial_state = {}

def accumulate(state, team)
state[team.id] = team.aggregate_review_grade
end
end
end
end
2 changes: 1 addition & 1 deletion app/models/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def aggregate_questionnaire_score
sum = 0
scores.each do |s|
# For quiz responses, the weights will be 1 or 0, depending on if correct
sum += s.answer * s.item.weight unless s.answer.nil? #|| !s.item.scorable?
sum += s.answer * s.item.weight unless s.answer.nil? #|| !s.item.scorable?
end
# puts "sum: #{sum}"
sum
Expand Down
10 changes: 10 additions & 0 deletions app/models/response_map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ class ResponseMap < ApplicationRecord

alias map_id id

# Returns the latest submitted response per round for this map.
# Relies on responses being eager-loaded (e.g. via includes) to avoid N+1.
# Output: { round => response }
def latest_submitted_response_by_round
responses
.select(&:is_submitted)
.group_by(&:round)
.transform_values { |rs| rs.max_by(&:id) }
end

# Shared helper for Response#rubric_label; looks up the declarative constant so each map advertises its UI label
def response_map_label
const_name = "#{self.class.name.demodulize.underscore.upcase}_TITLE"
Expand Down
2 changes: 2 additions & 0 deletions app/models/review_response_map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ class ReviewResponseMap < ResponseMap
include ExpertizaConstants::ResponseMapTitles
belongs_to :reviewee, class_name: 'Team', foreign_key: 'reviewee_id', inverse_of: false

scope :for_assignment, ->(assignment_id) { where(reviewed_object_id: assignment_id) }

# Returns the assignment associated with this review map.
def reviewer_assignment
return assignment
Expand Down
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@
delete :delete_participants
end
end
resources :reports, only: [] do
collection do
post :fetch_report
end
end

resources :grades do
collection do
get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores'
Expand Down
Loading