diff --git a/Gemfile b/Gemfile index aa91a954a..29e5d023b 100644 --- a/Gemfile +++ b/Gemfile @@ -3,30 +3,30 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby '3.4.5' +gem 'active_model_serializers', '~> 0.10.0' +gem 'bigdecimal' # Required for Ruby 3.4.5 compatibility +gem 'csv' # Required for Ruby 3.4.5 compatibility +gem 'date' # Required for Ruby 3.4.5 compatibility +gem 'delegate' # Required for Ruby 3.4.5 compatibility +gem 'faraday-retry' # Required for Faraday v2.0+ compatibility +gem 'forwardable' # Required for Ruby 3.4.5 compatibility +gem 'logger' # Required for Ruby 3.4.5 compatibility +gem 'mini_portile2', '~> 2.8' # Helps with native gem compilation +gem 'monitor' # Required for Ruby 3.4.5 compatibility +gem 'mutex_m' # Required for Ruby 3.4.5 compatibility gem 'mysql2', '~> 0.5.7' -gem 'sqlite3', '~> 1.4' # Alternative for development +gem 'observer' # Required for Ruby 3.4.5 compatibility with Rails 8.0 +gem 'ostruct' # Required for Ruby 3.4.5 compatibility +gem 'psych', '~> 5.2' # Ensure compatible psych version for Ruby 3.4.5 gem 'puma', '~> 6.4' gem 'rails', '~> 8.0', '>= 8.0.1' -gem 'mini_portile2', '~> 2.8' # Helps with native gem compilation -gem 'observer' # Required for Ruby 3.4.5 compatibility with Rails 8.0 -gem 'mutex_m' # Required for Ruby 3.4.5 compatibility -gem 'faraday-retry' # Required for Faraday v2.0+ compatibility -gem 'bigdecimal' # Required for Ruby 3.4.5 compatibility -gem 'csv' # Required for Ruby 3.4.5 compatibility -gem 'date' # Required for Ruby 3.4.5 compatibility -gem 'delegate' # Required for Ruby 3.4.5 compatibility -gem 'forwardable' # Required for Ruby 3.4.5 compatibility -gem 'logger' # Required for Ruby 3.4.5 compatibility -gem 'monitor' # Required for Ruby 3.4.5 compatibility -gem 'ostruct' # Required for Ruby 3.4.5 compatibility -gem 'set' # Required for Ruby 3.4.5 compatibility -gem 'singleton' # Required for Ruby 3.4.5 compatibility -gem 'timeout' # Required for Ruby 3.4.5 compatibility -gem 'uri' # Required for Ruby 3.4.5 compatibility gem 'rswag-api' gem 'rswag-ui' -gem 'active_model_serializers', '~> 0.10.0' -gem 'psych', '~> 5.2' # Ensure compatible psych version for Ruby 3.4.5 +gem 'set' # Required for Ruby 3.4.5 compatibility +gem 'singleton' # Required for Ruby 3.4.5 compatibility +gem 'sqlite3', '~> 1.4' # Alternative for development +gem 'timeout' # Required for Ruby 3.4.5 compatibility +gem 'uri' # Required for Ruby 3.4.5 compatibility # Build JSON APIs with ease [https://github.com/rails/jbuilder] # gem "jbuilder" @@ -55,20 +55,19 @@ gem 'lingua' # This is a really small gem that can be used to retrieve objects from the database in the order of the list given gem 'find_with_order' - group :development, :test do + gem 'database_cleaner-active_record' gem 'debug', platforms: %i[mri mingw x64_mingw] gem 'factory_bot_rails' - gem 'database_cleaner-active_record' gem 'faker' gem 'rspec-rails' gem 'rswag-specs' gem 'rubocop' gem 'simplecov', require: false, group: :test - gem 'coveralls' - gem 'simplecov_json_formatter' - gem 'shoulda-matchers' + # gem 'coveralls' gem 'danger' + gem 'shoulda-matchers' + gem 'simplecov_json_formatter' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 1570d2590..9a6d994dd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,29 +1,31 @@ GEM remote: https://rubygems.org/ specs: - actioncable (8.0.3) - actionpack (= 8.0.3) - activesupport (= 8.0.3) + action_text-trix (2.1.15) + railties + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.3) - actionpack (= 8.0.3) - activejob (= 8.0.3) - activerecord (= 8.0.3) - activestorage (= 8.0.3) - activesupport (= 8.0.3) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) - actionmailer (8.0.3) - actionpack (= 8.0.3) - actionview (= 8.0.3) - activejob (= 8.0.3) - activesupport (= 8.0.3) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.3) - actionview (= 8.0.3) - activesupport (= 8.0.3) + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -31,15 +33,16 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.3) - actionpack (= 8.0.3) - activerecord (= 8.0.3) - activestorage (= 8.0.3) - activesupport (= 8.0.3) + actiontext (8.1.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.3) - activesupport (= 8.0.3) + actionview (8.1.1) + activesupport (= 8.1.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -49,42 +52,41 @@ GEM activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (8.0.3) - activesupport (= 8.0.3) + activejob (8.1.1) + activesupport (= 8.1.1) globalid (>= 0.3.6) - activemodel (8.0.3) - activesupport (= 8.0.3) - activerecord (8.0.3) - activemodel (= 8.0.3) - activesupport (= 8.0.3) + activemodel (8.1.1) + activesupport (= 8.1.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) timeout (>= 0.4.0) - activestorage (8.0.3) - actionpack (= 8.0.3) - activejob (= 8.0.3) - activerecord (= 8.0.3) - activesupport (= 8.0.3) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) marcel (~> 1.0) - activesupport (8.0.3) + activesupport (8.1.1) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.1) - bigdecimal (3.2.3) - bootsnap (1.18.6) + bigdecimal (3.3.1) + bootsnap (1.19.0) msgpack (~> 1.2) builder (3.3.0) case_transform (0.2) @@ -96,15 +98,9 @@ GEM open4 (~> 1.3) colored2 (3.1.2) concurrent-ruby (1.3.5) - connection_pool (2.5.4) + connection_pool (2.5.5) cork (0.3.0) colored2 (~> 3.1) - coveralls (0.7.1) - multi_json (~> 1.3) - rest-client - simplecov (>= 0.7) - term-ansicolor - thor crass (1.0.6) csv (3.3.5) danger (9.5.3) @@ -125,18 +121,17 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) - date (3.4.1) + date (3.5.0) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) delegate (0.4.0) diff-lcs (1.6.2) docile (1.4.1) - domain_name (0.6.20240107) drb (2.2.3) - erb (5.0.2) + erb (6.0.0) erubi (1.13.1) - factory_bot (6.5.5) + factory_bot (6.5.6) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) @@ -149,8 +144,8 @@ GEM logger faraday-http-cache (2.5.1) faraday (>= 0.8) - faraday-net_http (3.4.1) - net-http (>= 0.5.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) faraday-retry (2.3.2) faraday (~> 2.0) find_with_order (1.3.1) @@ -163,18 +158,15 @@ GEM rchardet (~> 1.8) globalid (1.3.0) activesupport (>= 6.1) - http-accept (1.7.0) - http-cookie (1.1.0) - domain_name (~> 0.5) i18n (1.14.7) concurrent-ruby (~> 1.0) io-console (0.8.1) - irb (1.15.2) + irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.15.0) - json-schema (5.2.2) + json (2.16.0) + json-schema (6.0.0) addressable (~> 2.8) bigdecimal (~> 3.1) jsonapi-renderer (0.2.2) @@ -191,30 +183,25 @@ GEM loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp marcel (1.1.0) - mime-types (3.7.0) - logger - mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0924) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.25.5) - mize (0.6.1) + minitest (5.26.2) monitor (0.2.0) msgpack (1.8.0) - multi_json (1.17.0) mutex_m (0.3.0) mysql2 (0.5.7) bigdecimal nap (1.1.0) - net-http (0.6.0) - uri - net-imap (0.5.11) + net-http (0.8.0) + uri (>= 0.11.1) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -223,8 +210,7 @@ GEM timeout net-smtp (0.5.1) net-protocol - netrc (0.11.0) - nio4r (2.5.9) + nio4r (2.7.5) nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) @@ -240,23 +226,23 @@ GEM open4 (1.3.4) ostruct (0.6.3) parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.0) ast (~> 2.4.1) racc - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.5.1) + prism (1.6.0) process_executer (1.3.0) pstore (0.2.0) psych (5.2.6) date stringio - public_suffix (6.0.2) + public_suffix (7.0.0) puma (6.6.1) nio4r (~> 2.0) racc (1.8.1) - rack (3.2.1) + rack (3.2.4) rack-cors (3.0.0) logger rack (>= 3.0.14) @@ -267,20 +253,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.3) - actioncable (= 8.0.3) - actionmailbox (= 8.0.3) - actionmailer (= 8.0.3) - actionpack (= 8.0.3) - actiontext (= 8.0.3) - actionview (= 8.0.3) - activejob (= 8.0.3) - activemodel (= 8.0.3) - activerecord (= 8.0.3) - activestorage (= 8.0.3) - activesupport (= 8.0.3) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) bundler (>= 1.15.0) - railties (= 8.0.3) + railties (= 8.1.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -288,9 +274,9 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.0.3) - actionpack (= 8.0.3) - activesupport (= 8.0.3) + railties (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -298,26 +284,22 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.0) + rake (13.3.1) rchardet (1.10.0) - rdoc (6.14.2) + rdoc (6.16.1) erb psych (>= 4.0.0) + tsort regexp_parser (2.11.3) - reline (0.6.2) + reline (0.6.3) io-console (~> 0.5) - rest-client (2.1.0) - http-accept (>= 1.7.0, < 2.0) - http-cookie (>= 1.0.2, < 2.0) - mime-types (>= 1.16, < 4.0) - netrc (~> 0.8) rexml (3.4.4) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (8.0.2) @@ -329,18 +311,18 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.6) - rswag-api (2.16.0) - activesupport (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) - rswag-specs (2.16.0) - activesupport (>= 5.2, < 8.1) - json-schema (>= 2.2, < 6.0) - railties (>= 5.2, < 8.1) + rswag-api (2.17.0) + activesupport (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rswag-specs (2.17.0) + activesupport (>= 5.2, < 8.2) + json-schema (>= 2.2, < 7.0) + railties (>= 5.2, < 8.2) rspec-core (>= 2.14) - rswag-ui (2.16.0) - actionpack (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) - rubocop (1.81.1) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -351,17 +333,17 @@ GEM rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.47.1) + rubocop-ast (1.48.0) parser (>= 3.3.7.2) prism (~> 1.4) ruby-progressbar (1.13.0) - sawyer (0.9.2) + sawyer (0.9.3) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) set (1.1.2) - shoulda-matchers (6.5.0) - activesupport (>= 5.2.0) + shoulda-matchers (7.0.1) + activesupport (>= 7.1) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -372,18 +354,11 @@ GEM spring (4.4.0) sqlite3 (1.7.3) mini_portile2 (~> 2.8.0) - stringio (3.1.7) - sync (0.5.0) - term-ansicolor (1.11.3) - tins (~> 1) + stringio (3.1.9) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.4.0) - timeout (0.4.3) - tins (1.44.1) - bigdecimal - mize (~> 0.6) - sync + timeout (0.4.4) tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -392,7 +367,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.3) + uri (1.1.1) useragent (0.16.11) websocket-driver (0.8.0) base64 @@ -414,7 +389,6 @@ DEPENDENCIES bcrypt (~> 3.1.7) bigdecimal bootsnap (>= 1.18.4) - coveralls csv danger database_cleaner-active_record diff --git a/app/controllers/calibration_controller.rb b/app/controllers/calibration_controller.rb new file mode 100644 index 000000000..c8212e862 --- /dev/null +++ b/app/controllers/calibration_controller.rb @@ -0,0 +1,588 @@ +# app/controllers/calibration_controller.rb + +class CalibrationController < ApplicationController + # This function retrives all submissions that an intsructor has to review for calibration + # GET /calibration/assignments/{assignment_id}/submissions + def get_instructor_calibration_submissions + assignment_id = params[:assignment_id] + + # Get the instructor's participant ID for this assignment + instructor_participant_id = get_instructor_participant_id(assignment_id) + return render json: { error: 'Instructor not found' }, status: :not_found if instructor_participant_id.nil? + + # Find calibration submissions where the INSTRUCTOR is the REVIEWER + # ReviewResponseMap has reviewee_id as a Team ID + calibration_submissions = ResponseMap.where( + reviewed_object_id: assignment_id, + reviewer_id: instructor_participant_id, + for_calibration: true + ) + + # Get the details for each calibration submission + submissions = calibration_submissions.map do |response_map| + # For ReviewResponseMap, reviewee_id is a Team ID + reviewee_team = Team.find_by(id: response_map.reviewee_id) + next unless reviewee_team + + # Get team display name (team name or first member's name) + team_name = reviewee_team.name.present? ? reviewee_team.name : reviewee_team.participants.first&.user&.full_name || 'Unknown Team' + + # Get the submission content for that team + submitted_content = get_submitted_content(reviewee_team.id) + + # Check if the review has been started + review_status = get_review_status(response_map.id) + + { + team_name: team_name, + reviewee_id: response_map.reviewee_id, + response_map_id: response_map.id, + submitted_content: submitted_content, + review_status: review_status + } + end.compact + + render json: { calibration_submissions: submissions }, status: :ok + end + + # GET /calibration/calibration_student_report + # Compares the logged-in student's calibration reviews against the instructor's reviews. + # Returns detailed breakdown per question including scores, comments, and match statistics. + def calibration_student_report + student_participant_id = params[:student_participant_id] + assignment_id = params[:assignment_id] + + # Validate required parameters + if student_participant_id.blank? || assignment_id.blank? + render json: { error: 'Missing required parameters' }, status: :bad_request and return + end + + # Find all calibration reviews this student completed for this assignment + student_calibration_maps = ResponseMap.where( + reviewer_id: student_participant_id, + reviewed_object_id: assignment_id, + for_calibration: true + ) + + # Build comparison report for each calibration review + calibration_reviews = student_calibration_maps.map do |student_response_map| + # Get reviewee team information + reviewee_team = Team.find_by(id: student_response_map.reviewee_id) + next unless reviewee_team + + # Get team display name (consistent with other methods) + team_name = reviewee_team.name.present? ? reviewee_team.name : reviewee_team.participants.first&.user&.full_name || 'Unknown Team' + + # Get the student's most recent review for this calibration + student_review = Response.where(map_id: student_response_map.id) + .order(updated_at: :desc) + .first + + # Get the instructor's review of the same submission + instructor_review = get_instructor_review_for_reviewee( + assignment_id, + student_response_map.reviewee_id + ) + + # Compare the two reviews (includes scores, comments, match rate, etc.) + comparison_data = if instructor_review && student_review + instructor_review_better?(instructor_review, student_review) + else + { error: 'Missing review data' } + end + + { + reviewee_name: team_name, + reviewee_id: student_response_map.reviewee_id, + comparison: comparison_data + } + end.compact + + render json: { + student_participant_id: student_participant_id, + assignment_id: assignment_id, + calibration_reviews: calibration_reviews + }, status: :ok + end + + # GET /calibration/assignments/:assignment_id/students/:student_participant_id/summary + # For a given student + assignment, returns info about each submission + # they calibrated: + # - all reviewers (team members who submitted the work) + # - all hyperlinks submitted by that user/team + # - the for_calibration flag for that calibration review + def summary + student_participant_id = params[:student_participant_id].presence + assignment_id = params[:assignment_id].presence + + # 1) Hard-missing or empty params → 400 + if student_participant_id.nil? || assignment_id.nil? + render json: { error: 'Missing required parameters' }, status: :bad_request and return + end + + # 2) Resolve actual records + assignment = Assignment.find_by(id: assignment_id) + student = Participant.find_by(id: student_participant_id) + + # assignment or participant doesn't exist at all → 404 + unless assignment && student + render json: { error: 'Assignment or student not found' }, status: :not_found and return + end + + # 3) Ensure the participant belongs to that assignment + if student.parent_id != assignment.id + render json: { error: 'Student is not a participant in this assignment' }, status: :unprocessable_entity and return + end + + # 4) All calibration maps for this student on this assignment + # Use ReviewResponseMap – reviewee_id is a Team ID + student_calibration_maps = ReviewResponseMap.where( + reviewer_id: student.id, + reviewed_object_id: assignment.id, + for_calibration: true + ) + + submissions = student_calibration_maps.map do |student_response_map| + # For ReviewResponseMap, reviewee is a Team + reviewee_team = student_response_map.reviewee + next unless reviewee_team + + # IMPORTANT: match the spec's notion of team membership: + # participants whose parent_id == reviewee_team.id + team_members = Participant + .where(parent_id: reviewee_team.id, type: 'AssignmentParticipant') + .includes(:user) + + reviewers = team_members.map do |member| + { + participant_id: member.id, + full_name: member.user.full_name + } + end + + # Submitted content for this team (hyperlinks + files) + submitted_content = get_submitted_content(reviewee_team.id) || {} + hyperlinks = submitted_content[:hyperlinks] || + submitted_content['hyperlinks'] || + [] + + { + reviewee_team_id: reviewee_team.id, + reviewers: reviewers, + hyperlinks: hyperlinks, + for_calibration: student_response_map.for_calibration + } + end.compact + + render json: { + student_participant_id: student.id, + assignment_id: assignment.id, + submissions: submissions + }, status: :ok + end + + + # GET /calibration/assignments/:assignment_id/report/:reviewee_id + # Calculates aggregate statistics for the class on a specific calibration assignment + # Includes distribution of scores (exact, off by 1, off by 2, etc.) + def calibration_aggregate_report + assignment_id = params[:assignment_id] + reviewee_id = params[:reviewee_id] + + if assignment_id.blank? || reviewee_id.blank? + render json: { error: 'Missing required parameters' }, status: :bad_request and return + end + + # 1. Get the Instructor's Review (Answer Key) + instructor_review = get_instructor_review_for_reviewee(assignment_id, reviewee_id) + if instructor_review.nil? + render json: { error: 'Instructor review not found. Cannot generate report.' }, status: :not_found + return + end + + # 2. Find ALL student calibration reviews (excluding the instructor's own self-review) + instructor_participant_id = get_instructor_participant_id(assignment_id) + student_calibration_maps = ResponseMap.where( + reviewed_object_id: assignment_id, + reviewee_id: reviewee_id, + for_calibration: true + ).where.not(reviewer_id: instructor_participant_id) + + # 3. Collect the latest submitted Response for each student + # (Optimized to load map_id and id to avoid N+1 issues later if expanded) + student_responses = student_calibration_maps.map do |map| + Response.where(map_id: map.id).order(updated_at: :desc).first + end.compact + + # 4. Prepare Answer Data for Processing + # Optimization: Fetch all student answers in one query and group by Item ID + # This prevents running a DB query for every single question inside the loop + instructor_answers = Answer.where(response_id: instructor_review.id) + student_answers_flat = Answer.where(response_id: student_responses.map(&:id)) + student_answers_by_item = student_answers_flat.group_by(&:item_id) + + # 5. Process Question Breakdown + total_match_rate_sum = 0 + + question_breakdown = instructor_answers.map do |inst_answer| + stud_answers_for_q = student_answers_by_item[inst_answer.item_id] || [] + + # Use helper to calculate all stats (Match rate, off-by-1, etc.) + stats = calculate_aggregate_question_stats(inst_answer, stud_answers_for_q) + + total_match_rate_sum += stats[:match_rate] + stats + end + + # 6. Calculate Overall Aggregate Stats + num_questions = question_breakdown.size + avg_agreement_pct = num_questions > 0 ? (total_match_rate_sum / num_questions).round(2) : 0 + + # 7. Get Team Metadata + reviewee_team = Team.find_by(id: reviewee_id) + + # Fallback: directly look up a participant whose parent_id is this team + reviewee_participant = Participant.find_by(parent_id: reviewee_id) + + reviewee_name = + if reviewee_team&.name.present? + reviewee_team.name + elsif reviewee_participant&.user&.full_name.present? + reviewee_participant.user.full_name + else + 'Unknown Team' + end + + render json: { + reviewee_id: reviewee_id, + reviewee_name: reviewee_name, + assignment_id: assignment_id, + aggregate_stats: { + total_reviews: student_responses.size, + avg_agreement_percentage: avg_agreement_pct, + question_breakdown: question_breakdown + } + }, status: :ok + end + + private + + def get_instructor_participant_id(assignment_id) + # Get the instructor's user_id from assignments table + assignment = Assignment.find(assignment_id) + instructor_id = assignment.instructor_id + + # Find the instructor's participant record for THIS assignment + # Use parent_id (not assignment_id) which references the assignment + instructor_participant = Participant.find_by( + user_id: instructor_id, + parent_id: assignment_id, + type: 'AssignmentParticipant' + ) + + # Return the participant_id (used in ResponseMaps) + instructor_participant&.id + end + + # Retrieves submitted content (hyperlinks and files) for a team using SubmissionRecord model + # SubmissionRecord stores both hyperlinks and files with metadata + # This method queries the SubmissionRecord table for the given team and assignment + # Gets the latest submissions and reads files from the team's submission directory + def get_submitted_content(team_id) + # Find the team + team = Team.find_by(id: team_id) + unless team + Rails.logger.warn("Team not found: #{team_id}") + return { hyperlinks: [], files: [], error: 'Team not found' } + end + + # Get assignment from team (AssignmentTeam has assignment through parent_id) + assignment = team.assignment + unless assignment + Rails.logger.warn("Assignment not found for team: #{team_id}") + return { hyperlinks: [], files: [], error: 'Assignment not found' } + end + + # Get the most recent hyperlinks from SubmissionRecord (ordered by creation date, latest first) + submission_records = SubmissionRecord.where(team_id: team_id, assignment_id: assignment.id) + .order(created_at: :desc) + + # Retrieve hyperlinks from SubmissionRecord, excluding those with 'remove' operation + hyperlinks = build_hyperlinks_from_records(submission_records.hyperlinks.where.not(operation: 'remove')) + + # Retrieve files from the team's submission directory (actual files on filesystem) + files = get_team_submitted_files(team, assignment) + + { + hyperlinks: hyperlinks, + files: files + } + rescue StandardError => e + Rails.logger.error("Error fetching submitted content for team #{team_id}: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + { hyperlinks: [], files: [], error: "Content not available: #{e.message}" } + end + + # Retrieves actual submitted files from the team's submission directory on the filesystem + # Uses team's directory_num to construct the path where files are stored + def get_team_submitted_files(team, assignment) + files = [] + + # Return empty if team has no directory number (hasn't submitted yet) + return [] if team.directory_num.blank? + + # Construct the submission directory path: /submissions/{assignment_id}/{directory_num}/ + base_path = Rails.root.join('submissions', assignment.id.to_s, team.directory_num.to_s) + + # Return empty array if directory doesn't exist + unless File.exist?(base_path) && File.directory?(base_path) + Rails.logger.info("Submission directory not found: #{base_path}") + return [] + end + + # List all files in the submission directory + Dir.entries(base_path).each do |entry| + # Skip current directory and parent directory references + next if ['.', '..'].include?(entry) + + entry_path = File.join(base_path, entry) + + # Only include files, not subdirectories + next if File.directory?(entry_path) + + begin + files << { + filename: entry, + size: File.size(entry_path), + size_human: format_file_size(File.size(entry_path)), + type: File.extname(entry).delete_prefix('.'), + modified_at: File.mtime(entry_path), + download_url: "/submitted_content/download?team_id=#{team.id}&filename=#{CGI.escape(entry)}" + } + rescue StandardError => e + Rails.logger.warn("Error processing file #{entry_path}: #{e.message}") + next + end + end + + # Return files sorted alphabetically for consistent ordering + files.sort_by { |f| f[:filename].downcase } + rescue StandardError => e + Rails.logger.error("Error reading submitted files from #{base_path}: #{e.message}") + [] + end + + # Formats file size from bytes to human-readable format + def format_file_size(bytes) + return '0 B' if bytes.zero? + + if bytes < 1024 + "#{bytes} B" + elsif bytes < 1024 * 1024 + "#{(bytes / 1024.0).round(2)} KB" + else + "#{(bytes / (1024.0 * 1024.0)).round(2)} MB" + end + end + + # Builds hyperlink array from SubmissionRecord hyperlink records + # Each record contains: content (URL), user (string), created_at, operation + def build_hyperlinks_from_records(hyperlink_records) + hyperlink_records.map do |record| + { + url: record.content, + display_text: truncate_url(record.content), + submitted_by: record.user || 'Unknown', + submitted_at: record.created_at + } + end + rescue StandardError => e + Rails.logger.error("Error building hyperlinks from records: #{e.message}") + [] + end + + # Truncates URL for display purposes (shows first 50 chars, adds ellipsis if longer) + def truncate_url(url) + url.length > 50 ? "#{url[0..47]}..." : url + end + + # Check whether the review has been started, is in progress or is completed + def get_review_status(response_map_id) + response = Response.where(map_id: response_map_id) + .order(updated_at: :desc) + .first + + return 'not_started' if response.nil? + + response.is_submitted ? 'completed' : 'in_progress' + end + + def get_instructor_review_for_reviewee(assignment_id, reviewee_id) + instructor_participant_id = get_instructor_participant_id(assignment_id) + return nil if instructor_participant_id.nil? + + # Find the ResponseMap where instructor reviewed this reviewee + # ReviewResponseMap has reviewee_id as a Team ID + instructor_response_map = ResponseMap.find_by( + reviewer_id: instructor_participant_id, + reviewee_id: reviewee_id, + for_calibration: true + ) + + return nil if instructor_response_map.nil? # No review found + + # Get the most recent Response + Response.where(map_id: instructor_response_map.id) + .order(updated_at: :desc) + .first + end + + # Core logic: Compares instructor and student reviews question by question + # Returns detailed breakdown per question + summary stats + # Reusable by both Student Report and Aggregate Report + def instructor_review_better?(instructor_review, student_review) + return nil if instructor_review.nil? || student_review.nil? + + # Fetch all answers for both reviews + instructor_answers = Answer.where(response_id: instructor_review.id) + student_answers = Answer.where(response_id: student_review.id) + + # Index by item_id for fast O(1) lookup + instructor_scores = instructor_answers.index_by(&:item_id) + student_scores = student_answers.index_by(&:item_id) + + question_comparisons = [] + + # Statistics Counters + total_questions = 0 + exact_matches = 0 + close_matches = 0 # Off by 1 or 2 points + + instructor_scores.each do |item_id, instructor_answer| + student_answer = student_scores[item_id] + + # 1. Get Values (Handle nil student answers gracefully) + inst_val = instructor_answer.answer.to_i + stud_val = student_answer&.answer.to_i || 0 # Treats missing answers as 0 + diff = (inst_val - stud_val).abs + + # 2. Update Stats + if diff == 0 + exact_matches += 1 + elsif diff <= 2 + close_matches += 1 + end + total_questions += 1 + + # 3. Build Question Object (Includes Comments & Text) + question_comparisons << { + item_id: item_id, + question_text: get_question_text(item_id), + instructor: { + score: inst_val, + comments: instructor_answer.comments + }, + student: { + score: stud_val, + comments: student_answer&.comments + }, + difference: diff, + direction: student_review_higher?(stud_val, inst_val) + } + end + + # 4. Calculate Final Aggregates + match_rate = total_questions > 0 ? ((exact_matches.to_f / total_questions) * 100).round(2) : 0 + avg_diff = calculate_average_difference(question_comparisons) + + { + questions: question_comparisons, + stats: { + total_questions: total_questions, + exact_matches: exact_matches, + close_matches: close_matches, # Requirement: Off by 1 or 2 + match_rate_percentage: match_rate, # Requirement: Match Rate + average_difference: avg_diff # Requirement: Average Difference + } + } + end + + # Helper to safely retrieve question text using Item model + # Used to prevent crashes if an Item ID is invalid + def get_question_text(item_id) + Item.find(item_id).txt + rescue ActiveRecord::RecordNotFound + "Question #{item_id}" + end + + # Determines if the student scored higher, lower, or exact + def student_review_higher?(student_score, instructor_score) + if student_score == instructor_score + 'exact' + elsif student_score > instructor_score + 'over' # Student gave higher score + else + 'under' # Student gave lower score + end + end + + # Computes the mean difference across all questions + def calculate_average_difference(comparisons) + return 0 if comparisons.empty? + + total_diff = comparisons.sum { |c| c[:difference] } + (total_diff.to_f / comparisons.length).round(2) + end + + # Calculates detailed statistics for a single question across all students + # Used by calibration_aggregate_report + def calculate_aggregate_question_stats(instructor_answer, student_answers_list) + inst_score = instructor_answer.answer.to_i + total_students = student_answers_list.size + + # Initialize counters + stats = { + exact_matches: 0, + off_by_one: 0, + off_by_two: 0, + off_by_three_plus: 0, + total_student_score: 0 + } + + # Iterate through students to populate counters + student_answers_list.each do |ans| + stud_score = ans.answer.to_i + stats[:total_student_score] += stud_score + + diff = (inst_score - stud_score).abs + + if diff == 0 + stats[:exact_matches] += 1 + elsif diff == 1 + stats[:off_by_one] += 1 + elsif diff == 2 + stats[:off_by_two] += 1 + else + stats[:off_by_three_plus] += 1 + end + end + + # Calculate Derived Metrics + avg_score = total_students > 0 ? (stats[:total_student_score].to_f / total_students).round(2) : 0 + match_rate = total_students > 0 ? ((stats[:exact_matches].to_f / total_students) * 100).round(2) : 0 + + { + item_id: instructor_answer.item_id, + question_text: get_question_text(instructor_answer.item_id), # Reuses your existing helper + instructor_score: inst_score, + avg_student_score: avg_score, + match_rate: match_rate, + counts: { + exact: stats[:exact_matches], + off_by_one: stats[:off_by_one], + off_by_two: stats[:off_by_two], + off_by_three_plus: stats[:off_by_three_plus] + } + } + end +end diff --git a/config/routes.rb b/config/routes.rb index 25642363c..eb31f026e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -149,4 +149,26 @@ get '/:participant_id/instructor_review', to: 'grades#instructor_review' end end + resources :calibration, only: [] do + collection do + # Instructor views calibration submissions for an assignment + get 'assignments/:assignment_id/submissions', + to: 'calibration#get_instructor_calibration_submissions' + + # Student views their own calibration report + get 'calibration_student_report', + to: 'calibration#calibration_student_report' + + + + # Instructor views aggregate report for a specific calibration submission + get 'assignments/:assignment_id/report/:reviewee_id', + to: 'calibration#calibration_aggregate_report' + + # Summary of all calibration and student reviews of a specific user submission + get '/assignments/:assignment_id/students/:student_participant_id/summary', + to: 'calibration#summary' + + end + end end \ No newline at end of file diff --git a/db/migrate/20251202_add_for_calibration_to_response_maps.rb b/db/migrate/20251202_add_for_calibration_to_response_maps.rb new file mode 100644 index 000000000..49a9a2fe6 --- /dev/null +++ b/db/migrate/20251202_add_for_calibration_to_response_maps.rb @@ -0,0 +1,23 @@ +# db/migrate/20251202_add_for_calibration_to_response_maps.rb +class AddForCalibrationToResponseMaps < ActiveRecord::Migration[8.0] + def up + # 1. Add the column if it doesn't already exist + unless column_exists?(:response_maps, :for_calibration) + add_column :response_maps, :for_calibration, :boolean, default: false, null: false + end + + # 2. Backfill only if the old column exists in this DB + if column_exists?(:response_maps, :to_calibrate) + execute <<~SQL.squish + UPDATE response_maps + SET for_calibration = COALESCE(to_calibrate, false) + SQL + end + end + + def down + # Safe rollback + remove_column :response_maps, :for_calibration if column_exists?(:response_maps, :for_calibration) + end +end + diff --git a/db/schema.rb b/db/schema.rb index d3a15fcfa..149e7e59e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,27 +10,27 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_29_071649) do +ActiveRecord::Schema[8.1].define(version: 2025_10_29_071649) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "username" - t.string "full_name" + t.datetime "created_at", null: false t.string "email" - t.string "status" + t.string "full_name" + t.bigint "institution_id", null: false t.text "introduction" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.bigint "role_id", null: false - t.bigint "institution_id", null: false + t.string "status" + t.datetime "updated_at", null: false + t.string "username" t.index ["institution_id"], name: "index_account_requests_on_institution_id" t.index ["role_id"], name: "index_account_requests_on_role_id" end create_table "answers", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.integer "item_id", default: 0, null: false - t.integer "response_id" t.integer "answer" t.text "comments" t.datetime "created_at", null: false + t.integer "item_id", default: 0, null: false + t.integer "response_id" t.datetime "updated_at", null: false t.index ["item_id"], name: "fk_score_items" t.index ["response_id"], name: "fk_score_response" @@ -38,91 +38,91 @@ create_table "assignment_questionnaires", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "assignment_id" - t.integer "questionnaire_id" - t.integer "notification_limit", default: 15, null: false t.datetime "created_at", null: false + t.integer "notification_limit", default: 15, null: false + t.integer "questionnaire_id" + t.integer "questionnaire_weight" t.datetime "updated_at", null: false t.integer "used_in_round" - t.integer "questionnaire_weight" t.index ["assignment_id"], name: "fk_aq_assignments_id" t.index ["questionnaire_id"], name: "fk_aq_questionnaire_id" end create_table "assignments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name" - t.string "directory_path" - t.integer "submitter_count" - t.boolean "private" - t.integer "num_reviews" - t.integer "num_review_of_reviews" - t.integer "num_review_of_reviewers" - t.boolean "reviews_visible_to_all" - t.integer "num_reviewers" - t.text "spec_location" - t.integer "max_team_size" - t.boolean "staggered_deadline" + t.boolean "allow_selecting_additional_reviews_after_1st_round" t.boolean "allow_suggestions" - t.integer "days_between_submissions" - t.string "review_assignment_strategy" - t.integer "max_reviews_per_submission" - t.integer "review_topic_threshold" + t.boolean "availability_flag" + t.boolean "calculate_penalty" + t.boolean "can_choose_topic_to_review" + t.boolean "can_review_same_topic" t.boolean "copy_flag" - t.integer "rounds_of_reviews" - t.boolean "microtask" - t.boolean "require_quiz" - t.integer "num_quiz_questions" + t.bigint "course_id" + t.datetime "created_at", null: false + t.integer "days_between_submissions" + t.string "directory_path" + t.boolean "enable_pair_programming", default: false + t.boolean "has_badge" + t.boolean "has_teams", default: false + t.boolean "has_topics", default: false + t.bigint "instructor_id", null: false + t.boolean "is_anonymous" + t.boolean "is_answer_tagging_allowed" + t.boolean "is_calibrated" t.boolean "is_coding_assignment" t.boolean "is_intelligent" - t.boolean "calculate_penalty" - t.integer "late_policy_id" t.boolean "is_penalty_calculated" - t.integer "max_bids" - t.boolean "show_teammate_reviews" - t.boolean "availability_flag" - t.boolean "use_bookmark" - t.boolean "can_review_same_topic" - t.boolean "can_choose_topic_to_review" - t.boolean "is_calibrated" t.boolean "is_selfreview_enabled" - t.string "reputation_algorithm" - t.boolean "is_anonymous" - t.integer "num_reviews_required" - t.integer "num_metareviews_required" + t.integer "late_policy_id" + t.integer "max_bids" + t.integer "max_reviews_per_submission" + t.integer "max_team_size" + t.boolean "microtask" + t.string "name" t.integer "num_metareviews_allowed" + t.integer "num_metareviews_required" + t.integer "num_quiz_questions" + t.integer "num_review_of_reviewers" + t.integer "num_review_of_reviews" + t.integer "num_reviewers" + t.integer "num_reviews" t.integer "num_reviews_allowed" + t.integer "num_reviews_required" + t.boolean "private" + t.string "reputation_algorithm" + t.boolean "require_quiz" + t.string "review_assignment_strategy" + t.integer "review_topic_threshold" + t.boolean "reviews_visible_to_all" + t.integer "rounds_of_reviews" + t.integer "sample_assignment_id" + t.boolean "show_teammate_reviews" t.integer "simicheck" t.integer "simicheck_threshold" - t.boolean "is_answer_tagging_allowed" - t.boolean "has_badge" - t.boolean "allow_selecting_additional_reviews_after_1st_round" - t.integer "sample_assignment_id" - t.datetime "created_at", null: false + t.text "spec_location" + t.boolean "staggered_deadline" + t.integer "submitter_count" t.datetime "updated_at", null: false - t.bigint "instructor_id", null: false - t.bigint "course_id" - t.boolean "enable_pair_programming", default: false - t.boolean "has_teams", default: false - t.boolean "has_topics", default: false + t.boolean "use_bookmark" t.index ["course_id"], name: "index_assignments_on_course_id" t.index ["instructor_id"], name: "index_assignments_on_instructor_id" end create_table "bookmark_ratings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "bookmark_id" - t.integer "user_id" - t.integer "rating" t.datetime "created_at", null: false + t.integer "rating" t.datetime "updated_at", null: false + t.integer "user_id" end create_table "bookmarks", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.text "url" - t.text "title" + t.datetime "created_at", null: false t.text "description" - t.integer "user_id" + t.text "title" t.integer "topic_id" - t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.text "url" + t.integer "user_id" end create_table "cakes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -131,57 +131,57 @@ end create_table "courses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name" + t.datetime "created_at", null: false t.string "directory_path" t.text "info" + t.bigint "institution_id", null: false + t.bigint "instructor_id", null: false + t.string "name" t.boolean "private", default: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "instructor_id", null: false - t.bigint "institution_id", null: false t.index ["institution_id"], name: "index_courses_on_institution_id" t.index ["instructor_id"], name: "fk_course_users" t.index ["instructor_id"], name: "index_courses_on_instructor_id" end create_table "due_dates", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.datetime "due_at", null: false + t.datetime "created_at", null: false + t.string "deadline_name" t.integer "deadline_type_id", null: false - t.string "parent_type", null: false - t.bigint "parent_id", null: false - t.integer "submission_allowed_id", null: false - t.integer "review_allowed_id", null: false - t.integer "round" - t.boolean "flag", default: false - t.integer "threshold", default: 1 t.string "delayed_job_id" - t.string "deadline_name" t.string "description_url" + t.datetime "due_at", null: false + t.boolean "flag", default: false + t.bigint "parent_id", null: false + t.string "parent_type", null: false t.integer "quiz_allowed_id", default: 1 - t.integer "teammate_review_allowed_id", default: 3 - t.string "type", default: "AssignmentDueDate" - t.integer "resubmission_allowed_id" t.integer "rereview_allowed_id" + t.integer "resubmission_allowed_id" + t.integer "review_allowed_id", null: false t.integer "review_of_review_allowed_id" - t.datetime "created_at", null: false + t.integer "round" + t.integer "submission_allowed_id", null: false + t.integer "teammate_review_allowed_id", default: 3 + t.integer "threshold", default: 1 + t.string "type", default: "AssignmentDueDate" t.datetime "updated_at", null: false t.index ["parent_type", "parent_id"], name: "index_due_dates_on_parent" end create_table "institutions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name" t.datetime "created_at", null: false + t.string "name" t.datetime "updated_at", null: false end create_table "invitations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "assignment_id" - t.string "reply_status", limit: 1 t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.bigint "from_id", null: false - t.bigint "to_id", null: false t.bigint "participant_id", null: false + t.string "reply_status", limit: 1 + t.bigint "to_id", null: false + t.datetime "updated_at", null: false t.index ["assignment_id"], name: "fk_invitation_assignments" t.index ["from_id"], name: "index_invitations_on_from_id" t.index ["participant_id"], name: "index_invitations_on_participant_id" @@ -189,58 +189,58 @@ end create_table "items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.text "txt" - t.integer "weight" - t.decimal "seq", precision: 10 - t.string "question_type" - t.string "size" t.string "alternatives" t.boolean "break_before" + t.datetime "created_at", null: false t.string "max_label" t.string "min_label" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "question_type" t.bigint "questionnaire_id", null: false + t.decimal "seq", precision: 10 + t.string "size" + t.text "txt" + t.datetime "updated_at", null: false + t.integer "weight" t.index ["questionnaire_id"], name: "fk_question_questionnaires" t.index ["questionnaire_id"], name: "index_items_on_questionnaire_id" end create_table "join_team_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.text "comments" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.integer "participant_id" - t.integer "team_id" - t.text "comments" t.string "reply_status" + t.integer "team_id" + t.datetime "updated_at", null: false end create_table "nodes", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.integer "parent_id" + t.datetime "created_at", null: false t.integer "node_object_id" + t.integer "parent_id" t.string "type" - t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "participants", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "user_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "can_submit", default: true + t.string "authorization" + t.boolean "can_mentor" t.boolean "can_review", default: true + t.boolean "can_submit", default: true + t.boolean "can_take_quiz" + t.datetime "created_at", null: false + t.string "current_stage" + t.float "grade" t.string "handle" - t.boolean "permission_granted", default: false t.bigint "join_team_request_id" + t.integer "parent_id", null: false + t.boolean "permission_granted", default: false + t.datetime "stage_deadline" t.bigint "team_id" t.string "topic" - t.string "current_stage" - t.datetime "stage_deadline" - t.boolean "can_take_quiz" - t.boolean "can_mentor" - t.string "authorization" - t.integer "parent_id", null: false t.string "type", null: false - t.float "grade" + t.datetime "updated_at", null: false + t.bigint "user_id" t.index ["join_team_request_id"], name: "index_participants_on_join_team_request_id" t.index ["team_id"], name: "index_participants_on_team_id" t.index ["user_id"], name: "fk_participant_users" @@ -248,132 +248,133 @@ end create_table "question_advices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "question_id", null: false - t.integer "score" t.text "advice" t.datetime "created_at", null: false + t.bigint "question_id", null: false + t.integer "score" t.datetime "updated_at", null: false t.index ["question_id"], name: "index_question_advices_on_question_id" end create_table "question_types", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name" t.datetime "created_at", null: false + t.string "name" t.datetime "updated_at", null: false end create_table "questionnaire_types", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name" t.datetime "created_at", null: false + t.string "name" t.datetime "updated_at", null: false end create_table "questionnaires", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name" + t.datetime "created_at", null: false + t.string "display_type" + t.text "instruction_loc" t.integer "instructor_id" - t.boolean "private" - t.integer "min_question_score" t.integer "max_question_score" + t.integer "min_question_score" + t.string "name" + t.boolean "private" t.string "questionnaire_type" - t.string "display_type" - t.text "instruction_loc" - t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "quiz_question_choices", id: :integer, charset: "latin1", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "iscorrect", default: false t.integer "question_id" t.text "txt" - t.boolean "iscorrect", default: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false end create_table "response_maps", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.boolean "for_calibration", default: false, null: false t.integer "reviewed_object_id", default: 0, null: false - t.integer "reviewer_id", default: 0, null: false t.integer "reviewee_id", default: 0, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.integer "reviewer_id", default: 0, null: false t.string "type" + t.datetime "updated_at", null: false t.index ["reviewer_id"], name: "fk_response_map_reviewer" end create_table "responses", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.integer "map_id", default: 0, null: false t.text "additional_comment" - t.boolean "is_submitted", default: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.boolean "is_submitted", default: false + t.integer "map_id", default: 0, null: false t.integer "round" + t.datetime "updated_at", null: false t.integer "version_num" t.index ["map_id"], name: "fk_response_response_map" end create_table "roles", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "default_page_id" t.string "name" t.bigint "parent_id" - t.integer "default_page_id" - t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["parent_id"], name: "fk_rails_4404228d2f" end create_table "sign_up_topics", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.text "topic_name", null: false t.bigint "assignment_id", null: false - t.integer "max_choosers", default: 0, null: false t.text "category" - t.string "topic_identifier", limit: 10 - t.integer "micropayment", default: 0 - t.integer "private_to" + t.datetime "created_at", null: false t.text "description" t.string "link" - t.datetime "created_at", null: false + t.integer "max_choosers", default: 0, null: false + t.integer "micropayment", default: 0 + t.integer "private_to" + t.string "topic_identifier", limit: 10 + t.text "topic_name", null: false t.datetime "updated_at", null: false t.index ["assignment_id"], name: "fk_sign_up_categories_sign_up_topics" t.index ["assignment_id"], name: "index_sign_up_topics_on_assignment_id" end create_table "signed_up_teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "sign_up_topic_id", null: false - t.bigint "team_id", null: false + t.boolean "advertise_for_partner" + t.text "comments_for_advertisement" + t.datetime "created_at", null: false t.boolean "is_waitlisted" t.integer "preference_priority_number" - t.datetime "created_at", null: false + t.bigint "sign_up_topic_id", null: false + t.bigint "team_id", null: false t.datetime "updated_at", null: false - t.text "comments_for_advertisement" - t.boolean "advertise_for_partner" t.index ["sign_up_topic_id"], name: "index_signed_up_teams_on_sign_up_topic_id" t.index ["team_id"], name: "index_signed_up_teams_on_team_id" end create_table "ta_mappings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "course_id", null: false - t.bigint "user_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "user_id", null: false t.index ["course_id"], name: "index_ta_mappings_on_course_id" t.index ["user_id"], name: "fk_ta_mapping_users" t.index ["user_id"], name: "index_ta_mappings_on_user_id" end create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "comment_for_submission" + t.datetime "created_at", null: false + t.integer "grade_for_submission" t.string "name", null: false + t.integer "parent_id", null: false t.string "type", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "parent_id", null: false - t.integer "grade_for_submission" - t.string "comment_for_submission" end create_table "teams_participants", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "team_id", null: false - t.integer "duty_id" t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.integer "duty_id" t.bigint "participant_id", null: false + t.bigint "team_id", null: false + t.datetime "updated_at", null: false t.integer "user_id", null: false t.index ["participant_id"], name: "index_teams_participants_on_participant_id" t.index ["team_id"], name: "index_teams_participants_on_team_id" @@ -381,36 +382,36 @@ end create_table "teams_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "team_id", null: false - t.bigint "user_id", null: false t.datetime "created_at", null: false + t.bigint "team_id", null: false t.datetime "updated_at", null: false + t.bigint "user_id", null: false t.index ["team_id"], name: "index_teams_users_on_team_id" t.index ["user_id"], name: "index_teams_users_on_user_id" end create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name" - t.string "password_digest" - t.string "full_name" + t.boolean "copy_of_emails", default: false + t.datetime "created_at", null: false t.string "email" - t.string "mru_directory_path" t.boolean "email_on_review", default: false - t.boolean "email_on_submission", default: false t.boolean "email_on_review_of_review", default: false + t.boolean "email_on_submission", default: false + t.boolean "etc_icons_on_homepage", default: false + t.string "full_name" + t.string "handle" + t.bigint "institution_id" t.boolean "is_new_user", default: true + t.integer "locale" t.boolean "master_permission_granted", default: false - t.string "handle" + t.string "mru_directory_path" + t.string "name" + t.bigint "parent_id" + t.string "password_digest" t.string "persistence_token" + t.bigint "role_id", null: false t.string "timeZonePref" - t.boolean "copy_of_emails", default: false - t.boolean "etc_icons_on_homepage", default: false - t.integer "locale" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.bigint "institution_id" - t.bigint "role_id", null: false - t.bigint "parent_id" t.index ["institution_id"], name: "index_users_on_institution_id" t.index ["parent_id"], name: "index_users_on_parent_id" t.index ["role_id"], name: "index_users_on_role_id" diff --git a/spec/requests/api/v1/calibration_controller_spec.rb b/spec/requests/api/v1/calibration_controller_spec.rb new file mode 100644 index 000000000..95e95d72d --- /dev/null +++ b/spec/requests/api/v1/calibration_controller_spec.rb @@ -0,0 +1,948 @@ +# frozen_string_literal: true + +require 'swagger_helper' +require 'json_web_token' + +RSpec.describe 'Calibration API', type: :request do + before(:all) do + @roles = create_roles_hierarchy + end + + let(:adm) { + User.create( + name: "adma", + password_digest: "password", + role_id: @roles[:admin].id, + full_name: "Admin A", + email: "instructor@example.com", + mru_directory_path: "/home/testuser", + ) + } + + let(:token) { JsonWebToken.encode({id: adm.id}) } + let(:Authorization) { "Bearer #{token}" } + + path '/calibration/assignments/{assignment_id}/submissions' do + parameter name: 'assignment_id', in: :path, type: :integer, + description: 'ID of the assignment' + + get('get instructor calibration submissions') do + tags 'Calibration' + produces 'application/json' + + let(:instructor) { adm } + + let(:assignment) do + Assignment.create!( + name: 'Calibration Assignment', + instructor_id: instructor.id + ) + end + + let(:assignment_id) { assignment.id } + + # Instructor's participant record for this assignment + let!(:instructor_participant) do + Participant.create!( + user_id: instructor.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'instructor_handle' + ) + end + + # Reviewee team (reviewee_id is a Team ID) + let!(:reviewee_team) do + Team.create!( + name: 'Team A', + parent_id: assignment.id, + type: 'AssignmentTeam' # or whatever your app uses for assignment teams + ) + end + + # Instructor's participant record for this assignment + let!(:instructor_participant) do + Participant.create!( + user_id: instructor.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'instructor_handle' + ) + end + + # ResponseMap where the INSTRUCTOR is the reviewer (for_calibration: true) + let!(:response_map) do + ResponseMap.create!( + reviewer_id: instructor_participant.id, + reviewee_id: reviewee_team.id, # Team ID + reviewed_object_id: assignment.id, + for_calibration: true + ) + end + + # A submitted response so that get_review_status returns "completed" + let!(:response_record) do + Response.create!( + map_id: response_map.id, + is_submitted: true + ) + end + + # Stub submitted content for the team so the JSON is predictable + before do + # TODO (future): replace this stub with a real submission via SubmittedContentController. + # Once we’re ready to stop stubbing, we should: + # + # # 1. Create a participant on the reviewee_team + # # (this participant represents someone on that team). + # team_participant = AssignmentParticipant.create!( + # user_id: instructor.id, + # parent_id: reviewee_team.id, # team as parent for membership + # type: 'AssignmentParticipant', + # handle: 'instructor_on_team' + # ) + # + # # 2. Use the real SubmittedContentController endpoint to submit a hyperlink + # post '/submitted_content/submit_hyperlink', + # params: { + # id: team_participant.id, # participant ID + # submission: 'https://example.com/report' + # }, + # headers: { 'Authorization' => Authorization } + # + # # 3. Remove the stub below so CalibrationController#get_submitted_content + # # reads hyperlinks/files from the submission records created above. + + allow_any_instance_of(CalibrationController).to receive(:get_submitted_content) + .with(reviewee_team.id) + .and_return({ + hyperlinks: ['https://example.com/report'], + files: [] + }) + + allow_any_instance_of(CalibrationController).to receive(:get_instructor_participant_id) + .and_return(instructor_participant.id) + end + + response(200, 'successful') do + # rswag hook to capture an example response in the generated swagger + after do |example| + next unless response&.body.present? + + example.metadata[:response][:content] = { + 'application/json' => { + example: JSON.parse(response.body, symbolize_names: true) + } + } + end + + run_test! do |resp| + body = JSON.parse(resp.body) + + expect(body['calibration_submissions']).to be_an(Array) + expect(body['calibration_submissions'].size).to eq(1) + + submission = body['calibration_submissions'].first + + expect(submission['team_name']).to eq('Team A') + expect(submission['reviewee_id']).to eq(reviewee_team.id) + expect(submission['response_map_id']).to eq(response_map.id) + expect(submission['submitted_content']['hyperlinks']) + .to eq(['https://example.com/report']) + expect(submission['review_status']).to eq('completed') + end + end + end + end + + + path '/calibration/calibration_student_report' do + parameter name: 'assignment_id', in: :query, type: :integer, + description: 'ID of the assignment' + parameter name: 'student_participant_id', in: :query, type: :integer, + description: 'ID of the student participant' + + get 'get student calibration comparison' do + tags 'Calibration' + produces 'application/json' + + response(200, 'when both reviews exist') do + let(:instructor) { adm } + + let(:assignment) do + Assignment.create!( + name: 'Calibration Assignment', + instructor_id: instructor.id + ) + end + + let(:assignment_id) { assignment.id } + + # Student participant (the reviewer) + let(:student_user) do + User.create!( + name: 'student1', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Student One', + email: 'student1@example.com', + mru_directory_path: '/home/student1' + ) + end + + let!(:student_participant) do + Participant.create!( + user_id: student_user.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'student_handle' + ) + end + + let(:student_participant_id) { student_participant.id } + + # Team being reviewed (reviewee_id is a Team ID) + # If your app uses AssignmentTeam < Team, replace with AssignmentTeam.create! + let!(:reviewee_team) do + Team.create!( + name: 'Team A', + parent_id: assignment.id, + type: 'AssignmentTeam' # or whatever your app uses for assignment teams + ) + end + + # ResponseMap for the student's calibration review + let!(:student_response_map) do + ResponseMap.create!( + reviewer_id: student_participant.id, + reviewee_id: student_participant.id, # valid Participant for validation + reviewed_object_id: assignment.id, + for_calibration: true + ).tap do |rm| + # Now point it at the Team ID, which is what the controller uses + rm.update_column(:reviewee_id, reviewee_team.id) + end + end + + # ResponseMap where the INSTRUCTOR is the reviewer (for_calibration: true) + let!(:instructor_response_map) do + ResponseMap.create!( + reviewer_id: instructor_participant.id, + reviewee_id: instructor_participant.id, # valid Participant + reviewed_object_id: assignment.id, + for_calibration: true + ).tap do |rm| + rm.update_column(:reviewee_id, reviewee_team.id) + end + end + + # Instructor's participant record for this assignment + let!(:instructor_participant) do + Participant.create!( + user_id: instructor.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'instructor_handle' + ) + end + + # Student's review (so student_review is not nil) + let!(:student_review) do + Response.create!( + map_id: student_response_map.id, + is_submitted: true + ) + end + + # Fake instructor review object (we don't care about its internals here) + let!(:instructor_review) do + Response.create!( + map_id: instructor_response_map.id, + is_submitted: true + ) + end + + # Stub helper methods so we don't need to set up Answers/Questionnaire + before do + allow_any_instance_of(CalibrationController).to receive(:get_instructor_review_for_reviewee) + .and_return(instructor_review) + + allow_any_instance_of(CalibrationController).to receive(:instructor_review_better?) + .and_return({ + agreement_percentage: 100.0, + questions: [] + }) + end + + # Capture example for Swagger + after do |example| + # If the request never actually ran (e.g., setup error), response will be nil + next unless response&.body.present? + + example.metadata[:response][:content] = { + 'application/json' => { + example: JSON.parse(response.body, symbolize_names: true) + } + } + end + + run_test! do |resp| + body = JSON.parse(resp.body) + + expect(body['calibration_reviews']).to be_an(Array) + expect(body['calibration_reviews'].size).to eq(1) + first = body['calibration_reviews'].first + + expect(first['reviewee_name']).to eq('Team A') + expect(first['reviewee_id']).to eq(reviewee_team.id) + expect(first['comparison']['agreement_percentage']).to eq(100.0) + end + end + + response(200, 'when review data is missing') do + let(:instructor) { adm } + + let(:assignment) do + Assignment.create!( + name: 'Calibration Assignment', + instructor_id: instructor.id + ) + end + + let(:assignment_id) { assignment.id } + + let(:student_user) do + User.create!( + name: 'student2', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Student Two', + email: 'student2@example.com', + mru_directory_path: '/home/student2' + ) + end + + let!(:student_participant) do + Participant.create!( + user_id: student_user.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'student_handle' + ) + end + + let(:student_participant_id) { student_participant.id } + + let!(:reviewee_team) do + Team.create!( + name: 'Team B', + parent_id: assignment.id, + type: 'AssignmentTeam' # or whatever your app uses for assignment teams + ) + end + + let!(:student_response_map) do + ResponseMap.create!( + reviewer_id: student_participant.id, + reviewee_id: student_participant.id, + reviewed_object_id: assignment.id, + for_calibration: true + ).tap do |rm| + rm.update_column(:reviewee_id, reviewee_team.id) + end + end + + # NOTE: no Response created for the student → student_review will be nil + + # Also stub instructor review as nil to force the error branch + before do + allow_any_instance_of(CalibrationController).to receive(:get_instructor_review_for_reviewee) + .and_return(nil) + end + + run_test! do |resp| + body = JSON.parse(resp.body) + + expect(body['calibration_reviews']).to be_an(Array) + expect(body['calibration_reviews'].size).to eq(1) + + comparison = body['calibration_reviews'].first + expect(comparison['reviewee_name']).to eq('Team B') + + comparison_hash = comparison['comparison'] + expect(comparison_hash['error']).to eq('Missing review data') + end + end + + response(200, 'when there are no calibration maps') do + let(:instructor) { adm } + + let(:assignment) do + Assignment.create!( + name: 'Calibration Assignment', + instructor_id: instructor.id + ) + end + + let(:assignment_id) { assignment.id } + + let(:student_user) do + User.create!( + name: 'student3', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Student Three', + email: 'student3@example.com', + mru_directory_path: '/home/student3' + ) + end + + let!(:student_participant) do + Participant.create!( + user_id: student_user.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'student_handle' + ) + end + + let(:student_participant_id) { student_participant.id } + + # NOTE: no ResponseMap created at all + + run_test! do |resp| + body = JSON.parse(resp.body) + expect(body['calibration_reviews']).to eq([]) + end + end + end + end + + path '/calibration/assignments/{assignment_id}/students/{student_participant_id}/summary' do + parameter name: 'assignment_id', in: :path, type: :integer, + description: 'ID of the assignment' + parameter name: 'student_participant_id', in: :path, type: :integer, + description: 'ID of the student participant' + + get 'get calibration summary' do + tags 'Calibration' + produces 'application/json' + + response(200, 'when everything is working') do + let(:instructor) { adm } + + let(:assignment) do + Assignment.create!( + name: 'Calibration Assignment (summary)', + instructor_id: instructor.id + ) + end + + let(:assignment_id) { assignment.id } + + # Student participant (the reviewer) + let(:student_user) do + User.create!( + name: 'student_summary_1', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Student Summary One', + email: 'student_summary1@example.com', + mru_directory_path: '/home/student_summary1' + ) + end + + let!(:student_participant) do + Participant.create!( + user_id: student_user.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'student_handle' + ) + end + + let(:student_participant_id) { student_participant.id } + + # Team being reviewed (reviewee_team_id) + let!(:reviewee_team) do + Team.create!( + name: 'Summary Team A', + parent_id: assignment.id, + type: 'AssignmentTeam' # or whatever your app uses for assignment teams + ) + end + + # Team members (participants on this team) + let!(:team_member_user1) do + User.create!( + name: 'member1', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Member One', + email: 'member1@example.com', + mru_directory_path: '/home/member1' + ) + end + + let!(:team_member_user2) do + User.create!( + name: 'member2', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Member Two', + email: 'member2@example.com', + mru_directory_path: '/home/member2' + ) + end + + let!(:team_member1) do + Participant.create!( + user_id: team_member_user1.id, + parent_id: reviewee_team.id, # treat team as the parent in this context + type: 'AssignmentParticipant', + handle: 'member_one' + ) + end + + let!(:team_member2) do + Participant.create!( + user_id: team_member_user2.id, + parent_id: reviewee_team.id, + type: 'AssignmentParticipant', + handle: 'member_two' + ) + end + + # ResponseMap for this student’s calibration review + let!(:student_response_map) do + ReviewResponseMap.create!( + reviewer_id: student_participant.id, + reviewee_id: reviewee_team.id, + reviewed_object_id: assignment.id, + for_calibration: true + ) + end + + # Stub submitted content so hyperlinks are deterministic + before do + # TODO (future): when using real assignment files, create the artifact via + # SubmittedContentController instead of stubbing: + # + # # 1. Use one of the team members as the participant whose team is reviewee_team + # # (team_member1/team_member2 are already defined in this context). + # post '/submitted_content/submit_hyperlink', + # params: { + # id: team_member1.id, # participant on Summary Team A + # submission: 'https://example.com/artifact1' + # }, + # headers: { 'Authorization' => Authorization } + # + # # 2. Remove the stub below so CalibrationController#get_submitted_content + # # pulls from the hyperlinks stored by SubmittedContentController / team.hyperlinks. + # + allow_any_instance_of(CalibrationController).to receive(:get_submitted_content) + .with(reviewee_team.id) + .and_return({ + hyperlinks: ['https://example.com/artifact1'], + files: [] + }) + end + + # Capture example JSON in Swagger + # Inside the `response(200, 'when everything is working') do ... end` block + after do |example| + # If the request never ran (setup error), response will be nil + next unless response&.body.present? + + example.metadata[:response][:content] = { + 'application/json' => { + example: JSON.parse(response.body, symbolize_names: true) + } + } + end + + run_test! do |resp| + body = JSON.parse(resp.body) + + expect(body['student_participant_id']).to eq(student_participant_id.to_s).or eq(student_participant_id) + expect(body['assignment_id']).to eq(assignment_id.to_s).or eq(assignment_id) + expect(body['submissions']).to be_an(Array) + expect(body['submissions'].size).to eq(1) + + submission = body['submissions'].first + expect(submission['reviewee_team_id']).to eq(reviewee_team.id) + expect(submission['for_calibration']).to eq(true) + + reviewer_names = submission['reviewers'].map { |r| r['full_name'] } + expect(reviewer_names).to match_array(['Member One', 'Member Two']) + + expect(submission['hyperlinks']).to eq(['https://example.com/artifact1']) + end + end + + response(404, 'when student or assignment does not exist') do + # Use IDs that don't exist in the DB + let(:assignment_id) { 999_999 } + let(:student_participant_id) { 888_888 } + + run_test! do |resp| + expect(resp.status).to eq(404) + body = JSON.parse(resp.body) + expect(body['error']).to eq('Assignment or student not found') + end + end + + response(200, 'when there are no hyperlinks for a submission') do + let(:instructor) { adm } + + let(:assignment) do + Assignment.create!( + name: 'Calibration Assignment (no links)', + instructor_id: instructor.id + ) + end + + let(:assignment_id) { assignment.id } + + let(:student_user) do + User.create!( + name: 'student_summary_2', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Student Summary Two', + email: 'student_summary2@example.com', + mru_directory_path: '/home/student_summary2' + ) + end + + let!(:student_participant) do + Participant.create!( + user_id: student_user.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'student_handle' + ) + end + + let(:student_participant_id) { student_participant.id } + + let!(:reviewee_team) do + Team.create!( + name: 'Summary Team B', + parent_id: assignment.id, + type: 'AssignmentTeam' # or whatever your app uses for assignment teams + ) + end + + let!(:team_member_user) do + User.create!( + name: 'member_only', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Solo Member', + email: 'member_only@example.com', + mru_directory_path: '/home/member_only' + ) + end + + let!(:team_member) do + Participant.create!( + user_id: team_member_user.id, + parent_id: reviewee_team.id, + type: 'AssignmentParticipant', + handle: 'solo_member' + ) + end + + let!(:student_response_map) do + ReviewResponseMap.create!( + reviewer_id: student_participant.id, + reviewee_id: reviewee_team.id, + reviewed_object_id: assignment.id, + for_calibration: true + ) + end + + before do + # TODO (future): when using the real SubmittedContentController, simply do NOT + # create any hyperlinks via /submitted_content/submit_hyperlink for this team. + # For example, you could still exercise the file upload path: + # + # # Example of creating a file submission only (no hyperlinks): + # uploaded = fixture_file_upload('spec/fixtures/files/dummy.txt', 'text/plain') + # post '/submitted_content/submit_file', + # params: { + # id: team_member.id, # participant on Summary Team B + # uploaded_file: uploaded, + # current_folder: { name: '/' } + # }, + # headers: { 'Authorization' => Authorization } + # + # # Then remove the stub below so CalibrationController#get_submitted_content + # # observes hyperlinks: [] coming from the real storage. + # + # No hyperlinks; controller should output [] + allow_any_instance_of(CalibrationController).to receive(:get_submitted_content) + .with(reviewee_team.id) + .and_return({ + hyperlinks: [], + files: [] + }) + end + + run_test! do |resp| + body = JSON.parse(resp.body) + + expect(body['submissions']).to be_an(Array) + expect(body['submissions'].size).to eq(1) + + submission = body['submissions'].first + expect(submission['reviewee_team_id']).to eq(reviewee_team.id) + expect(submission['hyperlinks']).to eq([]) # allowed + end + end + end + end + + path '/calibration/assignments/{assignment_id}/report/{reviewee_id}' do + parameter name: 'assignment_id', in: :path, type: :integer, + description: 'ID of the assignment' + parameter name: 'reviewee_id', in: :path, type: :integer, + description: 'Team ID of the reviewee' + + get 'get calibration aggregate report' do + tags 'Calibration' + produces 'application/json' + + response(200, 'when report is generated successfully') do + let(:instructor) { adm } + + let(:assignment) do + Assignment.create!( + name: 'Calibration Assignment (aggregate)', + instructor_id: instructor.id + ) + end + + let(:assignment_id) { assignment.id } + + # Reviewee team (reviewee_id is a Team ID) + let!(:reviewee_team) do + Team.create!( + name: '', # force fallback to participant.user.full_name + parent_id: assignment.id, + type: 'AssignmentTeam' + ) + end + + let(:reviewee_id) { reviewee_team.id } + + # Team member used to derive reviewee_name + let!(:reviewee_user) do + User.create!( + name: 'reviewee_user', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Reviewee User', + email: 'reviewee@example.com', + mru_directory_path: '/home/reviewee' + ) + end + + let!(:reviewee_participant) do + Participant.create!( + user_id: reviewee_user.id, + parent_id: reviewee_team.id, # makes reviewee_team.participants work + type: 'AssignmentParticipant', + handle: 'reviewee_participant' + ) + end + + # Instructor's participant record for this assignment + let!(:instructor_participant) do + Participant.create!( + user_id: instructor.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'instructor_handle' + ) + end + + # ResponseMap where the INSTRUCTOR is the reviewer (for_calibration: true) + let!(:instructor_response_map) do + ReviewResponseMap.create!( + reviewer_id: instructor_participant.id, + reviewee_id: reviewee_team.id, + reviewed_object_id: assignment.id, + for_calibration: true + ) + end + + # Instructor review we will feed into the report + let!(:instructor_review) do + Response.create!( + map_id: instructor_response_map.id, + is_submitted: true + ) + end + + let!(:questionnaire) do + instructor + Questionnaire.create( + name: 'Questionnaire 1', + questionnaire_type: 'AuthorFeedbackReview', + private: true, + min_question_score: 0, + max_question_score: 10, + instructor_id: instructor.id + ) + end + + let!(:item) do + Item.create( + seq: 1, + txt: "test item 1", + question_type: "multiple_choice", + break_before: true, + weight: 5, + questionnaire: questionnaire + ) + end + + # Instructor's answers: one question with item_id 1, score 5 + let!(:instructor_answer) do + Answer.create!( + response_id: instructor_review.id, + item_id: item.id, + answer: 5 + ) + end + + # Student who did a calibration review + let!(:student_user) do + User.create!( + name: 'student_agg', + password_digest: 'password', + role_id: @roles[:student].id, + full_name: 'Student Agg', + email: 'student_agg@example.com', + mru_directory_path: '/home/student_agg' + ) + end + + let!(:student_participant) do + Participant.create!( + user_id: student_user.id, + parent_id: assignment.id, + type: 'AssignmentParticipant', + handle: 'student_handle' + ) + end + + # Student calibration map for this assignment + reviewee team + let!(:student_map) do + ReviewResponseMap.create!( + reviewer_id: student_participant.id, + reviewee_id: reviewee_team.id, + reviewed_object_id: assignment.id, + for_calibration: true + ) + end + + # Latest student response for that map + let!(:student_response) do + Response.create!( + map_id: student_map.id, + is_submitted: true + ) + end + + # Student answer for same item_id 1, also score 5 (perfect match) + let!(:student_answer) do + Answer.create!( + response_id: student_response.id, + item_id: 1, + answer: 5 + ) + end + + before do + # Stub helper methods so we don't depend on their internal queries + allow_any_instance_of(CalibrationController).to receive(:get_instructor_review_for_reviewee) + .and_return(instructor_review) + + # We don't care what this returns as long as it's NOT the student reviewer_id + allow_any_instance_of(CalibrationController).to receive(:get_instructor_participant_id) + .and_return(instructor_participant.id) + end + + # Capture example JSON for Swagger UI + after do |example| + next unless response&.body.present? + + example.metadata[:response][:content] = { + 'application/json' => { + example: JSON.parse(response.body, symbolize_names: true) + } + } + end + + run_test! do |resp| + body = JSON.parse(resp.body) + + expect(body['assignment_id']).to eq(assignment_id.to_s).or eq(assignment_id) + expect(body['reviewee_id']).to eq(reviewee_id.to_s).or eq(reviewee_id) + expect(body['reviewee_name']).to eq('Reviewee User') + + stats = body['aggregate_stats'] + expect(stats['total_reviews']).to eq(1) + expect(stats['avg_agreement_percentage']).to eq(100.0) + + qb = stats['question_breakdown'] + expect(qb).to be_an(Array) + expect(qb.size).to eq(1) + + q1 = qb.first + expect(q1['item_id']).to eq(1) + expect(q1['instructor_score']).to eq(5) + expect(q1['avg_student_score']).to eq(5.0) + expect(q1['match_rate']).to eq(100.0) + end + end + + response(404, 'when instructor review is missing') do + let(:instructor) { adm } + + let(:assignment) do + Assignment.create!( + name: 'Calibration Assignment (no instructor review)', + instructor_id: instructor.id + ) + end + + let(:assignment_id) { assignment.id } + + let!(:reviewee_team) do + Team.create!( + name: 'Team Without Instructor Review', + parent_id: assignment.id, + type: 'AssignmentTeam' # or whatever your app uses for assignment teams + ) + end + + let(:reviewee_id) { reviewee_team.id } + + before do + allow_any_instance_of(CalibrationController).to receive(:get_instructor_review_for_reviewee) + .and_return(nil) + end + + run_test! do |resp| + expect(resp.status).to eq(404) + body = JSON.parse(resp.body) + expect(body['error']).to eq('Instructor review not found. Cannot generate report.') + end + end + end + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5c35e0b5d..e27d68366 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -16,9 +16,9 @@ # # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration require 'simplecov' -require 'coveralls' -require "simplecov_json_formatter" -Coveralls.wear! 'rails' +# require 'coveralls' +require 'simplecov_json_formatter' +# Coveralls.wear! 'rails' SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter # SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ # SimpleCov::Formatter::HTMLFormatter, @@ -58,51 +58,49 @@ # triggering implicit auto-inclusion in groups with matching metadata. config.shared_context_metadata_behavior = :apply_to_host_groups -# The settings below are suggested to provide a good initial experience -# with RSpec, but feel free to customize to your heart's content. -=begin - # This allows you to limit a spec run to individual examples or groups - # you care about by tagging them with `:focus` metadata. When nothing - # is tagged with `:focus`, all examples get run. RSpec also provides - # aliases for `it`, `describe`, and `context` that include `:focus` - # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - config.filter_run_when_matching :focus - - # Allows RSpec to persist some state between runs in order to support - # the `--only-failures` and `--next-failure` CLI options. We recommend - # you configure your source control system to ignore this file. - config.example_status_persistence_file_path = "spec/examples.txt" - - # Limits the available syntax to the non-monkey patched syntax that is - # recommended. For more details, see: - # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ - config.disable_monkey_patching! - - # Many RSpec users commonly either run the entire suite or an individual - # file, and it's useful to allow more verbose output when running an - # individual spec file. - if config.files_to_run.one? - # Use the documentation formatter for detailed output, - # unless a formatter has already been configured - # (e.g. via a command-line flag). - config.default_formatter = "doc" - end - - # Print the 10 slowest examples and example groups at the - # end of the spec run, to help surface which specs are running - # particularly slow. - config.profile_examples = 10 - - # Run specs in random order to surface order dependencies. If you find an - # order dependency and want to debug it, you can fix the order by providing - # the seed, which is printed after each run. - # --seed 1234 - config.order = :random - - # Seed global randomization in this process using the `--seed` CLI option. - # Setting this allows you to use `--seed` to deterministically reproduce - # test failures related to randomization by passing the same `--seed` value - # as the one that triggered the failure. - Kernel.srand config.seed -=end + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # # This allows you to limit a spec run to individual examples or groups + # # you care about by tagging them with `:focus` metadata. When nothing + # # is tagged with `:focus`, all examples get run. RSpec also provides + # # aliases for `it`, `describe`, and `context` that include `:focus` + # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + # config.filter_run_when_matching :focus + # + # # Allows RSpec to persist some state between runs in order to support + # # the `--only-failures` and `--next-failure` CLI options. We recommend + # # you configure your source control system to ignore this file. + # config.example_status_persistence_file_path = "spec/examples.txt" + # + # # Limits the available syntax to the non-monkey patched syntax that is + # # recommended. For more details, see: + # # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + # config.disable_monkey_patching! + # + # # Many RSpec users commonly either run the entire suite or an individual + # # file, and it's useful to allow more verbose output when running an + # # individual spec file. + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end + # + # # Print the 10 slowest examples and example groups at the + # # end of the spec run, to help surface which specs are running + # # particularly slow. + # config.profile_examples = 10 + # + # # Run specs in random order to surface order dependencies. If you find an + # # order dependency and want to debug it, you can fix the order by providing + # # the seed, which is printed after each run. + # # --seed 1234 + # config.order = :random + # + # # Seed global randomization in this process using the `--seed` CLI option. + # # Setting this allows you to use `--seed` to deterministically reproduce + # # test failures related to randomization by passing the same `--seed` value + # # as the one that triggered the failure. + # Kernel.srand config.seed end