Rework timed submissions, support block / force submit at deadline - #8550
Rework timed submissions, support block / force submit at deadline#8550adi-herwana-nus wants to merge 4 commits into
Conversation
…past deadline - added flag column, schema migration, jbuilder specs, and assessment edit form controls
…sions - (fix) make force-submission toggle bound to the submission form, so that it will not trigger when user navigates away - make submission deadline backend-driven based on time limit and allow late submission controls - refactor TimeLimitBanner to use moment formatting and hide for periods > 24 hours
There was a problem hiding this comment.
Pull request overview
This PR reworks timed/deadline-enforced submissions to support “block edits / force-submit at deadline”, including a periodic sweep to (re-)schedule force-submit jobs and client-side timers based on a server-provided remaining duration.
Changes:
- Add
is_late_submission_allowedto assessments and deadline-aware logic (submission_deadline_for,submission_deadline_passed_for?) to block creation/edits when late submissions are disallowed. - Implement force-submit scheduling: per-submission force-submit time (
force_submit_at), grace period, creation-time scheduling horizon, periodic sweep job, and a force-submit job that re-checks live state. - Update client submission UI to use a server-provided
forceSubmitRemainingTimefor countdown + auto-finalise, and add corresponding translations/tests.
Reviewed changes
Copilot reviewed 37 out of 37 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/models/course/assessment/submission_force_submit_spec.rb | Model specs for force-submit timing, overdue logic, and job scheduling behavior. |
| spec/models/course/assessment/late_submission_spec.rb | Specs for new assessment deadline helper methods. |
| spec/models/course/assessment/late_submission_ability_spec.rb | Ability specs around blocking edits past deadline and unsubmit exemptions. |
| spec/jobs/course/assessment/submission/schedule_expiring_submissions_job_spec.rb | Specs for periodic sweep scheduling/rescheduling logic. |
| spec/jobs/course/assessment/submission/force_submit_timed_submission_job_spec.rb | Specs for force-submit job behavior and safety checks. |
| spec/controllers/course/assessment/submission/late_submission_spec.rb | Controller specs for create/update/edit behavior past deadlines and fail-safe force-submit. |
| db/schema.rb | Schema updates for new columns and assessment flag. |
| db/migrate/20260804222525_add_late_submission_support.rb | Migration adding late-submission flag + submission tracking columns. |
| config/schedule.yml | Cron schedule for the expiring-submissions sweep job. |
| config/locales/en/course/assessment/submission/submissions.yml | New server-side error message for deadline-passed cases. |
| client/locales/zh.json | Client i18n additions for late-submission toggle + time banner seconds format. |
| client/locales/ko.json | Client i18n additions for late-submission toggle + time banner seconds format. |
| client/locales/en.json | Client i18n additions for late-submission toggle + time banner seconds format. |
| client/app/bundles/course/assessment/submission/utils/timer.ts | Client timer now uses server-provided remaining duration for force-submit. |
| client/app/bundles/course/assessment/submission/utils/test/timer.test.ts | Jest tests for new timer helpers/behavior. |
| client/app/bundles/course/assessment/submission/types.ts | Add isLateSubmissionAllowed + forceSubmitRemainingTime to client state typing. |
| client/app/bundles/course/assessment/submission/translations.ts | Fix/extend time banner translation IDs (seconds). |
| client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/TimeLimitBanner.tsx | Countdown banner now driven by force-submit remaining time and hidden >24h. |
| client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx | Use new force-submit timer setup/teardown in the submission form. |
| client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx | Pass forceSubmitRemainingTime through Redux mapping and banner rendering. |
| client/app/bundles/course/assessment/pages/AssessmentsIndex/NewAssessmentFormButton.jsx | Default is_late_submission_allowed for new assessments. |
| client/app/bundles/course/assessment/components/AssessmentForm/useFormValidation.tsx | Add yup validation for is_late_submission_allowed. |
| client/app/bundles/course/assessment/components/AssessmentForm/translations.ts | Add translations for the late-submission toggle and hint text. |
| client/app/bundles/course/assessment/components/AssessmentForm/index.tsx | Add form checkbox to control late-submission behavior. |
| app/views/course/assessment/submission/submissions/edit.json.jbuilder | Expose isLateSubmissionAllowed and forceSubmitRemainingTime in submission edit payload. |
| app/views/course/assessment/submission/submissions/_submission.json.jbuilder | Adjust “late” flag behavior when late submissions are disallowed. |
| app/views/course/assessment/assessments/edit.json.jbuilder | Include is_late_submission_allowed in assessment edit payload. |
| app/models/course/assessment/submission.rb | Add force-submit scheduling, timing helpers, overdue detection, and force-submit action. |
| app/models/course/assessment/assessment_ability.rb | Add student restrictions on editing past enforced deadline. |
| app/models/course/assessment.rb | Add late-submission flag validation, scope, and deadline helper methods. |
| app/models/concerns/course/assessment/submission/workflow_event_concern.rb | Track/clear unsubmitted_at during workflow events. |
| app/jobs/course/assessment/submission/schedule_expiring_submissions_job.rb | New periodic sweep job to schedule/reschedule force-submits. |
| app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb | Update force-submit job to re-check due-ness and call force_submit!. |
| app/controllers/course/assessment/submission/submissions_controller.rb | Add deadline enforcement on create/update and edit fail-safe force-submit. |
| app/controllers/course/assessment/submission/answer/answers_controller.rb | Add deadline guard for answer update/submit routes. |
| app/controllers/course/assessment/assessments_controller.rb | Permit is_late_submission_allowed in assessment params. |
| app/controllers/concerns/course/assessment/submission_concern.rb | Add reusable check_submission_deadline! guard. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…-submission - force submit any past-due submission with deadline when opened by submission creator - block student from updating past-due submissions with deadline - add new cron job to schedule force-submissions for assessments with deadlines in > 6 hours
8a651e8 to
e46b308
Compare
- disable button for students if assessment is closed - wording pass: standardize "deadline" to "end_at"
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 47 out of 47 changed files in this pull request and generated 2 comments.
Suppressed comments (5)
app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb:13
- Existing delayed jobs were enqueued by the removed code with three arguments
(assessment, id, creator). After deployment, any such job still in the queue will call this two-argument method and fail withArgumentError, so timed attempts may not be finalised until the hourly recovery sweep runs. Keep an optional legacy third argument until old jobs have drained.
def perform_tracked(assessment, submission_id)
app/models/course/assessment.rb:296
- This predicate applies the new deadline to every course user, although the feature and permission changes are student-only. Staff are explicitly allowed to attempt assessments and the create controller exempts managers, but their submissions still receive deadline jobs here and the action serializer reports them as
closed. Return no enforced deadline for non-students so staff attempts are not force-submitted or hidden after the end date.
def submission_deadline_for(course_user)
return nil if is_late_submission_allowed
time_for(course_user).end_at
end
app/views/course/assessment/submission/submissions/edit.json.jbuilder:30
- The new server-derived deadline is used by the banner and submit timer, but
WarningDialogstill calculates its message fromattemptedAt + timeLimit. When an assessment end date is earlier than its time limit, the entry dialog therefore tells the student they have the full time limit even though this payload will force-submit them sooner. Update the dialog to consumeforceSubmitRemainingTimeas the same source of truth.
force_submit_at = @submission.attempting? ? @submission.force_submit_at : nil
json.forceSubmitRemainingTime force_submit_at && ((force_submit_at - Time.zone.now) * 1000).round
client/app/bundles/course/assessment/components/AssessmentForm/index.tsx:351
- Disabling this checkbox for Koditsu does not force its retained form value back to
true. An instructor can disable late submissions and then enable Koditsu (or edit such an assessment later); the false value remains submitted, while the backend still enforces it and can close/force-submit the local attempt despite the comment that this cannot be enforced on Koditsu. Reset the value when Koditsu is enabled and enforce the invariant server-side.
// The toggle only has an effect with a deadline, and cannot be enforced on Koditsu.
disabled={disabled || !endAt || isKoditsuAssessmentEnabled}
app/jobs/course/assessment/submission/schedule_expiring_submissions_job.rb:35
- This bulk sweep does not preload the lesson-plan timing associations. Each enforced-deadline candidate calls
force_submit_at→assessment.time_for, which queriespersonal_timesandreference_timesunless loaded;Course::LessonPlan::Item#time_forexplicitly makes bulk callers responsible for preloading them (app/models/course/lesson_plan/item.rb:120-125). The hourly global sweep therefore introduces an N+1 query pattern; preload those timing associations before iterating.
Course::Assessment::Submission.with_attempting_state.
joins(:assessment).merge(Course::Assessment.to_force_submit).
where(unsubmitted_at: nil).
includes(:assessment, experience_points_record: :course_user)
Adds a per-assessment "Allow submissions after end date" toggle. When disabled, students can no
longer create or edit submissions past the end date — any in-progress attempt is force-submitted
at the end date instead of merely being flagged late. Along the way this reworks the existing
timed-assessment force-submission onto one unified, more robust mechanism.
Four commits:
feat(submissions): add flag to allow/block students from submissions past deadline— the toggle, data model, and enforcement.feat(submissions): rework backend implementation for time-based force-submission— unify deadline + time-limit force submission; scheduling; fail-safe.feat(submissions): adapt frontend controls for force-submitted submissions— client force-submit timer, countdown banner.feat(submissions): notify users of attempt block in assessment UI— closed-state button, instructor settings row, terminology.Why
Previously a student could keep attempting and submitting after the end date; the submission was just
marked
LATEin grading. Instructors wanted a hard cut-off. Rather than bolt on a second enforcementpath, we noticed the existing timed-assessment feature already force-submits at
time_limit, andunified both under one model.
Data: is the end date / time limit actually a binding constraint?
Query over existing production-shaped data (
184timed assessments,4096submitted timedsubmissions), comparing each submission's
submitted_at − created_atagainst itstime_limit:time_limit→time_limit + 5m)time_limit + 5mthe cut-off is real and heavily exercised.
nearly a third of timed submissions finalise just after the limit — the client force-submit firing,
or a genuine last-second save. A hard cut-off at the exact instant would reject or lose these.
(of those, ~70% are within 2× the limit; the rest are pathological, up to 2817×). The old
after_create-only scheduling never re-enforced these — motivating the sweep + fail-safe here.What changed
Data model (
AddLateSubmissionSupport, one migration)course_assessments.is_late_submission_allowed(boolean, defaulttrue— existing behaviour preserved).course_assessment_submissions.unsubmitted_at— marks a staff-permitted redo (see decision 6).course_assessment_submissions.force_submit_scheduled_at— the sweep's dedup/reschedule marker.Model (
Course::Assessment,Course::Assessment::Submission)submission_deadline_for/submission_deadline_passed_for?(strict) /editing_deadline_passed_for?(with grace) — the enforcement predicates, personalised-timeline aware.
force_submit_at= the earlier of time-limit expiry and effective end date — the single source oftruth, nil when never force-submitted or when unsubmitted.
force_submit_job_at,force_submit_overdue?,force_submit!,schedule_force_submission,enqueue_force_submit_job,force_submit_job_scheduled?.Course::Assessment.to_force_submitscope.Jobs
ForceSubmitTimedSubmissionJob— now finalises viasubmission.force_submit!and re-checks livestate (
attempting?,force_submit_at > now) before acting; no token argument.ScheduleExpiringSubmissionsJob(hourly cron) — schedules precise future-dated force-submit jobs forsubmissions whose force-submit time is imminent and not yet scheduled; reschedules on end-date /
personal-time change; recovers lost jobs.
Enforcement
define_student_assessment_permissions):cannot :update/:submit_answerpast the edit grace.SubmissionsController:check_submission_deadline!(edit, grace), a strict end-date check inauthorize_assessment!(create), andforce_submit_if_overdue!before authorization (edit fail-safe).LATEflag: never flagged when late submissions are disallowed; comparison fixed to Time-vs-Time.Client
forceSubmitRemainingTime; the timer and countdown banner anchor it to the local clock.setTimerForForceSubmission(cleanup on unmount) and shows the countdown banner(hidden > 24h).
an end date is set).
i18n — new keys hand-added to
locales/{en,ko,zh}.jsonand Railsconfig/locales/{en,ko,zh}.Key decisions
Force submission is the enforcement, not just a disabled form. Once a submission leaves
attempting, existing ability rules make it read-only — so finalising at the end date enforces "noedits after end date" for free, and preserves the student's in-progress answers (the client submits
them with the finalise). Mirrors the timed-assessment dual mechanism (client timer + server backstop).
:attemptability left intact. Blocking it also revokesread_material, so a past-end-datestudent could no longer even view the assessment or their own submission. Instead edits are blocked
by
cannot :update(students only) and creation by an explicit controller check.Unified time-limit + end-date force submission behind
force_submit_at. One code path, onesource of truth. Also the natural extension point for future per-student time limits — it becomes a
change to
force_submit_atalone.Grace for editing, strict for creation. Editing an existing attempt is allowed until
end_date + FORCE_SUBMIT_DELAY(5 min): the client fires its own force-submitfinalisejust afterthe end date (carrying the latest answers), and a hard cut-off would 403 that request and lose work —
the 29% data point above. Creating a new submission is strict (no in-progress work to preserve).
No dedup token; a per-submission marker instead. The token's "invalidate a stale job" role is
already covered by the job's
force_submit_at > nowre-check. The real gap was rescheduling afteran end-date change, which a per-assessment token can't do (and it misses per-student personal-time
changes).
force_submit_scheduled_atrecords the time a job was scheduled for; the sweep rescheduleswhen it no longer matches, and dedups to zero otherwise — important for timed exams where every
submission is attempting near the deadline.
Cron pre-schedules precise jobs; it is not an "overdue sweep". Firing a future-dated job at the
exact moment preserves minute-granularity; a sweep that force-submits inline every N minutes would
lose it. The marker makes a wide look-ahead window and an infrequent (hourly) run safe.
Unsubmitted submissions are exempt from enforcement and force submission (staff explicitly
permitted the redo). Tracked with a dedicated
unsubmitted_atcolumn — notsubmitted_at, whichunsubmitnils, and whichlearning_rate_concern/counts_concernread as "was submitted" withouta workflow filter, so retaining it would skew statistics.
The edit-page fail-safe is creator-only. An instructor or admin opening a student's in-progress
attempt must not force-submit it on the student's behalf.
Remaining duration, not an absolute timestamp, is sent to the client and anchored to the local
clock at the timer/banner (a side-effect at the leaf, not in the reducer). A skewed client clock then
can't fire the force-submit early (cutting a student short) or late.
Terminology: "end date", not "deadline" — matches the existing End At field.
Pre-existing bugs fixed along the way
latecompared aString(submitted_at.iso8601) to aTimeWithZone(end_at) — worked only viaopaque coercion; now a clean Time comparison.
TimeLimitBanner.secondsmessage sharedminutesSeconds's message id (so the sub-minutecountdown resolved the wrong string); given its own id.
Testing
enforcement, the edit fail-safe (creator-only + unsubmitted exemption), the scheduler (schedule /
reschedule-on-change / dedup / lost-job recovery / unsubmitted exclusion), the force-submit job's
re-check, and the
lateflag. Regression suites green.getForceSubmitRemainingTime, the force-submit timer (fires on expiry; disarmedon navigate-away), and the closed-state button (disabled, no link, tooltip on hover). tsc + eslint clean.
Notes / follow-ups
FORCE_SUBMIT_SCHEDULING_HORIZON(6h),SCHEDULE_WINDOW(horizon + 30m), and the hourly cron aretunable dials.
with strict creation.
wait_untiljobs; thesweep-plus-short-horizon pattern established here is the argument for migrating those too (separate PR).