Skip to content

Rework timed submissions, support block / force submit at deadline - #8550

Open
adi-herwana-nus wants to merge 4 commits into
masterfrom
adi/timed-and-late-submission-rework
Open

Rework timed submissions, support block / force submit at deadline#8550
adi-herwana-nus wants to merge 4 commits into
masterfrom
adi/timed-and-late-submission-rework

Conversation

@adi-herwana-nus

@adi-herwana-nus adi-herwana-nus commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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:

  1. feat(submissions): add flag to allow/block students from submissions past deadline — the toggle, data model, and enforcement.
  2. feat(submissions): rework backend implementation for time-based force-submission — unify deadline + time-limit force submission; scheduling; fail-safe.
  3. feat(submissions): adapt frontend controls for force-submitted submissions — client force-submit timer, countdown banner.
  4. 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 LATE in grading. Instructors wanted a hard cut-off. Rather than bolt on a second enforcement
path, we noticed the existing timed-assessment feature already force-submits at time_limit, and
unified both under one model.

Data: is the end date / time limit actually a binding constraint?

Query over existing production-shaped data (184 timed assessments, 4096 submitted timed
submissions), comparing each submission's submitted_at − created_at against its time_limit:

Bucket Share
Submitted within the time limit 59.6%
Submitted in the 5-minute grace window (time_limittime_limit + 5m) 29.0%
Submitted beyond time_limit + 5m 11.5%
  • Median submission uses 99% of the time limit (p50 ratio 0.99) — students work right up to it, so
    the cut-off is real and heavily exercised.
  • 29% land in the 5-minute grace window. This directly validates the grace-period design (below):
    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.
  • The 11.5% "beyond grace" is dominated by abandoned-then-resumed attempts and lost force-submit jobs
    (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, default true — 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 of
    truth, 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_submit scope.

Jobs

  • ForceSubmitTimedSubmissionJob — now finalises via submission.force_submit! and re-checks live
    state
    (attempting?, force_submit_at > now) before acting; no token argument.
  • ScheduleExpiringSubmissionsJob (hourly cron) — schedules precise future-dated force-submit jobs for
    submissions whose force-submit time is imminent and not yet scheduled; reschedules on end-date /
    personal-time change; recovers lost jobs.

Enforcement

  • Ability (define_student_assessment_permissions): cannot :update/:submit_answer past the edit grace.
  • SubmissionsController: check_submission_deadline! (edit, grace), a strict end-date check in
    authorize_assessment! (create), and force_submit_if_overdue! before authorization (edit fail-safe).
  • LATE flag: never flagged when late submissions are disallowed; comparison fixed to Time-vs-Time.

Client

  • Server sends forceSubmitRemainingTime; the timer and countdown banner anchor it to the local clock.
  • Edit page arms setTimerForForceSubmission (cleanup on unmount) and shows the countdown banner
    (hidden > 24h).
  • Assessment form: the toggle (disabled without an end date / on Koditsu).
  • Closed state: a disabled "Attempt" button with a tooltip; an instructor settings row (shown only when
    an end date is set).

i18n — new keys hand-added to locales/{en,ko,zh}.json and Rails config/locales/{en,ko,zh}.


Key decisions

  1. 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 "no
    edits 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).

  2. :attempt ability left intact. Blocking it also revokes read_material, so a past-end-date
    student 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.

  3. Unified time-limit + end-date force submission behind force_submit_at. One code path, one
    source of truth. Also the natural extension point for future per-student time limits — it becomes a
    change to force_submit_at alone.

  4. 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-submit finalise just after
    the 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).

  5. 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 > now re-check. The real gap was rescheduling after
    an end-date change, which a per-assessment token can't do (and it misses per-student personal-time
    changes). force_submit_scheduled_at records the time a job was scheduled for; the sweep reschedules
    when it no longer matches, and dedups to zero otherwise — important for timed exams where every
    submission is attempting near the deadline.

  6. 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.

  7. Unsubmitted submissions are exempt from enforcement and force submission (staff explicitly
    permitted the redo). Tracked with a dedicated unsubmitted_at column — not submitted_at, which
    unsubmit nils, and which learning_rate_concern / counts_concern read as "was submitted" without
    a workflow filter, so retaining it would skew statistics.

  8. 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.

  9. 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.

  10. Terminology: "end date", not "deadline" — matches the existing End At field.

Pre-existing bugs fixed along the way

  • late compared a String (submitted_at.iso8601) to a TimeWithZone (end_at) — worked only via
    opaque coercion; now a clean Time comparison.
  • The TimeLimitBanner.seconds message shared minutesSeconds's message id (so the sub-minute
    countdown resolved the wrong string); given its own id.

Testing

  • Ruby: model, ability, controller, and job specs for the deadline predicates, strict-vs-grace
    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 late flag. Regression suites green.
  • Client: jest for getForceSubmitRemainingTime, the force-submit timer (fires on expiry; disarmed
    on navigate-away
    ), and the closed-state button (disabled, no link, tooltip on hover). tsc + eslint clean.
  • Rails 8 compatibility verified (the branch rebased across a 7.2→8.0 upgrade).

Notes / follow-ups

  • FORCE_SUBMIT_SCHEDULING_HORIZON (6h), SCHEDULE_WINDOW (horizon + 30m), and the hourly cron are
    tunable dials.
  • The closed "Attempt" button uses the strict end date (appears exactly at the end date), consistent
    with strict creation.
  • The token-based closing/opening reminder jobs still schedule months-ahead wait_until jobs; the
    sweep-plus-short-horizon pattern established here is the argument for migrating those too (separate PR).

…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_allowed to 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 forceSubmitRemainingTime for 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.

Comment thread app/views/course/assessment/submission/submissions/_submission.json.jbuilder Outdated
Comment thread app/views/course/assessment/submission/submissions/edit.json.jbuilder Outdated
…-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
@adi-herwana-nus
adi-herwana-nus force-pushed the adi/timed-and-late-submission-rework branch from 8a651e8 to e46b308 Compare August 16, 2026 04:08
- disable button for students if assessment is closed
- wording pass: standardize "deadline" to "end_at"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with ArgumentError, 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 WarningDialog still calculates its message from attemptedAt + 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 consume forceSubmitRemainingTime as 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_atassessment.time_for, which queries personal_times and reference_times unless loaded; Course::LessonPlan::Item#time_for explicitly 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)

Comment thread db/migrate/20260804222525_add_late_submission_support.rb
Comment thread app/models/course/assessment/assessment_ability.rb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants