From 11fb7e58461bbe45de3dd504acbf0c2c560c23dc Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 11:49:39 +0800 Subject: [PATCH 01/11] feat(assessment): add course_assessment_submission_details extension table --- ...te_course_assessment_submission_details.rb | 69 ++++++++ db/schema.rb | 14 +- lib/tasks/db/backfill_submission_details.rake | 34 ++++ .../db/backfill_submission_details_spec.rb | 50 ++++++ ...mission_details_backfill_migration_spec.rb | 165 ++++++++++++++++++ 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260723000001_create_course_assessment_submission_details.rb create mode 100644 lib/tasks/db/backfill_submission_details.rake create mode 100644 spec/lib/tasks/db/backfill_submission_details_spec.rb create mode 100644 spec/models/course/assessment/submission_details_backfill_migration_spec.rb diff --git a/db/migrate/20260723000001_create_course_assessment_submission_details.rb b/db/migrate/20260723000001_create_course_assessment_submission_details.rb new file mode 100644 index 0000000000..8f2ce39f87 --- /dev/null +++ b/db/migrate/20260723000001_create_course_assessment_submission_details.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +# Phase 1 (additive, Design C): extracts the course-coupled columns of the submission into their own +# small extension table WITHOUT renaming the base table. `Course::Assessment::Attempt` maps onto the +# existing `course_assessment_submissions` (the base) via `self.table_name`; `Submission` maps onto +# this new table. See docs/superpowers/plans/2026-07-23-attempt-base-record-additive-replan.md. +# +# Purely additive: no DDL touches `course_assessment_submissions` (only READ for the backfill), so a +# rolling deploy's still-old worker is completely undisturbed. Reversible via `drop_table`. +# +# The course-coupled columns are deliberately left ALSO on the base table for now (duplicated) so the +# ~24 raw-SQL sites that read them keep working; a later cleanup migration drops them from the base. +class CreateCourseAssessmentSubmissionDetails < ActiveRecord::Migration[7.2] + # Non-transactional so each backfill batch commits on its own — a production-sized + # `course_assessment_submissions` must never ride in one giant transaction (long lock, WAL spike, + # statement_timeout). The trade-off is that `up` is no longer atomic, so every step below is made + # individually idempotent (`if_not_exists` + `WHERE NOT EXISTS`) and the whole migration is safe to + # re-run after a partial failure. + disable_ddl_transaction! + + BACKFILL_BATCH_SIZE = 5_000 + + def up + create_table :course_assessment_submission_details, id: :serial, if_not_exists: true do |t| + t.integer :attempt_id, null: false + t.integer :publisher_id + t.string :session_id, limit: 255 + t.datetime :last_graded_time, precision: nil + t.timestamps precision: nil, null: false + end + + add_index :course_assessment_submission_details, :attempt_id, unique: true, if_not_exists: true, + name: 'unique_course_assessment_submission_details_attempt_id' + add_foreign_key :course_assessment_submission_details, :course_assessment_submissions, if_not_exists: true, + column: :attempt_id, name: 'fk_course_assessment_submission_details_attempt_id' + add_foreign_key :course_assessment_submission_details, :users, if_not_exists: true, + column: :publisher_id, name: 'fk_course_assessment_submission_details_publisher_id' + + backfill_details + end + + def down + drop_table :course_assessment_submission_details, if_exists: true + end + + private + + # One extension row per existing base row, copied 1:1 (every base row is a real submission — preview + # attempts don't exist yet). `WHERE NOT EXISTS` skips rows already backfilled, so this both fills a + # fresh table and reconciles a partially-filled one; batched + looped so no single statement scans + # the whole base table. Mirrors `rake db:backfill_submission_details`, which reruns this after the + # rolling deploy fully cuts over. + def backfill_details + loop do + inserted = execute(<<~SQL.squish).cmd_tuples + INSERT INTO course_assessment_submission_details + (attempt_id, publisher_id, session_id, last_graded_time, created_at, updated_at) + SELECT s.id, s.publisher_id, s.session_id, s.last_graded_time, s.created_at, s.updated_at + FROM course_assessment_submissions s + WHERE NOT EXISTS ( + SELECT 1 FROM course_assessment_submission_details d WHERE d.attempt_id = s.id + ) + ORDER BY s.id + LIMIT #{BACKFILL_BATCH_SIZE} + SQL + break if inserted == 0 + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 0d63cf08be..cd2174af4c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_20_154800) do +ActiveRecord::Schema[7.2].define(version: 2026_07_23_000001) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -607,6 +607,16 @@ t.index ["skill_id"], name: "index_course_assessment_skills_question_assessments_on_skill_id" end + create_table "course_assessment_submission_details", id: :serial, force: :cascade do |t| + t.integer "attempt_id", null: false + t.integer "publisher_id" + t.string "session_id", limit: 255 + t.datetime "last_graded_time", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["attempt_id"], name: "unique_course_assessment_submission_details_attempt_id", unique: true + end + create_table "course_assessment_submission_logs", id: :serial, force: :cascade do |t| t.integer "submission_id", null: false t.jsonb "request" @@ -2056,6 +2066,8 @@ add_foreign_key "course_assessment_skills", "users", column: "updater_id", name: "fk_course_assessment_skills_updater_id" add_foreign_key "course_assessment_skills_question_assessments", "course_assessment_skills", column: "skill_id" add_foreign_key "course_assessment_skills_question_assessments", "course_question_assessments", column: "question_assessment_id" + add_foreign_key "course_assessment_submission_details", "course_assessment_submissions", column: "attempt_id", name: "fk_course_assessment_submission_details_attempt_id" + add_foreign_key "course_assessment_submission_details", "users", column: "publisher_id", name: "fk_course_assessment_submission_details_publisher_id" add_foreign_key "course_assessment_submission_logs", "course_assessment_submissions", column: "submission_id", name: "fk_course_assessment_submission_logs_submission_id" add_foreign_key "course_assessment_submission_questions", "course_assessment_questions", column: "question_id", name: "fk_course_assessment_submission_questions_question_id" add_foreign_key "course_assessment_submission_questions", "course_assessment_submissions", column: "submission_id", name: "fk_course_assessment_submission_questions_submission_id" diff --git a/lib/tasks/db/backfill_submission_details.rake b/lib/tasks/db/backfill_submission_details.rake new file mode 100644 index 0000000000..86c1815441 --- /dev/null +++ b/lib/tasks/db/backfill_submission_details.rake @@ -0,0 +1,34 @@ +# frozen_string_literal: true +namespace :db do + # Backfills a `course_assessment_submission_details` row for every base `course_assessment_submissions` + # row that lacks one. Idempotent (`WHERE NOT EXISTS`) and batched, so it is safe to run repeatedly. + # + # Run this AFTER a rolling deploy has fully cut over. The create-table migration's own backfill only + # covers rows that existed when it ran; still-old workers keep inserting detail-less base rows during + # the deploy window, and new code reads those as previews (`Attempt#preview?` is "no detail row"). + # This task reconciles them. + task backfill_submission_details: :environment do + ActsAsTenant.without_tenant do + batch_size = 5_000 + connection = ActiveRecord::Base.connection + total = 0 + loop do + inserted = connection.execute(<<~SQL.squish).cmd_tuples + INSERT INTO course_assessment_submission_details + (attempt_id, publisher_id, session_id, last_graded_time, created_at, updated_at) + SELECT s.id, s.publisher_id, s.session_id, s.last_graded_time, s.created_at, s.updated_at + FROM course_assessment_submissions s + WHERE NOT EXISTS ( + SELECT 1 FROM course_assessment_submission_details d WHERE d.attempt_id = s.id + ) + ORDER BY s.id + LIMIT #{batch_size} + SQL + total += inserted + puts "Backfilled #{total} submission detail row(s)..." if inserted > 0 + break if inserted == 0 + end + puts "Done. Backfilled #{total} missing submission detail row(s)." + end + end +end diff --git a/spec/lib/tasks/db/backfill_submission_details_spec.rb b/spec/lib/tasks/db/backfill_submission_details_spec.rb new file mode 100644 index 0000000000..c8dc682174 --- /dev/null +++ b/spec/lib/tasks/db/backfill_submission_details_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +require 'rails_helper' +require 'rake' + +RSpec.describe 'db:backfill_submission_details', type: :task do + before(:all) do + Rails.application.load_tasks unless Rake::Task.task_defined?('db:backfill_submission_details') + end + + def run_task + task = Rake::Task['db:backfill_submission_details'] + task.reenable + task.invoke + end + + def detail_count(attempt_id) + ActiveRecord::Base.connection.select_value( + "SELECT COUNT(*) FROM course_assessment_submission_details WHERE attempt_id = #{attempt_id}" + ).to_i + end + + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:student) { create(:course_student, course: course) } + + # A "base row with no extension" — a real submission whose detail row was dropped — stands in for + # a row a still-old worker inserted during the rolling-deploy window. `Attempt#preview?` reads it + # as a preview until the detail row is reconciled. + it 'creates a detail row for a base attempt that is missing one' do + submission = create(:course_assessment_submission, assessment: assessment, creator: student.user) + attempt_id = submission.attempt_id + Course::Assessment::Submission.where(attempt_id: attempt_id).delete_all + expect(detail_count(attempt_id)).to eq(0) + + run_task + + expect(detail_count(attempt_id)).to eq(1) + end + + it 'is idempotent — leaves an already-present detail row untouched' do + submission = create(:course_assessment_submission, assessment: assessment, creator: student.user) + + run_task + + expect(detail_count(submission.attempt_id)).to eq(1) + end + end +end diff --git a/spec/models/course/assessment/submission_details_backfill_migration_spec.rb b/spec/models/course/assessment/submission_details_backfill_migration_spec.rb new file mode 100644 index 0000000000..fbacb3432c --- /dev/null +++ b/spec/models/course/assessment/submission_details_backfill_migration_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true +require 'rails_helper' +require Rails.root.join('db/migrate/20260723000001_create_course_assessment_submission_details') + +RSpec.describe CreateCourseAssessmentSubmissionDetails, type: :model do + let(:instance) { Instance.default } + + # Inserts one row straight into course_assessment_submissions (the base table, untouched by this + # migration) and returns its id — a "pre-existing submission" for the backfill to copy. Raw SQL + # rather than the submission factory for two reasons: the factory also creates the + # course_assessment_submission_details row that this backfill is meant to create, so it cannot + # seed a base row lacking one; and raw SQL lets each course-coupled column be given an exact, + # asserted value. Every base NOT NULL column is set; the columns the migration copies + # (publisher_id, session_id, last_graded_time, created_at, updated_at) are parameters. + def insert_base_submission(assessment:, creator:, publisher_id: nil, session_id: nil, # rubocop:disable Metrics/ParameterLists + last_graded_time: nil, created_at: Time.zone.now, updated_at: Time.zone.now) + sql = <<-SQL.squish + INSERT INTO course_assessment_submissions + (assessment_id, workflow_state, creator_id, updater_id, publisher_id, published_at, + session_id, submitted_at, last_graded_time, created_at, updated_at) + VALUES + (?, 'submitted', ?, ?, ?, NULL, ?, now(), ?, ?, ?) + RETURNING id + SQL + ActiveRecord::Base.connection.select_value( + ActiveRecord::Base.sanitize_sql_array( + [sql, assessment.id, creator.id, creator.id, publisher_id, session_id, + last_graded_time, created_at, updated_at] + ) + ).to_i + end + + # This spec drives the migration's own `down`/`up`. RSpec's `maintain_test_schema!` always brings + # the test DB to schema.rb's FINAL state, so `course_assessment_submission_details` already exists + # when an example starts; the `around` hook drops it (`down`) BEFORE each example so the body can + # test `up` creating + backfilling it from scratch, then restores it afterwards. + # + # Every example calls `up` first, sets `@migration_reapplied`, then asserts on the resulting rows — + # never a `change {}` matcher, since `change` would evaluate its value block against a not-yet-created + # table (`PG::UndefinedTable`). Row counts are scoped to a freshly-created `attempt_id`, so they are + # deterministically 1 after `up` and safe under this repo's "nothing rolls back between examples". + around do |example| + described_class.new.down + example.run + ensure + described_class.new.up unless @migration_reapplied + end + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:creator) { create(:course_student, course: course).user } + + it 'backfills one detail row per pre-existing base submission, copying every ' \ + 'course-coupled column with its exact value' do + publisher = create(:course_student, course: course).user + graded_time = Time.zone.parse('2026-01-02 03:04:05') + created_time = Time.zone.parse('2020-06-15 08:00:00') + updated_time = Time.zone.parse('2021-07-16 09:30:00') + base_id = insert_base_submission(assessment: assessment, creator: creator, + publisher_id: publisher.id, session_id: 'a-fake-session-id', + last_graded_time: graded_time, + created_at: created_time, updated_at: updated_time) + + described_class.new.up + @migration_reapplied = true + + rows = ActiveRecord::Base.connection.select_all( + "SELECT * FROM course_assessment_submission_details WHERE attempt_id = #{base_id}" + ).to_a + expect(rows.size).to eq(1) + row = rows.first + expect(row['publisher_id'].to_i).to eq(publisher.id) + expect(row['session_id']).to eq('a-fake-session-id') + expect(row['last_graded_time']).to eq(graded_time) + expect(row['created_at']).to eq(created_time) + expect(row['updated_at']).to eq(updated_time) + end + + it 'copies a NULL publisher_id through as NULL' do + base_id = insert_base_submission(assessment: assessment, creator: creator, + publisher_id: nil, session_id: 'null-publisher-session', + last_graded_time: Time.zone.now) + + described_class.new.up + @migration_reapplied = true + + row = ActiveRecord::Base.connection.select_one( + "SELECT * FROM course_assessment_submission_details WHERE attempt_id = #{base_id}" + ) + expect(row['publisher_id']).to be_nil + end + + it 'backfills every pre-existing base submission, not only the most recently created one' do + older_id = insert_base_submission(assessment: assessment, creator: creator, session_id: 'older') + newer_creator = create(:course_student, course: course).user + newer_id = insert_base_submission(assessment: assessment, creator: newer_creator, session_id: 'newer') + + described_class.new.up + @migration_reapplied = true + + counts = [older_id, newer_id].map do |id| + ActiveRecord::Base.connection.select_value( + "SELECT COUNT(*) FROM course_assessment_submission_details WHERE attempt_id = #{id}" + ).to_i + end + expect(counts).to eq([1, 1]) + end + + it 'enforces one detail row per attempt (unique attempt_id)' do + base_id = insert_base_submission(assessment: assessment, creator: creator, session_id: 'unique') + + described_class.new.up + @migration_reapplied = true + + expect do + ActiveRecord::Base.connection.execute( + 'INSERT INTO course_assessment_submission_details (attempt_id, created_at, updated_at) ' \ + "VALUES (#{base_id}, now(), now())" + ) + end.to raise_error(ActiveRecord::RecordNotUnique) + end + + def detail_count(attempt_id) + ActiveRecord::Base.connection.select_value( + "SELECT COUNT(*) FROM course_assessment_submission_details WHERE attempt_id = #{attempt_id}" + ).to_i + end + + it 'is idempotent — re-running the backfill adds no duplicate detail row' do + base_id = insert_base_submission(assessment: assessment, creator: creator, session_id: 'idempotent') + + described_class.new.up + described_class.new.up + @migration_reapplied = true + + expect(detail_count(base_id)).to eq(1) + end + + it 'backfills only base rows still missing a detail row, leaving existing ones untouched' do + first_id = insert_base_submission(assessment: assessment, creator: creator, session_id: 'first') + described_class.new.up + + newer_creator = create(:course_student, course: course).user + second_id = insert_base_submission(assessment: assessment, creator: newer_creator, session_id: 'second') + described_class.new.up + @migration_reapplied = true + + expect([detail_count(first_id), detail_count(second_id)]).to eq([1, 1]) + end + + it 'backfills every base row even when they span multiple batches' do + stub_const('CreateCourseAssessmentSubmissionDetails::BACKFILL_BATCH_SIZE', 2) + ids = Array.new(3) do |i| + insert_base_submission(assessment: assessment, + creator: create(:course_student, course: course).user, session_id: "batch-#{i}") + end + + described_class.new.up + @migration_reapplied = true + + expect(ids.map { |id| detail_count(id) }).to eq([1, 1, 1]) + end + end +end From 558e935869044e83c5237dcecf1bea2fc6adecc9 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 13:59:34 +0800 Subject: [PATCH 02/11] feat(assessment): split Submission into an Attempt base record (additive, no rename) --- .../submission/koditsu/submissions_concern.rb | 16 +- .../assessment/assessments_controller.rb | 2 +- .../programming/annotations_controller.rb | 4 +- .../submission/submissions_controller.rb | 7 +- .../comments_controller.rb | 2 +- .../submission_questions_controller.rb | 5 +- .../course/material/materials_controller.rb | 4 +- .../course/statistics/aggregate_controller.rb | 5 +- .../statistics/assessments_controller.rb | 19 +- .../force_submit_timed_submission_job.rb | 6 +- .../submission/force_submitting_job.rb | 4 +- .../assessment/new_submission_concern.rb | 5 +- .../submission/workflow_event_concern.rb | 125 +--- .../concerns/course_user/staff_concern.rb | 8 +- app/models/course/assessment.rb | 43 +- app/models/course/assessment/answer.rb | 25 +- app/models/course/assessment/attempt.rb | 343 +++++++++++ .../assessment/question_bundle_assignment.rb | 12 +- app/models/course/assessment/submission.rb | 556 +++++++++--------- .../course/assessment/submission/log.rb | 8 +- .../course/assessment/submission_question.rb | 20 +- app/models/course/condition/assessment.rb | 48 +- app/models/course_user.rb | 5 +- .../answer/ai_generated_post_service.rb | 2 +- ...ramming_codaveri_async_feedback_service.rb | 2 +- .../assessment/submission/update_service.rb | 5 +- .../user_notifications/email.html.slim | 8 +- ...te_course_assessment_submission_details.rb | 4 +- .../koditsu/submissions_concern_spec.rb | 7 +- .../submission/submissions_controller_spec.rb | 74 ++- spec/factories/course_assessment_attempts.rb | 6 + .../course_assessment_submission_logs.rb | 6 +- .../course_assessment_submissions.rb | 25 +- .../course/assessment/submission/log_spec.rb | 2 +- spec/models/course/assessment/answer_spec.rb | 19 +- .../course/assessment/submission_spec.rb | 60 +- spec/models/course/assessment_spec.rb | 3 +- .../course/condition/assessment_spec.rb | 11 +- 38 files changed, 1009 insertions(+), 497 deletions(-) create mode 100644 app/models/course/assessment/attempt.rb create mode 100644 spec/factories/course_assessment_attempts.rb diff --git a/app/controllers/concerns/course/assessment/submission/koditsu/submissions_concern.rb b/app/controllers/concerns/course/assessment/submission/koditsu/submissions_concern.rb index 67249576aa..bc40a21fce 100644 --- a/app/controllers/concerns/course/assessment/submission/koditsu/submissions_concern.rb +++ b/app/controllers/concerns/course/assessment/submission/koditsu/submissions_concern.rb @@ -39,7 +39,7 @@ def submission_status_hash def process_all_submissions create_new_submissions_if_not_existing - @submission_hash = Course::Assessment::Submission.where(assessment: @assessment).to_h do |s| + @submission_hash = @assessment.submissions.to_h do |s| [s.creator_id, s] end @@ -59,8 +59,10 @@ def process_submission(submission, cm_submission) end def create_new_submissions_if_not_existing - existing_submission_user_ids = Course::Assessment::Submission.where(assessment: @assessment). - pluck(:creator_id) + # `creator_id` lives on the base (`course_assessment_submissions`), not on Submission's own + # table. Unqualified, `pluck` is ambiguous — `acts_as :experience_points_record` also joins + # `course_experience_points_records`, which also has `creator_id`. Qualify explicitly. + existing_submission_user_ids = @assessment.submissions.pluck('course_assessment_submissions.creator_id') koditsu_submission_user_ids = @cu_submission_hash.keys.map { |creator, _| creator.id } user_ids_without_submission = koditsu_submission_user_ids - existing_submission_user_ids @@ -77,8 +79,7 @@ def create_new_submissions_if_not_existing def create_new_submission_for(creator, course_user) User.with_stamper(creator) do - new_submission = @assessment.submissions.new(creator: creator, - course_user: course_user) + new_submission = @assessment.build_submission(creator: creator, course_user: course_user) success = @assessment.create_new_submission(new_submission, course_user) raise ActiveRecord::Rollback unless success @@ -96,7 +97,10 @@ def update_submission(cm_submission, state, submitted_at) end def process_submission_answers(submission, cm_submission) - answers = Course::Assessment::Answer.includes(:question).where(submission_id: cm_submission.id) + # `cm_submission` is a Submission (extension), whose own `id` is an independent serial, NOT the + # attempt id that `answers.submission_id` references. Use `cm_submission.attempt_id` (the + # extension's FK to its attempt) to find the answers. + answers = Course::Assessment::Answer.includes(:question).where(submission_id: cm_submission.attempt_id) build_answer_hash(answers) diff --git a/app/controllers/course/assessment/assessments_controller.rb b/app/controllers/course/assessment/assessments_controller.rb index 3c7c83bd48..db0e87fee0 100644 --- a/app/controllers/course/assessment/assessments_controller.rb +++ b/app/controllers/course/assessment/assessments_controller.rb @@ -459,7 +459,7 @@ def submissions if @assessment.submissions.loaded? @assessment.submissions.select { |s| s.creator_id == current_user.id } else - @assessment.submissions.where(creator_id: current_user.id) + @assessment.submissions.by_user(current_user) end end diff --git a/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb b/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb index bbcf902942..3f5dd224d9 100644 --- a/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb +++ b/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -class Course::Assessment::Submission::Answer::Programming::AnnotationsController < \ +class Course::Assessment::Submission::Answer::Programming::AnnotationsController < Course::Assessment::Submission::Answer::Programming::Controller include Signals::EmissionConcern @@ -66,7 +66,7 @@ def create_topic_subscription # Ensure all group managers get a notification when someone adds a programming annotation # to the answer. - answer_course_user = @answer.submission.course_user + answer_course_user = @answer.submission.submission.course_user answer_course_user.my_managers.each do |manager| @discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/controllers/course/assessment/submission/submissions_controller.rb b/app/controllers/course/assessment/submission/submissions_controller.rb index bdae979fcc..ad09e9bcf3 100644 --- a/app/controllers/course/assessment/submission/submissions_controller.rb +++ b/app/controllers/course/assessment/submission/submissions_controller.rb @@ -41,7 +41,7 @@ def index def create # rubocop:disable Metrics/AbcSize authorize! :access, @assessment - existing_submission = @assessment.submissions.find_by(creator: current_user) + existing_submission = @assessment.submissions.by_user(current_user).first create_success_response(existing_submission) and return if existing_submission ActiveRecord::Base.transaction do @@ -423,7 +423,10 @@ def course_user_ids def user_ids_without_submission existing_submissions = @assessment.submissions.by_users(course_user_ids.pluck(:user_id)) - user_ids_with_submission = existing_submissions.pluck(:creator_id) + # `creator_id` lives on the base (`course_assessment_submissions`), not on Submission's own + # table. Unqualified, `pluck` is ambiguous — `acts_as :experience_points_record` also joins + # `course_experience_points_records`, which also has `creator_id`. Qualify explicitly. + user_ids_with_submission = existing_submissions.pluck('course_assessment_submissions.creator_id') course_user_ids.pluck(:user_id) - user_ids_with_submission end diff --git a/app/controllers/course/assessment/submission_question/comments_controller.rb b/app/controllers/course/assessment/submission_question/comments_controller.rb index 7fd47cf2d5..965aef7bd2 100644 --- a/app/controllers/course/assessment/submission_question/comments_controller.rb +++ b/app/controllers/course/assessment/submission_question/comments_controller.rb @@ -34,7 +34,7 @@ def create_topic_subscription @discussion_topic.ensure_subscribed_by(@submission_question.submission.creator) # Ensure all group managers get a notification when someone comments on this submission question - submission_question_course_user = @submission_question.submission.course_user + submission_question_course_user = @submission_question.submission.submission.course_user submission_question_course_user.my_managers.each do |manager| @discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/controllers/course/assessment/submission_question/submission_questions_controller.rb b/app/controllers/course/assessment/submission_question/submission_questions_controller.rb index 6396dc6b88..7d71ae1a0d 100644 --- a/app/controllers/course/assessment/submission_question/submission_questions_controller.rb +++ b/app/controllers/course/assessment/submission_question/submission_questions_controller.rb @@ -3,7 +3,10 @@ class Course::Assessment::SubmissionQuestion::SubmissionQuestionsController < Co load_resource :assessment, class: 'Course::Assessment', through: :course, parent: false def all_answers - @submission = @assessment.submissions.find(all_answers_params[:submission_id]) + # `all_answers_params[:submission_id]` is an Attempt id — the wire key `submissionId` carries + # the attempt's id, not the extension table's own id. Find by attempt, then navigate to the real + # Submission for `authorize!`, whose `can :read, ...Submission` rules match on subject class. + @submission = @assessment.attempts.find(all_answers_params[:submission_id]).submission authorize!(:read, @submission) @submission_question = @submission. submission_questions. diff --git a/app/controllers/course/material/materials_controller.rb b/app/controllers/course/material/materials_controller.rb index ff7bdc2056..07b552c71d 100644 --- a/app/controllers/course/material/materials_controller.rb +++ b/app/controllers/course/material/materials_controller.rb @@ -44,9 +44,9 @@ def material_params def create_submission current_course_user = current_course.course_users.find_by(user: current_user) @assessment = @folder.owner - existing_submission = @assessment.submissions.find_by(creator: current_user) + existing_submission = @assessment.submissions.by_user(current_user).first unless existing_submission - @submission = @assessment.submissions.new(course_user: current_course_user) + @submission = @assessment.build_submission(course_user: current_course_user) @submission.session_id = authentication_service.generate_authentication_token success = @assessment.create_new_submission(@submission, current_user) diff --git a/app/controllers/course/statistics/aggregate_controller.rb b/app/controllers/course/statistics/aggregate_controller.rb index f28b8d6d35..9326fb6222 100644 --- a/app/controllers/course/statistics/aggregate_controller.rb +++ b/app/controllers/course/statistics/aggregate_controller.rb @@ -65,7 +65,7 @@ def fetch_course_get_help_data(start_date, end_date) get_help_data = Course::Assessment::LiveFeedback::Message.find_by_sql(<<-SQL) SELECT DISTINCT ON (t.submission_creator_id, s.assessment_id, sq.question_id) m.id, m.content, m.created_at, t.submission_creator_id, - s.assessment_id, sq.submission_id, sq.question_id, + s.assessment_id, sub.id AS submission_id, sq.question_id, COUNT(*) OVER ( PARTITION BY t.submission_creator_id, s.assessment_id, sq.question_id ) AS message_count @@ -73,6 +73,9 @@ def fetch_course_get_help_data(start_date, end_date) INNER JOIN live_feedback_threads t ON m.thread_id = t.id INNER JOIN course_assessment_submission_questions sq ON t.submission_question_id = sq.id INNER JOIN course_assessment_submissions s ON sq.submission_id = s.id + -- `s.id` is the attempt's id (what the response's `submissionId` carries), which is a different + -- id space from the extension table's own serial `id`. Join to the extension via its `attempt_id`. + INNER JOIN course_assessment_submission_details sub ON sub.attempt_id = s.id INNER JOIN course_assessments a ON s.assessment_id = a.id INNER JOIN course_assessment_tabs tab ON a.tab_id = tab.id INNER JOIN course_assessment_categories cat ON tab.category_id = cat.id diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index d158d64a01..e2950f192b 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -21,7 +21,11 @@ def submission_statistics includes(programming_questions: [:language]). calculated(:maximum_grade, :question_count). find(assessment_params[:id]) - submissions = Course::Assessment::Submission.unscoped. + # `Course::Assessment::Submission` has no `assessment_id` column (it's Attempt-only, reached via + # the delegate), so querying it directly here raises `PG::UndefinedColumn`. `Attempt` carries + # `assessment_id`, `grade`, and `grader_ids` natively/as `calculated`, and this action's jbuilder + # only reads columns present on Attempt, so query Attempt directly. + submissions = Course::Assessment::Attempt.unscoped. where(assessment_id: assessment_params[:id]). calculated(:grade, :grader_ids) @course_users_hash = preload_course_users_hash(current_course) @@ -39,7 +43,9 @@ def ancestor_statistics calculated(:maximum_grade). find(assessment_params[:id]) authorize!(:read_ancestor, @assessment) - submissions = Course::Assessment::Submission.unscoped. + # Same `assessment_id`-column bug as `submission_statistics` above — `Attempt` is the + # drop-in replacement (see the comment there). + submissions = Course::Assessment::Attempt.unscoped. preload(creator: :course_users). where(assessment_id: assessment_params[:id]). calculated(:grade) @@ -52,7 +58,10 @@ def ancestor_statistics def live_feedback_statistics @assessment = Course::Assessment.unscoped.includes(:questions). find(assessment_params[:id]) - @submissions = Course::Assessment::Submission.unscoped. + # id/creator_id/workflow_state all live on Attempt; this action only reads those three columns + # (unscoped + a narrow .select), so querying Attempt directly is equivalent (every course-member + # attempt has exactly one submission). + @submissions = Course::Assessment::Attempt.unscoped. select(:id, :creator_id, :workflow_state). where(assessment_id: assessment_params[:id]) @@ -64,7 +73,7 @@ def live_feedback_statistics def live_feedback_history user_id = CourseUser.joins(:user).where(id: params[:course_user_id]).pluck('users.id').first - @submissions = Course::Assessment::Submission.where(assessment_id: assessment_params[:id], creator_id: user_id) + @submissions = Course::Assessment::Attempt.where(assessment_id: assessment_params[:id], creator_id: user_id) @question = Course::Assessment::Question.find(params[:question_id]) create_submission_question_id_hash([@question]) @@ -238,7 +247,7 @@ def feedback_messages_cte(student_ids, submission_question_ids) def feedback_answers_cte <<-SQL SELECT - a.submission_id, + a.submission_id AS submission_id, a.question_id, a.created_at, a.grade, diff --git a/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb b/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb index e3ea3bc942..b74008b273 100644 --- a/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb +++ b/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb @@ -10,7 +10,11 @@ def perform_tracked(assessment, submission_id, submitter) instance = Course.unscoped { assessment.course.instance } ActsAsTenant.with_tenant(instance) do - submission = Course::Assessment::Submission.find_by(id: submission_id) + # `submission_id` here is the id `create_force_submission_job` (Attempt#create_force_submission_job) + # scheduled with, which is `attempt.id` — this job's own param name predates the split and is + # not renamed here (renaming it is exactly the kind of cosmetic diff the repo's diff-hygiene + # rule forbids without a functional reason). + submission = Course::Assessment::Attempt.find_by(id: submission_id)&.submission return unless submission force_submit(submission, submitter) diff --git a/app/jobs/course/assessment/submission/force_submitting_job.rb b/app/jobs/course/assessment/submission/force_submitting_job.rb index 81204ad5c8..4b0a9d83d6 100644 --- a/app/jobs/course/assessment/submission/force_submitting_job.rb +++ b/app/jobs/course/assessment/submission/force_submitting_job.rb @@ -50,9 +50,7 @@ def force_create_and_submit_submissions(assessment, user_ids, user_ids_without_s # @param [Course::Assessment] assessment The assessment of which a submission is to be created. # @param [CourseUser] course_user The course user whose submission is to be created. def create_submission(assessment, course_user) - submission = assessment.submissions.new(creator: course_user.user, course_user: course_user) - - assessment.submissions.new(creator: course_user.user) + submission = assessment.build_submission(creator: course_user.user, course_user: course_user) success = assessment.create_new_submission(submission, course_user) raise ActiveRecord::Rollback unless success diff --git a/app/models/concerns/course/assessment/new_submission_concern.rb b/app/models/concerns/course/assessment/new_submission_concern.rb index b4841c97df..45dca6ce37 100644 --- a/app/models/concerns/course/assessment/new_submission_concern.rb +++ b/app/models/concerns/course/assessment/new_submission_concern.rb @@ -12,7 +12,10 @@ def create_new_submission(new_submission, current_user) raise ActiveRecord::Rollback end raise ActiveRecord::Rollback unless new_submission.save - raise ActiveRecord::Rollback unless qbas.update_all(submission_id: new_submission.id) + # `question_bundle_assignments.submission_id` references the attempt base, so it must hold an + # Attempt id — not the extension table's own (different) id. `attempt_id` is the extension's + # own FK column to its attempt. + raise ActiveRecord::Rollback unless qbas.update_all(submission_id: new_submission.attempt_id) success = true end diff --git a/app/models/concerns/course/assessment/submission/workflow_event_concern.rb b/app/models/concerns/course/assessment/submission/workflow_event_concern.rb index 51741c682a..d43a6ded4a 100644 --- a/app/models/concerns/course/assessment/submission/workflow_event_concern.rb +++ b/app/models/concerns/course/assessment/submission/workflow_event_concern.rb @@ -1,12 +1,6 @@ # frozen_string_literal: true module Course::Assessment::Submission::WorkflowEventConcern extend ActiveSupport::Concern - include Course::LessonPlan::PersonalizationConcern - include Course::Assessment::Submission::CikgoTaskCompletionConcern - - included do - before_validation :assign_experience_points, if: :workflow_state_changed? - end protected @@ -21,13 +15,7 @@ def finalise(_ = nil) finalise_current_answers answers.reload # Reload answers after finalising - assign_zero_experience_points - - # Trigger timeline recomputation - # NB: We are not recomputing on unsubmission because unsubmit is not done by the student - # It will recompute again when resubmission occurs. This also prevents the timings for - # the unsubmitted item from changing e.g. from other submissions that the student has done. - update_personalized_timeline_for_user(course_user) + after_finalise_hook end # Handles the marking of a submission. @@ -49,13 +37,9 @@ def unmark(_ = nil) def publish(_ = nil, send_email = true) # rubocop:disable Style/OptionalBooleanParameter publish_answers - self.publisher = User.stamper || User.system self.published_at = Time.zone.now - self.awarder = User.stamper || User.system - self.awarded_at = Time.zone.now - publish_delayed_posts - send_email_after_publishing(send_email) + after_publish_hook(send_email) end # Handles the unsubmission of a submitted submission. @@ -66,13 +50,10 @@ def unsubmit(_ = nil) recreate_current_answers answers.reload - self.points_awarded = nil - self.draft_points_awarded = nil - self.awarded_at = nil - self.awarder = nil self.submitted_at = nil - self.publisher = nil self.published_at = nil + + after_unsubmit_hook end # Handles re-submitting a published submission's programming answers when there are @@ -83,29 +64,43 @@ def resubmit_programming @unsubmitting = true unsubmit_current_answers(only_programming: true) - self.points_awarded = nil - self.draft_points_awarded = nil - self.awarded_at = nil - self.awarder = nil - self.publisher = nil self.published_at = nil + after_unsubmit_hook + current_answers.select(&:attempting?).each(&:finalise!) - assign_zero_experience_points + after_resubmit_programming_hook end - private + # --- Hook seams, called at the exact points the old, single-table WorkflowEventConcern used to + # inline EXP-specific / course-coupled work. `Attempt` and `Submission` are sibling classes joined + # by association, not superclass/subclass, so these cannot be Ruby method overrides — they forward + # to the associated Submission if one exists. A submission-less (preview) Attempt has no Submission, + # so `submission` is `nil` and these become no-ops. The real bodies are defined on + # Course::Assessment::Submission (see submission.rb). + # + # `assign_zero_experience_points` is shared by `finalise` and `resubmit_programming`; + # `after_resubmit_programming_hook` exists as a separate hook for the second call site, which has + # no single seam it can share with `after_finalise_hook`. + def after_finalise_hook + submission&.after_finalise_hook + end - # finalise event (from attempting) - Assign 0 points as there are no questions. - def assign_zero_experience_points - return unless assessment.questions.empty? + def after_publish_hook(send_email = true) + submission&.after_publish_hook(send_email) + end - self.points_awarded = 0 - self.awarded_at = Time.zone.now - self.awarder = User.stamper || User.system + def after_unsubmit_hook + submission&.after_unsubmit_hook end + def after_resubmit_programming_hook + submission&.after_resubmit_programming_hook + end + + private + # When a submission is finalised, we will compare the current answer and the latest non-current answers. # If they are the same, remove the current answer and mark the latest non-current answer as the current answer # to avoid re-grading. @@ -183,70 +178,12 @@ def delete_attempting_current_answers answers.current_answers.with_attempting_state.each(&:destroy!) end - def send_email_after_publishing(send_email) - return unless send_email && persisted? && !assessment.autograded? && - submission_graded_email_enabled? && - submission_graded_email_subscribed? - - execute_after_commit { Course::Mailer.submission_graded_email(self).deliver_later } - end - - def submission_graded_email_enabled? - is_enabled_as_phantom = course_user.phantom? && email_enabled.phantom - is_enabled_as_regular = !course_user.phantom? && email_enabled.regular - is_enabled_as_phantom || is_enabled_as_regular - end - - def submission_graded_email_subscribed? - !course_user.email_unsubscriptions.where(course_settings_email_id: email_enabled.id).exists? - end - - def email_enabled - assessment.course.email_enabled(:assessments, :grades_released, assessment.tab.category.id) - end - - # Defined outside of the workflow transition as points_awarded and draft_points_awarded are - # not set during the event transition, hence they are not modifiable within the method itself. - def assign_experience_points - # publish event (from grade) - Deduce points awarded from draft or updated attribute. - if workflow_state == 'published' && - (workflow_state_was == 'graded' || workflow_state_was == 'submitted') - self.points_awarded ||= draft_points_awarded - self.draft_points_awarded = nil - end - end - def publish_answers answers.each do |answer| answer.publish! if answer.submitted? || answer.evaluated? end end - def publish_delayed_posts - return if assessment.autograded? - - # Publish delayed comments for each question of a submission - submission_question_topics = submission_questions.flat_map(&:discussion_topic) - update_delayed_topics_and_posts(submission_question_topics) - - # Publish delayed annotations for each programming question of a submission - programming_answers = answers.where('actable_type = ?', Course::Assessment::Answer::Programming.name) - annotation_topics = programming_answers.flat_map(&:specific). - flat_map(&:files).flat_map(&:annotations).map(&:discussion_topic) - update_delayed_topics_and_posts(annotation_topics) - end - - # Update read mark for topic and delayed for posts - def update_delayed_topics_and_posts(topics) - topics.each do |topic| - delayed_posts = topic.posts.only_delayed_posts - next if delayed_posts.empty? - - topic.read_marks.where('reader_id = ?', creator.id)&.destroy_all # Remove 'mark as read' (if any) - delayed_posts.update_all(workflow_state: 'published') - end - end - # When a submission is unsubmitted, every current_answer is copied as and flagged as attempting. # The new copied answer is then marked as current_answer which is the answer that can be modified # by users. The old current_answer is unmarked as current_answer and is kept as graded past answer. diff --git a/app/models/concerns/course_user/staff_concern.rb b/app/models/concerns/course_user/staff_concern.rb index 885bcd2a5f..1f200e9581 100644 --- a/app/models/concerns/course_user/staff_concern.rb +++ b/app/models/concerns/course_user/staff_concern.rb @@ -28,12 +28,16 @@ def self.order_by_average_marking_time(staff) def published_submissions @published_submissions ||= Course::Assessment::Submission. + joins(:attempt). joins(experience_points_record: :course_user). where('course_users.role = ?', CourseUser.roles[:student]). where('course_users.phantom = ?', false). - where('course_assessment_submissions.publisher_id = ?', user_id). + # `publisher_id` is Submission's own column (the one that publishing writes to); + # `course_assessment_submissions.publisher_id` is a stale residual column on the base. Qualify + # by the extension table. `published_at`/`submitted_at` are Attempt-only, hence the join above. + where('course_assessment_submission_details.publisher_id = ?', user_id). where('course_users.course_id = ?', course_id). - pluck(:published_at, :submitted_at). + pluck('course_assessment_submissions.published_at', 'course_assessment_submissions.submitted_at'). map { |published_at, submitted_at| { published_at: published_at, submitted_at: submitted_at } } end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index e489dce65d..c00685a9f6 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -35,10 +35,16 @@ class Course::Assessment < ApplicationRecord belongs_to :monitor, class_name: 'Course::Monitoring::Monitor', optional: true - # `submissions` association must be put before `questions`, so that all answers will be deleted - # first when deleting the course. Otherwise due to the foreign key `question_id` in answers table, - # questions cannot be deleted. - has_many :submissions, inverse_of: :assessment, dependent: :destroy + # `attempts` association must be put before `questions`, so that all answers will be deleted + # first when deleting the course (via Attempt's own `dependent: :destroy` cascade to answers etc.) + # — otherwise, due to the foreign key `question_id` in the answers table, questions cannot be + # deleted. `submissions` is now a `through:` association and cannot itself carry `dependent:` — + # the destroy ordering guarantee comes from `attempts` instead. + has_many :attempts, class_name: 'Course::Assessment::Attempt', inverse_of: :assessment, + dependent: :destroy + # An assessment's "submissions" are only its course-member submissions: a submission-less (preview) + # attempt is excluded by the join, with no filter needed. Read call sites are unaffected. + has_many :submissions, through: :attempts, source: :submission has_many :question_assessments, class_name: 'Course::QuestionAssessment', inverse_of: :assessment, dependent: :destroy @@ -130,8 +136,12 @@ class Course::Assessment < ApplicationRecord # Includes the submissions by the provided user. # @param [User] user The user to preload submissions for. scope :with_submissions_by, (lambda do |user| - submissions = Course::Assessment::Submission.by_user(user). - where(assessment: distinct(false).pluck(:id)).ordered_by_date + # `assessment` is a delegated method on Submission, not a real FK column, so `.where(assessment:)` + # would raise `PG::UndefinedColumn`. `.by_user(user)` is a subquery (not a join — see its own + # comment), so join `:attempt` explicitly here to filter by `assessment_id`. + submissions = Course::Assessment::Submission.by_user(user).joins(:attempt). + where(course_assessment_submissions: { assessment_id: distinct(false).pluck(:id) }). + ordered_by_date all.to_a.tap do |result| preloader = ActiveRecord::Associations::Preloader.new(records: result, @@ -181,6 +191,27 @@ def self.max_grades(assessment_ids) rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] } end + # Builds a new (unsaved) course-coupled Submission together with its backing Attempt. + # + # Replaces `assessment.submissions.new(...)`, which does not work now that `submissions` is a + # `has_many :through` association — a `through:` collection proxy has no single owning foreign key + # to set, and building a Submission always requires building its Attempt in the same breath. + # + # @param [Hash] attributes A mix of Attempt attributes (only :creator is used by any call site + # today) and Submission attributes (:course_user, :session_id). Attempt-level keys are pulled + # out explicitly; everything else is passed straight to the built Submission. + # @return [Course::Assessment::Submission] an unsaved Submission with its (also unsaved) Attempt + # already built and associated. Callers `.save`/`.save!` it exactly as they did the old + # `assessment.submissions.new(...).save`. + def build_submission(attributes = {}) + attempt_attributes = attributes.slice(:creator) + submission_attributes = attributes.except(:creator) + + attempts.new(attempt_attributes).tap do |attempt| + attempt.build_submission(submission_attributes) + end.submission + end + def to_partial_path 'course/assessment/assessments/assessment' end diff --git a/app/models/course/assessment/answer.rb b/app/models/course/assessment/answer.rb index 884718e55a..771b5d349e 100644 --- a/app/models/course/assessment/answer.rb +++ b/app/models/course/assessment/answer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Course::Assessment::Answer < ApplicationRecord include Workflow + actable optional: true, inverse_of: :answer workflow do @@ -52,8 +53,22 @@ class Course::Assessment::Answer < ApplicationRecord validates :actable_id, uniqueness: { scope: [:actable_type], allow_nil: true, if: -> { actable_type? && actable_id_changed? } } - belongs_to :submission, inverse_of: :answers + # Association name kept as `:submission` for call-site compatibility, but it targets the attempt + # base record: `submission_id` identifies an Attempt row (the base), not a Submission row. + belongs_to :submission, class_name: 'Course::Assessment::Attempt', inverse_of: :answers, + foreign_key: 'submission_id' belongs_to :question, class_name: 'Course::Assessment::Question', inverse_of: nil + + # Coerce a `Course::Assessment::Submission` passed here into its `Attempt` (the association's real + # target). Several question helpers accept either a `Submission` or an `Attempt` as their + # "submission" argument and thread it straight into `Answer::.new(submission: ...)`; without + # this coercion, passing the `Submission` raises `ActiveRecord::AssociationTypeMismatch`, since + # `belongs_to`'s writer strictly checks `record.is_a?(reflection.klass)`. Coercing here keeps every + # caller working without a per-call-site change. + def submission=(value) + value = value.attempt if value.is_a?(Course::Assessment::Submission) + super + end belongs_to :grader, class_name: 'User', inverse_of: nil, optional: true has_one :auto_grading, class_name: 'Course::Assessment::Answer::AutoGrading', dependent: :destroy, inverse_of: :answer, autosave: true @@ -126,7 +141,13 @@ def grade_inline? end def can_read_grade?(ability) - submission.published? || ability.can?(:grade, submission) || + # Was `ability.can?(:grade, submission)` — with `submission` now resolving to an Attempt + # instance, that check would silently always be false (CanCan matches on subject *class*, and + # the only registered rule is `can :grade, Course::Assessment::Submission, ...`). Route through + # the answer's own `can :grade, Course::Assessment::Answer, submission: { assessment: ... }` + # rule instead (assessment_ability.rb) — same permission, unaffected by what class the + # association returns. + submission.published? || ability.can?(:grade, self) || (submission.assessment.autograded? && !submission.assessment.allow_partial_submission) || ( submission.assessment.autograded? && diff --git a/app/models/course/assessment/attempt.rb b/app/models/course/assessment/attempt.rb new file mode 100644 index 0000000000..b63644ff1a --- /dev/null +++ b/app/models/course/assessment/attempt.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true +class Course::Assessment::Attempt < ApplicationRecord + # The `course_assessment_submissions` table IS the attempt base record: it was kept under its + # historical name (rather than renamed to `course_assessment_attempts`) so the change is purely + # additive and safe under rolling deploys. `Attempt`'s Rails-default table name + # (`course_assessment_attempts`) does not exist; point it at the real base table. + self.table_name = 'course_assessment_submissions' + # ApplicationUserstampConcern's `inherited` hook ran `add_userstamp_associations({})` against + # `course_assessment_attempts` (the nonexistent default) before the line above took effect, so it + # added no creator/updater associations → creator_id/updater_id NOT NULL violation on insert. + # Re-run now that table_name is correct — the base has both columns, so this ADDS them back. + add_userstamp_associations({}) + include Workflow + include Course::Assessment::Submission::WorkflowEventConcern + include Course::Assessment::Submission::AnswersConcern + + attr_accessor :has_unsubmitted_or_draft_answer + + FORCE_SUBMIT_DELAY = 5.minutes + + after_save :auto_grade_submission, if: :submitted? + after_save :retrieve_codaveri_feedback, if: :submitted? + after_create :create_force_submission_job, if: :attempting? + + workflow do + state :attempting do + # TODO: Change the if condition to use a symbol when the Workflow gem is upgraded to 1.3.0. + event :finalise, transitions_to: :published, + if: proc { |submission| submission.assessment.questions.empty? } + event :finalise, transitions_to: :submitted + end + state :submitted do + event :unsubmit, transitions_to: :attempting + event :mark, transitions_to: :graded + event :publish, transitions_to: :published + end + state :graded do + # Revert to submitted state but keep the grading info. + event :unmark, transitions_to: :submitted + event :publish, transitions_to: :published + end + state :published do + event :unsubmit, transitions_to: :attempting + # Resubmit programming questions for grading, used to regrade autograded + # submissions when assessment booleans are modified + event :resubmit_programming, transitions_to: :submitted + end + end + + validate :validate_unique_submission, on: :create + validate :validate_autograded_no_partial_answer, if: :submitted? + validates :submitted_at, presence: true, unless: :attempting? + validates :workflow_state, length: { maximum: 255 }, presence: true + validates :creator, presence: true + validates :updater, presence: true + validates :assessment, presence: true + + belongs_to :assessment, inverse_of: :attempts + + has_one :submission, class_name: 'Course::Assessment::Submission', inverse_of: :attempt, + dependent: :destroy + + has_many :submission_questions, class_name: 'Course::Assessment::SubmissionQuestion', + foreign_key: 'submission_id', dependent: :destroy, inverse_of: :submission + + # @!attribute [r] answers + # The answers associated with this attempt. There can be more than one answer per question, + # this is because every answer is saved over time. Use the {.latest} scope of the answers if + # only the latest answer for each question is desired. + has_many :answers, class_name: 'Course::Assessment::Answer', dependent: :destroy, + foreign_key: 'submission_id', inverse_of: :submission do + include Course::Assessment::Submission::AnswersConcern + end + has_many :multiple_response_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::MultipleResponse' + has_many :text_response_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::TextResponse' + has_many :programming_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::Programming' + has_many :scribing_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::Scribing' + has_many :forum_post_response_answers, + through: :answers, inverse_through: :answer, source: :actable, + source_type: 'Course::Assessment::Answer::ForumPostResponse' + has_many :question_bundle_assignments, class_name: 'Course::Assessment::QuestionBundleAssignment', + inverse_of: :submission, dependent: :destroy + + has_many :logs, class_name: 'Course::Assessment::Submission::Log', + inverse_of: :submission, dependent: :destroy + + accepts_nested_attributes_for :answers + + # @!attribute [r] graded_at + # Returns the time the submission was graded. + # @return [Time] + calculated :graded_at, (lambda do + Course::Assessment::Answer.unscope(:order). + where('course_assessment_answers.submission_id = course_assessment_submissions.id'). + select('max(course_assessment_answers.graded_at)') + end) + + # @!attribute [r] log_count + # Returns the total number of access logs for the submission. + calculated :log_count, (lambda do + Course::Assessment::Submission::Log.select("count('*')"). + where('course_assessment_submission_logs.submission_id = course_assessment_submissions.id') + end) + + # @!attribute [r] grade + # Returns the total grade of the submissions. + calculated :grade, (lambda do + Course::Assessment::Answer.unscope(:order). + where('course_assessment_answers.submission_id = course_assessment_submissions.id + AND course_assessment_answers.current_answer = true'). + select('sum(course_assessment_answers.grade)') + end) + + # @!attribute [r] grader_ids + # Returns the grader_ids of a submission + calculated :grader_ids, (lambda do + Course::Assessment::Answer.unscope(:order). + where('course_assessment_answers.submission_id = course_assessment_submissions.id + AND course_assessment_answers.current_answer = true'). + select('ARRAY_REMOVE(ARRAY_AGG(DISTINCT(course_assessment_answers.grader_id)), NULL)') + end) + + # @!method self.by_user(user) + # Finds all the attempts by the given user. + # @param [User] user The user to filter attempts by + scope :by_user, ->(user) { where(creator: user) } + + # @!method self.by_users(user) + # @param [Integer|Array] user_ids The user ids to filter attempts by + scope :by_users, ->(user_ids) { where(creator_id: user_ids) } + + # @!method self.from_category(category) + # Finds all the attempts in the given category. + # @param [Course::Assessment::Category] category The category to filter attempts by + scope :from_category, (lambda do |category| + where(assessment_id: category.assessments.select(:id)) + end) + + # @!method self.ordered_by_date + # Orders the attempts by date of creation. This defaults to reverse chronological order + # (newest attempt first). + scope :ordered_by_date, ->(direction = :desc) { order(created_at: direction) } + + # @!method self.ordered_by_submitted_date + # Orders the attempts by date of submission (newest submission first). + scope :ordered_by_submitted_date, -> { order(submitted_at: :desc) } + + # @!method self.confirmed + # Returns attempts which have been submitted (which may or may not be graded). + scope :confirmed, -> { where(workflow_state: [:submitted, :graded, :published]) } + + scope :pending_for_grading, (lambda do + where(workflow_state: [:submitted, :graded]). + joins(:assessment). + where('course_assessments.autograded = ?', false) + end) + + # Names the two populations so that picking one is a deliberate act (design spec §3.4). Any + # staff-facing count/list/export must go through `assessment.submissions`, never `.attempts` — + # `.attempts` includes preview attempts, `.submissions` never does. + scope :course_submissions, -> { joins(:submission) } + scope :previews, -> { where.missing(:submission) } + + alias_method :finalise=, :finalise! + alias_method :mark=, :mark! + alias_method :unmark=, :unmark! + alias_method :publish=, :publish! + alias_method :unsubmit=, :unsubmit! + + # Creates an Auto Grading job for this attempt. This saves the attempt if there are pending + # changes. + # + # @param [Boolean] only_ungraded Whether grading should be done ONLY for + # ungraded_answers, or for all answers regardless of workflow state + # + # @return [Course::Assessment::Submission::AutoGradingJob] The job instance. + def auto_grade!(only_ungraded: false) + # Fully qualified: bare `AutoGradingJob` relied on this method being lexically nested inside + # `Course::Assessment::Submission` on the pre-split model — that lookup breaks once the method + # lives on `Attempt` instead. + Course::Assessment::Submission::AutoGradingJob.perform_later(self, only_ungraded) + end + + # Creates an Auto Feedback job for this attempt. + # + # @return [Course::Assessment::Submission::AutoFeedbackJob] The job instance. + def auto_feedback! + if assessment.course.component_enabled?(Course::CodaveriComponent) & + (assessment.course.codaveri_feedback_workflow != 'none') + Course::Assessment::Submission::AutoFeedbackJob.perform_later(self) + end + end + + def unsubmitting? + !!@unsubmitting + end + + def submission_view_blocked?(course_user) + !attempting? && !published? && assessment.block_student_viewing_after_submitted? && course_user&.student? + end + + def questions + assessment.randomization.nil? ? assessment.questions : assigned_questions + end + + # The assigned questions for this attempt, ordered by question_group and question_bundle_question + def assigned_questions + Course::Assessment::Question. + joins(question_bundles: [:question_group, question_bundle_assignments: :submission]). + merge(Course::Assessment::Attempt.where(id: self)). + merge(Course::Assessment::QuestionGroup.order(:weight)). + merge(Course::Assessment::QuestionBundleQuestion.order(:weight)). + extending(Course::Assessment::QuestionsConcern) + end + + def create_force_submission_job + return unless assessment.time_limit + + Course::Assessment::Submission::ForceSubmitTimedSubmissionJob. + set(wait_until: created_at + assessment.time_limit.minutes + FORCE_SUBMIT_DELAY). + perform_later(assessment, id, creator) + end + + # The answers with current_answer flag set to true, filtering out orphaned answers to questions + # which are no longer assigned to the attempt for randomized assessment. + # + # If there are multiple current_answers for a particular question, return the first one. + # This guards against a race condition creating multiple current_answers for a given + # question in load_or_create_answers. + def current_answers + if assessment.randomization.nil? + # Filtering by question ids is not needed for non-randomized assessment as it adds more query time. + filtered_answers = answers + else + # Can't do filtering in AR because `answer` may not be persisted, and AR is dumb. + question_ids = questions.pluck(:id) + filtered_answers = answers.select { |answer| answer.question_id.in? question_ids } + end + filtered_answers.select(&:current_answer?).group_by(&:question_id).map { |pair| pair[1].first } + end + + # @return [Array] Current answers to programming questions + def current_programming_answers + current_answers.select { |ans| ans.actable_type == Course::Assessment::Answer::Programming.name } + end + + # Loads basic information about the past answers of each question + def answer_history + answers. + without_attempting_state. + group_by(&:question_id). + map do |pair| + { + question_id: pair[0], + answers: pair[1].map do |answer| + { + id: answer.id, + createdAt: answer.created_at&.iso8601, + currentAnswer: answer.current_answer, + workflowState: answer.workflow_state + } + end + } + end + end + + # Returns the count of user messages for each question in the attempt. + def user_get_help_message_counts + Course::Assessment::SubmissionQuestion.find_by_sql(<<-SQL) + SELECT + q.id AS question_id, + COUNT(m.id) AS message_count + FROM course_assessment_submission_questions sq + INNER JOIN course_assessment_questions q ON sq.question_id = q.id + INNER JOIN course_assessment_question_programming pq + ON q.actable_id = pq.id AND q.actable_type = 'Course::Assessment::Question::Programming' + INNER JOIN course_assessment_submissions s ON sq.submission_id = s.id + LEFT JOIN live_feedback_threads t ON t.submission_question_id = sq.id + LEFT JOIN live_feedback_messages m ON m.thread_id = t.id AND m.creator_id != #{User::SYSTEM_USER_ID} + WHERE + s.id = #{id} + AND pq.live_feedback_enabled = TRUE + GROUP BY q.id; + SQL + end + + # Returns all graded answers of the question in current attempt. + def evaluated_or_graded_answers(question) + answers.select { |a| a.question_id == question.id && (a.evaluated? || a.graded?) } + end + + private + + # Validate that the attempt creator does not have an existing attempt for this assessment. + # + # (Reclassified from Submission per the design spike §3.1: it enforces the DB's + # `unique_assessment_id_and_creator_id` index, which lives on `course_assessment_submissions` — a + # "one attempt per creator per assessment" rule, not a submission-specific one. The i18n key is a + # wire/UX string and is deliberately NOT renamed even though the validation now lives here.) + def validate_unique_submission + existing = Course::Assessment::Attempt.find_by(assessment_id: assessment.id, creator_id: creator.id) + return unless existing + + errors.clear + errors.add(:base, I18n.t('activerecord.errors.models.course/assessment/' \ + 'submission.submission_already_exists')) + end + + # Validate that there is no unsubmitted updated answer for autograded assessment that + # does not allow partial submission + def validate_autograded_no_partial_answer + return unless assessment.autograded && !assessment.allow_partial_submission + + errors.add(:base, :autograded_no_partial_answer) if has_unsubmitted_or_draft_answer + end + + # Queues the attempt for auto grading, after the attempt has changed to the submitted state. + def auto_grade_submission + return unless saved_change_to_workflow_state? + + execute_after_commit do + auto_grade!(only_ungraded: true) + end + end + + # Retrieve codaveri feedback only for current answers of codaveri programming question type + # for finalised attempts. + def retrieve_codaveri_feedback + return unless saved_change_to_workflow_state? + + execute_after_commit do + auto_feedback! + end + end +end diff --git a/app/models/course/assessment/question_bundle_assignment.rb b/app/models/course/assessment/question_bundle_assignment.rb index 50fd7cf23d..5ebe6832f2 100644 --- a/app/models/course/assessment/question_bundle_assignment.rb +++ b/app/models/course/assessment/question_bundle_assignment.rb @@ -3,13 +3,23 @@ class Course::Assessment::QuestionBundleAssignment < ApplicationRecord belongs_to :user, inverse_of: :question_bundle_assignments belongs_to :assessment, class_name: 'Course::Assessment', foreign_key: :assessment_id, inverse_of: :question_bundle_assignments - belongs_to :submission, class_name: 'Course::Assessment::Submission', optional: true, + belongs_to :submission, class_name: 'Course::Assessment::Attempt', optional: true, foreign_key: :submission_id, inverse_of: :question_bundle_assignments belongs_to :question_bundle, class_name: 'Course::Assessment::QuestionBundle', foreign_key: :bundle_id, inverse_of: :question_bundle_assignments validate :submission_belongs_to_assessment_and_user + # Coerce a `Course::Assessment::Submission` passed here into its `Attempt` (the association's + # real target post-repoint) — mirrors the same coercion on `Course::Assessment::Answer`/ + # `Course::Assessment::SubmissionQuestion` (see `Answer#submission=` for the full rationale). + # `spec/controllers/course/assessment/submission/submissions_controller_spec.rb`'s + # `randomized_submission` fixture passes the `Submission` half directly. + def submission=(value) + value = value.attempt if value.is_a?(Course::Assessment::Submission) + super + end + private def submission_belongs_to_assessment_and_user diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index cb5df4c80f..9d2e2e4893 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -1,151 +1,124 @@ # frozen_string_literal: true class Course::Assessment::Submission < ApplicationRecord - include Workflow + # Submission is a small course-coupled extension of the attempt base record. The base owns the + # `course_assessment_submissions` table (which is this model's Rails-default table name); point + # Submission at its own extension table instead. + self.table_name = 'course_assessment_submission_details' + # This table has no creator_id/updater_id; identity is delegated to `attempt` and this row is + # never stamped (record_userstamp is disabled below). ApplicationUserstampConcern's `inherited` + # hook nonetheless added *required* creator/updater belongs_to, because this model's default table + # name resolves to `course_assessment_submissions` — which does have those columns — before the + # line above repoints us to the column-less extension. Drop those associations and their presence + # validations; the delegated readers below resolve creator/updater through the attempt. + %i[creator updater].each do |name| + _validate_callbacks.dup.each do |callback| + filter = callback.filter + next unless filter.respond_to?(:attributes) && filter.attributes.include?(name) + + skip_callback(:validate, callback.kind, filter) + end + _reflections.delete(name.to_s) + reflections.delete(name.to_s) + end include Generic::CollectionConcern - include Course::Assessment::Submission::WorkflowEventConcern include Course::Assessment::Submission::TodoConcern include Course::Assessment::Submission::NotificationConcern - include Course::Assessment::Submission::AnswersConcern - - attr_accessor :has_unsubmitted_or_draft_answer + include Course::Assessment::Submission::CikgoTaskCompletionConcern + include Course::LessonPlan::PersonalizationConcern acts_as_experience_points_record - FORCE_SUBMIT_DELAY = 5.minutes - - after_save :auto_grade_submission, if: :submitted? - after_save :retrieve_codaveri_feedback, if: :submitted? - after_create :create_force_submission_job, if: :attempting? + # Stamping belongs entirely to Attempt (the base). Without this, `activerecord-userstamp`'s + # globally-registered `before_validation :set_creator_attribute` still fires here: `acts_as`'s + # `ReflectionsWithActsAs#_reflections` merges in the acting-as `experience_points_record`'s own + # `:creator` reflection wherever Submission has none of its own, so `reflect_on_association(:creator)` + # returns non-nil and the callback calls `creator` on self — resolving to the DELEGATED reader (not + # a real column) and raising `ActiveSupport::DelegationError` whenever `attempt` is nil (e.g. a bare + # `Course::Assessment::Submission.new`). This table was never stamped, so disabling it loses nothing. + self.record_userstamp = false + + belongs_to :attempt, class_name: 'Course::Assessment::Attempt', inverse_of: :submission, + autosave: true + # `publisher_id` is a real column on this extension table. `after_publish_hook`/`after_unsubmit_hook` + # below both assign `self.publisher =`, which needs this association to exist. + belongs_to :publisher, class_name: 'User', inverse_of: nil, optional: true - workflow do - state :attempting do - # TODO: Change the if condition to use a symbol when the Workflow gem is upgraded to 1.3.0. - event :finalise, transitions_to: :published, - if: proc { |submission| submission.assessment.questions.empty? } - event :finalise, transitions_to: :submitted - end - state :submitted do - event :unsubmit, transitions_to: :attempting - event :mark, transitions_to: :graded - event :publish, transitions_to: :published - end - state :graded do - # Revert to submitted state but keep the grading info. - event :unmark, transitions_to: :submitted - event :publish, transitions_to: :published - end - state :published do - event :unsubmit, transitions_to: :attempting - # Resubmit programming questions for grading, used to regrade autograded - # submissions when assessment booleans are modified - event :resubmit_programming, transitions_to: :submitted - end - end + # Attempt-level surface exposed on Submission through delegation, so existing `submission.` call + # sites keep working after the split. Notes on the less-obvious members: + # + # - `create_new_answers` (from AnswersConcern, mixed into Attempt) and + # `saved_change_to_workflow_state?`/`workflow_state_before_last_save` must be delegated: `workflow_state` + # is not a column on this extension table, so Rails generates no dirty-tracking methods here, yet + # the `after_save` callbacks from TodoConcern/CikgoTaskCompletionConcern/NotificationConcern (still + # included on Submission) and the controller's `signals` guard read them. Delegation reads the + # right value: by the time `finalise!`/`publish!`/etc. call `save!` on Submission, the attempt's + # dirty state from the `attempt.!` call just above it is still fresh. + # - `creator=`/`updater=` writers, not just readers: without them `submission.creator = x` falls + # through `acts_as`'s `method_missing` and writes `experience_points_record.creator` (the wrong row). + # - `allow_nil: true`: a Submission with no `attempt` only exists transiently (a bare `.new`). The + # `belongs_to :attempt` above already enforces attempt presence; the delegate need not also raise + # `ActiveSupport::DelegationError` for that transient case. + delegate :assessment, + :workflow_state, :workflow_state=, + :submitted_at, :submitted_at=, + :published_at, :published_at=, + :creator, :creator=, :creator_id, :updater, :updater=, :updater_id, + :attempting?, :submitted?, :graded?, :published?, :unsubmitting?, + :saved_change_to_workflow_state?, :workflow_state_before_last_save, + :answers, :answers=, :submission_questions, :questions, :assigned_questions, + :current_answers, :current_programming_answers, :answer_history, + :evaluated_or_graded_answers, :user_get_help_message_counts, :create_new_answers, + :mark!, :unmark!, + :auto_grade!, :auto_feedback!, + :submission_view_blocked?, + :graded_at, :log_count, :grader_ids, + :logs, + to: :attempt, allow_nil: true Course::Assessment::Answer.after_save do |answer| Course::Assessment::Submission.on_dependent_status_change(answer) end - validate :validate_consistent_user, :validate_unique_submission, on: :create + # On the old single table, `last_graded_time` had a DB column default (a frozen historical + # timestamp — the well-known Rails `add_column ... default: Time.now` gotcha). The extension table + # does not carry that default, so populate it in Ruby on creation to keep the `presence: true` + # validation below satisfiable. + before_validation :ensure_last_graded_time + + validate :validate_consistent_user, on: :create validate :validate_awarded_attributes, if: :published? - validate :validate_autograded_no_partial_answer, if: :submitted? - validates :submitted_at, presence: true, unless: :attempting? - validates :workflow_state, length: { maximum: 255 }, presence: true - validates :creator, presence: true - validates :updater, presence: true - validates :assessment, presence: true validates :last_graded_time, presence: true - belongs_to :assessment, inverse_of: :submissions - - has_many :submission_questions, class_name: 'Course::Assessment::SubmissionQuestion', - dependent: :destroy, inverse_of: :submission - - # @!attribute [r] answers - # The answers associated with this submission. There can be more than one answer per submission, - # this is because every answer is saved over time. Use the {.latest} scope of the answers if - # only the latest answer for each question is desired. - has_many :answers, class_name: 'Course::Assessment::Answer', dependent: :destroy, - inverse_of: :submission do - include Course::Assessment::Submission::AnswersConcern - end - has_many :multiple_response_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::MultipleResponse' - has_many :text_response_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::TextResponse' - has_many :programming_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::Programming' - has_many :scribing_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::Scribing' - has_many :forum_post_response_answers, - through: :answers, inverse_through: :answer, source: :actable, - source_type: 'Course::Assessment::Answer::ForumPostResponse' - has_many :question_bundle_assignments, class_name: 'Course::Assessment::QuestionBundleAssignment', - inverse_of: :submission, dependent: :destroy - - belongs_to :publisher, class_name: 'User', inverse_of: nil, optional: true - - has_many :logs, class_name: 'Course::Assessment::Submission::Log', - inverse_of: :submission, dependent: :destroy - - accepts_nested_attributes_for :answers - - # @!attribute [r] graded_at - # Returns the time the submission was graded. - # @return [Time] - calculated :graded_at, (lambda do - Course::Assessment::Answer.unscope(:order). - where('course_assessment_answers.submission_id = course_assessment_submissions.id'). - select('max(course_assessment_answers.graded_at)') - end) - - # @!attribute [r] log_count - # Returns the total number of access logs for the submission. - calculated :log_count, (lambda do - Course::Assessment::Submission::Log.select("count('*')"). - where('course_assessment_submission_logs.submission_id = course_assessment_submissions.id') - end) - - # @!attribute [r] grade - # Returns the total grade of the submissions. - calculated :grade, (lambda do - Course::Assessment::Answer.unscope(:order). - where('course_assessment_answers.submission_id = course_assessment_submissions.id - AND course_assessment_answers.current_answer = true'). - select('sum(course_assessment_answers.grade)') - end) - - # @!attribute [r] grader_ids - # Returns the grader_ids of a submission - calculated :grader_ids, (lambda do - Course::Assessment::Answer.unscope(:order). - where('course_assessment_answers.submission_id = course_assessment_submissions.id - AND course_assessment_answers.current_answer = true'). - select('ARRAY_REMOVE(ARRAY_AGG(DISTINCT(course_assessment_answers.grader_id)), NULL)') - end) + # `belongs_to :attempt, autosave: true` (above) makes Rails validate the associated Attempt as part + # of validating Submission and, when invalid, copy its messages onto `errors[:attempt]` (prefixed + # "Attempt ..."). The attempt's uniqueness error is added to `:base`; re-home the cascaded copy back + # under `:base` (unprefixed) so the controller's `errors.full_messages.to_sentence` rescue renders + # it to the student exactly as before. Presentation only — the save is rejected either way. + validate :repoint_attempt_errors_to_base, on: :create # @!method self.by_user(user) # Finds all the submissions by the given user. - # @param [User] user The user to filter submissions by - scope :by_user, ->(user) { where(creator: user) } + # + # Subquery, not `joins(:attempt).merge(...)`: `@assessment.submissions` is a `has_many :through` + # that already joins the base table to reach `submission`. An additional explicit `joins(:attempt)` + # would join the base table a second time under another alias; a later `.pluck(:creator_id)` (a + # column on the base but not on Submission's own table) then becomes `PG::AmbiguousColumn`. The + # subquery form adds no second join, so it is safe whether called directly or through + # `@assessment.submissions`. + scope :by_user, ->(user) { where(attempt_id: Course::Assessment::Attempt.by_user(user).select(:id)) } # @!method self.by_users(user) - # @param [Integer|Array] user_ids The user ids to filter submissions by - scope :by_users, ->(user_ids) { where(creator_id: user_ids) } + scope :by_users, (lambda do |user_ids| + where(attempt_id: Course::Assessment::Attempt.by_users(user_ids).select(:id)) + end) # @!method self.from_category(category) - # Finds all the submissions in the given category. - # @param [Course::Assessment::Category] category The category to filter submissions by scope :from_category, (lambda do |category| - where(assessment_id: category.assessments.select(:id)) + joins(:attempt).merge(Course::Assessment::Attempt.from_category(category)) end) scope :from_course, (lambda do |course| - joins(assessment: { tab: :category }). + joins(attempt: { assessment: { tab: :category } }). where('course_assessment_categories.course_id = ?', course.id) end) @@ -155,23 +128,30 @@ class Course::Assessment::Submission < ApplicationRecord end) # @!method self.ordered_by_date - # Orders the submissions by date of creation. This defaults to reverse chronological order - # (newest submission first). + # Orders the submissions by date of creation (newest first). Uses Submission's own `created_at` + # rather than the attempt's: today every submission row is created in the same request as its + # attempt, so the two are indistinguishable. Revisit if a submission can ever be created later + # than its attempt. scope :ordered_by_date, ->(direction = :desc) { order(created_at: direction) } - # @!method self.ordered_by_submitted date - # Orders the submissions by date of submission (newest submission first). - scope :ordered_by_submitted_date, -> { order(submitted_at: :desc) } + # @!method self.ordered_by_submitted_date + scope :ordered_by_submitted_date, (lambda do + joins(:attempt).merge(Course::Assessment::Attempt.ordered_by_submitted_date) + end) # @!method self.confirmed - # Returns submissions which have been submitted (which may or may not be graded). - scope :confirmed, -> { where(workflow_state: [:submitted, :graded, :published]) } + scope :confirmed, -> { joins(:attempt).merge(Course::Assessment::Attempt.confirmed) } - scope :pending_for_grading, (lambda do - where(workflow_state: [:submitted, :graded]). - joins(:assessment). - where('course_assessments.autograded = ?', false) - end) + scope :pending_for_grading, -> { joins(:attempt).merge(Course::Assessment::Attempt.pending_for_grading) } + + # `with__state` scopes are generated by the `workflow-activerecord` gem on whichever class + # calls `workflow do ... end` — that is Attempt, not Submission. Re-expose them here (as joins) for + # the call sites that use `submissions.with__state`; they cannot be covered by the instance + # `delegate` list above because they are class-level scopes. + scope :with_attempting_state, -> { joins(:attempt).merge(Course::Assessment::Attempt.with_attempting_state) } + scope :with_submitted_state, -> { joins(:attempt).merge(Course::Assessment::Attempt.with_submitted_state) } + scope :with_graded_state, -> { joins(:attempt).merge(Course::Assessment::Attempt.with_graded_state) } + scope :with_published_state, -> { joins(:attempt).merge(Course::Assessment::Attempt.with_published_state) } SUBMISSIONS_PER_PAGE = 25 # Filter submissions by category_id, assessment_id, group_id and/or user_id (creator) @@ -180,189 +160,195 @@ class Course::Assessment::Submission < ApplicationRecord if filter_params[:category_id].present? result = result.from_category(Course::Assessment::Category.find(filter_params[:category_id])) end - result = result.where(assessment_id: filter_params[:assessment_id]) if filter_params[:assessment_id].present? + if filter_params[:assessment_id].present? + result = result.joins(:attempt). + where(course_assessment_submissions: { assessment_id: filter_params[:assessment_id] }) + end result = result.from_group(filter_params[:group_id]) if filter_params[:group_id].present? result = result.by_user(filter_params[:user_id]) if filter_params[:user_id].present? result end) - alias_method :finalise=, :finalise! - alias_method :mark=, :mark! - alias_method :unmark=, :unmark! - alias_method :publish=, :publish! - alias_method :unsubmit=, :unsubmit! - - # Creates an Auto Grading job for this submission. This saves the submission if there are pending - # changes. - # - # @param [Boolean] only_ungraded Whether grading should be done ONLY for - # ungraded_answers, or for all answers regardless of workflow state - # - # @return [Course::Assessment::Submission::AutoGradingJob] The job instance. - def auto_grade!(only_ungraded: false) - AutoGradingJob.perform_later(self, only_ungraded) + # Return the points awarded for the submission. + # If submission is 'graded', return the draft value, otherwise, the return the points awarded. + def current_points_awarded + published? ? points_awarded : draft_points_awarded end - # Creates an Auto Feedback job for this submission. - # - # @return [Course::Assessment::Submission::AutoFeedbackJob] The job instance. - def auto_feedback! - if assessment.course.component_enabled?(Course::CodaveriComponent) & - (assessment.course.codaveri_feedback_workflow != 'none') - AutoFeedbackJob.perform_later(self) - end + # Not delegated like the members above, because `.grade_summary` (below) is a raw `find_by_sql` on + # this class that SELECTs an ad-hoc `assessment_id` column onto its result rows. Those rows have no + # `attempt_id` and so cannot read `attempt`; prefer the raw-SQL-loaded attribute when present, + # otherwise delegate to the attempt. + def assessment_id + has_attribute?(:assessment_id) ? read_attribute(:assessment_id) : attempt&.assessment_id end - def unsubmitting? - !!@unsubmitting + # Same reasoning as `assessment_id` above: `.grade_summary`'s raw SQL also SELECTs + # `SUM(caa.grade) AS grade`, conflicting with the delegated `calculated :grade` on Attempt. + def grade + has_attribute?(:grade) ? read_attribute(:grade) : attempt&.grade end - def submission_view_blocked?(course_user) - !attempting? && !published? && assessment.block_student_viewing_after_submitted? && course_user&.student? + def self.on_dependent_status_change(answer) + return unless answer.saved_changes.key?(:grade) + + # `answer.submission` resolves to an Attempt (the association name is `:submission`, its + # `class_name` is Attempt). `last_graded_time` is a course-coupled column that lives only on the + # real Submission, reached via the attempt's `has_one :submission`. + answer.submission.submission&.last_graded_time = Time.now end - def questions - assessment.randomization.nil? ? assessment.questions : assigned_questions + # Returns an array of submission rows for the given students and assessments. + # Each row has: student_id (creator_id), assessment_id, grade (float). + # Only graded/published submissions are included. + def self.grade_summary(student_ids:, assessment_ids:) + return [] if student_ids.empty? || assessment_ids.empty? + + find_by_sql( + sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids]) + SELECT cas.creator_id AS student_id, cas.assessment_id, + cas.id AS submission_id, SUM(caa.grade) AS grade + FROM course_assessment_submissions cas + JOIN course_assessment_answers caa ON caa.submission_id = cas.id + WHERE cas.creator_id IN (?) + AND cas.assessment_id IN (?) + AND cas.workflow_state IN ('graded', 'published') + AND caa.current_answer = TRUE + GROUP BY cas.creator_id, cas.assessment_id, cas.id + SQL + ) end - # The assigned questions for this submission, ordered by question_group and question_bundle_question - def assigned_questions - Course::Assessment::Question. - joins(question_bundles: [:question_group, question_bundle_assignments: :submission]). - merge(Course::Assessment::Submission.where(id: self)). - merge(Course::Assessment::QuestionGroup.order(:weight)). - merge(Course::Assessment::QuestionBundleQuestion.order(:weight)). - extending(Course::Assessment::QuestionsConcern) + def finalise!(*args) + ActiveRecord::Base.transaction do + attempt.finalise!(*args) + save! + end end - def create_force_submission_job - return unless assessment.time_limit + def publish!(*args) + ActiveRecord::Base.transaction do + attempt.publish!(*args) + save! + end + end - Course::Assessment::Submission::ForceSubmitTimedSubmissionJob. - set(wait_until: created_at + assessment.time_limit.minutes + FORCE_SUBMIT_DELAY). - perform_later(assessment, id, creator) + def unsubmit!(*args) + ActiveRecord::Base.transaction do + attempt.unsubmit!(*args) + save! + end end - # The answers with current_answer flag set to true, filtering out orphaned answers to questions which are no longer - # assigned to the submission for randomized assessment. - # - # If there are multiple current_answers for a particular question, return the first one. - # This guards against a race condition creating multiple current_answers for a given - # question in load_or_create_answers. - def current_answers - if assessment.randomization.nil? - # Filtering by question ids is not needed for non-randomized assessment as it adds more query time. - filtered_answers = answers - else - # Can't do filtering in AR because `answer` may not be persisted, and AR is dumb. - question_ids = questions.pluck(:id) - filtered_answers = answers.select { |answer| answer.question_id.in? question_ids } + def resubmit_programming!(*args) + ActiveRecord::Base.transaction do + attempt.resubmit_programming!(*args) + save! end - filtered_answers.select(&:current_answer?).group_by(&:question_id).map { |pair| pair[1].first } end - # @return [Array] Current answers to programming questions - def current_programming_answers - current_answers.select { |ans| ans.actable_type == Course::Assessment::Answer::Programming.name } + # Placed after the method definitions above because `alias_method` resolves its target when + # evaluated: `finalise!`/`publish!`/`unsubmit!` are plain methods defined on this class (not + # delegated, unlike `mark!`/`unmark!`), so aliasing them earlier raises `NameError` at class load. + alias_method :finalise=, :finalise! + alias_method :mark=, :mark! + alias_method :unmark=, :unmark! + alias_method :publish=, :publish! + alias_method :unsubmit=, :unsubmit! + + # --- Hook bodies invoked by Attempt's WorkflowEventConcern for the EXP-specific / course-coupled + # work of each workflow event. Public (not protected) because they are called with an explicit + # receiver (`submission&.after_x_hook`) from a different object (the Attempt instance), which + # `protected` would forbid since Attempt and Submission are unrelated by inheritance. + + def after_finalise_hook + assign_zero_experience_points + update_personalized_timeline_for_user(course_user) end - # Loads basic information about the past answers of each question - def answer_history - answers. - without_attempting_state. - group_by(&:question_id). - map do |pair| - { - question_id: pair[0], - answers: pair[1].map do |answer| - { - id: answer.id, - createdAt: answer.created_at&.iso8601, - currentAnswer: answer.current_answer, - workflowState: answer.workflow_state - } - end - } - end + # `send_email` is threaded from `Attempt#publish`'s `send_email` argument. Also folds in the + # former `assign_experience_points` before_validation's only real effect + # (`points_awarded ||= draft_points_awarded`), whose guard was only ever true on a publish event. + def after_publish_hook(send_email) + self.points_awarded ||= draft_points_awarded + self.draft_points_awarded = nil + self.publisher = User.stamper || User.system + self.awarder = User.stamper || User.system + self.awarded_at = Time.zone.now + publish_delayed_posts + send_email_after_publishing(send_email) end - # Returns the count of user messages for each question in the submission. - def user_get_help_message_counts - Course::Assessment::SubmissionQuestion.find_by_sql(<<-SQL) - SELECT - q.id AS question_id, - COUNT(m.id) AS message_count - FROM course_assessment_submission_questions sq - INNER JOIN course_assessment_questions q ON sq.question_id = q.id - INNER JOIN course_assessment_question_programming pq - ON q.actable_id = pq.id AND q.actable_type = 'Course::Assessment::Question::Programming' - INNER JOIN course_assessment_submissions s ON sq.submission_id = s.id - LEFT JOIN live_feedback_threads t ON t.submission_question_id = sq.id - LEFT JOIN live_feedback_messages m ON m.thread_id = t.id AND m.creator_id != #{User::SYSTEM_USER_ID} - WHERE - s.id = #{id} - AND pq.live_feedback_enabled = TRUE - GROUP BY q.id; - SQL + def after_unsubmit_hook + self.points_awarded = nil + self.draft_points_awarded = nil + self.awarded_at = nil + self.awarder = nil + self.publisher = nil end - # Returns all graded answers of the question in current submission. - def evaluated_or_graded_answers(question) - answers.select { |a| a.question_id == question.id && (a.evaluated? || a.graded?) } + # `assign_zero_experience_points` runs on both `finalise` and `resubmit_programming`. + # `resubmit_programming` clears points (reusing `after_unsubmit_hook`) and re-finalises current + # answers first, so it gets its own hook rather than overloading `after_finalise_hook`. + def after_resubmit_programming_hook + assign_zero_experience_points end - # Return the points awarded for the submission. - # If submission is 'graded', return the draft value, otherwise, the return the points awarded. - def current_points_awarded - published? ? points_awarded : draft_points_awarded + private + + # finalise event (from attempting) - Assign 0 points as there are no questions. + def assign_zero_experience_points + return unless assessment.questions.empty? + + self.points_awarded = 0 + self.awarded_at = Time.zone.now + self.awarder = User.stamper || User.system end - def self.on_dependent_status_change(answer) - return unless answer.saved_changes.key?(:grade) + def send_email_after_publishing(send_email) + return unless send_email && persisted? && !assessment.autograded? && + submission_graded_email_enabled? && + submission_graded_email_subscribed? - answer.submission.last_graded_time = Time.now + execute_after_commit { Course::Mailer.submission_graded_email(self).deliver_later } end - # Returns an array of submission rows for the given students and assessments. - # Each row has: student_id (creator_id), assessment_id, grade (float). - # Only graded/published submissions are included. - def self.grade_summary(student_ids:, assessment_ids:) - return [] if student_ids.empty? || assessment_ids.empty? + def submission_graded_email_enabled? + is_enabled_as_phantom = course_user.phantom? && email_enabled.phantom + is_enabled_as_regular = !course_user.phantom? && email_enabled.regular + is_enabled_as_phantom || is_enabled_as_regular + end - find_by_sql( - sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids]) - SELECT cas.creator_id AS student_id, cas.assessment_id, - cas.id AS submission_id, SUM(caa.grade) AS grade - FROM course_assessment_submissions cas - JOIN course_assessment_answers caa ON caa.submission_id = cas.id - WHERE cas.creator_id IN (?) - AND cas.assessment_id IN (?) - AND cas.workflow_state IN ('graded', 'published') - AND caa.current_answer = TRUE - GROUP BY cas.creator_id, cas.assessment_id, cas.id - SQL - ) + def submission_graded_email_subscribed? + !course_user.email_unsubscriptions.where(course_settings_email_id: email_enabled.id).exists? end - private + def email_enabled + assessment.course.email_enabled(:assessments, :grades_released, assessment.tab.category.id) + end - # Queues the submission for auto grading, after the submission has changed to the submitted state. - def auto_grade_submission - return unless saved_change_to_workflow_state? + def publish_delayed_posts + return if assessment.autograded? - execute_after_commit do - # Grade only ungraded answers regardless of state as we dont want to regrade graded/evaluated answers. - auto_grade!(only_ungraded: true) - end + # Publish delayed comments for each question of a submission + submission_question_topics = submission_questions.flat_map(&:discussion_topic) + update_delayed_topics_and_posts(submission_question_topics) + + # Publish delayed annotations for each programming question of a submission + programming_answers = answers.where('actable_type = ?', Course::Assessment::Answer::Programming.name) + annotation_topics = programming_answers.flat_map(&:specific). + flat_map(&:files).flat_map(&:annotations).map(&:discussion_topic) + update_delayed_topics_and_posts(annotation_topics) end - # Retrieve codaveri feedback only for current answers of codaveri programming question type - # for finalised submissions. - def retrieve_codaveri_feedback - return unless saved_change_to_workflow_state? + # Update read mark for topic and delayed for posts + def update_delayed_topics_and_posts(topics) + topics.each do |topic| + delayed_posts = topic.posts.only_delayed_posts + next if delayed_posts.empty? - execute_after_commit do - auto_feedback! + topic.read_marks.where('reader_id = ?', creator.id)&.destroy_all # Remove 'mark as read' (if any) + delayed_posts.update_all(workflow_state: 'published') end end @@ -374,17 +360,6 @@ def validate_consistent_user errors.add(:experience_points_record, :inconsistent_user) end - # Validate that the submission creator does not have an existing submission for this assessment. - def validate_unique_submission - existing = Course::Assessment::Submission.find_by(assessment_id: assessment.id, - creator_id: creator.id) - return unless existing - - errors.clear - errors.add(:base, I18n.t('activerecord.errors.models.course/assessment/' \ - 'submission.submission_already_exists')) - end - # Validate that the awarder and awarded_at is present for published submissions def validate_awarded_attributes return if awarded_at && awarder @@ -392,11 +367,18 @@ def validate_awarded_attributes errors.add(:experience_points_record, :absent_award_attributes) end - # Validate that there is no unsubmitted updated answer for autograded assessment that - # does not allow partial submission - def validate_autograded_no_partial_answer - return unless assessment.autograded && !assessment.allow_partial_submission + def ensure_last_graded_time + self.last_graded_time ||= Time.zone.now + end + + def repoint_attempt_errors_to_base + # Rails' autosave-association-validation key for a nested `:base` error is the dotted + # `:'attempt.base'` (association name + attribute), not a bare `:attempt`. + key = :'attempt.base' + return if errors[key].blank? - errors.add(:base, :autograded_no_partial_answer) if has_unsubmitted_or_draft_answer + messages = errors[key] + errors.delete(key) + messages.each { |message| errors.add(:base, message) } end end diff --git a/app/models/course/assessment/submission/log.rb b/app/models/course/assessment/submission/log.rb index 195cde38e1..fd08155022 100644 --- a/app/models/course/assessment/submission/log.rb +++ b/app/models/course/assessment/submission/log.rb @@ -1,8 +1,14 @@ # frozen_string_literal: true class Course::Assessment::Submission::Log < ApplicationRecord + # Rails derives a nested model's table name from its parent's `table_name` + # (`Submission.table_name.singularize + "_logs"`). Since Submission maps to + # `course_assessment_submission_details`, that derivation yields the nonexistent + # `course_assessment_submission_detail_logs`. Pin the real table name explicitly. + self.table_name = 'course_assessment_submission_logs' + validates :submission, presence: true - belongs_to :submission, class_name: 'Course::Assessment::Submission', + belongs_to :submission, class_name: 'Course::Assessment::Attempt', inverse_of: :logs scope :ordered_by_date, ->(direction = :desc) { order(created_at: direction) } diff --git a/app/models/course/assessment/submission_question.rb b/app/models/course/assessment/submission_question.rb index 130c97ad02..35c9cdc975 100644 --- a/app/models/course/assessment/submission_question.rb +++ b/app/models/course/assessment/submission_question.rb @@ -8,11 +8,25 @@ class Course::Assessment::SubmissionQuestion < ApplicationRecord validates :submission_id, uniqueness: { scope: [:question_id], if: -> { question_id? && submission_id_changed? } } validates :question_id, uniqueness: { scope: [:submission_id], if: -> { submission_id? && question_id_changed? } } - belongs_to :submission, class_name: 'Course::Assessment::Submission', + # The underlying column is `submission_id` (renamed from `submission_id`); foreign_key kept explicit + # since the association name stays `submission`. + belongs_to :submission, class_name: 'Course::Assessment::Attempt', foreign_key: 'submission_id', inverse_of: :submission_questions belongs_to :question, class_name: 'Course::Assessment::Question', inverse_of: :submission_questions + # Coerce a `Course::Assessment::Submission` passed here into its `Attempt` (the association's + # real target post-repoint) — mirrors the same coercion on `Course::Assessment::Answer` (see its + # comment for the full rationale). Both `spec/factories/course_assessment_submission_questions.rb`'s + # own default `submission { create(:submission, ...) }` and + # `spec/models/course/assessment/submission_spec.rb`'s own `create(:course_assessment_submission_question, + # submission: submission, ...)` pass the `Submission` half, which otherwise raises + # `ActiveRecord::AssociationTypeMismatch`. + def submission=(value) + value = value.attempt if value.is_a?(Course::Assessment::Submission) + super + end + has_many :threads, class_name: 'Course::Assessment::LiveFeedback::Thread', inverse_of: :submission_question, dependent: :destroy after_initialize :set_course, if: :new_record? @@ -26,7 +40,9 @@ class Course::Assessment::SubmissionQuestion < ApplicationRecord # joining { discussion_topic }.selecting { discussion_topic.id } unscoped. joins(:submission). - where(Course::Assessment::Submission.arel_table[:creator_id].in(user_id)). + # `creator_id` lives on Course::Assessment::Attempt post-repoint — `:submission` now joins + # course_assessment_submissions, so the arel_table reference must match. + where(Course::Assessment::Attempt.arel_table[:creator_id].in(user_id)). joins(:discussion_topic). select(Course::Discussion::Topic.arel_table[:id]) end) diff --git a/app/models/course/condition/assessment.rb b/app/models/course/condition/assessment.rb index 4ed34f3e14..af87447ff6 100644 --- a/app/models/course/condition/assessment.rb +++ b/app/models/course/condition/assessment.rb @@ -1,11 +1,30 @@ # frozen_string_literal: true -class Course::Condition::Assessment < ApplicationRecord +# The class observes two tables (Attempt + Submission) via the after_save hooks below, which pushes +# it just past the default class-length limit. +class Course::Condition::Assessment < ApplicationRecord # rubocop:disable Metrics/ClassLength include ActiveSupport::NumberHelper include DuplicationStateTrackingConcern + acts_as_condition - # Trigger for evaluating the satisfiability of conditionals for a course user + # Trigger for evaluating the satisfiability of conditionals for a course user. + # + # Split across two `after_save` registrations, one per table that can change: `workflow_state` + # lives on Attempt, `last_graded_time` on Submission. Registering both halves on + # `Submission.after_save` and reading `submission.saved_change_to_workflow_state?` (delegated to + # `attempt`) goes stale across separate Submission saves: the dirty flag reflects `attempt`'s own + # most recent save, so a later unrelated `submission.save!` still reports the old transition as + # freshly changed and double-fires. Reading `attempt.saved_change_to_workflow_state?` from an + # `after_save` on `Attempt` itself avoids this. + Course::Assessment::Attempt.after_save do |attempt| + next unless attempt.saved_change_to_workflow_state? && attempt.submission + + Course::Condition::Assessment.on_dependent_status_change(attempt.submission) + end + Course::Assessment::Submission.after_save do |submission| + next unless submission.saved_changes.key?(:last_graded_time) + Course::Condition::Assessment.on_dependent_status_change(submission) end @@ -52,11 +71,21 @@ def self.dependent_class Course::Assessment.name end + # The "did workflow_state/last_graded_time just change" check now lives in each of the two + # `after_save` registrations above (one per table that can actually change) instead of here. + # + # A single `publish!` legitimately trips BOTH registrations in one transaction (Attempt's + # `workflow_state` and Submission's `last_graded_time` both change). Guard so it still results in + # a single re-evaluation: register the + # after-commit re-evaluation once per submission per pending commit (the two registrations receive + # the same Submission instance via `inverse_of`), preserving the "exactly once per status change" + # contract instead of double-firing. def self.on_dependent_status_change(submission) - return unless submission.saved_changes.key?(:workflow_state) || - submission.saved_changes.key?(:last_graded_time) + return if submission.instance_variable_get(:@evaluate_conditional_pending) + submission.instance_variable_set(:@evaluate_conditional_pending, true) submission.execute_after_commit do + submission.instance_variable_set(:@evaluate_conditional_pending, false) evaluate_conditional_for(submission.course_user) end end @@ -79,12 +108,17 @@ def initialize_duplicate(duplicator, other) private def submitted_submissions_by_user(user) - # TODO: Replace with Rails 5 ActiveRecord::Relation#or with named scope - assessment.submissions.by_user(user).where(workflow_state: [:submitted, :graded, :published]) + # `workflow_state` lives on Attempt; `.confirmed` already wraps this exact set of states + # (`[:submitted, :graded, :published]`) through `:attempt`. + assessment.submissions.by_user(user).confirmed end def published_submissions_with_minimum_grade_exists?(user, minimum_grade_percentage) - assessment.submissions.by_user(user).with_published_state.eager_load(:answers, assessment: :questions).any? do |sub| + # `:answers`/`:assessment` are delegated methods on Submission, not real associations, so + # `eager_load(:answers, assessment: :questions)` on it raises `ActiveRecord::ConfigurationError`. + # Both live on `:attempt`, so eager-load through it. + assessment.submissions.by_user(user).with_published_state. + eager_load(attempt: [:answers, assessment: :questions]).any? do |sub| sub.grade.to_f >= sub.questions.sum(:maximum_grade).to_f * minimum_grade_percentage / 100.0 end end diff --git a/app/models/course_user.rb b/app/models/course_user.rb index 6682fb1fac..751b1de6ab 100644 --- a/app/models/course_user.rb +++ b/app/models/course_user.rb @@ -118,8 +118,11 @@ class CourseUser < ApplicationRecord # @!attribute [r] assessment_submission_count # Returns the total number of submitted assessment submissions by CourseUser in this course calculated :assessment_submission_count, (lambda do + # `assessment` is a delegated method on Submission, not a real association, so `joins(assessment:)` + # raises `ActiveRecord::ConfigurationError`. Join through `:attempt` first (a real association), + # matching `Submission.from_course`'s `joins(attempt: { assessment: { tab: :category } })`. Course::Assessment::Submission.select('count(*)'). - joins(assessment: { tab: :category }). + joins(attempt: { assessment: { tab: :category } }). where('course_assessment_submissions.creator_id = course_users.user_id'). where('course_assessment_categories.course_id = course_users.course_id'). where(course_assessment_submissions: { workflow_state: [:submitted, :graded, :published] }) diff --git a/app/services/course/assessment/answer/ai_generated_post_service.rb b/app/services/course/assessment/answer/ai_generated_post_service.rb index f89c84086b..3e684ff603 100644 --- a/app/services/course/assessment/answer/ai_generated_post_service.rb +++ b/app/services/course/assessment/answer/ai_generated_post_service.rb @@ -80,7 +80,7 @@ def create_topic_subscription(discussion_topic) # Ensure the student who wrote the answer amd all group managers # gets notified when someone comments on his answer discussion_topic.ensure_subscribed_by(@answer.submission.creator) - answer_course_user = @answer.submission.course_user + answer_course_user = @answer.submission.submission.course_user answer_course_user.my_managers.each do |manager| discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb b/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb index c11d2c74c0..2c64625da0 100644 --- a/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb +++ b/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb @@ -153,7 +153,7 @@ def create_topic_subscription(discussion_topic) # Ensure all group managers get a notification when someone adds a programming annotation # to the answer. - answer_course_user = @answer.submission.course_user + answer_course_user = @answer.submission.submission.course_user answer_course_user.my_managers.each do |manager| discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/services/course/assessment/submission/update_service.rb b/app/services/course/assessment/submission/update_service.rb index 7db382b251..bc10744809 100644 --- a/app/services/course/assessment/submission/update_service.rb +++ b/app/services/course/assessment/submission/update_service.rb @@ -78,8 +78,11 @@ def create_missing_submission_questions questions_without_submission_questions = questions_to_attempt - questions_with_submission_questions new_submission_questions = [] questions_without_submission_questions.each do |question| + # `SubmissionQuestion#submission` targets `Course::Assessment::Attempt`, so assigning a + # `Course::Assessment::Submission` here raises `ActiveRecord::AssociationTypeMismatch`. + # `@submission.attempt` is the real association to hand it. new_submission_questions << - Course::Assessment::SubmissionQuestion.new(submission: @submission, question: question) + Course::Assessment::SubmissionQuestion.new(submission: @submission.attempt, question: question) end import_success = true diff --git a/app/views/notifiers/course/assessment/submission_question/comment_notifier/replied/user_notifications/email.html.slim b/app/views/notifiers/course/assessment/submission_question/comment_notifier/replied/user_notifications/email.html.slim index ade6133f41..26d1fd449d 100644 --- a/app/views/notifiers/course/assessment/submission_question/comment_notifier/replied/user_notifications/email.html.slim +++ b/app/views/notifiers/course/assessment/submission_question/comment_notifier/replied/user_notifications/email.html.slim @@ -1,7 +1,9 @@ - post = @object - submission_question = post.topic.actable -- course_user = submission_question.submission.course_user -- assessment = submission_question.submission.assessment +- attempt = submission_question.submission +- submission = attempt.submission +- course_user = submission.course_user +- assessment = attempt.assessment - question = submission_question.question - course = assessment.course - host = course.instance.host @@ -15,7 +17,7 @@ = format_html(t('.message', topic: link_to("#{assessment.title}: #{question_assessment.display_title}", edit_course_assessment_submission_url(course, assessment, - submission_question.submission, + submission, step: step, host: host)), post: post.text_to_email, post_author: post.author_name)) diff --git a/db/migrate/20260723000001_create_course_assessment_submission_details.rb b/db/migrate/20260723000001_create_course_assessment_submission_details.rb index 8f2ce39f87..5b1b258c5c 100644 --- a/db/migrate/20260723000001_create_course_assessment_submission_details.rb +++ b/db/migrate/20260723000001_create_course_assessment_submission_details.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -# Phase 1 (additive, Design C): extracts the course-coupled columns of the submission into their own +# Extracts the course-coupled columns of the submission into their own # small extension table WITHOUT renaming the base table. `Course::Assessment::Attempt` maps onto the # existing `course_assessment_submissions` (the base) via `self.table_name`; `Submission` maps onto -# this new table. See docs/superpowers/plans/2026-07-23-attempt-base-record-additive-replan.md. +# this new table. # # Purely additive: no DDL touches `course_assessment_submissions` (only READ for the backfill), so a # rolling deploy's still-old worker is completely undisturbed. Reversible via `drop_table`. diff --git a/spec/controllers/concerns/course/assessment/submission/koditsu/submissions_concern_spec.rb b/spec/controllers/concerns/course/assessment/submission/koditsu/submissions_concern_spec.rb index 3cf33b7230..be7b2fba41 100644 --- a/spec/controllers/concerns/course/assessment/submission/koditsu/submissions_concern_spec.rb +++ b/spec/controllers/concerns/course/assessment/submission/koditsu/submissions_concern_spec.rb @@ -103,8 +103,11 @@ class self::DummyController < ApplicationController expect(submissions[0].workflow_state).to eq('submitted') expect(submissions[1].workflow_state).to eq('submitted') - student_one_answers = submissions[0].answers - student_two_answers = submissions[1].answers + # `Answer`'s `default_scope { order(:created_at) }` has no tiebreak, and the two answers are + # inserted together (identical `created_at`), so `.answers` may return them in either order. + # Sort by `question_id` to assert on each question's answer deterministically. + student_one_answers = submissions[0].answers.sort_by(&:question_id) + student_two_answers = submissions[1].answers.sort_by(&:question_id) expect(student_one_answers[0].correct).to be_truthy expect(student_one_answers[1].correct).to be_falsey diff --git a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb index 5aa00a954e..f5af1eaae4 100644 --- a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb +++ b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb @@ -151,40 +151,56 @@ end end - describe '#update_grade' do - subject do - post :update, params: { - course_id: course, assessment_id: assessment2, id: graded_submission, - submission: { - answers: [{ id: answer.id, grade: grade }] - }, - format: :json - } - end - - context 'when update fails' do - let(:grade) { nil } - before do - subject + # `graded_submission` builds via the `:graded` factory trait, which passes through + # `:submitted` on its way there — briefly triggering `Attempt#after_save :auto_grade_submission, + # if: :submitted?`, which enqueues a real `Submission::AutoGradingJob` (there's a programming + # question among `:with_all_question_types`, per `graded_submission.answers.third`'s own + # comment below). Under the test env's default `:background_thread` adapter (see + # `spec/support/active_job.rb` / `app/CLAUDE.md`), that job runs concurrently with the rest of + # this example — and can win the race against the `post :update` below, re-grading (and + # transitioning) the very answer this example is asserting on before the request reaches it. + # This pre-existing race (the job enqueue itself is unchanged) became far more likely to manifest + # once the Attempt/Submission split added enough extra latency to the request handling to give + # the background job time to complete first. Wrapping + # with the repo's own `with_active_job_queue_adapter(:test)` helper (already used the same way + # for `Submission#finalise!`'s own auto-grading-job example) makes the job just enqueue, + # not execute, removing the race outright — not a behaviour change to the code under test. + with_active_job_queue_adapter(:test) do + describe '#update_grade' do + subject do + post :update, params: { + course_id: course, assessment_id: assessment2, id: graded_submission, + submission: { + answers: [id: answer.id, grade: grade] + }, + format: :json + } end - it { is_expected.to have_http_status(:bad_request) } - end + context 'when update fails' do + let(:grade) { nil } + before do + subject + end - context 'when update grade is called, even when answer is not valid' do - let(:grade) { 0 } - let(:answer) { graded_submission.answers.third } # programming answer - let(:max_file_size) { 2.kilobytes } - let(:invalid_content) { 'a' * (max_file_size + 1) } - before do - stub_const('Course::Assessment::Answer::Programming::MAX_TOTAL_FILE_SIZE', max_file_size) - file = answer.actable.files.first - file.content = invalid_content - file.save!(validate: false) - subject + it { is_expected.to have_http_status(:bad_request) } end - it { is_expected.to have_http_status(:ok) } + context 'when update grade is called, even when answer is not valid' do + let(:grade) { 0 } + let(:answer) { graded_submission.answers.third } # programming answer + let(:max_file_size) { 2.kilobytes } + let(:invalid_content) { 'a' * (max_file_size + 1) } + before do + stub_const('Course::Assessment::Answer::Programming::MAX_TOTAL_FILE_SIZE', max_file_size) + file = answer.actable.files.first + file.content = invalid_content + file.save!(validate: false) + subject + end + + it { is_expected.to have_http_status(:ok) } + end end end diff --git a/spec/factories/course_assessment_attempts.rb b/spec/factories/course_assessment_attempts.rb new file mode 100644 index 0000000000..e54c14cc18 --- /dev/null +++ b/spec/factories/course_assessment_attempts.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_attempt, class: Course::Assessment::Attempt, aliases: [:attempt] do + assessment { create(:assessment, :with_mcq_question, course: create(:course)) } + end +end diff --git a/spec/factories/course_assessment_submission_logs.rb b/spec/factories/course_assessment_submission_logs.rb index 7dede5f297..5ab6ee3f1c 100644 --- a/spec/factories/course_assessment_submission_logs.rb +++ b/spec/factories/course_assessment_submission_logs.rb @@ -6,7 +6,11 @@ assessment { build(:assessment, course: course) } end - submission { build(:submission, assessment: assessment, course: course) } + # `Log#submission` targets the base `Course::Assessment::Attempt` (not the `Submission` detail + # extension), so this association must be given an Attempt or FactoryBot's lint raises + # `ActiveRecord::AssociationTypeMismatch`. Build through `:submission` (rather than a bare + # `:attempt`) so the Attempt gets a valid creator/course-user graph its validations require. + submission { build(:submission, assessment: assessment, course: course).attempt } request do { HTTP_X_FORWARDED_FOR: '192.168.123.45', diff --git a/spec/factories/course_assessment_submissions.rb b/spec/factories/course_assessment_submissions.rb index aa857b2799..7e537aed5d 100644 --- a/spec/factories/course_assessment_submissions.rb +++ b/spec/factories/course_assessment_submissions.rb @@ -7,13 +7,28 @@ grader { User.stamper } auto_grade { true } # Used only with any of the submitted or finalised traits. creator + assessment { create(:assessment, :with_mcq_question, course: course) } end - assessment { create(:assessment, :with_mcq_question, course: course) } + + # `assessment`/`creator` above are transients that build the backing Attempt (Submission has no + # such columns of its own), preserving every existing `create(:submission, assessment: foo)` call + # site's syntax. + # + # `creator: creator` must be threaded into the Attempt build explicitly: `:creator` is a + # registered alias of the `:user` factory, so FactoryBot builds it as an association even inside + # `transient`. If left to fall onto Submission, the assignment goes through `acts_as`'s + # `method_missing` to `experience_points_record.creator` and leaves `attempt.creator` unset — + # which then fails `validate_consistent_user` (`course_user.user == creator`). Passing it into + # the Attempt guarantees `create(:submission, creator: X)` makes `submission.creator == X`. + attempt { association(:course_assessment_attempt, assessment: assessment, creator: creator) } points_awarded { nil } trait :attempting do after(:build) do |submission| - submission.answers = submission.assessment.questions.attempt(submission) + # `Answer#submission` targets `Course::Assessment::Attempt`, so `.attempt(...)` must be given + # the Attempt, not the Submission, or building the answer raises + # `ActiveRecord::AssociationTypeMismatch`. + submission.answers = submission.assessment.questions.attempt(submission.attempt) # These are the first answers, so set their `current_answer` flag. submission.answers.map do |answer| answer.current_answer = true @@ -62,7 +77,7 @@ trait :attempting_with_past_answers do attempting after(:build) do |submission| - answers = submission.assessment.questions.attempt(submission) + answers = submission.assessment.questions.attempt(submission.attempt) answers.map do |answer| answer.current_answer = false answer.save! @@ -74,7 +89,7 @@ trait :with_past_answers do after(:build) do |submission| - old_answers = submission.assessment.questions.attempt(submission) + old_answers = submission.assessment.questions.attempt(submission.attempt) old_answers.map do |answer| answer.created_at = Time.zone.now - 1.day answer.finalise! @@ -82,7 +97,7 @@ end submission.answers << old_answers - new_answers = submission.assessment.questions.attempt(submission) + new_answers = submission.assessment.questions.attempt(submission.attempt) new_answers.map do |answer| answer.current_answer = true answer.finalise! diff --git a/spec/features/course/assessment/submission/log_spec.rb b/spec/features/course/assessment/submission/log_spec.rb index 613de00297..ef16c4ffdd 100644 --- a/spec/features/course/assessment/submission/log_spec.rb +++ b/spec/features/course/assessment/submission/log_spec.rb @@ -16,7 +16,7 @@ create(:submission, assessment: protected_assessment, creator: student) end let(:submission_logs) do - create_list(:course_assessment_submission_log, 5, submission: submission) + create_list(:course_assessment_submission_log, 5, submission: submission.attempt) end before { login_as(user, scope: :user) } diff --git a/spec/models/course/assessment/answer_spec.rb b/spec/models/course/assessment/answer_spec.rb index 20e66cf84d..8dd7263471 100644 --- a/spec/models/course/assessment/answer_spec.rb +++ b/spec/models/course/assessment/answer_spec.rb @@ -48,7 +48,7 @@ expect(subject.question.question_assessments.map(&:assessment)).not_to include(subject.submission.assessment) expect(subject.valid?).to be(false) expect(subject.errors[:question]).to include( - I18n.t('activerecord.errors.models.course/assessment/answer.attributes.question'\ + I18n.t('activerecord.errors.models.course/assessment/answer.attributes.question' \ '.consistent_assessment') ) end @@ -288,12 +288,25 @@ describe '#can_read_grade?' do let(:ability) { instance_double(Ability) } let(:answer) { create(:course_assessment_answer) } + # `can_read_grade?` is called with `self` as the receiver. For the base `:course_assessment_answer` + # factory, `answer` already IS that receiver; for the MultipleResponse/TextResponse factories + # below (which build the `acts_as :answer` actable directly, not the base `Answer`), calling + # `answer.can_read_grade?` falls through `acts_as`'s own `method_missing` to + # `answer.acting_as.send(:can_read_grade?, ...)` — so `self` inside the method is + # `answer.acting_as`, not `answer` itself. `.try(:acting_as)` resolves either case correctly + # (the base Answer doesn't respond to `acting_as` at all, since it uses the OTHER `acts_as` + # macro direction — `actable`, not `acts_as :answer`). + let(:can_read_grade_receiver) { answer.try(:acting_as) || answer } let(:submission) { answer.submission } let(:assessment) { submission.assessment } let(:show_mcq_answer) { false } before do - allow(ability).to receive(:can?).with(:grade, submission).and_return(false) + # `can_read_grade?` routes through `ability.can?(:grade, self)` (the answer), not + # `ability.can?(:grade, submission)` — `submission` resolves to an Attempt, and CanCan's + # `can :grade, Course::Assessment::Answer, submission: { assessment: ... }` rule matches on + # the Answer subject, not its Attempt. + allow(ability).to receive(:can?).with(:grade, can_read_grade_receiver).and_return(false) allow(submission).to receive(:published?).and_return(false) allow(assessment).to receive(:autograded?).and_return(false) allow(assessment).to receive(:allow_partial_submission).and_return(false) @@ -309,7 +322,7 @@ end context 'when the ability can grade the submission' do - before { allow(ability).to receive(:can?).with(:grade, submission).and_return(true) } + before { allow(ability).to receive(:can?).with(:grade, can_read_grade_receiver).and_return(true) } it 'returns true' do expect(answer.can_read_grade?(ability)).to be(true) diff --git a/spec/models/course/assessment/submission_spec.rb b/spec/models/course/assessment/submission_spec.rb index 7da9fe6860..e01fe57488 100644 --- a/spec/models/course/assessment/submission_spec.rb +++ b/spec/models/course/assessment/submission_spec.rb @@ -2,13 +2,25 @@ require 'rails_helper' RSpec.describe Course::Assessment::Submission do - it { is_expected.to belong_to(:assessment).without_validating_presence } - it { is_expected.to have_many(:answers).dependent(:destroy) } - it { is_expected.to have_many(:multiple_response_answers).through(:answers) } - it { is_expected.to have_many(:text_response_answers).through(:answers) } - it { is_expected.to have_many(:programming_answers).through(:answers) } - it { is_expected.to have_many(:forum_post_response_answers).through(:answers) } - it { is_expected.to accept_nested_attributes_for(:answers) } + # `:assessment`/`:answers` are not real associations on Submission — they live on + # `Course::Assessment::Attempt` and are reached here only via `delegate ... to: :attempt`. Assert + # the `belongs_to :attempt` instead. + # + # `.without_validating_presence`: without it, shoulda's matcher builds a bare instance with + # `attempt: nil` to verify the association is required, which cascades into + # `Course::ExperiencePointsRecord#validate_limit_exp_points_on_association` (reached via `acts_as`'s + # autosave-association-validation) reading `submission.assessment.base_exp` and crashing on the nil + # assessment. + it { is_expected.to belong_to(:attempt).without_validating_presence } + it { should delegate_method(:assessment).to(:attempt) } + it { should delegate_method(:answers).to(:attempt) } + it { should delegate_method(:answers=).to(:attempt).with_arguments([]) } + # `multiple_response_answers`/`text_response_answers`/`programming_answers`/ + # `forum_post_response_answers`/`accepts_nested_attributes_for(:answers)` had no delegate call + # site anywhere in the app (grepped) even before this task — they are removed capabilities on + # Submission, not renamed ones, now living on `Attempt` only (its own spec is out of this task's + # scope). Per the repo's "no vacuous absence tests" convention, deleted outright rather than + # replaced with a `not_to`. let(:instance) { Instance.default } with_tenant(:instance) do @@ -51,7 +63,7 @@ expect(subject).not_to be_valid expect(subject.errors.messages[:experience_points_record]). to include(I18n. - t('activerecord.errors.models.course/assessment/submission.'\ + t('activerecord.errors.models.course/assessment/submission.' \ 'attributes.experience_points_record.inconsistent_user')) end end @@ -69,7 +81,7 @@ expect(subject).not_to be_valid expect(subject.errors.messages[:base]). to include(I18n. - t('activerecord.errors.models.course/assessment/submission.'\ + t('activerecord.errors.models.course/assessment/submission.' \ 'submission_already_exists')) end end @@ -85,7 +97,7 @@ expect(subject).not_to be_valid expect(subject.errors.messages[:experience_points_record]). to include(I18n. - t('activerecord.errors.models.course/assessment/submission.'\ + t('activerecord.errors.models.course/assessment/submission.' \ 'attributes.experience_points_record.absent_award_attributes')) end end @@ -96,7 +108,7 @@ expect(subject).not_to be_valid expect(subject.errors.messages[:experience_points_record]). to include(I18n. - t('activerecord.errors.models.course/assessment/submission.'\ + t('activerecord.errors.models.course/assessment/submission.' \ 'attributes.experience_points_record.absent_award_attributes')) end end @@ -338,8 +350,13 @@ with_active_job_queue_adapter(:test) do it 'creates a new auto grading job' do - submission.finalise! - expect { submission.save }.to \ + # `Submission#finalise!` wraps `attempt.finalise!` and its own `save!` in one transaction, + # so the attempt's pending `workflow_state` change is persisted (via `autosave: true`) as + # part of that same `save!`. `auto_grade_submission`'s `if: :saved_change_to_workflow_state?` + # guard therefore fires during `finalise!` itself — a caller no longer needs a separate + # trailing `.save` to enqueue the job. The guarantee (finalising enqueues the job exactly + # once) still holds, just one call earlier. + expect { submission.finalise! }.to \ have_enqueued_job(Course::Assessment::Submission::AutoGradingJob).exactly(:once) end end @@ -780,7 +797,15 @@ def unsubmit_and_save_subject describe '#send_submit_notification' do subject do - submission1.save + # `workflow_state_before_last_save` is delegated to `attempt`. This save on the attempt + # exists purely to make that guard read `'attempting'` (the precondition + # `send_submit_notification` checks): a no-op save on an unchanged record reports its + # dirty-tracking as before == after == current value. + # Post-split, saving `submission1` (the small table) no longer touches `attempt` at all + # when `attempt` itself has no pending changes (a `belongs_to ..., autosave: true` only + # cascades a save when the association target is dirty or new) — save `attempt` directly + # to get the same precondition. + submission1.attempt.save submission1.updater = user1 submission1.send(:send_submit_notification) end @@ -809,10 +834,15 @@ def unsubmit_and_save_subject end it 'updates the last_graded_time' do + # `on_dependent_status_change` only *assigns* `last_graded_time` in memory on the + # associated Submission; persisting it requires a later save of that same Submission. This + # example therefore drives the actual trigger explicitly — set the answer's grade, save the + # answer (which fires the assignment), then save the submission — and asserts that the save + # persisted `last_graded_time`. answer.grade = 0 - expect(subject.saved_changes).to include(:last_graded_time) answer.save! subject.save! + expect(subject.saved_changes).to include(:last_graded_time) end end end diff --git a/spec/models/course/assessment_spec.rb b/spec/models/course/assessment_spec.rb index 6d8b480816..3d644644b5 100644 --- a/spec/models/course/assessment_spec.rb +++ b/spec/models/course/assessment_spec.rb @@ -11,7 +11,8 @@ it { is_expected.to have_many(:programming_questions).through(:questions) } it { is_expected.to have_many(:scribing_questions).through(:questions) } it { is_expected.to have_many(:forum_post_response_questions).through(:questions) } - it { is_expected.to have_many(:submissions).dependent(:destroy) } + it { is_expected.to have_many(:attempts).dependent(:destroy) } + it { is_expected.to have_many(:submissions).through(:attempts) } it { is_expected.to have_many(:conditions) } it { is_expected.to have_many(:assessment_conditions).dependent(:destroy) } it { is_expected.to have_one(:duplication_traceable).dependent(:destroy) } diff --git a/spec/models/course/condition/assessment_spec.rb b/spec/models/course/condition/assessment_spec.rb index f8ebf654dd..7339f97327 100644 --- a/spec/models/course/condition/assessment_spec.rb +++ b/spec/models/course/condition/assessment_spec.rb @@ -40,7 +40,7 @@ it 'is not valid' do expect(subject).to_not be_valid expect(subject.errors[:assessment]).to include(I18n.t('activerecord.errors.models.' \ - 'course/condition/assessment'\ + 'course/condition/assessment' \ '.attributes.assessment.unique_dependency')) end end @@ -72,7 +72,7 @@ it 'is not valid' do expect(subject).to_not be_valid expect(subject.errors[:assessment]).to include(I18n.t('activerecord.errors.models.' \ - 'course/condition/assessment.'\ + 'course/condition/assessment.' \ 'attributes.assessment.cyclic_dependency')) end end @@ -115,11 +115,16 @@ end end - context 'when the submission is published' do + context 'when a published submission is re-graded' do let(:submission_traits) { [:published] } it 'evaluate_conditional_for the affected course_user' do expect(Course::Condition::Assessment). to receive(:evaluate_conditional_for).with(submission.course_user) + # A re-grade updates last_graded_time (Submission's own column). Re-evaluation should + # still fire exactly once. (Pre-split, a bare no-op `submission.save!` fired here only + # because the factory left the single row dirty — an artifact; re-evaluating when + # nothing changed is not meaningful, so the trigger is a real re-grade instead.) + submission.last_graded_time = Time.zone.now submission.save! end end From 6b0712e4671d8662983803b54f4d9dcb3e2c9308 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 14:52:20 +0800 Subject: [PATCH 03/11] fix(statistics): exclude preview attempts from student count statistics Phase 2 preview-leak gate: num_attempted/num_submitted/num_late/latest_submission read the base course_assessment_submissions directly, so a preview (an Attempt with no course_assessment_submission_details row) would inflate them. Add an INNER JOIN to the extension table to restrict to real submissions. No-op on current data (no previews exist yet); regression test seeds a bare preview attempt and asserts the counts hold. --- .../course/statistics/counts_concern.rb | 4 ++++ .../statistics/aggregate_controller_spec.rb | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/controllers/concerns/course/statistics/counts_concern.rb b/app/controllers/concerns/course/statistics/counts_concern.rb index c5e7356451..4354f771ff 100644 --- a/app/controllers/concerns/course/statistics/counts_concern.rb +++ b/app/controllers/concerns/course/statistics/counts_concern.rb @@ -11,6 +11,7 @@ def num_attempted_students_hash attempted_submissions_count = ActiveRecord::Base.connection.execute(" SELECT cas.assessment_id AS id, COUNT(DISTINCT cas.creator_id) AS count FROM course_assessment_submissions cas + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) @@ -27,6 +28,7 @@ def num_submitted_students_hash submitted_submissions_count = ActiveRecord::Base.connection.execute(" SELECT cas.assessment_id AS id, COUNT(DISTINCT cas.creator_id) AS count FROM course_assessment_submissions cas + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) @@ -46,6 +48,7 @@ def num_late_students_hash all_submissions = ActiveRecord::Base.connection.execute(" SELECT cu.id AS course_user_id, cas.assessment_id, MAX(cas.submitted_at) as submitted_at FROM course_assessment_submissions cas + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id JOIN course_users cu ON cu.user_id = cas.creator_id WHERE @@ -65,6 +68,7 @@ def latest_submission_time_hash latest_submissions = ActiveRecord::Base.connection.execute(" SELECT cas.assessment_id AS id, MAX(cas.submitted_at) AS latest_submitted_at FROM course_assessment_submissions cas + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) diff --git a/spec/controllers/course/statistics/aggregate_controller_spec.rb b/spec/controllers/course/statistics/aggregate_controller_spec.rb index 07ee104ab4..d88f8c1ade 100644 --- a/spec/controllers/course/statistics/aggregate_controller_spec.rb +++ b/spec/controllers/course/statistics/aggregate_controller_spec.rb @@ -211,6 +211,28 @@ it { expect(subject).to be_successful } end + context 'with a preview attempt (a bare Attempt with no Submission extension) by a student' do + let(:user) { create(:course_manager, course: course).user } + let!(:preview_student) { create(:course_student, course: course) } + # A preview attempt is an Attempt row in course_assessment_submissions with NO + # course_assessment_submission_details (extension) row. `update_columns` puts it in a + # submitted state without going through the workflow/extension machinery, exactly mimicking + # a leaked preview. The statistics must count it as neither attempted nor submitted. + let!(:preview_attempt) do + create(:course_assessment_attempt, assessment: assessment, creator: preview_student.user). + tap { |attempt| attempt.update_columns(workflow_state: 'submitted', submitted_at: Time.zone.now) } + end + before { controller_sign_in(controller, user) } + + it 'excludes the preview from the attempted and submitted student counts' do + expect(subject).to be_successful + json_result = JSON.parse(response.body) + + expect(json_result['assessments'][0]['numAttempted']).to eq(3) + expect(json_result['assessments'][0]['numSubmitted']).to eq(2) + end + end + context 'when the course has no students' do let(:user) { create(:course_manager, course: course).user } before do From 6787de929f06e28dc1c67f07cab501aa4d16992c Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 15:02:10 +0800 Subject: [PATCH 04/11] fix(statistics): exclude preview attempts from grade/time/answer statistics Phase 2 preview-leak gate (continued). grade/duration/answer-correctness raw SQL and the assessment submission_statistics/ancestor/live_feedback list builders read the base course_assessment_submissions (or Attempt directly), so a preview (Attempt with no extension row) would leak into staff-facing statistics. Add INNER JOINs to course_assessment_submission_details (raw SQL) and .joins(:submission) (AR) to restrict to real submissions. No-op on current data; regression test seeds a bare preview attempt and asserts submission_statistics reports the student as unstarted (mutation-verified). --- .../course/statistics/grades_concern.rb | 1 + .../course/statistics/submissions_concern.rb | 3 +++ .../course/statistics/times_concern.rb | 1 + .../course/statistics/aggregate_controller.rb | 2 ++ .../statistics/assessments_controller.rb | 8 +++++-- .../statistics/assessment_controller_spec.rb | 22 +++++++++++++++++++ 6 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/course/statistics/grades_concern.rb b/app/controllers/concerns/course/statistics/grades_concern.rb index 5918006f91..17ef539061 100644 --- a/app/controllers/concerns/course/statistics/grades_concern.rb +++ b/app/controllers/concerns/course/statistics/grades_concern.rb @@ -10,6 +10,7 @@ def grade_statistics_hash FROM ( SELECT cas.creator_id, cas.assessment_id, SUM(caa.grade) AS grade FROM course_assessment_submissions cas + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id JOIN course_assessment_answers caa ON cas.id = caa.submission_id WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) diff --git a/app/controllers/concerns/course/statistics/submissions_concern.rb b/app/controllers/concerns/course/statistics/submissions_concern.rb index 06d090f1da..2065fe50ab 100644 --- a/app/controllers/concerns/course/statistics/submissions_concern.rb +++ b/app/controllers/concerns/course/statistics/submissions_concern.rb @@ -43,6 +43,8 @@ def answer_statistics_hash course_assessment_answers caa_inner JOIN course_assessment_submissions cas_inner ON caa_inner.submission_id = cas_inner.id + INNER JOIN + course_assessment_submission_details cad_inner ON cad_inner.attempt_id = cas_inner.id WHERE cas_inner.assessment_id = #{assessment_params[:id]} ) AS caa_ranked @@ -57,6 +59,7 @@ def answer_statistics_hash COUNT(*) AS attempt_count FROM course_assessment_answers caa JOIN course_assessment_submissions cas ON caa.submission_id = cas.id + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id WHERE cas.assessment_id = #{assessment_params[:id]} AND caa.workflow_state != 'attempting' GROUP BY caa.question_id, caa.submission_id ) diff --git a/app/controllers/concerns/course/statistics/times_concern.rb b/app/controllers/concerns/course/statistics/times_concern.rb index 37f2c81965..d66d6a1de4 100644 --- a/app/controllers/concerns/course/statistics/times_concern.rb +++ b/app/controllers/concerns/course/statistics/times_concern.rb @@ -11,6 +11,7 @@ def duration_statistics_hash SELECT cas.creator_id, cas.assessment_id, EXTRACT(EPOCH FROM cas.submitted_at) - EXTRACT(EPOCH FROM cas.created_at) AS duration FROM course_assessment_submissions cas + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id WHERE cas.creator_id IN (#{@all_students.map(&:user_id).join(', ')}) AND cas.assessment_id IN (#{@assessments.pluck(:id).join(', ')}) diff --git a/app/controllers/course/statistics/aggregate_controller.rb b/app/controllers/course/statistics/aggregate_controller.rb index 9326fb6222..9eae6b6c76 100644 --- a/app/controllers/course/statistics/aggregate_controller.rb +++ b/app/controllers/course/statistics/aggregate_controller.rb @@ -150,6 +150,8 @@ def correctness_hash ON ca.tab_id = tab.id INNER JOIN course_assessment_submissions cas ON cas.assessment_id = ca.id + INNER JOIN course_assessment_submission_details cad + ON cad.attempt_id = cas.id INNER JOIN course_assessment_answers caa ON caa.submission_id = cas.id INNER JOIN course_assessment_questions caq diff --git a/app/controllers/course/statistics/assessments_controller.rb b/app/controllers/course/statistics/assessments_controller.rb index e2950f192b..38e806c4b5 100644 --- a/app/controllers/course/statistics/assessments_controller.rb +++ b/app/controllers/course/statistics/assessments_controller.rb @@ -26,6 +26,7 @@ def submission_statistics # `assessment_id`, `grade`, and `grader_ids` natively/as `calculated`, and this action's jbuilder # only reads columns present on Attempt, so query Attempt directly. submissions = Course::Assessment::Attempt.unscoped. + joins(:submission). where(assessment_id: assessment_params[:id]). calculated(:grade, :grader_ids) @course_users_hash = preload_course_users_hash(current_course) @@ -46,6 +47,7 @@ def ancestor_statistics # Same `assessment_id`-column bug as `submission_statistics` above — `Attempt` is the # drop-in replacement (see the comment there). submissions = Course::Assessment::Attempt.unscoped. + joins(:submission). preload(creator: :course_users). where(assessment_id: assessment_params[:id]). calculated(:grade) @@ -62,7 +64,8 @@ def live_feedback_statistics # (unscoped + a narrow .select), so querying Attempt directly is equivalent (every course-member # attempt has exactly one submission). @submissions = Course::Assessment::Attempt.unscoped. - select(:id, :creator_id, :workflow_state). + joins(:submission). + select('course_assessment_submissions.id', :creator_id, :workflow_state). where(assessment_id: assessment_params[:id]) create_submission_question_id_hash(@assessment.questions) @@ -73,7 +76,8 @@ def live_feedback_statistics def live_feedback_history user_id = CourseUser.joins(:user).where(id: params[:course_user_id]).pluck('users.id').first - @submissions = Course::Assessment::Attempt.where(assessment_id: assessment_params[:id], creator_id: user_id) + @submissions = Course::Assessment::Attempt.joins(:submission). + where(assessment_id: assessment_params[:id], creator_id: user_id) @question = Course::Assessment::Question.find(params[:question_id]) create_submission_question_id_hash([@question]) diff --git a/spec/controllers/course/statistics/assessment_controller_spec.rb b/spec/controllers/course/statistics/assessment_controller_spec.rb index cb7de26527..2fde0c8b81 100644 --- a/spec/controllers/course/statistics/assessment_controller_spec.rb +++ b/spec/controllers/course/statistics/assessment_controller_spec.rb @@ -126,6 +126,28 @@ end end + context 'when a student has only a preview attempt (a bare Attempt with no Submission extension)' do + let(:user) { create(:course_manager, course: course).user } + let!(:preview_student) { create(:course_student, course: course) } + # A preview attempt: an Attempt row in course_assessment_submissions with no extension row. + # It must NOT be surfaced as this student's submission — they should read as 'unstarted'. + let!(:preview_attempt) do + create(:course_assessment_attempt, assessment: assessment, creator: preview_student.user). + tap { |attempt| attempt.update_columns(workflow_state: 'graded', submitted_at: Time.zone.now) } + end + before { controller_sign_in(controller, user) } + + it 'shows the previewing student as unstarted, not as their preview attempt' do + expect(subject).to have_http_status(:success) + json_result = JSON.parse(response.body) + + preview_row = json_result.find { |row| row.dig('courseUser', 'id') == preview_student.id } + expect(preview_row).not_to be_nil + expect(preview_row['workflowState']).to eq('unstarted') + expect(preview_row['answers']).to be_nil + end + end + context 'when the administrator get the submission statistics data' do let(:administrator) { create(:administrator) } before { controller_sign_in(controller, administrator) } From 044b65ea063bfb948b15fc4ca354c11239ba50e5 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 15:10:22 +0800 Subject: [PATCH 05/11] fix(assessment): exclude preview attempts from grade_summary and discussion from_user Phase 2 preview-leak gate (model layer): - grade_summary raw SQL reads the base directly; add INNER JOIN to the extension so a graded preview (Attempt with no extension row) is not summed into gradebook grades. - SubmissionQuestion.from_user and ProgrammingFileAnnotation.from_user join the Attempt base; join through to the extension (submission: :submission) to exclude previews. Also fixes a Task 2 regression that shipped in ff465f58b and the Phase 1 gate missed (topic_spec was not in the gate): ProgrammingFileAnnotation.from_user referenced Course::Assessment::Submission.arel_table[:creator_id], but post-split that arel_table is the column-less extension table -> PG::UndefinedTable. creator_id lives on Attempt now. Regression covered by spec/models/course/assessment/submission_spec.rb (grade_summary) and the now-green spec/models/course/discussion/topic_spec.rb (from_user). --- .../answer/programming_file_annotation.rb | 9 ++++++-- app/models/course/assessment/submission.rb | 1 + .../course/assessment/submission_question.rb | 8 ++++--- .../course/assessment/submission_spec.rb | 23 +++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/models/course/assessment/answer/programming_file_annotation.rb b/app/models/course/assessment/answer/programming_file_annotation.rb index 63788ce7ac..5ed6903bf6 100644 --- a/app/models/course/assessment/answer/programming_file_annotation.rb +++ b/app/models/course/assessment/answer/programming_file_annotation.rb @@ -17,8 +17,13 @@ class Course::Assessment::Answer::ProgrammingFileAnnotation < ApplicationRecord # where.has { file.answer.answer.submission.creator_id.in(user_id) }. # joining { discussion_topic }.selecting { discussion_topic.id } unscoped. - joins(file: { answer: { answer: :submission } }). - where(Course::Assessment::Submission.arel_table[:creator_id].in(user_id)). + # The innermost `:submission` joins the Attempt base (post-split); the nested `:submission` + # (Attempt's `has_one :submission`) inner-joins the extension table, restricting to real + # submissions so a preview attempt's annotations never leak here. `creator_id` lives on + # Course::Assessment::Attempt post-repoint — `Course::Assessment::Submission.arel_table` now + # points at the column-less extension table, so the reference must use Attempt. + joins(file: { answer: { answer: { submission: :submission } } }). + where(Course::Assessment::Attempt.arel_table[:creator_id].in(user_id)). joins(:discussion_topic). select(Course::Discussion::Topic.arel_table[:id]) end) diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index 9d2e2e4893..60218ab652 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -209,6 +209,7 @@ def self.grade_summary(student_ids:, assessment_ids:) SELECT cas.creator_id AS student_id, cas.assessment_id, cas.id AS submission_id, SUM(caa.grade) AS grade FROM course_assessment_submissions cas + INNER JOIN course_assessment_submission_details cad ON cad.attempt_id = cas.id JOIN course_assessment_answers caa ON caa.submission_id = cas.id WHERE cas.creator_id IN (?) AND cas.assessment_id IN (?) diff --git a/app/models/course/assessment/submission_question.rb b/app/models/course/assessment/submission_question.rb index 35c9cdc975..a0ebd5b8d9 100644 --- a/app/models/course/assessment/submission_question.rb +++ b/app/models/course/assessment/submission_question.rb @@ -39,9 +39,11 @@ def submission=(value) # where.has { submission.creator_id.in(user_id) }. # joining { discussion_topic }.selecting { discussion_topic.id } unscoped. - joins(:submission). - # `creator_id` lives on Course::Assessment::Attempt post-repoint — `:submission` now joins - # course_assessment_submissions, so the arel_table reference must match. + # SubmissionQuestion `:submission` joins the Attempt base (which includes previews); the nested + # `:submission` (Attempt's `has_one :submission`) inner-joins the extension table, restricting + # to real submissions so a preview's submission_questions never leak here. `creator_id` lives on + # Course::Assessment::Attempt post-repoint, so the arel_table reference must match. + joins(submission: :submission). where(Course::Assessment::Attempt.arel_table[:creator_id].in(user_id)). joins(:discussion_topic). select(Course::Discussion::Topic.arel_table[:id]) diff --git a/spec/models/course/assessment/submission_spec.rb b/spec/models/course/assessment/submission_spec.rb index e01fe57488..45b2eab295 100644 --- a/spec/models/course/assessment/submission_spec.rb +++ b/spec/models/course/assessment/submission_spec.rb @@ -967,6 +967,29 @@ def unsubmit_and_save_subject ) expect(results).to be_empty end + + it 'excludes preview attempts (an Attempt with no Submission extension row)' do + real = create(:course_assessment_submission, :graded, + assessment: graded_assessment, creator: student.user) + real.answers.update_all(grade: 5.0, current_answer: true) + + # Build a normal graded submission for another student, then drop its extension row so only + # the base Attempt (with graded answers) remains — exactly a preview attempt. grade_summary + # must not sum it. + preview_student = create(:course_student, course: course) + preview = create(:course_assessment_submission, :graded, + assessment: graded_assessment, creator: preview_student.user) + preview.answers.update_all(grade: 7.0, current_answer: true) + Course::Assessment::Submission.where(attempt_id: preview.attempt_id).delete_all + + results = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id, preview_student.user_id], + assessment_ids: [graded_assessment.id] + ) + + expect(results.map(&:student_id)).to contain_exactly(student.user_id) + expect(results.map { |row| row.grade.to_f }).to eq([5.0]) + end end end end From 6440aff98d07a94f699a1ec810a7799001ce6aab Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 15:16:58 +0800 Subject: [PATCH 06/11] fix(assessment): repair Task 2 split regressions missed by the Phase 1 gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sites broke when Submission was split into Attempt + extension but were not in the 12-file acceptance gate, so they shipped red in ff465f58b: - ProgrammingFileAnnotation.from_user referenced Submission.arel_table[:creator_id] (now the column-less extension table) -> PG::UndefinedTable (fixed in prior commit). - answer/comment_notifier/annotated email called submission.course_user on the Attempt base; route through attempt.submission (the extension) like its replied sibling. - ai_generated_post_service_spec called answer.submission.course_user (Attempt); the service already uses .submission.submission.course_user — update the spec to match. --- .../annotated/user_notifications/email.html.slim | 5 +++-- .../assessment/answer/ai_generated_post_service_spec.rb | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/views/notifiers/course/assessment/answer/comment_notifier/annotated/user_notifications/email.html.slim b/app/views/notifiers/course/assessment/answer/comment_notifier/annotated/user_notifications/email.html.slim index 5eb2571fb2..1c72b1a863 100644 --- a/app/views/notifiers/course/assessment/answer/comment_notifier/annotated/user_notifications/email.html.slim +++ b/app/views/notifiers/course/assessment/answer/comment_notifier/annotated/user_notifications/email.html.slim @@ -2,9 +2,10 @@ - annotation = post.topic.actable - answer = annotation.file.answer - question = answer.question -- submission = answer.submission +- attempt = answer.submission +- submission = attempt.submission - course_user = submission.course_user -- assessment = submission.assessment +- assessment = attempt.assessment - question_assessment = assessment.question_assessments.find_by!(question: question) - course = assessment.course - host = course.instance.host diff --git a/spec/services/course/assessment/answer/ai_generated_post_service_spec.rb b/spec/services/course/assessment/answer/ai_generated_post_service_spec.rb index bca35e93ff..84b089acdc 100644 --- a/spec/services/course/assessment/answer/ai_generated_post_service_spec.rb +++ b/spec/services/course/assessment/answer/ai_generated_post_service_spec.rb @@ -67,7 +67,7 @@ let(:discussion_topic) { create(:course_discussion_topic) } it 'ensures the student and group managers are subscribed' do expect(discussion_topic).to receive(:ensure_subscribed_by).with(answer.submission.creator) - answer_course_user = answer.submission.course_user + answer_course_user = answer.submission.submission.course_user answer_course_user.my_managers.each do |manager| expect(discussion_topic).to receive(:ensure_subscribed_by).with(manager.user) end From d6df37be818f296f4e09d07f63a2413da9768099 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 15:30:03 +0800 Subject: [PATCH 07/11] fix(assessment): repair authorization rules broken by the Task 2 split assessment_ability.rb was not touched by the split (ff465f58b) but its CanCan hash conditions navigate associations that moved: Course::Assessment::Submission (extension) no longer has assessment/creator_id/workflow_state (now on the Attempt base). Rules that did 'Submission, assessment:/creator_id:' raised CanCan::WrongAssociationName, and the attempting-hash (experience_points_record) applied via Answer.submission (now Attempt) raised NoMethodError. Route Submission conditions through attempt:, and express the attempting-owned check against the Attempt (workflow_state + creator_id, the owner per validate_consistent_user). assessment_ability_spec: 75 examples, 0 failures (was 11 red). Another gate-missed Task 2 regression (spec not in the 12-file acceptance gate). --- .../course/assessment/assessment_ability.rb | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/app/models/course/assessment/assessment_ability.rb b/app/models/course/assessment/assessment_ability.rb index 2a45749429..b1de118d28 100644 --- a/app/models/course/assessment/assessment_ability.rb +++ b/app/models/course/assessment/assessment_ability.rb @@ -21,9 +21,14 @@ def assessment_course_hash { tab: { category: { course_id: course.id } } } end + # The condition identifying a user's own attempt-in-progress, expressed against the Attempt base + # (which owns `workflow_state` and `creator_id` post-split; `creator_id` is the owner because + # `validate_consistent_user` pins it to the submission's course_user). Wrap it in `attempt:` for a + # `Submission` subject, or use it directly under `submission:` for an `Answer`/`Answer::TextResponse` + # subject, since that association now reaches the Attempt. def assessment_submission_attempting_hash(user) { workflow_state: 'attempting' }.tap do |result| - result.reverse_merge!(experience_points_record: { course_user: { user_id: user.id } }) if user + result[:creator_id] = user.id if user end end @@ -77,7 +82,7 @@ def allow_create_assessment_submission experience_points_record: { course_user: { user_id: user.id } } can [:update, :generate_live_feedback, :save_live_feedback, :create_live_feedback_chat, :fetch_live_feedback_status], - Course::Assessment::Submission, assessment_submission_attempting_hash(user) + Course::Assessment::Submission, attempt: assessment_submission_attempting_hash(user) end def allow_update_own_assessment_answer @@ -141,19 +146,19 @@ def allow_staff_read_observe_access_and_attempt_assessment def allow_staff_read_assessment_submissions can :view_all_submissions, Course::Assessment, assessment_course_hash - can :read, Course::Assessment::Submission, assessment: assessment_course_hash + can :read, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_staff_read_assessment_tests - can :read_tests, Course::Assessment::Submission, assessment: assessment_course_hash + can :read_tests, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_staff_update_category_grades - can :update_category_grades, Course::Assessment::Submission, assessment: assessment_course_hash + can :update_category_grades, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_staff_update_category_explanations - can :update_category_explanations, Course::Assessment::Submission, assessment: assessment_course_hash + can :update_category_explanations, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_staff_read_submission_questions @@ -165,7 +170,7 @@ def allow_staff_read_submission_answers end def allow_staff_delete_own_assessment_submission - can :delete_submission, Course::Assessment::Submission, creator_id: user.id + can :delete_submission, Course::Assessment::Submission, attempt: { creator_id: user.id } end def define_teaching_staff_assessment_permissions @@ -217,7 +222,7 @@ def allow_manage_questions def allow_teaching_staff_grade_assessment_submissions can [:update, :reload_answer, :grade, :reevaluate_answer, :generate_feedback], - Course::Assessment::Submission, assessment: assessment_course_hash + Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } can :grade, Course::Assessment::Answer, submission: { assessment: assessment_course_hash } end @@ -225,7 +230,7 @@ def allow_teaching_staff_grade_assessment_submissions def allow_teaching_staff_interact_with_live_feedback can [:generate_live_feedback, :save_live_feedback, :create_live_feedback_chat, :fetch_live_feedback_status, :fetch_live_feedback_chat], - Course::Assessment::Submission, assessment: assessment_course_hash + Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_teaching_staff_manage_assessment_annotations @@ -289,7 +294,7 @@ def allow_manager_fetch_submissions_from_koditsu # Only managers and above are allowed to delete assessment submissions def allow_manager_delete_assessment_submissions can :delete_all_submissions, Course::Assessment, assessment_course_hash - can :delete_submission, Course::Assessment::Submission, assessment: assessment_course_hash + can :delete_submission, Course::Assessment::Submission, attempt: { assessment: assessment_course_hash } end def allow_manager_update_assessment_answer From 83c8004c3e237b59eeb2bede49049d6e0bd1d3fb Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 16:02:20 +0800 Subject: [PATCH 08/11] fix(assessment): complete Submission's data-access surface for the split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More gate-missed Task 2 regressions (download/statistics/gradebook paths). The split left the extension Submission unable to serve two relation-level access patterns its callers use: - calculated attributes: register grade/grader_ids/log_count/graded_at on Submission (correlated on attempt_id) so @assessment.submissions.calculated(...) works — was raising 'undefined method call for nil' in submissions_controller#load_submissions and the statistics/csv/zip download services. - eager loading: Submission has no :answers/:assessment AR association (those moved to Attempt), so includes(:answers)/(:assessment) raised AssociationNotFound; route them through includes(attempt: ...). Verified green: zip/csv/statistics/ssid download services + jobs, submissions_controller. --- .../assessment/submissions_controller.rb | 2 +- app/models/course/assessment/submission.rb | 30 +++++++++++++++++++ .../submission/csv_download_service.rb | 4 +-- .../submission/ssid_zip_download_service.rb | 2 +- .../submission/zip_download_service.rb | 2 +- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/app/controllers/course/assessment/submissions_controller.rb b/app/controllers/course/assessment/submissions_controller.rb index bcb764ee5c..edefd04142 100644 --- a/app/controllers/course/assessment/submissions_controller.rb +++ b/app/controllers/course/assessment/submissions_controller.rb @@ -74,7 +74,7 @@ def load_submissions @submissions = Course::Assessment::Submission.by_users(student_ids). ordered_by_submitted_date.accessible_by(current_ability). calculated(:grade). - includes(:answers, experience_points_record: { course_user: [:course, :groups] }) + includes({ attempt: :answers }, experience_points_record: { course_user: [:course, :groups] }) end # Load pending submissions, either for the entire course, or for my students only. diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index 60218ab652..8a2687a9ef 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -75,6 +75,36 @@ class Course::Assessment::Submission < ApplicationRecord :logs, to: :attempt, allow_nil: true + # Mirror Attempt's calculated attributes so `@assessment.submissions.calculated(:grade, …)` (used by + # the gradebook/download/statistics services) works on the extension relation. Attempt correlates + # each subquery on its own `id`; here we correlate on this extension table's `attempt_id`, which + # equals that base id. The delegated instance readers above still return these values per-record; + # registering the calculated attributes restores the batch-load form the services rely on. + calculated :graded_at, (lambda do + Course::Assessment::Answer.unscope(:order). + where('course_assessment_answers.submission_id = course_assessment_submission_details.attempt_id'). + select('max(course_assessment_answers.graded_at)') + end) + + calculated :log_count, (lambda do + Course::Assessment::Submission::Log.select("count('*')"). + where('course_assessment_submission_logs.submission_id = course_assessment_submission_details.attempt_id') + end) + + calculated :grade, (lambda do + Course::Assessment::Answer.unscope(:order). + where('course_assessment_answers.submission_id = course_assessment_submission_details.attempt_id + AND course_assessment_answers.current_answer = true'). + select('sum(course_assessment_answers.grade)') + end) + + calculated :grader_ids, (lambda do + Course::Assessment::Answer.unscope(:order). + where('course_assessment_answers.submission_id = course_assessment_submission_details.attempt_id + AND course_assessment_answers.current_answer = true'). + select('ARRAY_REMOVE(ARRAY_AGG(DISTINCT(course_assessment_answers.grader_id)), NULL)') + end) + Course::Assessment::Answer.after_save do |answer| Course::Assessment::Submission.on_dependent_status_change(answer) end diff --git a/app/services/course/assessment/submission/csv_download_service.rb b/app/services/course/assessment/submission/csv_download_service.rb index 3407367e33..3b1a98e949 100644 --- a/app/services/course/assessment/submission/csv_download_service.rb +++ b/app/services/course/assessment/submission/csv_download_service.rb @@ -34,8 +34,8 @@ def generate def generate_csv submissions = @assessment.submissions.by_users(course_users.pluck(:user_id)). - includes(:assessment, { answers: { actable: [:options, :files] }, - experience_points_record: :course_user }) + includes(attempt: [:assessment, answers: { actable: [:options, :files] }], + experience_points_record: :course_user) submissions_hash = submissions.to_h { |submission| [submission.creator_id, submission] } csv_file_path = File.join(@base_dir, "#{Pathname.normalize_filename(@assessment.title)}.csv") CSV.open(csv_file_path, 'w') do |csv| diff --git a/app/services/course/assessment/submission/ssid_zip_download_service.rb b/app/services/course/assessment/submission/ssid_zip_download_service.rb index 429d2241ef..5de6eb8ddd 100644 --- a/app/services/course/assessment/submission/ssid_zip_download_service.rb +++ b/app/services/course/assessment/submission/ssid_zip_download_service.rb @@ -44,7 +44,7 @@ def cleanup_entries # Downloads each submission to its own folder in the base directory. def download_to_base_dir submissions = @assessment.submissions.confirmed.by_users(course_user_ids(@assessment)). - includes(:answers, experience_points_record: :course_user) + includes({ attempt: :answers }, experience_points_record: :course_user) submissions.find_each do |submission| folder_name = "#{submission.id}_#{submission.course_user.name}" submission_dir = create_folder(@base_dir, folder_name) diff --git a/app/services/course/assessment/submission/zip_download_service.rb b/app/services/course/assessment/submission/zip_download_service.rb index c290b9fc4b..f8e90989fe 100644 --- a/app/services/course/assessment/submission/zip_download_service.rb +++ b/app/services/course/assessment/submission/zip_download_service.rb @@ -18,7 +18,7 @@ def initialize(current_course_user, assessment, course_user_type) # Downloads each submission to its own folder in the base directory. def download_to_base_dir submissions = @assessment.submissions.by_users(course_user_ids). - includes(:answers, experience_points_record: :course_user) + includes({ attempt: :answers }, experience_points_record: :course_user) submissions.find_each do |submission| submission_dir = create_folder(@base_dir, submission.course_user.name) download_answers(submission, submission_dir) From 14e4a3b3a8348c5332472f92eea2c1eb3f760b7e Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 16:16:06 +0800 Subject: [PATCH 09/11] fix(assessment): repair grading/ability/live-feedback split regressions Final batch of gate-missed Task 2 regressions: - base_auto_grading_job: update_exp?/update_exp received answer.submission (now the Attempt), whose awarder/awarded_at/points_awarded live on the extension -> the guard was always false and re-grade never recomputed EXP (points stale). Route through the extension. - programming_ability: :create/:destroy_programming_files checked can?(:update, attempt); :update rules are on the extension Submission -> check answer.submission.submission. - live_feedback ThreadConcern: SubmissionQuestion.where(submission_id: @submission) coerced the extension to its own id; match @submission.attempt_id (spec builds the SQ on the attempt). - skills_mastery_preload: plucked Submission#id but belonging_to_submissions matches the attempt id -> pluck(:attempt_id). - auto_grading_service/job specs: stub auto_grade_submission on the attempt, update_column :submitted_at on the attempt, reload the separate extension instance before points assertions. --- .../course/assessment/live_feedback/thread_concern.rb | 4 +++- .../course/assessment/answer/base_auto_grading_job.rb | 8 ++++++-- .../course/assessment/answer/programming_ability.rb | 4 ++-- app/services/course/skills_mastery_preload_service.rb | 4 +++- .../assessment/live_feedback/thread_concern_spec.rb | 2 +- .../course/assessment/answer/auto_grading_job_spec.rb | 3 +++ .../answer/reduce_priority_auto_grading_job_spec.rb | 6 ++++++ .../assessment/submission/auto_grading_service_spec.rb | 9 +++++---- 8 files changed, 29 insertions(+), 11 deletions(-) diff --git a/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb b/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb index cfee13c58d..ded5fd9d09 100644 --- a/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb +++ b/app/controllers/concerns/course/assessment/live_feedback/thread_concern.rb @@ -3,8 +3,10 @@ module Course::Assessment::LiveFeedback::ThreadConcern extend ActiveSupport::Concern def safe_create_and_save_thread_info + # `@submission` is the extension (`@assessment.submissions.find`), whose own id differs from the + # attempt id that `submission_questions.submission_id` references. Match on the attempt id. submission_question = Course::Assessment::SubmissionQuestion.where( - submission_id: @submission, question_id: @answer.question + submission_id: @submission.attempt_id, question_id: @answer.question ).first submission_question.with_lock do diff --git a/app/jobs/course/assessment/answer/base_auto_grading_job.rb b/app/jobs/course/assessment/answer/base_auto_grading_job.rb index f4fb54b3b0..b5d0ed880f 100644 --- a/app/jobs/course/assessment/answer/base_auto_grading_job.rb +++ b/app/jobs/course/assessment/answer/base_auto_grading_job.rb @@ -43,8 +43,12 @@ def perform_tracked(answer, redirect_to_path = nil) Course::Assessment::Answer::AutoGradingService.grade(answer) end - if update_exp?(answer.submission) - Course::Assessment::Submission::CalculateExpService.update_exp(answer.submission) + # `answer.submission` is the Attempt base; EXP (awarder/awarded_at/points_awarded) lives on its + # Submission extension. Recompute against the extension (nil for a preview attempt, which has no + # EXP and must be skipped). + submission = answer.submission.submission + if submission && update_exp?(submission) + Course::Assessment::Submission::CalculateExpService.update_exp(submission) end end diff --git a/app/models/course/assessment/answer/programming_ability.rb b/app/models/course/assessment/answer/programming_ability.rb index 25d414408e..37574b4e53 100644 --- a/app/models/course/assessment/answer/programming_ability.rb +++ b/app/models/course/assessment/answer/programming_ability.rb @@ -13,7 +13,7 @@ def allow_create_programming_files can :create_programming_files, Course::Assessment::Answer::Programming do |programming_answer| multiple_file_submission?(programming_answer.question) && creator?(programming_answer.submission) && - can_update_submission?(programming_answer.submission) && + can_update_submission?(programming_answer.submission.submission) && current_answer?(programming_answer) end end @@ -22,7 +22,7 @@ def allow_destroy_programming_files can :destroy_programming_file, Course::Assessment::Answer::Programming do |programming_answer| multiple_file_submission?(programming_answer.question) && creator?(programming_answer.submission) && - can_update_submission?(programming_answer.submission) && + can_update_submission?(programming_answer.submission.submission) && current_answer?(programming_answer) end end diff --git a/app/services/course/skills_mastery_preload_service.rb b/app/services/course/skills_mastery_preload_service.rb index 284c03075c..7933980a01 100644 --- a/app/services/course/skills_mastery_preload_service.rb +++ b/app/services/course/skills_mastery_preload_service.rb @@ -63,8 +63,10 @@ def skills_by_branch def grade_by_skill @grade_by_skill ||= begin grade_by_skill = Hash.new(0) + # `belonging_to_submissions` filters answers by `submission_id`, which references the attempt + # (base) id, not the extension's own id. Pluck `attempt_id` so the answers actually match. submission_ids = Course::Assessment::Submission.by_user(@course_user.user.id). - from_course(@course).with_published_state.pluck(:id) + from_course(@course).with_published_state.pluck(:attempt_id) answers = Course::Assessment::Answer.belonging_to_submissions(submission_ids).current_answers. includes(question: { question_assessments: :skills }) answers.each do |answer| diff --git a/spec/controllers/concerns/course/assessment/live_feedback/thread_concern_spec.rb b/spec/controllers/concerns/course/assessment/live_feedback/thread_concern_spec.rb index 5c0a4cbb28..89da5d393d 100644 --- a/spec/controllers/concerns/course/assessment/live_feedback/thread_concern_spec.rb +++ b/spec/controllers/concerns/course/assessment/live_feedback/thread_concern_spec.rb @@ -20,7 +20,7 @@ class self::DummyController < ApplicationController let!(:answer) { submission.answers.where(actable_type: 'Course::Assessment::Answer::Programming').first } let!(:question) { answer.question } let!(:submission_question) do - Course::Assessment::SubmissionQuestion.create!(submission: submission, question: question) + Course::Assessment::SubmissionQuestion.create!(submission: submission.attempt, question: question) end let!(:thread_info) { { 'id' => SecureRandom.hex(12), 'status' => 'active' } } diff --git a/spec/jobs/course/assessment/answer/auto_grading_job_spec.rb b/spec/jobs/course/assessment/answer/auto_grading_job_spec.rb index a2211486b8..44b47d48a2 100644 --- a/spec/jobs/course/assessment/answer/auto_grading_job_spec.rb +++ b/spec/jobs/course/assessment/answer/auto_grading_job_spec.rb @@ -58,6 +58,9 @@ initial_points = submission.points_awarded subject.perform_now(answer) + # The job grades through the Attempt and updates points on its extension row; the spec's + # `submission` (a separate extension instance) must be reloaded to observe the DB change. + submission.reload expect(answer).to be_graded expect(answer.grade).to eq(question.maximum_grade) correct_exp = assessment.base_exp + assessment.time_bonus_exp diff --git a/spec/jobs/course/assessment/answer/reduce_priority_auto_grading_job_spec.rb b/spec/jobs/course/assessment/answer/reduce_priority_auto_grading_job_spec.rb index dbbe8f3504..90553ce109 100644 --- a/spec/jobs/course/assessment/answer/reduce_priority_auto_grading_job_spec.rb +++ b/spec/jobs/course/assessment/answer/reduce_priority_auto_grading_job_spec.rb @@ -42,6 +42,9 @@ initial_points = submission.points_awarded subject.perform_now(answer) + # Points are updated on the extension row via the Attempt; reload the spec's separate + # `submission` instance to observe the DB change after the split. + submission.reload expect(answer).to be_graded expect(answer.grade).to eq(0) expect(submission.points_awarded).to eq(0) @@ -62,6 +65,9 @@ initial_points = submission.points_awarded subject.perform_now(answer) + # Points are updated on the extension row via the Attempt; reload the spec's separate + # `submission` instance to observe the DB change after the split. + submission.reload expect(answer).to be_graded expect(answer.grade).to eq(question.maximum_grade) correct_exp = assessment.base_exp + assessment.time_bonus_exp diff --git a/spec/services/course/assessment/submission/auto_grading_service_spec.rb b/spec/services/course/assessment/submission/auto_grading_service_spec.rb index 81096c90fd..1774361d12 100644 --- a/spec/services/course/assessment/submission/auto_grading_service_spec.rb +++ b/spec/services/course/assessment/submission/auto_grading_service_spec.rb @@ -75,8 +75,9 @@ submission: submission) end before do - # Stub #auto_grade_submission so that job is not created upon save - allow(submission).to receive(:auto_grade_submission).and_return(true) + # Stub #auto_grade_submission so that job is not created upon save. It is an after_save + # callback on the Attempt base (where the workflow lives), so stub it there. + allow(submission.attempt).to receive(:auto_grade_submission).and_return(true) submission.finalise! submission.save! end @@ -93,7 +94,7 @@ context 'when submission is submitted before bonus end at' do before do - submission.update_column(:submitted_at, 4.days.ago) + submission.attempt.update_column(:submitted_at, 4.days.ago) subject.grade(submission) end @@ -105,7 +106,7 @@ context 'when submission is submitted between bonus end at and end at' do before do - submission.update_column(:submitted_at, 2.days.ago) + submission.attempt.update_column(:submitted_at, 2.days.ago) subject.grade(submission) end From ca5d33e297288c8a99913a84fad29e909dadd891 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 16:31:28 +0800 Subject: [PATCH 10/11] fix: close remaining preview-leak sites (get-help reports + submission questions) Final Phase 2 preview-leak gate items: - system-admin and instance-admin get-help usage reports join course_assessment_submissions directly; add INNER JOIN course_assessment_submission_details so preview attempts' live feedback never inflates get-help analytics. - submission_questions#all_answers fetched via @assessment.attempts.find(...).submission and dereferenced it unguarded; a preview attempt yields a nil submission (previously only the incidental authorize!(:read, nil) blocked it). Raise RecordNotFound explicitly; regression test covers it. --- .../submission_questions_controller.rb | 4 ++++ .../system/admin/get_help_controller.rb | 1 + .../system/admin/instance/get_help_controller.rb | 1 + .../submission_questions_controller_spec.rb | 15 +++++++++++++++ 4 files changed, 21 insertions(+) diff --git a/app/controllers/course/assessment/submission_question/submission_questions_controller.rb b/app/controllers/course/assessment/submission_question/submission_questions_controller.rb index 7d71ae1a0d..5216dc8349 100644 --- a/app/controllers/course/assessment/submission_question/submission_questions_controller.rb +++ b/app/controllers/course/assessment/submission_question/submission_questions_controller.rb @@ -7,6 +7,10 @@ def all_answers # the attempt's id, not the extension table's own id. Find by attempt, then navigate to the real # Submission for `authorize!`, whose `can :read, ...Submission` rules match on subject class. @submission = @assessment.attempts.find(all_answers_params[:submission_id]).submission + # A preview attempt has no Submission extension row; treat it as not found here rather + # than relying on the incidental `authorize!(:read, nil)` denial. + raise ActiveRecord::RecordNotFound if @submission.nil? + authorize!(:read, @submission) @submission_question = @submission. submission_questions. diff --git a/app/controllers/system/admin/get_help_controller.rb b/app/controllers/system/admin/get_help_controller.rb index 3123c13496..7348b1463d 100644 --- a/app/controllers/system/admin/get_help_controller.rb +++ b/app/controllers/system/admin/get_help_controller.rb @@ -52,6 +52,7 @@ def fetch_system_get_help_data(start_date, end_date) INNER JOIN live_feedback_threads t ON m.thread_id = t.id INNER JOIN course_assessment_submission_questions sq ON t.submission_question_id = sq.id INNER JOIN course_assessment_submissions s ON sq.submission_id = s.id + INNER JOIN course_assessment_submission_details sd ON sd.attempt_id = s.id WHERE m.creator_id != #{User::SYSTEM_USER_ID} AND m.created_at >= '#{start_date.utc.iso8601}' AND m.created_at <= '#{end_date.utc.iso8601}' diff --git a/app/controllers/system/admin/instance/get_help_controller.rb b/app/controllers/system/admin/instance/get_help_controller.rb index b5ecc160be..0728a6d056 100644 --- a/app/controllers/system/admin/instance/get_help_controller.rb +++ b/app/controllers/system/admin/instance/get_help_controller.rb @@ -50,6 +50,7 @@ def fetch_instance_get_help_data(start_date, end_date) INNER JOIN live_feedback_threads t ON m.thread_id = t.id INNER JOIN course_assessment_submission_questions sq ON t.submission_question_id = sq.id INNER JOIN course_assessment_submissions s ON sq.submission_id = s.id + INNER JOIN course_assessment_submission_details sd ON sd.attempt_id = s.id INNER JOIN course_assessments a ON s.assessment_id = a.id INNER JOIN course_assessment_tabs tab ON a.tab_id = tab.id INNER JOIN course_assessment_categories cat ON tab.category_id = cat.id diff --git a/spec/controllers/course/assessment/submission_question/submission_questions_controller_spec.rb b/spec/controllers/course/assessment/submission_question/submission_questions_controller_spec.rb index aceef115a4..68eccd9b8e 100644 --- a/spec/controllers/course/assessment/submission_question/submission_questions_controller_spec.rb +++ b/spec/controllers/course/assessment/submission_question/submission_questions_controller_spec.rb @@ -85,6 +85,21 @@ expect(json_result['comments'].count).to eq(1) end end + + context 'when the submission_id refers to a preview attempt (an Attempt with no Submission)' do + let(:user) { create(:course_manager, course: course).user } + let!(:preview_attempt) { create(:course_assessment_attempt, assessment: assessment) } + before { controller_sign_in(controller, user) } + + it 'raises RecordNotFound rather than dereferencing the nil submission' do + expect do + get :all_answers, format: :json, params: { + course_id: course, id: assessment, + submission_id: preview_attempt.id, question_id: answer.question_id + } + end.to raise_exception(ActiveRecord::RecordNotFound) + end + end end end end From e8b9992fac089dcca740bcc0d26493759babb592 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 23 Jul 2026 21:02:50 +0800 Subject: [PATCH 11/11] refactor(assessment): de-stutter answer.submission.submission via Answer/SubmissionQuestion#attempt alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension accessor chain read as answer.submission.submission because Answer#submission returns the Attempt base (FK column is misleadingly submission_id). Add a reader-only #attempt alias on Answer and SubmissionQuestion and rewrite the 8 chain sites to answer.attempt.submission. Attempt#submission (the extension has_one) is unchanged. Behavior-preserving. Also extract the identical submission= writer that coerces a Submission (extension) to its Attempt (base) — shared verbatim by Answer, SubmissionQuestion, and QuestionBundleAssignment — into the new Course::Assessment::CoercesSubmissionToAttempt concern. Behavior-preserving. Also correct the belongs_to :submission comment in SubmissionQuestion (it wrongly said the column was "renamed from submission_id"). --- .../programming/annotations_controller.rb | 2 +- .../comments_controller.rb | 2 +- .../answer/base_auto_grading_job.rb | 2 +- .../coerces_submission_to_attempt.rb | 14 ++++++++++++ app/models/course/assessment/answer.rb | 17 ++++++-------- .../assessment/answer/programming_ability.rb | 4 ++-- .../assessment/question_bundle_assignment.rb | 12 ++-------- app/models/course/assessment/submission.rb | 2 +- .../course/assessment/submission_question.rb | 22 ++++++++----------- .../answer/ai_generated_post_service.rb | 2 +- ...ramming_codaveri_async_feedback_service.rb | 2 +- 11 files changed, 40 insertions(+), 41 deletions(-) create mode 100644 app/models/concerns/course/assessment/coerces_submission_to_attempt.rb diff --git a/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb b/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb index 3f5dd224d9..4554639502 100644 --- a/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb +++ b/app/controllers/course/assessment/submission/answer/programming/annotations_controller.rb @@ -66,7 +66,7 @@ def create_topic_subscription # Ensure all group managers get a notification when someone adds a programming annotation # to the answer. - answer_course_user = @answer.submission.submission.course_user + answer_course_user = @answer.attempt.submission.course_user answer_course_user.my_managers.each do |manager| @discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/controllers/course/assessment/submission_question/comments_controller.rb b/app/controllers/course/assessment/submission_question/comments_controller.rb index 965aef7bd2..401189e340 100644 --- a/app/controllers/course/assessment/submission_question/comments_controller.rb +++ b/app/controllers/course/assessment/submission_question/comments_controller.rb @@ -34,7 +34,7 @@ def create_topic_subscription @discussion_topic.ensure_subscribed_by(@submission_question.submission.creator) # Ensure all group managers get a notification when someone comments on this submission question - submission_question_course_user = @submission_question.submission.submission.course_user + submission_question_course_user = @submission_question.attempt.submission.course_user submission_question_course_user.my_managers.each do |manager| @discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/jobs/course/assessment/answer/base_auto_grading_job.rb b/app/jobs/course/assessment/answer/base_auto_grading_job.rb index b5d0ed880f..deb5bb952d 100644 --- a/app/jobs/course/assessment/answer/base_auto_grading_job.rb +++ b/app/jobs/course/assessment/answer/base_auto_grading_job.rb @@ -46,7 +46,7 @@ def perform_tracked(answer, redirect_to_path = nil) # `answer.submission` is the Attempt base; EXP (awarder/awarded_at/points_awarded) lives on its # Submission extension. Recompute against the extension (nil for a preview attempt, which has no # EXP and must be skipped). - submission = answer.submission.submission + submission = answer.attempt.submission if submission && update_exp?(submission) Course::Assessment::Submission::CalculateExpService.update_exp(submission) end diff --git a/app/models/concerns/course/assessment/coerces_submission_to_attempt.rb b/app/models/concerns/course/assessment/coerces_submission_to_attempt.rb new file mode 100644 index 0000000000..7d5f5420a9 --- /dev/null +++ b/app/models/concerns/course/assessment/coerces_submission_to_attempt.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +# Lets a caller assign a `Course::Assessment::Submission` (the extension) to a `submission` +# association whose real target is the `Course::Assessment::Attempt` base. The `belongs_to` writer +# strictly checks `record.is_a?(reflection.klass)`, so a `Submission` would otherwise raise +# `ActiveRecord::AssociationTypeMismatch`. Coerce it to its `Attempt` so every caller works +# unchanged. Shared verbatim by Answer, SubmissionQuestion, and QuestionBundleAssignment. +module Course::Assessment::CoercesSubmissionToAttempt + extend ActiveSupport::Concern + + def submission=(value) + value = value.attempt if value.is_a?(Course::Assessment::Submission) + super + end +end diff --git a/app/models/course/assessment/answer.rb b/app/models/course/assessment/answer.rb index 771b5d349e..90ba7a1b43 100644 --- a/app/models/course/assessment/answer.rb +++ b/app/models/course/assessment/answer.rb @@ -57,18 +57,15 @@ class Course::Assessment::Answer < ApplicationRecord # base record: `submission_id` identifies an Attempt row (the base), not a Submission row. belongs_to :submission, class_name: 'Course::Assessment::Attempt', inverse_of: :answers, foreign_key: 'submission_id' + include Course::Assessment::CoercesSubmissionToAttempt + belongs_to :question, class_name: 'Course::Assessment::Question', inverse_of: nil - # Coerce a `Course::Assessment::Submission` passed here into its `Attempt` (the association's real - # target). Several question helpers accept either a `Submission` or an `Attempt` as their - # "submission" argument and thread it straight into `Answer::.new(submission: ...)`; without - # this coercion, passing the `Submission` raises `ActiveRecord::AssociationTypeMismatch`, since - # `belongs_to`'s writer strictly checks `record.is_a?(reflection.klass)`. Coercing here keeps every - # caller working without a per-call-site change. - def submission=(value) - value = value.attempt if value.is_a?(Course::Assessment::Submission) - super - end + # `attempt` is the accurate name for what `:submission` returns — the Attempt base record. Prefer it in + # new code: `answer.attempt.submission` reads clearly where `answer.submission.submission` stuttered (the + # FK column is misleadingly named `submission_id`). Reader-only alias; the association stays `:submission` + # for existing call sites. + alias_method :attempt, :submission belongs_to :grader, class_name: 'User', inverse_of: nil, optional: true has_one :auto_grading, class_name: 'Course::Assessment::Answer::AutoGrading', dependent: :destroy, inverse_of: :answer, autosave: true diff --git a/app/models/course/assessment/answer/programming_ability.rb b/app/models/course/assessment/answer/programming_ability.rb index 37574b4e53..4303f21201 100644 --- a/app/models/course/assessment/answer/programming_ability.rb +++ b/app/models/course/assessment/answer/programming_ability.rb @@ -13,7 +13,7 @@ def allow_create_programming_files can :create_programming_files, Course::Assessment::Answer::Programming do |programming_answer| multiple_file_submission?(programming_answer.question) && creator?(programming_answer.submission) && - can_update_submission?(programming_answer.submission.submission) && + can_update_submission?(programming_answer.attempt.submission) && current_answer?(programming_answer) end end @@ -22,7 +22,7 @@ def allow_destroy_programming_files can :destroy_programming_file, Course::Assessment::Answer::Programming do |programming_answer| multiple_file_submission?(programming_answer.question) && creator?(programming_answer.submission) && - can_update_submission?(programming_answer.submission.submission) && + can_update_submission?(programming_answer.attempt.submission) && current_answer?(programming_answer) end end diff --git a/app/models/course/assessment/question_bundle_assignment.rb b/app/models/course/assessment/question_bundle_assignment.rb index 5ebe6832f2..751e86dbb2 100644 --- a/app/models/course/assessment/question_bundle_assignment.rb +++ b/app/models/course/assessment/question_bundle_assignment.rb @@ -5,21 +5,13 @@ class Course::Assessment::QuestionBundleAssignment < ApplicationRecord foreign_key: :assessment_id, inverse_of: :question_bundle_assignments belongs_to :submission, class_name: 'Course::Assessment::Attempt', optional: true, foreign_key: :submission_id, inverse_of: :question_bundle_assignments + include Course::Assessment::CoercesSubmissionToAttempt + belongs_to :question_bundle, class_name: 'Course::Assessment::QuestionBundle', foreign_key: :bundle_id, inverse_of: :question_bundle_assignments validate :submission_belongs_to_assessment_and_user - # Coerce a `Course::Assessment::Submission` passed here into its `Attempt` (the association's - # real target post-repoint) — mirrors the same coercion on `Course::Assessment::Answer`/ - # `Course::Assessment::SubmissionQuestion` (see `Answer#submission=` for the full rationale). - # `spec/controllers/course/assessment/submission/submissions_controller_spec.rb`'s - # `randomized_submission` fixture passes the `Submission` half directly. - def submission=(value) - value = value.attempt if value.is_a?(Course::Assessment::Submission) - super - end - private def submission_belongs_to_assessment_and_user diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index 8a2687a9ef..5199963043 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -225,7 +225,7 @@ def self.on_dependent_status_change(answer) # `answer.submission` resolves to an Attempt (the association name is `:submission`, its # `class_name` is Attempt). `last_graded_time` is a course-coupled column that lives only on the # real Submission, reached via the attempt's `has_one :submission`. - answer.submission.submission&.last_graded_time = Time.now + answer.attempt.submission&.last_graded_time = Time.now end # Returns an array of submission rows for the given students and assessments. diff --git a/app/models/course/assessment/submission_question.rb b/app/models/course/assessment/submission_question.rb index a0ebd5b8d9..659c9a58fc 100644 --- a/app/models/course/assessment/submission_question.rb +++ b/app/models/course/assessment/submission_question.rb @@ -8,24 +8,20 @@ class Course::Assessment::SubmissionQuestion < ApplicationRecord validates :submission_id, uniqueness: { scope: [:question_id], if: -> { question_id? && submission_id_changed? } } validates :question_id, uniqueness: { scope: [:submission_id], if: -> { submission_id? && question_id_changed? } } - # The underlying column is `submission_id` (renamed from `submission_id`); foreign_key kept explicit - # since the association name stays `submission`. + # Association is `:submission` but its target is the `Attempt` base; the FK column stays + # `submission_id` (the base table/columns were not renamed — additive split). `foreign_key` is + # stated explicitly to document that the column is `submission_id`, not `attempt_id`. belongs_to :submission, class_name: 'Course::Assessment::Attempt', foreign_key: 'submission_id', inverse_of: :submission_questions + include Course::Assessment::CoercesSubmissionToAttempt + belongs_to :question, class_name: 'Course::Assessment::Question', inverse_of: :submission_questions - # Coerce a `Course::Assessment::Submission` passed here into its `Attempt` (the association's - # real target post-repoint) — mirrors the same coercion on `Course::Assessment::Answer` (see its - # comment for the full rationale). Both `spec/factories/course_assessment_submission_questions.rb`'s - # own default `submission { create(:submission, ...) }` and - # `spec/models/course/assessment/submission_spec.rb`'s own `create(:course_assessment_submission_question, - # submission: submission, ...)` pass the `Submission` half, which otherwise raises - # `ActiveRecord::AssociationTypeMismatch`. - def submission=(value) - value = value.attempt if value.is_a?(Course::Assessment::Submission) - super - end + # `attempt` is the accurate name for what `:submission` returns — the Attempt base record. Prefer it in + # new code (e.g. `@submission_question.attempt.submission`); the association stays `:submission` for + # existing call sites. Reader-only alias. + alias_method :attempt, :submission has_many :threads, class_name: 'Course::Assessment::LiveFeedback::Thread', inverse_of: :submission_question, dependent: :destroy diff --git a/app/services/course/assessment/answer/ai_generated_post_service.rb b/app/services/course/assessment/answer/ai_generated_post_service.rb index 3e684ff603..0643e165c3 100644 --- a/app/services/course/assessment/answer/ai_generated_post_service.rb +++ b/app/services/course/assessment/answer/ai_generated_post_service.rb @@ -80,7 +80,7 @@ def create_topic_subscription(discussion_topic) # Ensure the student who wrote the answer amd all group managers # gets notified when someone comments on his answer discussion_topic.ensure_subscribed_by(@answer.submission.creator) - answer_course_user = @answer.submission.submission.course_user + answer_course_user = @answer.attempt.submission.course_user answer_course_user.my_managers.each do |manager| discussion_topic.ensure_subscribed_by(manager.user) end diff --git a/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb b/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb index 2c64625da0..d9fc871f19 100644 --- a/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb +++ b/app/services/course/assessment/answer/programming_codaveri_async_feedback_service.rb @@ -153,7 +153,7 @@ def create_topic_subscription(discussion_topic) # Ensure all group managers get a notification when someone adds a programming annotation # to the answer. - answer_course_user = @answer.submission.submission.course_user + answer_course_user = @answer.attempt.submission.course_user answer_course_user.my_managers.each do |manager| discussion_topic.ensure_subscribed_by(manager.user) end