Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
70 changes: 70 additions & 0 deletions app/controllers/reports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

# Reports live in their own controller per Expertiza convention: a single
# controller responsible only for rendering reports, dispatching to per-report
# actions. Feature controllers (e.g. ReviewMappingsController) should not
# embed report logic, since that violates SRP and hides the code from other
# consumers who need the same information.
class ReportsController < ApplicationController
include Authorization
before_action :set_assignment

# Reports are viewable only by teaching staff for the assignment (instructor
# of the assignment, the course instructor, or a TA mapped to the course).
def action_allowed?
current_user_teaching_staff_of_assignment?(params[:assignment_id])
end

# GET /assignments/:assignment_id/reports/calibration/:map_id
#
# Returns the comparison data for a single instructor calibration review:
# the instructor's submitted response, the rubric items, the student
# calibration responses for the same reviewee, a per-item summary produced
# by CalibrationPerItemSummary, and the reviewee team's submitted content.
def calibration
instructor_map = ReviewResponseMap.find_by!(
id: params[:map_id],
reviewed_object_id: @assignment.id,
for_calibration: true
)

instructor_response = instructor_map.latest_submitted_response
return render_error('Submitted instructor calibration response not found', :unprocessable_entity) unless instructor_response

rubric_items = instructor_response.rubric_items
return render_error('Review rubric not found', :unprocessable_entity) if rubric_items.empty?

student_responses = ReviewResponseMap.peer_calibration_responses_for(instructor_map)

per_item_summary = CalibrationPerItemSummary.build(
items: rubric_items,
instructor_response: instructor_response,
student_responses: student_responses
)

reviewee = instructor_map.reviewee

render json: {
map_id: instructor_map.id,
assignment_id: @assignment.id,
reviewee_id: instructor_map.reviewee_id,
rubric_items: rubric_items.map(&:as_calibration_json),
instructor_response: instructor_response.as_calibration_json,
student_responses: student_responses.map(&:as_calibration_json),
per_item_summary: per_item_summary,
submitted_content: reviewee.respond_to?(:submitted_content) ? reviewee.submitted_content : { hyperlinks: [], files: [] }
}, status: :ok
rescue ActiveRecord::RecordNotFound
render_error('Calibration review map not found', :not_found)
end

private

def set_assignment
@assignment = Assignment.find(params[:assignment_id])
end

def render_error(message, status)
render json: { error: message }, status: status
end
end
186 changes: 186 additions & 0 deletions app/controllers/review_mappings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,107 @@ def assign_calibration_artifacts
render json: { status: "ok", message: "Calibration reviews assigned to all reviewers" }
end

# ===== CALIBRATION PARTICIPANTS =====
# The "Calibration" tab on the assignment edit view designates one or more
# users as calibration submitters. The instructor types a username into the
# text box and that user is added as an AssignmentParticipant, a team is
# created for them (so submissions flow through the normal team-based
# infrastructure), and a ReviewResponseMap with for_calibration = true is
# created with the instructor as reviewer. The instructor later opens the
# calibration report for this map to enter/compare the calibration review.

# GET /assignments/:assignment_id/review_mappings/calibration_participants
def list_calibration_participants
render json: {
assignment_id: @assignment.id,
calibration_participants: calibration_participant_rows
}, status: :ok
end

# POST /assignments/:assignment_id/review_mappings/calibration_participants
# Body: { username: "unctlt1" }
def add_calibration_participant
username = (params[:username] || params.dig(:calibration_participant, :username)).to_s.strip
return render json: { error: 'username is required' }, status: :bad_request if username.blank?

user = User.find_by(name: username) || User.find_by(email: username)
return render json: { error: "User '#{username}' not found" }, status: :not_found unless user

instructor_participant = find_or_create_instructor_participant
return render json: { error: 'Assignment has no instructor' }, status: :unprocessable_entity unless instructor_participant

participant = nil
team = nil
map = nil

ActiveRecord::Base.transaction do
participant = AssignmentParticipant.find_by(parent_id: @assignment.id, user_id: user.id) ||
@assignment.add_participant(user.id)

team = participant.team || Team.create_team_for_participant(participant)

map = ReviewResponseMap.find_or_create_by!(
reviewer_id: instructor_participant.id,
reviewee_id: team.id,
reviewed_object_id: @assignment.id,
for_calibration: true
)
end

render json: serialize_calibration_row(participant, team, map), status: :created
rescue ActiveRecord::RecordInvalid, ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end

# DEMO_INSTRUCTOR_RESPONSE
# POST /assignments/:assignment_id/review_mappings/:map_id/mock_instructor_response
#
# Demo-only: materializes a submitted instructor calibration Response with
# default answers for the given calibration map so the calibration report
# has data to display while the real review form lives outside this
# project's scope. The "how" of fabricating data lives in
# Demo::CalibrationInstructorSeeder; this controller is responsible only for
# locating the map and translating outcomes into HTTP responses.
# See Demo::CalibrationInstructorSeeder for the full removal checklist.
def submit_mock_instructor_calibration_response
map = ReviewResponseMap.find_by!(
id: params[:map_id],
reviewed_object_id: @assignment.id,
for_calibration: true
)

response = Demo::CalibrationInstructorSeeder.seed!(map)

render json: {
map_id: map.id,
response_id: response.id,
instructor_review_status: 'submitted'
}, status: :ok
rescue ActiveRecord::RecordNotFound
render json: { error: 'Calibration review map not found' }, status: :not_found
rescue ArgumentError, ActiveRecord::RecordInvalid => e
render json: { error: e.message }, status: :unprocessable_entity
end

# DELETE /assignments/:assignment_id/review_mappings/calibration_participants/:participant_id
def remove_calibration_participant
participant = AssignmentParticipant.find_by(id: params[:participant_id], parent_id: @assignment.id)
return render json: { error: 'Calibration participant not found' }, status: :not_found unless participant

team = participant.team
return render json: { error: 'Participant has no team' }, status: :unprocessable_entity unless team

ReviewResponseMap.where(
reviewed_object_id: @assignment.id,
reviewee_id: team.id,
for_calibration: true
).destroy_all

render json: { message: "Calibration participant #{participant.id} removed." }, status: :ok
end

# ===== DELETE =====
def destroy
handler = ReviewMappingHandler.new(@assignment)
Expand All @@ -85,9 +186,94 @@ def grade_review
render json: { status: "ok", message: "Review graded" }
end

# Actions that designate calibration submitters require teaching staff
# privileges; everything else defaults to allowed and relies on
# ApplicationController's own checks. We reuse the shared
# `current_user_teaching_staff_of_assignment?` helper from the Authorization
# concern instead of duplicating the logic here.
CALIBRATION_PARTICIPANT_ACTIONS = %w[
list_calibration_participants
add_calibration_participant
remove_calibration_participant
submit_mock_instructor_calibration_response
].freeze # DEMO_INSTRUCTOR_RESPONSE: drop the last entry when the demo seeder is removed.

def action_allowed?
return current_user_teaching_staff_of_assignment?(params[:assignment_id]) if CALIBRATION_PARTICIPANT_ACTIONS.include?(params[:action])

true
end

private

def set_assignment
@assignment = Assignment.find(params[:assignment_id])
end

# ----- Helpers for the calibration participants endpoints -----

# The instructor is the reviewer on every calibration ReviewResponseMap it
# creates, so the instructor must also be registered as an
# AssignmentParticipant on this assignment. Create that record lazily.
def find_or_create_instructor_participant
instructor = @assignment.instructor
return nil unless instructor

AssignmentParticipant.find_by(parent_id: @assignment.id, user_id: instructor.id) ||
@assignment.add_participant(instructor.id)
end

# Build one row per calibration submitter. A submitter is identified as the
# (sole) member of a team that is the reviewee of any for_calibration map on
# this assignment. Prefer the instructor's map as the "Begin" target.
def calibration_participant_rows
maps = ReviewResponseMap.where(
reviewed_object_id: @assignment.id,
for_calibration: true
).order(:id)

instructor_user_id = @assignment.instructor_id
maps_by_team = maps.group_by(&:reviewee_id)

maps_by_team.map do |team_id, team_maps|
team = AssignmentTeam.find_by(id: team_id)
next nil unless team

instructor_map = team_maps.find { |m| m.reviewer&.user_id == instructor_user_id } || team_maps.first
submitter = team.participants.where(type: 'AssignmentParticipant').first
next nil unless submitter

serialize_calibration_row(submitter, team, instructor_map)
end.compact
end

def serialize_calibration_row(participant, team, instructor_map)
{
participant_id: participant.id,
user_id: participant.user_id,
username: participant.user&.name,
full_name: participant.user&.full_name,
handle: participant.handle,
team_id: team&.id,
team_name: team&.name,
instructor_review_map_id: instructor_map&.id,
instructor_review_status: instructor_review_status_for(instructor_map),
submissions: team.respond_to?(:submitted_content_detail) ? team.submitted_content_detail : { hyperlinks: [], files: [] }
}
end

# Classify the instructor's progress on a calibration review map so the UI can
# show "Begin" when no response exists yet and "View | Edit" once one does.
# - :not_started -> no Response rows for this map
# - :in_progress -> at least one Response, none submitted yet
# - :submitted -> at least one submitted Response
def instructor_review_status_for(instructor_map)
return :not_started unless instructor_map

responses = instructor_map.responses
return :not_started if responses.empty?
return :submitted if responses.where(is_submitted: true).exists?

:in_progress
end
end
15 changes: 15 additions & 0 deletions app/models/Item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ def as_json(options = {})
end
end

# JSON representation used by calibration reports. Kept on the item itself
# (Information Expert) so other report/view code does not re-implement this
# shape.
def as_calibration_json
{
id: id,
txt: txt,
seq: seq,
question_type: question_type,
weight: weight,
min_score: questionnaire.min_question_score,
max_score: questionnaire.max_question_score
}
end

def strategy
case question_type
when 'dropdown'
Expand Down
33 changes: 33 additions & 0 deletions app/models/assignment_team.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,39 @@ def hyperlinks
submitted_hyperlinks.blank? ? [] : YAML.safe_load(submitted_hyperlinks)
end

# SubmissionRecords for files this team has submitted against its assignment.
# Lives on the team (Information Expert) so callers don't have to know about
# SubmissionRecord's schema.
def submitted_file_records
SubmissionRecord.files.where(team_id: id, assignment_id: parent_id).order(created_at: :asc)
end

# Submitted content in the simple shape expected by reports: hyperlinks and
# file paths only. Used by calibration/report views where we just need the
# URLs.
def submitted_content
{
hyperlinks: hyperlinks,
files: submitted_file_records.pluck(:content)
}
end

# Submitted content in the rich shape expected by the calibration
# participants listing (per-file id, name, path, timestamps, submitter).
def submitted_content_detail
files = submitted_file_records.map do |record|
{
id: record.id,
name: File.basename(record.content.to_s),
path: record.content,
submitted_at: record.created_at,
submitted_by: record.user
}
end

{ hyperlinks: hyperlinks, files: files }
end

def submit_hyperlink(hyperlink)
hyperlink.strip!
raise 'The hyperlink cannot be empty!' if hyperlink.empty?
Expand Down
32 changes: 32 additions & 0 deletions app/models/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,38 @@ def aggregate_questionnaire_score
sum
end

# Ordered rubric items used for this response, or [] if the rubric cannot be
# resolved (e.g. no AssignmentQuestionnaire for this round). Belongs here
# rather than in a controller because the response knows which questionnaire
# applies to it.
def rubric_items
questionnaire.items.order(:seq)
rescue NoMethodError
[]
end

# JSON representation of this response for calibration reports. Keeps the
# serialization next to the class that owns the data (Information Expert),
# so any consumer that needs a response rendered for a calibration view can
# ask the response for it.
def as_calibration_json
{
id: id,
map_id: map_id,
reviewer_id: map.reviewer_id,
reviewer_name: map.reviewer&.fullname,
is_submitted: is_submitted,
updated_at: updated_at,
answers: scores.map do |answer|
{
item_id: answer.item_id,
score: answer.answer,
comments: answer.comments
}
end
}
end

# Returns the maximum possible score for this response
def maximum_score
# only count the scorable questions, only when the answer is not nil (we accept nil as
Expand Down
7 changes: 7 additions & 0 deletions app/models/response_map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ def response_assignment
return reviewer.assignment
end

# Most recently submitted response on this map, or nil. Callers that need
# "the map's current submitted response" should ask the map directly rather
# than re-query Response in a controller.
def latest_submitted_response
responses.where(is_submitted: true).order(updated_at: :desc).first
end

def self.assessments_for(team)
responses = []
# stime = Time.now
Expand Down
Loading
Loading