-
Notifications
You must be signed in to change notification settings - Fork 194
Review report #351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Review report #351
Changes from all commits
6f32448
533cab0
8fd2d66
b9332a6
3e2529e
49871f8
b8f3ebc
a118c02
06e3dc9
36d78be
ea432c5
94cac89
1d96437
eeaa067
10a6a4c
52a6acb
f270833
4871ec9
ec2614a
0e5a061
afb74c5
bbeb1f1
acdbeb3
c4274fc
2c1bfd8
2ff9d8a
41f3944
8f2fa18
db61e26
a3e205d
7c641e6
33cf6c1
b53c92a
cde422b
e8b6bc3
0a5e6e1
f69797b
ca39294
91d2fef
73cbbdf
55c60c0
284246f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not expose raw exception messages in API responses. Line 37 returns 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 | ||
| 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 |
| 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 |
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needs a method comment.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
Uh oh!
There was an error while loading. Please reload this page.