Skip to content

Store assignment due dates in UTC - #1324

Open
bnmnetp wants to merge 3 commits into
mainfrom
duedate-utc
Open

Store assignment due dates in UTC#1324
bnmnetp wants to merge 3 commits into
mainfrom
duedate-utc

Conversation

@bnmnetp

@bnmnetp bnmnetp commented Jul 28, 2026

Copy link
Copy Markdown
Member

assignments.duedate has always held a naive datetime in course-local wall clock time, while visible_on, hidden_on and every answer timestamp are naive UTC. Every grading path had to convert before comparing — and several didn't. This stores the due date in UTC and converts only for display.

Three commits: the storage change, the display change, then a migration guard.

Why this is worth the churn

Three latent bugs fall out of the inconsistency, all fixed here:

where bug
grading_helpers/regrade.py _effective_deadline compared a course-local due date directly against naive-UTC answer timestamps. Batch regrades used a cutoff off by the course's UTC offset.
lti1p3/core.py pushed duedate.isoformat() to the LMS with no offset, so the LMS was free to read it as its own local time.
admin_server_api/routers/instructor.py copy-assignment subtracted a timezone-less term-start midnight from the due date. Once duedate is UTC the operands are in different frames — a Chicago course copying a 23:59 assignment landed on 00:59 the next day.

There is also a standing TODO: end this craziness by storing the deadline in UTC in the web2py code that this resolves.

Migration (c4e8a1f7b2d9)

Shifts duedate using courses.timezone, and backfills NULL timezones to 'UTC'.

The timezone column was added in 8f857bdfef19 without a backfill, so most legacy courses are NULL and have no defensible source timezone — those rows are left byte-identical. On the dev database that was 27 of 35 courses.

  • Courses that were backfilled are recorded in duedate_utc_tz_backfill, so downgrade() restores NULL for exactly those and not for courses that already said 'UTC'.
  • downgrade() recomputes local time from the stored UTC rather than restoring a snapshot, so assignments created or edited after the upgrade convert correctly instead of being clobbered.
  • A pre-flight check rejects unrecognized courses.timezone values with a message naming the offending courses, instead of letting AT TIME ZONE abort mid-statement. Courses with a bad timezone but no assignments don't block.
  • A second pre-flight refuses to run while any NULL-timezone course still has a future due date (see below). DUEDATE_UTC_ALLOW_NULL_TIMEZONE=1 overrides.

Display

Due dates render on the reader's own clock, so a travelling student never converts a deadline by hand. course_datetime_tag() emits

<time datetime="2026-09-02T04:59:00Z" data-rs-localize="long"
      title="Sep 01, 2026 11:59 PM CDT course time">Sep 01, 2026 11:59 PM CDT</time>

and staticAssets/js/localize-times.js rewrites the text. Without JS the page still shows a correct, timezone-labelled time; course time stays in the tooltip. The zone abbreviation is always shown, so no displayed time is ambiguous.

Instructor analytics stay course-local — they show a date with no time, and a browser-local shift can move a late-evening deadline onto the neighbouring day in a report read against the course calendar.

An instructor authoring from a different timezone than the course gets a warning under the due date field showing the chosen instant in course time, so "11:59 PM" typed in Chicago for a Los Angeles course doesn't quietly become 9:59 PM for the students.

Please look closely at

  1. The NULL-timezone decision. Treating NULL as UTC means those courses' due dates don't move. The alternative — guessing a timezone — seemed worse, but it's the call with the widest blast radius.

    In practice this is now a small, bounded set. Every course created in the last year has a timezone, and instructors can set their own, so the NULL courses are overwhelmingly dormant ones whose deadlines are long past — where leaving the value alone has no effect at all. On the dev database only 6 of 27 NULL-timezone courses owned any assignments; the rest were irrelevant to the migration entirely. A production check turned up only a handful, which will be dealt with before deploy.

    The residual risk was a course created more than a year ago that is still running — long-running, self-paced or multi-term — since it would still be NULL and still have live deadlines. The migration now blocks on exactly that case rather than leaving it to the runbook:

    Cannot convert assignment due dates to UTC: these courses have deadlines in the
    future but no timezone set: course 24 ('PTXSB') latest due 2026-08-07 00:55:00.
    Set courses.timezone for them and re-run -- their due dates will then convert
    correctly. To proceed anyway and have their deadlines treated as UTC, set
    DUEDATE_UTC_ALLOW_NULL_TIMEZONE=1.
    

    This matters because fixing such a course is much cheaper before the migration than after: setting courses.timezone first converts it correctly along with everyone else, whereas afterwards its due dates are fixed instants and nothing records which zone they were meant to be in. Note also that setting a course timezone after the migration will not retroactively reinterpret existing due dates — it affects display and future deadlines only.

  2. _term_start_utc in admin_server_api/routers/instructor.py. The copy-assignment date maths is the subtlest change here.

  3. The per-page localize include. PreTeXt books ship their own _base.html and get_jinja_templates() searches the book directory first, so a base-template include silently vanished for exactly those courses. It's now included per page — any new page using course_datetime_tag() must include common/localize_times.html. That's the one part of the design that isn't self-enforcing.

Deploying

Set courses.timezone on any still-running course that lacks one first — the migration will refuse to run otherwise and tell you which ones.

The migration and the code must ship together. A shifted duedate read by unconverted code (or the reverse) mis-grades. Single migration, single deploy, low-traffic window, and confirm no timed exams are in flight — one that starts before the cutover and ends after it straddles the shift.

Rollback is alembic downgrade -1 plus rolling the code back in the same window.

web2py is deliberately not converted — it's being retired. Until then its pages subtract the browser tz_offset on the assumption the due date is course-local, so they'll show deadlines shifted by the course's UTC offset. /runestone/dashboard/grades is the one still-live surface (linked from templates/author/logfiles.html:21). Grades computed by the FastAPI path are unaffected.

Testing

Python 181 → 235, JS 2124 → 2130. Green in UTC, America/Chicago, Asia/Tokyo, Pacific/Kiritimati (+14) and Pacific/Midway (−11).

New coverage includes the migration round trip run against a real database in a rolled-back transaction, the copy-between-terms regression, and the first LTI 1.3 tests in the repo (only lti1p1 existed).

Verified in the running app after rebuilding book, assignment, admin and applying the migration to the dev database — 7 assignments shifted, 27 courses backfilled:

  • every due date renders at its original wall clock
  • the same assignment reads Apr 24, 2026, 12:36 PM PDT to a Los Angeles browser and Apr 25, 2026, 04:36 AM GMT+9 to a Tokyo one
  • the is_assigned scoring boundary, checked by moving a due date either side of now: due in 1h + enforce_due → assigned; due 1h ago + enforce_due → not assigned; due 1h ago without → assigned

🤖 Generated with Claude Code

https://claude.ai/code/session_013xK9HD5NSi1EAWY3cSJPmC

bnmnetp and others added 2 commits July 27, 2026 19:18
assignments.duedate held a naive datetime in course-local wall clock time,
while visible_on, hidden_on and every answer timestamp are naive UTC. Every
grading path had to convert before comparing, and several forgot to. Store
the due date in UTC and convert only for display.

Migration (c4e8a1f7b2d9) shifts duedate using courses.timezone, and backfills
NULL timezones to 'UTC'. The timezone column was added in 8f857bdfef19 without
a backfill, so most legacy courses are NULL and have no defensible source
timezone; those rows are left byte-identical. Courses that were backfilled are
recorded in duedate_utc_tz_backfill so downgrade() restores NULL for exactly
those. downgrade() recomputes local time from the stored UTC rather than
restoring a snapshot, so rows created or edited after the upgrade convert
correctly. A pre-flight check rejects unrecognized timezone values with an
actionable message instead of letting AT TIME ZONE abort mid-statement.

Fixes three latent bugs:

- regrade.py::_effective_deadline compared a course-local due date directly
  against naive-UTC answer timestamps, so batch regrades used a cutoff that
  was off by the course's UTC offset.
- lti1p3/core.py pushed duedate.isoformat() to the LMS with no offset, so the
  LMS was free to read it as its own local time.
- admin instructor.py::_copy_one_assignment subtracted a timezone-less term
  start midnight from the due date. Once duedate is UTC the two operands are
  in different frames; a Chicago course copying a 23:59 assignment landed on
  00:59 the next day. Term starts are now anchored in the course timezone.

Display converts back to course-local via the new course_datetime Jinja
filter, with the timezone abbreviation shown. Every server now builds
templates through get_shared_templates() so the filter is always registered.
The React builder switches to the UTC date helpers; since due dates were the
only consumer of the local ones, DateTimePicker's utc prop and four now-dead
helpers are removed.

web2py is deliberately not converted -- it is being retired, and its pages
will show deadlines shifted by the course's UTC offset until then.

Tests: 181 -> 225, covering the migration round trip against a real database,
the copy-between-terms regression, the LTI 1.3 due date exchange (the first
LTI 1.3 tests in the repo), and course-local formatting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xK9HD5NSi1EAWY3cSJPmC
…mismatch

Deadlines were rendered in the course timezone. A student travelling, or
simply enrolled from elsewhere, then had to convert the deadline themselves,
which is exactly the situation where a mistake is most costly. Render them on
the reader's own clock instead, which also makes the server pages agree with
the React builder rather than each using a different frame.

The server cannot know the browser timezone, so course_datetime_tag() emits

  <time datetime="<utc>" data-rs-localize="long" title="<course time>">

with the course-local time as the text, and staticAssets/js/localize-times.js
rewrites that text on load. Without JavaScript the page still shows a correct,
timezone-labelled time; the course time stays in the title so a student
comparing notes with classmates or an instructor can see the frame the course
itself uses. The timezone abbreviation is always shown, so no displayed time is
ambiguous about which clock it means.

The script is included per page rather than from _base.html. PreTeXt books ship
their own _base.html and get_jinja_templates() searches the book directory
first, so a base-template include silently vanished for exactly those courses
-- caught only because the local dev course is PreTeXt.

Instructor analytics stay course-local: they show a date with no time, and a
browser-local shift can move a late-evening deadline onto the neighbouring day
in a report being read against the course calendar. format_course_datetime()
keeps that job plus the fallback and tooltip.

An instructor authoring from a different timezone than the course now gets a
warning under the due date field showing what the chosen instant is in course
time, so "11:59 PM" typed in Chicago for a Los Angeles course does not quietly
become 9:59 PM for the students. The comparison ignores zones that merely have
different names but the same offset, and uses the offset in effect at the
chosen instant so a DST boundary does not mislead.

Verified in the running app: the same assignment renders as
"Apr 24, 2026, 12:36 PM PDT" to a Los Angeles browser and
"Apr 25, 2026, 04:36 AM GMT+9" to a Tokyo one, with course time in the
tooltip; the warning appears for Chicago and Tokyo instructors and stays
hidden for a Los Angeles one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xK9HD5NSi1EAWY3cSJPmC
Copilot AI review requested due to automatic review settings July 28, 2026 00:47

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 standardizes assignments.duedate storage to naive UTC (matching other timestamps in the schema) and updates rendering so deadlines are displayed on the viewer’s local clock (with a correct, course-time fallback when JS is unavailable). This removes multiple grading/LTI/copy-assignment inconsistencies caused by mixing course-local naive datetimes with naive-UTC timestamps.

Changes:

  • Adds an Alembic migration to shift stored due dates to UTC based on courses.timezone, with a backfill table to support a correct downgrade.
  • Introduces shared datetime formatting/rendering helpers (format_course_datetime, course_datetime_tag) and a JS localizer to render <time> elements in the viewer’s timezone.
  • Updates grading helpers, LTI 1.3 due date exchange, analytics/reporting, and assignment-copy date math to operate in UTC consistently, with new regression tests.

Reviewed changes

Copilot reviewed 53 out of 56 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/migrations/test_duedate_to_utc.py End-to-end migration roundtrip tests (upgrade/downgrade) against a real DB connection.
test/components/rsptx/templates/test_core.py Unit tests for new datetime formatting and <time> tag rendering behavior.
test/components/rsptx/lti1p3/test_duedate_exchange.py Tests for LTI 1.3 due date ingest/push semantics with offsets vs naive timestamps.
test/components/rsptx/lti1p3/init.py Package init for LTI 1.3 tests.
test/components/rsptx/grading_helpers/test_regrade_batch.py Regression tests pinning _effective_deadline to naive UTC behavior.
test/components/rsptx/grading_helpers/test_core.py Updates deadline/late-submission tests for UTC-stored due dates.
test/bases/rsptx/admin_server_api/test_copy_assignment_dates.py Tests for copy-assignment date arithmetic across timezones/DST boundaries.
test/bases/rsptx/admin_server_api/test_analytics.py Tests for course-local date-only rendering in analytics with UTC-stored due dates.
migrations/versions/c4e8a1f7b2d9_duedate_to_utc.py Alembic migration shifting assignments.duedate to naive UTC with timezone backfill tracking.
components/rsptx/templates/staticAssets/js/localize-times.js Client-side rewrite of server-rendered <time data-rs-localize> into viewer-local time.
components/rsptx/templates/core.py Adds timezone resolution + format_course_datetime + course_datetime_tag, and installs shared filters/globals.
components/rsptx/templates/common/localize_times.html Shared per-page include for the localize-times JS.
components/rsptx/templates/common/ebook_config.html Exposes eBookConfig.courseTimezone for instructor-side timezone mismatch warnings.
components/rsptx/templates/book/course/current_course.html Includes localize-times JS on current course page.
components/rsptx/templates/assignment/student/peer_student.html Uses course_datetime_tag for due dates and includes localize-times JS.
components/rsptx/templates/assignment/student/doAssignment.html Uses course_datetime_tag for “Due:” and includes localize-times JS.
components/rsptx/templates/assignment/student/chooseAssignment.html Includes localize-times JS on assignment chooser page.
components/rsptx/templates/assignment/student/assignment_block.html Renders assignment due dates via course_datetime_tag in list rows.
components/rsptx/templates/assignment/instructor/peer_instructor.html Uses course_datetime_tag for due dates and includes localize-times JS.
components/rsptx/templates/init.py Re-exports shared template helpers/formatters.
components/rsptx/lti1p3/core.py Ensures pushed LMS due dates carry explicit UTC offset (Z).
components/rsptx/grading_helpers/regrade.py Documents and enforces _effective_deadline returning naive UTC.
components/rsptx/grading_helpers/core.py Removes course-timezone conversion paths; compares naive UTC directly.
components/rsptx/exceptions/core.py Switches error-page template rendering to shared templates (filters/globals installed).
components/rsptx/db/crud/scoring.py Removes course-local “now” computations; uses canonical UTC now for comparisons.
bases/rsptx/book_server_api/routers/rslogging.py Drops passing timezone into grading; grading now operates in UTC.
bases/rsptx/book_server_api/routers/course.py Removes browser tz-offset sorting logic; sorts using UTC due dates vs UTC now.
bases/rsptx/book_server_api/routers/books.py Removes timezone-derived branch in reading assignment spec lookup (now uses UTC comparisons).
bases/rsptx/author_server_api/main.py Switches to shared templates so new filters/globals are available everywhere.
bases/rsptx/assignment_server_api/routers/student.py Removes tz-offset logic, uses shared templates, and renders date-only due dates in course-local frame where needed.
bases/rsptx/assignment_server_api/routers/peer.py Switches to shared templates to ensure datetime helpers are available in peer pages.
bases/rsptx/assignment_server_api/routers/instructor.py Uses shared templates and adds _term_start_utc to preserve copy offsets across DST/timezones.
bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.ts Removes “local naive” due-date helpers; treats stored datetimes as naive UTC consistently.
bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.spec.ts Updates tests to reflect removal of local-naive date helpers.
bases/rsptx/assignment_server_api/assignment_builder/src/utils/courseTimezone.ts Adds browser-vs-course timezone mismatch detection + formatting helper.
bases/rsptx/assignment_server_api/assignment_builder/src/utils/courseTimezone.spec.ts Tests for timezone mismatch detection and timezone formatting.
bases/rsptx/assignment_server_api/assignment_builder/src/global.d.ts Types eBookConfig.courseTimezone.
bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.tsx Removes utc toggle; picker always crosses boundary as UTC strings.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx Parses due dates as naive UTC before rendering in viewer-local time.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/defaultAssignment.ts Defaults new assignment due date using UTC conversion helper.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.tsx Adds course-timezone mismatch notice under due date field.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx Removes now-unnecessary utc prop usage in pickers.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx Renders due dates via UTC parsing/display helper instead of local-naive display.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx Removes now-unnecessary utc prop usage in pickers.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/CourseTimezoneNotice.tsx Implements instructor warning UI when browser timezone differs from course timezone.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/CourseTimezoneNotice.spec.tsx Component tests for the timezone mismatch notice UI.
bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx Adds course-timezone mismatch notice under due date field in edit flow.
bases/rsptx/assignment_server_api/assignment_builder/package-lock.json Updates npm/node engine requirements in the assignment builder lockfile.
bases/rsptx/admin_server_api/routers/start.py Switches to shared templates.
bases/rsptx/admin_server_api/routers/problem_report.py Switches to shared templates.
bases/rsptx/admin_server_api/routers/lti1p3.py Updates due date ingestion to store naive UTC; switches to shared templates.
bases/rsptx/admin_server_api/routers/lti1p1.py Switches to shared templates.
bases/rsptx/admin_server_api/routers/legal.py Switches to shared templates.
bases/rsptx/admin_server_api/routers/instructor.py Switches to shared templates and adjusts copy-assignment date arithmetic for UTC due dates.
bases/rsptx/admin_server_api/routers/auth.py Switches to shared templates.
bases/rsptx/admin_server_api/routers/analytics.py Uses shared templates and shifts UTC stored due dates into course timezone before date-only display.
Files not reviewed (1)
  • bases/rsptx/assignment_server_api/assignment_builder/package-lock.json: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +549 to +552
if lms_due.tzinfo is None:
tz = ZoneInfo(course.timezone) if course.timezone else datetime.timezone.utc
lms_due = lms_due.replace(tzinfo=tz)
lms_due = lms_due.astimezone(datetime.timezone.utc).replace(tzinfo=None)
Comment on lines +1175 to +1177
midnight = datetime.datetime.combine(term_start_date, datetime.time())
tz = ZoneInfo(timezone) if timezone else datetime.timezone.utc
return (
A NULL courses.timezone is left alone by the migration and backfilled to
'UTC'. That is harmless for a course whose deadlines have passed, but a course
still running keeps the wall clock its instructor typed while that value
starts being read as UTC.

Correcting such a course is far cheaper before the migration than after:
setting courses.timezone first converts it correctly along with everyone
else, whereas afterwards its due dates are fixed instants and nothing records
which zone they were meant to be in. That ordering was documented but not
enforced, so upgrade() now refuses to run while any NULL-timezone course has a
due date in the future, naming the courses and the fix. It mirrors the
existing unrecognized-timezone pre-flight.

DUEDATE_UTC_ALLOW_NULL_TIMEZONE=1 proceeds anyway and treats those deadlines
as UTC, for the case where an operator has decided that is what they want.

The live check is approximate by design: due dates are still course-local when
it runs, so a course is judged live to within its UTC offset, which is well
inside the granularity this cares about.

The migration test fixture now gives a timezone to pre-existing seed courses
that have none and far-future deadlines, so each test controls the precondition
it is exercising -- the guard was correctly objecting to the shared test
database's own data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xK9HD5NSi1EAWY3cSJPmC
Copilot AI review requested due to automatic review settings July 28, 2026 00:55

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 53 out of 56 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • bases/rsptx/assignment_server_api/assignment_builder/package-lock.json: Generated file
Comments suppressed due to low confidence (2)

bases/rsptx/admin_server_api/routers/lti1p3.py:552

  • When ingesting a naive LMS due date, this code calls ZoneInfo(course.timezone) without guarding against an unrecognized timezone string. Because the whole function is wrapped in a blanket except Exception: pass, an invalid course.timezone will cause the due date update to be silently skipped even if the LMS timestamp itself is valid. It’s safer to treat an unknown course timezone as UTC (matching the migration’s fallback) and optionally log a warning.
        if lms_due.tzinfo is None:
            tz = ZoneInfo(course.timezone) if course.timezone else datetime.timezone.utc
            lms_due = lms_due.replace(tzinfo=tz)
        lms_due = lms_due.astimezone(datetime.timezone.utc).replace(tzinfo=None)

bases/rsptx/admin_server_api/routers/instructor.py:1176

  • _term_start_utc uses ZoneInfo(timezone) directly. If a course somehow has an invalid timezone string (e.g., legacy data, LTI/other non-UI writes), this will raise and break copy-assignment. Since other parts of this PR intentionally fall back to UTC on bad timezone values, it would be more robust to do the same here.
    midnight = datetime.datetime.combine(term_start_date, datetime.time())
    tz = ZoneInfo(timezone) if timezone else datetime.timezone.utc

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