Store assignment due dates in UTC - #1324
Conversation
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
There was a problem hiding this comment.
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.
| 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) |
| 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
There was a problem hiding this comment.
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 blanketexcept Exception: pass, an invalidcourse.timezonewill 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_utcusesZoneInfo(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
assignments.duedatehas always held a naive datetime in course-local wall clock time, whilevisible_on,hidden_onand 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:
grading_helpers/regrade.py_effective_deadlinecompared 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.pyduedate.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.pyduedateis 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 UTCin the web2py code that this resolves.Migration (
c4e8a1f7b2d9)Shifts
duedateusingcourses.timezone, and backfills NULL timezones to'UTC'.The
timezonecolumn was added in8f857bdfef19without 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.duedate_utc_tz_backfill, sodowngrade()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.courses.timezonevalues with a message naming the offending courses, instead of lettingAT TIME ZONEabort mid-statement. Courses with a bad timezone but no assignments don't block.DUEDATE_UTC_ALLOW_NULL_TIMEZONE=1overrides.Display
Due dates render on the reader's own clock, so a travelling student never converts a deadline by hand.
course_datetime_tag()emitsand
staticAssets/js/localize-times.jsrewrites 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
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:
This matters because fixing such a course is much cheaper before the migration than after: setting
courses.timezonefirst 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._term_start_utcinadmin_server_api/routers/instructor.py. The copy-assignment date maths is the subtlest change here.The per-page localize include. PreTeXt books ship their own
_base.htmlandget_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 usingcourse_datetime_tag()must includecommon/localize_times.html. That's the one part of the design that isn't self-enforcing.Deploying
Set
courses.timezoneon 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
duedateread 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 -1plus 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_offseton the assumption the due date is course-local, so they'll show deadlines shifted by the course's UTC offset./runestone/dashboard/gradesis the one still-live surface (linked fromtemplates/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
lti1p1existed).Verified in the running app after rebuilding
book,assignment,adminand applying the migration to the dev database — 7 assignments shifted, 27 courses backfilled:Apr 24, 2026, 12:36 PM PDTto a Los Angeles browser andApr 25, 2026, 04:36 AM GMT+9to a Tokyo oneis_assignedscoring 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