diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb new file mode 100644 index 000000000..9f94af676 --- /dev/null +++ b/app/controllers/reports_controller.rb @@ -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 + 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 + 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 diff --git a/app/models/Item.rb b/app/models/Item.rb index 0b6535228..807bcf06a 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -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 diff --git a/app/models/reports/base_reducer.rb b/app/models/reports/base_reducer.rb new file mode 100644 index 000000000..e9eb34475 --- /dev/null +++ b/app/models/reports/base_reducer.rb @@ -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 \ No newline at end of file diff --git a/app/models/reports/basic_report.rb b/app/models/reports/basic_report.rb new file mode 100644 index 000000000..a1b5ac614 --- /dev/null +++ b/app/models/reports/basic_report.rb @@ -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 diff --git a/app/models/reports/review_report.rb b/app/models/reports/review_report.rb new file mode 100644 index 000000000..f97b98512 --- /dev/null +++ b/app/models/reports/review_report.rb @@ -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 + 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 \ No newline at end of file diff --git a/app/models/response.rb b/app/models/response.rb index c6233dabe..e8ed2d7ba 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -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 diff --git a/app/models/response_map.rb b/app/models/response_map.rb index bac967bfb..4fb0e6df8 100644 --- a/app/models/response_map.rb +++ b/app/models/response_map.rb @@ -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" diff --git a/app/models/review_response_map.rb b/app/models/review_response_map.rb index e339e1b62..0a8e02697 100644 --- a/app/models/review_response_map.rb +++ b/app/models/review_response_map.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 47da3d9e0..f8ee581b4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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'