diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb new file mode 100644 index 000000000..b4171d5eb --- /dev/null +++ b/app/controllers/reports_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class ReportsController < ApplicationController + REPORT_CLASSES = { + 'review_response_map' => Reports::ReviewReport, + 'feedback_response_map' => Reports::FeedbackReport, + 'teammate_review_response_map' => Reports::TeammateReviewReport, + 'bookmark_rating_response_map' => Reports::BookmarkRatingReport, + 'answer_tagging' => Reports::AnswerTaggingReport, + 'basic' => Reports::BasicReport + }.freeze + + # Only TAs, instructors, and admins may view reports. + def action_allowed? + current_user_has_ta_privileges? + end + + # POST /reports/fetch_response_report + # Returns the requested report as JSON. + def fetch_response_report + assignment_id = params[:assignment_id] + 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 + + assignment = Assignment.find(assignment_id) + data = report_class.for_assignment(assignment).run + render json: { type: type, assignment_id: assignment_id.to_i }.merge(data) + rescue ActiveRecord::RecordNotFound + render json: { error: 'Assignment not found' }, status: :not_found + rescue StandardError => e + render json: { error: e.message }, status: :internal_server_error + end +end diff --git a/app/helpers/report_formatter_helper.rb b/app/helpers/report_formatter_helper.rb new file mode 100644 index 000000000..6297c0fca --- /dev/null +++ b/app/helpers/report_formatter_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Report logic has been moved to app/services/reports/. +# This module is retained as a namespace placeholder for any future +# view-layer formatting helpers. +module ReportFormatterHelper +end diff --git a/app/models/Item.rb b/app/models/Item.rb index 0b6535228..a908656ef 100644 --- a/app/models/Item.rb +++ b/app/models/Item.rb @@ -5,6 +5,8 @@ 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 + + scope :for_questionnaire_and_type, ->(questionnaire_id, question_type) { where(questionnaire_id: questionnaire_id, question_type: question_type) } 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 diff --git a/app/models/answer.rb b/app/models/answer.rb index 3c3320afe..a62268d94 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -3,4 +3,17 @@ class Answer < ApplicationRecord belongs_to :response belongs_to :item + + scope :for_items_and_responses, ->(item_ids, response_ids) { where(item_id: item_ids, response_id: response_ids) } + + scope :taggable_for_assignment, ->(assignment_id, item_ids, type:, threshold: nil) { + scope = joins(response: :response_map) + .where( + item_id: item_ids, + responses: { is_submitted: true }, + response_maps: { reviewed_object_id: assignment_id, type: type } + ) + scope = scope.where('LENGTH(answers.comments) > ?', threshold) if threshold + scope + } end diff --git a/app/models/answer_tag.rb b/app/models/answer_tag.rb new file mode 100644 index 000000000..003649411 --- /dev/null +++ b/app/models/answer_tag.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AnswerTag < ApplicationRecord + belongs_to :answer + belongs_to :tag_prompt_deployment + belongs_to :user + + scope :for_deployment, ->(deployment_id) { where(tag_prompt_deployment_id: deployment_id) } + + validates :answer_id, presence: true + validates :tag_prompt_deployment_id, presence: true + validates :user_id, presence: true + validates :value, presence: true +end diff --git a/app/models/assignment_questionnaire.rb b/app/models/assignment_questionnaire.rb index 87a55883d..3efa4e9bc 100644 --- a/app/models/assignment_questionnaire.rb +++ b/app/models/assignment_questionnaire.rb @@ -3,4 +3,8 @@ class AssignmentQuestionnaire < ApplicationRecord belongs_to :assignment belongs_to :questionnaire + + scope :for_assignment_and_type, ->(assignment_id, questionnaire_type) { + joins(:questionnaire).where(assignment_id: assignment_id, questionnaires: { questionnaire_type: questionnaire_type }) + } end diff --git a/app/models/reports/answer_tagging_report.rb b/app/models/reports/answer_tagging_report.rb new file mode 100644 index 000000000..c74a5e5dc --- /dev/null +++ b/app/models/reports/answer_tagging_report.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +module Reports + # Answer-tagging report: shows per-user tagging progress for each + # TagPromptDeployment on the assignment, plus a cross-deployment summary. + # + # Output shape: + # { + # questionnaire_tagging_report: { + # => { + # questionnaire_name:, prompt:, question_type:, + # answer_length_threshold:, + # user_stats: [{ user_id:, name:, full_name:, percentage:, + # cnt_tagged:, cnt_not_tagged:, cnt_taggable:, + # tag_update_intervals: }] + # } + # }, + # user_tagging_report: { + # => { user_id:, name:, full_name:, percentage:, + # cnt_tagged:, cnt_not_tagged:, cnt_taggable: } + # } + # } + class AnswerTaggingReport + def self.for_assignment(assignment) + new(assignment) + end + + def initialize(assignment) + @reportable = assignment + end + + def run + per_deployment = {} + # The coordinator runs one DeploymentReducer per TagPromptDeployment, + # then passes the results to UserSummaryReducer for cross-deployment totals. + TagPromptDeployment + .where(assignment_id: @reportable.id) + .includes(:tag_prompt, :questionnaire) + .each do |deployment| + per_deployment[deployment.id] = DeploymentReducer.new(@reportable, deployment).run + end + user_summary = UserSummaryReducer.new(per_deployment).run + { + questionnaire_tagging_report: per_deployment, + user_tagging_report: user_summary + } + end + + # ------------------------------------------------------------------------- + # Coordinator — runs two streaming reducers for one TagPromptDeployment + # and merges their results: + # + # TaggableAnswersReducer — streams taggable Answer rows (joined with + # response_maps, filtered by item type and threshold). Returns per-user + # lists of taggable answer_ids. + # Output: { user_id => [answer_ids] } + # + # TaggingStatsReducer — streams all AnswerTag rows for this deployment + # (joined with answers to get answer_id). No item/threshold filtering + # in SQL — filtering happens in finalize by comparing answer_ids. + # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } + # + # Precomputed once, shared across both reducers: + # @item_ids — IDs of items in the deployment's questionnaire (tiny) + # @users_by_team — TeamsUser records grouped by team_id; also used in + # finalize to build per-user name info + # ------------------------------------------------------------------------- + class DeploymentReducer + def initialize(reportable, deployment) + @reportable = reportable + @deployment = deployment + @item_ids = Item.for_questionnaire_and_type(deployment.questionnaire_id, deployment.question_type).pluck(:id) + @users_by_team = TeamsUser.for_assignment(deployment.assignment_id).includes(:user).group_by(&:team_id) + end + + def run + taggable_data = TaggableAnswersReducer.new(@reportable, @deployment, @item_ids, @users_by_team).run + tagging_stats = TaggingStatsReducer.new(@reportable, @deployment).run + finalize(taggable_data, tagging_stats) + end + + private + + def finalize(taggable_data, tagging_stats) + user_info = @users_by_team.values.flatten.each_with_object({}) do |teams_user, info| + info[teams_user.user_id] = { name: teams_user.user.name, full_name: teams_user.user.full_name } + end + + user_stats = user_info.map do |user_id, info| + taggable_answer_ids = taggable_data.fetch(user_id, []).to_set + cnt_taggable = taggable_answer_ids.size + + # Filter tags to only those on answers that are taggable for this user. + # TODO: confirm with prof — if a reviewer submits multiple responses for the + # same round, only the latest submitted response should be counted as taggable. + # TaggableAnswersReducer may need to deduplicate by keeping only the most + # recently submitted response per (reviewer, round). + matching_tags = tagging_stats.fetch(user_id, []).select { |tag| taggable_answer_ids.include?(tag[:answer_id]) } + cnt_tagged = matching_tags.size + cnt_not_tagged = cnt_taggable - cnt_tagged + + tag_times_in_order = matching_tags.map { |tag| tag[:updated_at] }.sort + intervals_between_tags = tag_times_in_order.each_cons(2).map { |earlier, later| later - earlier } + + { + user_id: user_id, + name: info[:name], + full_name: info[:full_name], + percentage: cnt_taggable.zero? ? '0.0' : format('%.1f', cnt_tagged.to_f / cnt_taggable * 100), + cnt_tagged: cnt_tagged, + cnt_not_tagged: cnt_not_tagged, + cnt_taggable: cnt_taggable, + tag_update_intervals: intervals_between_tags + } + end + + { + questionnaire_name: @deployment.questionnaire.name, + prompt: @deployment.tag_prompt.prompt, + question_type: @deployment.question_type, + answer_length_threshold: @deployment.answer_length_threshold, + user_stats: user_stats + } + end + + # ----------------------------------------------------------------------- + # Reducer 1 — per-user taggable answer IDs. + # + # Streams taggable Answer rows (joined with responses and response_maps, + # filtered by item type and length threshold). Each row is one (answer, team) + # pair — answers.id is unique per row so find_each paginates correctly. + # For each row, adds the answer_id to all users of the team. + # + # Output: { user_id => [answer_ids] } + # ----------------------------------------------------------------------- + class TaggableAnswersReducer < BaseReport + def initialize(reportable, deployment, item_ids, users_by_team) + super(reportable) + @deployment = deployment + @item_ids = item_ids + @users_by_team = users_by_team + end + + def source + return Answer.none if @item_ids.empty? + + Answer + .taggable_for_assignment( + @deployment.assignment_id, @item_ids, + type: 'ReviewResponseMap', + threshold: @deployment.answer_length_threshold + ) + .select('answers.id, response_maps.reviewee_id as team_id') + end + + def state_key_for = ->(answer) { answer.team_id } + + def initial_state + Hash.new { |state, user_id| state[user_id] = [] } + end + + def accumulate(state, team_id, answer) + (@users_by_team[team_id] || []).each do |teams_user| + state[teams_user.user_id] << answer.id + end + end + end + + # ----------------------------------------------------------------------- + # Reducer 2 — per-user answer tags with answer context. + # + # Streams all AnswerTag rows for this deployment (joined with answers). + # No item or threshold filtering in SQL — the finalize step filters tags + # by comparing their answer_id against the taggable answer_ids from + # Reducer 1. + # + # Output: { user_id => [{ answer_id:, response_id:, updated_at: }] } + # ----------------------------------------------------------------------- + class TaggingStatsReducer < BaseReport + def initialize(reportable, deployment) + super(reportable) + @deployment = deployment + end + + def source + AnswerTag + .for_deployment(@deployment.id) + .joins(:answer) + .select('answer_tags.id, answer_tags.user_id, answer_tags.answer_id, answer_tags.updated_at, answers.response_id') + end + + def state_key_for = ->(tag) { tag.user_id } + + def initial_state + Hash.new { |state, user_id| state[user_id] = [] } + end + + def accumulate(state, user_id, tag) + state[user_id] << { answer_id: tag.answer_id, response_id: tag.response_id, updated_at: tag.updated_at } + end + end + end + + # ------------------------------------------------------------------------- + # Reducer 3 — cross-deployment per-user summary. + # Consumes DeploymentReducer output; no additional DB queries. + # ------------------------------------------------------------------------- + class UserSummaryReducer + def initialize(per_deployment_result) + @per_deployment = per_deployment_result + end + + def run + summary = {} + @per_deployment.each_value do |deployment_data| + deployment_data[:user_stats].each do |stat| + key = stat[:user_id] + if summary.key?(key) + entry = summary[key] + entry[:cnt_tagged] += stat[:cnt_tagged] + entry[:cnt_not_tagged] += stat[:cnt_not_tagged] + entry[:cnt_taggable] += stat[:cnt_taggable] + entry[:percentage] = entry[:cnt_taggable].zero? ? '-' : format('%.1f', entry[:cnt_tagged].to_f / entry[:cnt_taggable] * 100) + else + summary[key] = stat.slice(:user_id, :name, :full_name, :cnt_tagged, :cnt_not_tagged, :cnt_taggable, :percentage) + end + end + end + summary + end + end + end +end diff --git a/app/models/reports/base_report.rb b/app/models/reports/base_report.rb new file mode 100644 index 000000000..d11718cc5 --- /dev/null +++ b/app/models/reports/base_report.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Reports + # Template for a streaming reduce-based report. + # + # 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 report 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) + # state_key_for → lambda(row) → state bucket key + # Separates "what bucket does this row belong to?" from + # "what do I do with a row in that bucket?" (accumulate). + # BaseReport#run calls state_key_for.call(row) and passes the + # result as the key to accumulate — subclasses get this + # wiring for free and can see at a glance what each + # reducer is aggregating over. Examples: + # ScoresReducer groups by reviewer_id — all responses + # from the same reviewer go into the same bucket + # AvgRangesReducer groups by team_id — all review mappings + # for the same team go into the same bucket + # TaggableAnswersReducer groups by team_id — all answers + # received by the same team go into the same bucket + # initial_state → empty accumulator value + # accumulate(state, key, row) → mutates state in place; key is the result + # of state_key_for.call(row). Answers "what do I do with a row + # in this bucket?" — all domain math lives here, not in + # the base class. Note: BaseReport passes the key but does not + # enforce that accumulate uses it — subclasses are trusted to + # use it as the state bucket key; using a different key silently + # breaks the grouping contract. + # + # Subclasses may override (private): + # finalize(state) → transforms finished state into the output hash + # (default: returns state unchanged) + class BaseReport + # 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 → group → 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 report 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/state_key_for/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, state_key_for.call(row), row) + end + finalize(state) + end + + private + + def source = raise NotImplementedError, "#{self.class}#source" + def state_key_for = raise NotImplementedError, "#{self.class}#state_key_for" + def initial_state = raise NotImplementedError, "#{self.class}#initial_state" + + def accumulate(_state, _key, _row) + raise NotImplementedError, "#{self.class}#accumulate" + end + + def finalize(state) = state + end +end diff --git a/app/models/reports/bookmark_rating_report.rb b/app/models/reports/bookmark_rating_report.rb new file mode 100644 index 000000000..a6cdc95e9 --- /dev/null +++ b/app/models/reports/bookmark_rating_report.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Reports + # Bookmark-rating report: which bookmarks were rated under this assignment + # and the associated project topics. + # + # Accumulator: groups BookmarkRatingResponseMap rows by reviewee_id (the + # bookmark being rated) and collects distinct bookmark IDs in one pass. + # Project topics are fetched separately (one query) since they are not + # streamed per row. + class BookmarkRatingReport < BaseReport + def source + BookmarkRatingResponseMap.where(reviewed_object_id: @reportable.id) + end + + def state_key_for = ->(map) { map.reviewee_id } + def initial_state = Set.new + + def accumulate(state, bookmark_id, _map) + state.add(bookmark_id) + end + + def finalize(bookmark_ids) + topics = @reportable.project_topics.map { |t| { id: t.id, topic_name: t.topic_name } } + { bookmark_ids: bookmark_ids.to_a, topics: topics } + end + end +end diff --git a/app/models/reports/feedback_report.rb b/app/models/reports/feedback_report.rb new file mode 100644 index 000000000..122a07303 --- /dev/null +++ b/app/models/reports/feedback_report.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Reports + # Author-feedback report: shows the latest review response IDs that received + # author feedback, bucketed by round for varying-rubric assignments. + # + # Pipeline: + # source — Responses on ReviewResponseMaps for this assignment, + # ordered newest-first so the first occurrence per (map, round) + # is the latest revision. + # state_key_for — [map_id, round]: deduplication key (one response per map per round) + # accumulate — skips duplicates; buckets response IDs by round + # finalize — fetches authors (one query), returns shaped hash + class FeedbackReport < BaseReport + def source + Response + .joins(:response_map) + .where(response_maps: { type: 'ReviewResponseMap', reviewed_object_id: @reportable.id }) + .order(created_at: :desc) + end + + def state_key_for = ->(r) { [r.map_id, r.round] } + + def initial_state + { seen: Set.new, round_1: [], round_2: [], round_3: [], all: [] } + end + + def accumulate(state, key, response) + return if state[:seen].include?(key) + + state[:seen].add(key) + + if @reportable.varying_rubrics_by_round? + case response.round + when 1 then state[:round_1] << response.id + when 2 then state[:round_2] << response.id + when 3 then state[:round_3] << response.id + end + else + state[:all] << response.id + end + end + + def finalize(state) + authors = fetch_authors + + if @reportable.varying_rubrics_by_round? + { + authors: authors.map { |a| format_participant(a) }, + review_response_ids: { + round_1: state[:round_1], + round_2: state[:round_2], + round_3: state[:round_3] + } + } + else + { + authors: authors.map { |a| format_participant(a) }, + review_response_ids: state[:all] + } + end + end + + private + + def fetch_authors + teams = AssignmentTeam.includes(:users).where(parent_id: @reportable.id) + teams.flat_map do |team| + team.users.filter_map do |user| + AssignmentParticipant.find_by(parent_id: @reportable.id, user_id: user.id) + end + end + end + + def format_participant(p) + return {} unless p + + { + id: p.id, + user_id: p.user_id, + name: p.user&.name, + full_name: p.user&.full_name + } + 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..2720da4e6 --- /dev/null +++ b/app/models/reports/review_report.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Reports + # Peer-review report composed of three streaming reducers. + # + # ReviewersReducer and ScoresReducer share a single state keyed by reviewer_id + # so reviewer info and scores are co-located without a merge loop: + # { reviewer_id => { id:, user_id:, name:, full_name:, handle:, + # scores: { reviewee_id => { round => score_pct } } } } + # + # AvgRangesReducer runs independently: + # { team_id => avg_score } + # + # Each reducer streams its source via find_each — no full result set is ever + # materialised in Ruby at once. Scores and questionnaires are eagerly loaded + # to avoid N+1 inside calculate_total_score and maximum_score (ScorableHelper). + class ReviewReport + def self.for_assignment(assignment) + new(assignment) + end + + def initialize(reportable) + @reportable = reportable + end + + def run + shared_state = Hash.new do |state, reviewer_id| + state[reviewer_id] = { + id: nil, + user_id: nil, + name: nil, + full_name: nil, + handle: nil, + scores: Hash.new { |by_reviewee, reviewee_id| by_reviewee[reviewee_id] = {} } + } + end + + ReviewersReducer.new(@reportable).run(shared_state) + ScoresReducer.new(@reportable).run(shared_state) + + { + reviewers: shared_state.values.sort_by { |r| r[:full_name].to_s.downcase }, + avg_and_ranges: AvgRangesReducer.new(@reportable).run + } + end + + # Shared source for ScoresReducer. + # Streams submitted ReviewResponseMap responses with scores and questionnaires + # eagerly loaded to avoid N+1 inside calculate_total_score and maximum_score. + module ReviewResponseShared + def source + Response + .submitted_review_responses_for(@reportable.id) + .includes(:response_map, scores: { item: :questionnaire }) + end + end + + # ----------------------------------------------------------------------- + # Reducer 1 — reviewer info. + # Streams ReviewResponseMap rows; writes reviewer details into shared state + # on first occurrence per reviewer. + # ----------------------------------------------------------------------- + class ReviewersReducer < BaseReport + def source + ReviewResponseMap.for_assignment(@reportable.id).includes(reviewer: :user) + end + + def state_key_for = ->(response_map) { response_map.reviewer_id } + + def initial_state = {} + + def accumulate(state, reviewer_id, response_map) + return if state.key?(reviewer_id) + + reviewer = response_map.reviewer + return unless reviewer + + state[reviewer_id][:id] = reviewer.id + state[reviewer_id][:user_id] = reviewer.user_id + state[reviewer_id][:name] = reviewer.user&.name + state[reviewer_id][:full_name] = reviewer.user&.full_name + state[reviewer_id][:handle] = reviewer.handle + end + end + + # ----------------------------------------------------------------------- + # Reducer 2 — per-reviewer × reviewee × round score percentages. + # Streams submitted Responses; writes score_pct into shared state under + # state[reviewer_id][:scores][reviewee_id][round]. + # ----------------------------------------------------------------------- + class ScoresReducer < BaseReport + include ReviewResponseShared + + def state_key_for = ->(response) { response.response_map.reviewer_id } + + def initial_state = {} + + def accumulate(state, reviewer_id, response) + return if response.maximum_score.zero? + + reviewee_id = response.response_map.reviewee_id + round = response.round || 1 + score_pct = (response.calculate_total_score.to_f / response.maximum_score * 100).round(2) + + state[reviewer_id][:scores][reviewee_id][round] = score_pct + end + end + + # ----------------------------------------------------------------------- + # Reducer 3 — 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 (via ReviewAggregator concern) which picks the + # latest submitted response per round per map and normalises the score. + # Output: { team_id => avg_score } + # ----------------------------------------------------------------------- + class AvgRangesReducer < BaseReport + def source + AssignmentTeam + .where(parent_id: @reportable.id) + .includes(review_mappings: { responses: { scores: :item } }) + end + + def state_key_for = ->(team) { team.id } + + def initial_state = {} + + def accumulate(state, team_id, team) + state[team_id] = team.aggregate_review_grade + end + end + end +end diff --git a/app/models/reports/teammate_review_report.rb b/app/models/reports/teammate_review_report.rb new file mode 100644 index 000000000..ebdba29c9 --- /dev/null +++ b/app/models/reports/teammate_review_report.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Reports + # Teammate-review report: for each reviewer, shows how many teammates they + # have in total, how many they reviewed, and the individual reviewee details. + # + # Coordinator runs two reducers into a single shared state: + # + # TeammateReviewReducer — streams TeammateReviewResponseMap rows. + # For each row, initializes the reviewer entry if absent and appends + # the reviewee to the reviewees list. + # Writes to state: { user_id => { ..., reviewed_count:, reviewees: [] } } + # + # TeamSizeReducer — streams TeamsUser rows for the assignment. + # For each row, sets teammate_count for that user if they appear in state + # (i.e. they are a reviewer). No extra loop needed — writes directly into + # the same shared state. + # Writes to state: { user_id => { ..., teammate_count: } } + # + # Shared state shape (all keys present from initialization, keyed by user_id): + # { user_id => { reviewer_id:, user_id:, name:, full_name:, ← filled by TeammateReviewReducer + # teammate_count:, ← filled by TeamSizeReducer + # reviewed_count:, ← filled by TeammateReviewReducer + # reviewees: [{ reviewee_id:, name:, full_name: }] } } + class TeammateReviewReport + def self.for_assignment(assignment) + new(assignment) + end + + def initialize(reportable) + @reportable = reportable + end + + def run + shared_state = Hash.new do |state, user_id| + state[user_id] = { + reviewer_id: nil, + user_id: nil, + name: nil, + full_name: nil, + teammate_count: 0, + reviewed_count: 0, + reviewees: [] + } + end + TeammateReviewReducer.new(@reportable).run(shared_state) + TeamSizeReducer.new(@reportable).run(shared_state) + { reviewers: shared_state.values } + end + + # ----------------------------------------------------------------------- + # Reducer 1 — per-reviewer reviewee details and reviewed count. + # Streams TeammateReviewResponseMap rows grouped by reviewer_id. + # ----------------------------------------------------------------------- + class TeammateReviewReducer < BaseReport + def source + TeammateReviewResponseMap + .where(reviewed_object_id: @reportable.id) + .includes(reviewer: :user, reviewee: :user) + end + + def state_key_for = ->(map) { map.reviewer&.user_id } + + def initial_state = {} + + def accumulate(state, user_id, map) + reviewer = map.reviewer + return unless reviewer + + unless state.key?(user_id) + state[user_id][:reviewer_id] = reviewer.id + state[user_id][:user_id] = reviewer.user_id + state[user_id][:name] = reviewer.user&.name + state[user_id][:full_name] = reviewer.user&.full_name + end + + reviewee = map.reviewee + return unless reviewee + + state[user_id][:reviewees] << { + reviewee_id: reviewee.id, + name: reviewee.user&.name, + full_name: reviewee.user&.full_name + } + state[user_id][:reviewed_count] += 1 + end + end + + # ----------------------------------------------------------------------- + # Reducer 2 — per-reviewer teammate count. + # Streams TeamsUser rows for the assignment. Only updates state for + # users who are already reviewers (present in shared state). + # ----------------------------------------------------------------------- + class TeamSizeReducer < BaseReport + def source + TeamsUser.for_assignment(@reportable.id).includes(:user) + end + + def state_key_for = ->(teams_user) { teams_user.user_id } + + def initial_state = {} + + def accumulate(state, user_id, _teams_user) + # teammate_count is the number of TeamsUser rows for this user's team, + # which equals the number of teammates (including themselves). + state[user_id][:teammate_count] += 1 if state.key?(user_id) + end + end + end +end diff --git a/app/models/response.rb b/app/models/response.rb index c6233dabe..59b07b4a3 100644 --- a/app/models/response.rb +++ b/app/models/response.rb @@ -5,6 +5,20 @@ class Response < ApplicationRecord include MetricHelper belongs_to :response_map, class_name: 'ResponseMap', foreign_key: 'map_id', inverse_of: false + + scope :submitted_review_responses_for, ->(assignment_id) { + # Subquery picks the latest submitted response per (response_map, round) + # so that only the most recent submission per reviewer-reviewee pair per round is counted. + latest_ids = joins(:response_map) + .where( + response_maps: { reviewed_object_id: assignment_id, type: 'ReviewResponseMap' }, + is_submitted: true + ) + .group('response_maps.id, responses.round') + .select('MAX(responses.id)') + + where(id: latest_ids) + } has_many :scores, class_name: 'Answer', foreign_key: 'response_id', dependent: :destroy, inverse_of: false accepts_nested_attributes_for :scores @@ -88,7 +102,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/review_response_map.rb b/app/models/review_response_map.rb index e339e1b62..0c8bfa7ca 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 @@ -25,4 +27,12 @@ def get_title def review_map_type 'ReviewResponseMap' end + + # Returns an array of distinct reviewers (AssignmentParticipant objects) for the given assignment. + # Reviewers are sorted by their user's full name. + def self.review_response_report(assignment_id) + distinct_reviewer_ids = where(reviewed_object_id: assignment_id).distinct.pluck(:reviewer_id) + reviewers = AssignmentParticipant.where(id: distinct_reviewer_ids, parent_id: assignment_id) + reviewers.sort_by { |r| r.user&.full_name.to_s.downcase } + end end diff --git a/app/models/teams_user.rb b/app/models/teams_user.rb index 8afd23cce..8e82c704f 100644 --- a/app/models/teams_user.rb +++ b/app/models/teams_user.rb @@ -4,6 +4,8 @@ class TeamsUser < ApplicationRecord belongs_to :user belongs_to :team + scope :for_assignment, ->(assignment_id) { joins(:team).where(teams: { parent_id: assignment_id }) } + def name(ip_address = nil) name = user.name(ip_address) end diff --git a/app/services/reports/feedback_report.rb b/app/services/reports/feedback_report.rb new file mode 100644 index 000000000..9da2e4709 --- /dev/null +++ b/app/services/reports/feedback_report.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Reports + # Author-feedback report: shows the latest review response IDs that received + # author feedback, bucketed by round for varying-rubric assignments. + # + # Pipeline: + # source — Responses on ReviewResponseMaps for this assignment, + # ordered newest-first so the first occurrence per (map, round) + # is the latest revision. + # grouper — [map_id, round]: deduplication key (one response per map per round) + # accumulate — skips duplicates; buckets response IDs by round + # finalize — fetches authors (one query), returns shaped hash + class FeedbackReport < BaseReport + def source + Response + .joins(:response_map) + .where(response_maps: { type: 'ReviewResponseMap', reviewed_object_id: @assignment.id }) + .order(created_at: :desc) + end + + def grouper = ->(r) { [r.map_id, r.round] } + + def initial_state + { seen: Set.new, round_1: [], round_2: [], round_3: [], all: [] } + end + + def accumulate(state, key, response) + return if state[:seen].include?(key) + + state[:seen].add(key) + + if @assignment.varying_rubrics_by_round? + case response.round + when 1 then state[:round_1] << response.id + when 2 then state[:round_2] << response.id + when 3 then state[:round_3] << response.id + end + else + state[:all] << response.id + end + end + + def finalize(state) + authors = fetch_authors + + if @assignment.varying_rubrics_by_round? + { + authors: authors.map { |a| format_participant(a) }, + review_response_ids: { + round_1: state[:round_1], + round_2: state[:round_2], + round_3: state[:round_3] + } + } + else + { + authors: authors.map { |a| format_participant(a) }, + review_response_ids: state[:all] + } + end + end + + private + + def fetch_authors + teams = AssignmentTeam.includes(:users).where(parent_id: @assignment.id) + teams.flat_map do |team| + team.users.filter_map do |user| + AssignmentParticipant.find_by(parent_id: @assignment.id, user_id: user.id) + end + end + end + + def format_participant(p) + return {} unless p + + { + id: p.id, + user_id: p.user_id, + name: p.user&.name, + full_name: p.user&.full_name + } + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 47da3d9e0..a5c0e2b65 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,23 +77,23 @@ end end - resources :review_mappings, only: [] do - collection do - post :assign_round_robin - post :assign_random - post :assign_from_csv - post :request_review_fewest - post :assign_calibration - post :assign_quiz - delete :delete_all_for_reviewer - end - - member do - patch :submit_review - patch :unsubmit_review - patch :grade_review - delete :delete_mapping - end + resources :review_mappings, only: [] do + collection do + post :assign_round_robin + post :assign_random + post :assign_from_csv + post :request_review_fewest + post :assign_calibration + post :assign_quiz + delete :delete_all_for_reviewer + end + + member do + patch :submit_review + patch :unsubmit_review + patch :grade_review + delete :delete_mapping + end end resources :signed_up_teams do @@ -101,13 +101,13 @@ post '/sign_up', to: 'signed_up_teams#sign_up' post '/sign_up_student', to: 'signed_up_teams#sign_up_student' end - member do + member do post :create_advertisement patch :update_advertisement delete :remove_advertisement end end - + resources :submitted_content do collection do get :download @@ -166,10 +166,10 @@ resources :student_teams, only: %i[create update] do collection do - get :view + get :view get :mentor get :remove_participant - put '/leave', to: 'student_teams#leave_team' + put '/leave', to: 'student_teams#leave_team' end end @@ -193,9 +193,15 @@ post :add_participant delete :delete_participants end + end + resources :reports, only: [] do + collection do + post :fetch_response_report + end end + resources :grades do - collection do + collection do get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' patch '/:participant_id/assign_grade', to: 'grades#assign_grade' get '/:participant_id/edit', to: 'grades#edit' @@ -219,4 +225,4 @@ resources :assignments do resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] end -end +end \ No newline at end of file