From c75e2b6c9c39fce405e73720ca5fb822ebc9ce9e Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Mon, 27 Jul 2026 17:59:34 -0500 Subject: [PATCH 1/3] Store assignment due dates in UTC 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 Claude-Session: https://claude.ai/code/session_013xK9HD5NSi1EAWY3cSJPmC --- .../admin_server_api/routers/analytics.py | 45 ++-- bases/rsptx/admin_server_api/routers/auth.py | 5 +- .../admin_server_api/routers/instructor.py | 60 +++-- bases/rsptx/admin_server_api/routers/legal.py | 5 +- .../rsptx/admin_server_api/routers/lti1p1.py | 5 +- .../rsptx/admin_server_api/routers/lti1p3.py | 26 ++- .../routers/problem_report.py | 5 +- bases/rsptx/admin_server_api/routers/start.py | 5 +- .../components/edit/VisibilityControl.tsx | 4 - .../components/list/AssignmentList.tsx | 4 +- .../components/list/VisibilityDropdown.tsx | 4 - .../AssignmentBuilder/defaultAssignment.ts | 4 +- .../Grader/pages/GraderAssignmentsPage.tsx | 7 +- .../ui/DateTimePicker/DateTimePicker.tsx | 20 +- .../assignment_builder/src/utils/date.spec.ts | 98 +------- .../assignment_builder/src/utils/date.ts | 55 +---- .../routers/instructor.py | 19 +- .../assignment_server_api/routers/peer.py | 15 +- .../assignment_server_api/routers/student.py | 64 +++-- bases/rsptx/author_server_api/main.py | 5 +- bases/rsptx/book_server_api/routers/books.py | 9 +- bases/rsptx/book_server_api/routers/course.py | 37 +-- .../book_server_api/routers/rslogging.py | 11 +- components/rsptx/db/crud/scoring.py | 17 +- components/rsptx/exceptions/core.py | 5 +- components/rsptx/grading_helpers/core.py | 21 +- components/rsptx/grading_helpers/regrade.py | 5 + components/rsptx/lti1p3/core.py | 9 +- components/rsptx/templates/__init__.py | 16 +- .../instructor/peer_instructor.html | 2 +- .../assignment/student/assignment_block.html | 2 +- .../assignment/student/doAssignment.html | 2 +- .../assignment/student/peer_student.html | 2 +- components/rsptx/templates/core.py | 78 ++++++- .../versions/c4e8a1f7b2d9_duedate_to_utc.py | 181 ++++++++++++++ .../rsptx/admin_server_api/test_analytics.py | 32 +++ .../test_copy_assignment_dates.py | 160 +++++++++++++ .../rsptx/grading_helpers/test_core.py | 19 +- .../grading_helpers/test_regrade_batch.py | 49 ++++ test/components/rsptx/lti1p3/__init__.py | 0 .../rsptx/lti1p3/test_duedate_exchange.py | 142 +++++++++++ test/components/rsptx/templates/test_core.py | 115 +++++++++ test/migrations/test_duedate_to_utc.py | 221 ++++++++++++++++++ 43 files changed, 1204 insertions(+), 386 deletions(-) create mode 100644 migrations/versions/c4e8a1f7b2d9_duedate_to_utc.py create mode 100644 test/bases/rsptx/admin_server_api/test_copy_assignment_dates.py create mode 100644 test/components/rsptx/lti1p3/__init__.py create mode 100644 test/components/rsptx/lti1p3/test_duedate_exchange.py create mode 100644 test/migrations/test_duedate_to_utc.py diff --git a/bases/rsptx/admin_server_api/routers/analytics.py b/bases/rsptx/admin_server_api/routers/analytics.py index 0fa886d94..20587f692 100644 --- a/bases/rsptx/admin_server_api/routers/analytics.py +++ b/bases/rsptx/admin_server_api/routers/analytics.py @@ -22,14 +22,13 @@ status, ) from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse -from fastapi.templating import Jinja2Templates from sqlalchemy import create_engine from rsptx.auth.session import auth_manager from rsptx.configuration import settings from rsptx.endpoint_validators import instructor_role_required, with_course from rsptx.logging import rslogger -from rsptx.templates import template_folder +from rsptx.templates import format_course_datetime, get_shared_templates # --------------------------------------------------------------------------- # Router @@ -40,9 +39,26 @@ tags=["analytics"], ) -templates = Jinja2Templates(directory=template_folder) +templates = get_shared_templates() + # --------------------------------------------------------------------------- +def _format_duedate(value, course_timezone: Optional[str]) -> str: + """Render a duedate read via pandas as a course-local date string. + + ``pd.read_sql_query`` yields ``pd.Timestamp`` (a ``datetime`` subclass) and + ``pd.NaT`` for nulls, and ``NaT`` is not caught by an ``is None`` check, so + screen for it before handing off to the shared formatter. + """ + if value is None or pd.isna(value): + return "" + if hasattr(value, "to_pydatetime"): + value = value.to_pydatetime() + return format_course_datetime( + value, course_timezone, fmt="%Y-%m-%d", show_timezone=False + ) + + # Redis helpers # --------------------------------------------------------------------------- @@ -1213,16 +1229,12 @@ async def get_assignmentoverview( params={"course_name": course.course_name}, ) assignments = assignments_df.to_dict(orient="records") - # Convert Timestamps to strings for template rendering + # Convert Timestamps to strings for template rendering. duedate is stored + # as naive UTC, so it has to be shifted into the course timezone before the + # date is taken -- a late-evening deadline lands on the following day in + # UTC. No zone label: only the date is shown. for row in assignments: - dt = row.get("duedate") - if dt is not None and pd.notna(dt): - try: - row["duedate"] = dt.strftime("%Y-%m-%d") - except Exception: - row["duedate"] = str(dt) - else: - row["duedate"] = "" + row["duedate"] = _format_duedate(row.get("duedate"), course.timezone) context = { "request": request, @@ -1381,14 +1393,7 @@ async def get_assignment_student_detail( detail=f"Assignment {assignment_id} not found", ) assignment = assignment_row.iloc[0].to_dict() - dt = assignment.get("duedate") - if dt is not None and pd.notna(dt): - try: - assignment["duedate"] = dt.strftime("%Y-%m-%d") - except Exception: - assignment["duedate"] = str(dt) - else: - assignment["duedate"] = "" + assignment["duedate"] = _format_duedate(assignment.get("duedate"), course.timezone) detail = _build_assignment_student_detail( engine, assignment_id, course.course_name, sid, tz_offset_hours diff --git a/bases/rsptx/admin_server_api/routers/auth.py b/bases/rsptx/admin_server_api/routers/auth.py index 6681ae3d3..2100420a6 100644 --- a/bases/rsptx/admin_server_api/routers/auth.py +++ b/bases/rsptx/admin_server_api/routers/auth.py @@ -4,7 +4,6 @@ from fastapi import APIRouter, Form, Request, status from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.templating import Jinja2Templates from pydal.validators import CRYPT from rsptx.auth.email import send_email @@ -31,14 +30,14 @@ from rsptx.db.models import AuthUserValidator from rsptx.logging import rslogger from rsptx.response_helpers.core import canonical_utcnow -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates router = APIRouter( prefix="/auth", tags=["auth"], ) -templates = Jinja2Templates(directory=template_folder) +templates = get_shared_templates() # All browser-facing URLs use /admin/auth/... (nginx routes /admin/auth/ → container /auth/) _LOGIN = "/admin/auth/login" diff --git a/bases/rsptx/admin_server_api/routers/instructor.py b/bases/rsptx/admin_server_api/routers/instructor.py index 2f615b0b8..154f14946 100644 --- a/bases/rsptx/admin_server_api/routers/instructor.py +++ b/bases/rsptx/admin_server_api/routers/instructor.py @@ -12,10 +12,11 @@ HTMLResponse, JSONResponse, ) -from fastapi.templating import Jinja2Templates from pydantic import BaseModel import csv from io import StringIO +from typing import Optional +from zoneinfo import ZoneInfo # Local application imports # ------------------------- @@ -60,7 +61,7 @@ ) from rsptx.auth.session import auth_manager from rsptx.auth.email import send_welcome_email -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates from rsptx.configuration import settings from rsptx.endpoint_validators import with_course, instructor_role_required from rsptx.logging import rslogger @@ -117,7 +118,7 @@ async def get_instructor_menu( Display the main instructor menu dashboard. """ rslogger.info(f"Rendering instructor menu for course: {course.course_name}") - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() context = { "course": course, "user": user, @@ -142,7 +143,7 @@ async def get_manage_students( """ Display the student management interface. """ - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Get all students in the course students = {} # This would normally be populated from the database @@ -173,7 +174,7 @@ async def get_copy_assignments( """ Display the copy assignments interface. """ - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Get instructor's available courses for copying from instructor_course_relationships = await fetch_instructor_courses(user.id) @@ -393,7 +394,7 @@ async def get_course_settings( """ Display the course settings interface. """ - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Get all course attributes course_attrs = await fetch_all_course_attributes(course.id) @@ -447,7 +448,7 @@ async def get_lti_config( management (the LTI 1.3 pieces are configured elsewhere and are only surfaced here for informational purposes and to allow removing an association). """ - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() lti_key = await fetch_lti1p1_config(course.id) course_attrs = await fetch_all_course_attributes(course.id) @@ -528,7 +529,7 @@ async def get_assessment_reset( """ Display the assessment reset interface. """ - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Get all students in the course students = await fetch_users_for_course(course.course_name) @@ -625,7 +626,7 @@ async def get_course_delete( rslogger.info( f"Rendering course deletion page for course: {course.course_name}" ) - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() context = { "course": course, "student_count": student_count, @@ -720,7 +721,7 @@ async def get_add_instructor( """ Render the Add Instructor page. """ - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() context = { "course": course, "user": user, @@ -821,7 +822,7 @@ async def remove_student( Remove one or more students from the current course. Expects form data with student_id (can be a single value or a list). """ - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() try: form = await request.form() @@ -1055,7 +1056,7 @@ async def enroll_students( mess = f"Enrollment completed with {enrolled} successful enrollments and {failed} failures." else: mess = f"All {enrolled} students enrolled successfully." - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() return templates.TemplateResponse( "admin/instructor/enroll_results.html", { @@ -1162,6 +1163,24 @@ async def copy_assignment( ) +def _term_start_utc(term_start_date, timezone: Optional[str]) -> datetime.datetime: + """Midnight local time on the first day of term, expressed as naive UTC. + + ``term_start_date`` is a bare date, so on its own it has no timezone. Due + dates are stored as naive UTC, so the term start has to be anchored in the + course timezone before the two can be subtracted -- otherwise the offset + from the start of term is wrong by the course's UTC offset. A course with + no timezone is treated as UTC, matching the ``duedate`` migration. + """ + midnight = datetime.datetime.combine(term_start_date, datetime.time()) + tz = ZoneInfo(timezone) if timezone else datetime.timezone.utc + return ( + midnight.replace(tzinfo=tz) + .astimezone(datetime.timezone.utc) + .replace(tzinfo=None) + ) + + async def _copy_one_assignment( source_course_name: str, old_assignment_id: int, target_course ) -> str: @@ -1179,15 +1198,16 @@ async def _copy_one_assignment( source_course = await fetch_course(source_course_name) old_assignment = await fetch_one_assignment(old_assignment_id) - # Calculate due date adjustment based on course start dates + # Calculate due date adjustment based on course start dates. Both term + # starts are anchored in their own course timezone so the offset from + # the start of term is preserved as local wall clock time, even when + # the two terms fall on opposite sides of a DST change. if source_course.term_start_date and target_course.term_start_date: - due_delta = old_assignment.duedate - datetime.datetime.combine( - source_course.term_start_date, datetime.time() + due_delta = old_assignment.duedate - _term_start_utc( + source_course.term_start_date, source_course.timezone ) due_date = ( - datetime.datetime.combine( - target_course.term_start_date, datetime.time() - ) + _term_start_utc(target_course.term_start_date, target_course.timezone) + due_delta ) else: @@ -1313,7 +1333,7 @@ async def get_create_course_page(request: Request, user=Depends(auth_manager)): """ Display the course designer form for instructors. """ - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Fetch real course list from the library table course = user.course_name @@ -1369,7 +1389,7 @@ async def post_create_course_page( """ Process the course designer form submission. """ - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Prepare course data for validator try: course_data = { diff --git a/bases/rsptx/admin_server_api/routers/legal.py b/bases/rsptx/admin_server_api/routers/legal.py index 8005761e5..e6d24336d 100644 --- a/bases/rsptx/admin_server_api/routers/legal.py +++ b/bases/rsptx/admin_server_api/routers/legal.py @@ -13,16 +13,15 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates router = APIRouter( prefix="/legal", tags=["legal"], ) -templates = Jinja2Templates(directory=template_folder) +templates = get_shared_templates() # --------------------------------------------------------------------------- # Document registry diff --git a/bases/rsptx/admin_server_api/routers/lti1p1.py b/bases/rsptx/admin_server_api/routers/lti1p1.py index 686922db6..d20f55871 100644 --- a/bases/rsptx/admin_server_api/routers/lti1p1.py +++ b/bases/rsptx/admin_server_api/routers/lti1p1.py @@ -23,7 +23,6 @@ import oauth2 from fastapi import APIRouter, Request, status from fastapi.responses import HTMLResponse, RedirectResponse, Response -from fastapi.templating import Jinja2Templates from pydantic import ValidationError # Local application imports @@ -54,7 +53,7 @@ ) from rsptx.logging import rslogger from rsptx.response_helpers.core import canonical_utcnow -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates from .lti1p3 import add_w2py_session_cookie, get_domain, get_web2py_session_cookie @@ -98,7 +97,7 @@ def _launch_url(request: Request) -> str: def _render_error(request: Request, errors: list) -> HTMLResponse: """Render the LTI launch error page.""" - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() context = {"request": request, "lti_errors": errors} return templates.TemplateResponse( "admin/lti1p1/launch_error.html", context, status_code=400 diff --git a/bases/rsptx/admin_server_api/routers/lti1p3.py b/bases/rsptx/admin_server_api/routers/lti1p3.py index a9fa4dc57..aa679ce66 100644 --- a/bases/rsptx/admin_server_api/routers/lti1p3.py +++ b/bases/rsptx/admin_server_api/routers/lti1p3.py @@ -36,7 +36,6 @@ RedirectResponse, HTMLResponse, ) -from fastapi.templating import Jinja2Templates from pydantic import ValidationError import jwt @@ -86,7 +85,7 @@ from rsptx.logging import rslogger from rsptx.auth.session import auth_manager from rsptx.response_helpers.core import canonical_utcnow -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates from rsptx.endpoint_validators import with_course, instructor_role_required from rsptx.lti1p3.pylti1p3.lineitem import LineItem @@ -542,13 +541,16 @@ async def update_rsassignment_from_lti( ) lms_due = datetime.datetime.fromisoformat(normalized_due_string) - # If LMS provided timezone info and course has a timezone, convert to course local time. - if lms_due.tzinfo is not None and course.timezone: - lms_due = lms_due.astimezone(ZoneInfo(course.timezone)) - rslogger.info( - f"LTI1p3 - Converted to {lms_due} in timezone {course.timezone}" - ) - lms_due = lms_due.replace(tzinfo=None) + # duedate is stored as naive UTC. A timestamp carrying an offset can be + # converted straight to UTC; a naive one has to be read as course-local + # wall clock first, which is what such a value meant before due dates + # moved to UTC. A course with no timezone is treated as UTC, matching + # the duedate migration. + 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) + rslogger.info(f"LTI1p3 - Storing {lms_due} UTC for assignment {assign.name}") if ( lms_due is not None and lms_due != assign.duedate @@ -605,7 +607,7 @@ async def register( await upsert_lti1p3_config(lti_conf) - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() return templates.TemplateResponse( "admin/lti1p3/registration_confirm.html", request=request, @@ -829,7 +831,7 @@ async def deep_link_login(request: Request): fapi_request, tool_conf, launch_data_storage=get_launch_data_storage() ) rslogger.debug(f"LTI1p3 - rs-login request: {fapi_request.__dict__}") - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() tpl_kwargs = { "request": request, "launch_id": message_launch.get_launch_id(), @@ -979,7 +981,7 @@ async def dynamic_link_entry(request: Request): "authentication_nonce": authentication_nonce, } - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() resp = templates.TemplateResponse( name="admin/lti1p3/pick_links.html", context=tpl_kwargs ) diff --git a/bases/rsptx/admin_server_api/routers/problem_report.py b/bases/rsptx/admin_server_api/routers/problem_report.py index 210208b16..0f032dcaf 100644 --- a/bases/rsptx/admin_server_api/routers/problem_report.py +++ b/bases/rsptx/admin_server_api/routers/problem_report.py @@ -16,21 +16,20 @@ from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates from rsptx.auth.session import auth_manager from rsptx.configuration import settings from rsptx.db.crud import fetch_course, fetch_instructor_courses from rsptx.logging import rslogger from rsptx.response_helpers.core import canonical_utcnow -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates router = APIRouter( prefix="/problem", tags=["problem-report"], ) -templates = Jinja2Templates(directory=template_folder) +templates = get_shared_templates() # Browser-facing URL: nginx/caddy route /admin/problem/ -> container /problem/ _REPORT_URL = "/admin/problem/report" diff --git a/bases/rsptx/admin_server_api/routers/start.py b/bases/rsptx/admin_server_api/routers/start.py index e6d495142..217247513 100644 --- a/bases/rsptx/admin_server_api/routers/start.py +++ b/bases/rsptx/admin_server_api/routers/start.py @@ -12,16 +12,15 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates router = APIRouter( prefix="/get-started", tags=["get-started"], ) -templates = Jinja2Templates(directory=template_folder) +templates = get_shared_templates() @router.get("", response_class=HTMLResponse) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx index f9dd44bcd..b528dcc8d 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/VisibilityControl.tsx @@ -139,7 +139,6 @@ export const VisibilityControl = ({ control, watch, setValue }: VisibilityContro dateField.onChange(val)} - utc ariaLabel="Visible on date" /> )} @@ -158,7 +157,6 @@ export const VisibilityControl = ({ control, watch, setValue }: VisibilityContro dateField.onChange(val)} - utc ariaLabel="Hidden on date" /> )} @@ -182,7 +180,6 @@ export const VisibilityControl = ({ control, watch, setValue }: VisibilityContro id="visibility-visible-from" value={dateField.value} onChange={(val) => handleVisibleOnChange(val)} - utc /> )} /> @@ -199,7 +196,6 @@ export const VisibilityControl = ({ control, watch, setValue }: VisibilityContro id="visibility-hidden-after" value={dateField.value} onChange={(val) => handleHiddenOnChange(val)} - utc /> )} /> diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx index 8b7322f19..6c9c0af73 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx @@ -9,7 +9,7 @@ import { ColumnDef, OnChangeFn, SortingState } from "@tanstack/react-table"; import classNames from "classnames"; import { Assignment } from "@/types/assignment"; -import { formatLocalDateForDisplay, formatUTCDateForDisplay } from "@/utils/date"; +import { formatUTCDateForDisplay } from "@/utils/date"; import { VisibilityDropdown } from "./VisibilityDropdown"; @@ -169,7 +169,7 @@ export const AssignmentList = ({ cellClassName: classNames(styles.dateCell, styles.clickableCell), onCellClick: onEdit }, - cell: ({ row }) => formatLocalDateForDisplay(row.original.duedate, DATE_FORMAT) + cell: ({ row }) => formatUTCDateForDisplay(row.original.duedate, DATE_FORMAT) }, { accessorKey: "updated_date", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx index 1d4f4399c..35e578df1 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx @@ -172,7 +172,6 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP @@ -185,7 +184,6 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP @@ -202,7 +200,6 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP @@ -214,7 +211,6 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/defaultAssignment.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/defaultAssignment.ts index f085ee607..4fda2e161 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/defaultAssignment.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/defaultAssignment.ts @@ -1,10 +1,10 @@ import { CreateAssignmentPayload } from "@/types/assignment"; -import { convertDateToLocalISO } from "@/utils/date"; +import { convertDateToISO } from "@/utils/date"; export const defaultAssignment: CreateAssignmentPayload = { name: "", description: "", - duedate: convertDateToLocalISO(new Date()), + duedate: convertDateToISO(new Date()), points: 0, kind: "Regular", time_limit: null, diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx index c1fb279d9..4bddcb144 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx @@ -7,6 +7,7 @@ import { Link, useNavigate } from "react-router-dom"; import { DataGrid } from "@/components/ui/DataGrid"; import { Icon } from "@/components/ui/Icon"; +import { parseUTCDate } from "@/utils/date"; import { useGetAssignmentsQuery } from "@store/assignment/assignment.logic.api"; import { ReleaseStatusBadge } from "../components/ReleaseStatusBadge"; @@ -23,7 +24,9 @@ const VIEW_MODE_STORAGE_KEY = "grader.assignmentsViewMode"; const formatDate = (iso?: string | null) => { if (!iso) return "No due date"; try { - return new Date(iso).toLocaleDateString(undefined, { + // duedate arrives as a naive UTC string; parseUTCDate renders it in the + // viewer's local timezone rather than reading it as already-local. + return parseUTCDate(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" @@ -202,7 +205,7 @@ export const GraderAssignmentsPage: React.FC = () => { const allRows: AssignmentRow[] = assignments.map((a) => ({ ...a, - duedateDate: a.duedate ? new Date(a.duedate) : null, + duedateDate: a.duedate ? parseUTCDate(a.duedate) : null, duedateDisplay: formatDate(a.duedate) })) as AssignmentRow[]; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.tsx index 56904f38d..10301aebe 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/DateTimePicker/DateTimePicker.tsx @@ -4,23 +4,20 @@ import { Icon } from "@components/ui/Icon"; import classNames from "classnames"; import DatePicker from "react-datepicker"; -import { - convertDateToISO, - convertDateToLocalISO, - getDatePickerFormat, - parseUTCDate, - parseLocalDate -} from "@/utils/date"; +import { convertDateToISO, getDatePickerFormat, parseUTCDate } from "@/utils/date"; import styles from "./DateTimePicker.module.css"; +/** + * The picker shows and edits dates in the viewer's local timezone, but every + * datetime the backend stores -- duedate, visible_on, hidden_on -- is naive + * UTC, so values always cross this boundary as UTC. + */ interface DateTimePickerProps { value: string | null | undefined; onChange: (isoString: string) => void; placeholder?: string; className?: string; - /** If true, treat dates as UTC. If false (default), treat as local time. */ - utc?: boolean; /** If false, render the calendar inline instead of portaling it to #root. */ withinPortal?: boolean; id?: string; @@ -32,18 +29,17 @@ export const DateTimePicker = ({ onChange, placeholder = "Select date and time", className, - utc = false, withinPortal = true, id, ariaLabel }: DateTimePickerProps) => { const handleChange = (date: Date | null) => { if (date) { - onChange(utc ? convertDateToISO(date) : convertDateToLocalISO(date)); + onChange(convertDateToISO(date)); } }; - const parseDate = (val: string) => (utc ? parseUTCDate(val) : parseLocalDate(val)); + const parseDate = (val: string) => parseUTCDate(val); return (
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.spec.ts index 97ab452f5..708b9d150 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.spec.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.spec.ts @@ -1,13 +1,9 @@ import { convertDateToISO, - convertDateToLocalISO, getDatePickerFormat, parseUTCDate, - parseLocalDate, formatUTCDateForDisplay, - formatLocalDateForDisplay, - formatUTCDateLocaleString, - formatLocalDateLocaleString + formatUTCDateLocaleString } from "./date"; describe("convertDateToISO", () => { @@ -30,34 +26,6 @@ describe("convertDateToISO", () => { }); }); -describe("convertDateToLocalISO", () => { - it("formats each date component with zero-padding", () => { - const date = new Date(2026, 0, 5, 9, 7, 3); - const result = convertDateToLocalISO(date); - expect(result).toBe("2026-01-05T09:07:03"); - }); - - it("returns a string matching the ISO-like local pattern", () => { - const date = new Date(2026, 11, 31, 23, 59, 59); - const result = convertDateToLocalISO(date); - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); - expect(result).toBe("2026-12-31T23:59:59"); - }); - - it("does not append a timezone designator", () => { - const date = new Date(2026, 5, 15, 12, 30, 0); - const result = convertDateToLocalISO(date); - expect(result.endsWith("Z")).toBe(false); - expect(result).not.toMatch(/[+-]\d{2}:\d{2}$/); - }); - - it("pads single-digit month and day", () => { - const date = new Date(2026, 2, 3, 8, 5, 2); - const result = convertDateToLocalISO(date); - expect(result).toBe("2026-03-03T08:05:02"); - }); -}); - describe("getDatePickerFormat", () => { it("returns US date format when locale ends with US", () => { expect(getDatePickerFormat("en-US")).toBe("MM/dd/yyyy h:mm aa"); @@ -113,38 +81,6 @@ describe("parseUTCDate", () => { }); }); -describe("parseLocalDate", () => { - it("parses a naive date string as local time (no Z appended)", () => { - const dateStr = "2026-06-15T10:30:00"; - const result = parseLocalDate(dateStr); - expect(result).toBeInstanceOf(Date); - expect(result.getFullYear()).toBe(2026); - expect(result.getMonth()).toBe(5); - expect(result.getDate()).toBe(15); - expect(result.getHours()).toBe(10); - expect(result.getMinutes()).toBe(30); - }); - - it("parses a string ending with Z as-is (UTC)", () => { - const result = parseLocalDate("2026-02-24T15:00:00Z"); - expect(result.getUTCHours()).toBe(15); - }); - - it("parses a string with positive timezone offset as-is", () => { - const result = parseLocalDate("2026-02-24T17:00:00+02:00"); - expect(result.getUTCHours()).toBe(15); - }); - - it("parses a string with negative timezone offset as-is", () => { - const result = parseLocalDate("2026-02-24T10:00:00-05:00"); - expect(result.getUTCHours()).toBe(15); - }); - - it("returns a Date instance", () => { - expect(parseLocalDate("2026-01-01T00:00:00")).toBeInstanceOf(Date); - }); -}); - describe("formatUTCDateForDisplay", () => { it("returns a non-empty string for a valid UTC naive string", () => { const result = formatUTCDateForDisplay("2026-02-24T15:00:00"); @@ -168,24 +104,6 @@ describe("formatUTCDateForDisplay", () => { }); }); -describe("formatLocalDateForDisplay", () => { - it("returns a non-empty string for a valid local naive string", () => { - const result = formatLocalDateForDisplay("2026-06-15T10:30:00"); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - }); - - it("accepts custom Intl.DateTimeFormatOptions and uses them", () => { - const result = formatLocalDateForDisplay("2026-06-15T10:30:00", { - year: "numeric", - month: "long", - day: "numeric" - }); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - }); -}); - describe("formatUTCDateLocaleString", () => { it("returns a non-empty locale string for a valid UTC naive string", () => { const result = formatUTCDateLocaleString("2026-02-24T15:00:00"); @@ -199,17 +117,3 @@ describe("formatUTCDateLocaleString", () => { expect(result.length).toBeGreaterThan(0); }); }); - -describe("formatLocalDateLocaleString", () => { - it("returns a non-empty locale string for a valid local naive string", () => { - const result = formatLocalDateLocaleString("2026-06-15T10:30:00"); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - }); - - it("returns a string for a string with timezone info", () => { - const result = formatLocalDateLocaleString("2026-06-15T10:30:00Z"); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - }); -}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.ts index c4cc29e61..0b9af3993 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/date.ts @@ -1,27 +1,12 @@ /** * Converts a local Date object to a UTC ISO string (without 'Z' suffix) - * for sending to the backend which stores dates in UTC. + * for sending to the backend, which stores every datetime -- duedate, + * visible_on, hidden_on -- as naive UTC. */ export const convertDateToISO = (date: Date): string => { return date.toISOString().slice(0, 19); // UTC ISO string without 'Z' and milliseconds }; -/** - * Converts a local Date object to a local ISO-like string (without timezone info) - * for sending to the backend which stores due dates in local time (naive datetime). - * This preserves the original behavior where due dates are stored as-is in the instructor's - * local timezone without any UTC conversion. - */ -export const convertDateToLocalISO = (date: Date): string => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; -}; - export const getDatePickerFormat = (locale = navigator.language) => { return locale.endsWith("US") ? "MM/dd/yyyy h:mm aa" : "dd/MM/yyyy HH:mm"; }; @@ -39,21 +24,6 @@ export const parseUTCDate = (dateString: string): Date => { return new Date(dateString + "Z"); }; -/** - * Parses a naive date string from the backend as LOCAL time (not UTC). - * Backend stores due dates in the instructor's local timezone as naive strings - * (e.g., "2026-02-24T15:00:00"). We parse them without appending 'Z' so - * JavaScript interprets them as local time. - */ -export const parseLocalDate = (dateString: string): Date => { - // If the string already has timezone info, parse as-is - if (dateString.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(dateString)) { - return new Date(dateString); - } - // Parse as local time by NOT appending 'Z' - return new Date(dateString); -}; - /** * Formats a UTC date string from the backend for display in the user's local timezone. */ @@ -65,18 +35,6 @@ export const formatUTCDateForDisplay = ( return date.toLocaleDateString(undefined, options); }; -/** - * Formats a local (naive) date string from the backend for display. - * Since the date is already in local time, no timezone conversion is needed. - */ -export const formatLocalDateForDisplay = ( - localString: string, - options?: Intl.DateTimeFormatOptions -): string => { - const date = parseLocalDate(localString); - return date.toLocaleDateString(undefined, options); -}; - /** * Formats a UTC date string from the backend as a locale string in the user's local timezone. */ @@ -84,12 +42,3 @@ export const formatUTCDateLocaleString = (utcString: string): string => { const date = parseUTCDate(utcString); return date.toLocaleString(); }; - -/** - * Formats a local (naive) date string from the backend as a locale string. - * Since the date is already in local time, no timezone conversion is needed. - */ -export const formatLocalDateLocaleString = (localString: string): string => { - const date = parseLocalDate(localString); - return date.toLocaleString(); -}; diff --git a/bases/rsptx/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index 6bf993ad0..af0d2010f 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -21,7 +21,6 @@ JSONResponse, StreamingResponse, ) -from fastapi.templating import Jinja2Templates from sqlalchemy import create_engine from pydantic import BaseModel from typing import List, Optional, Annotated @@ -86,7 +85,7 @@ is_assignment_visible_to_students, ) from rsptx.auth.session import auth_manager, is_instructor -from rsptx.templates import template_folder +from rsptx.templates import format_course_datetime, get_shared_templates from rsptx.configuration import settings from rsptx.response_helpers import construct_course_url from rsptx.response_helpers.core import ( @@ -245,7 +244,9 @@ async def review_peer_assignment( "assignment_details": { "id": assignment.id, "name": assignment.name, - "due_date": assignment.duedate.strftime("%Y-%m-%d %H:%M:%S"), + "due_date": format_course_datetime( + assignment.duedate, course.timezone, fmt="%Y-%m-%d %H:%M:%S" + ), "visible": assignment.visible, "released": assignment.released, "description": assignment.description, @@ -259,7 +260,7 @@ async def review_peer_assignment( "settings": settings, } - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() response = templates.TemplateResponse( "assignment/instructor/reviewPeerAssignment.html", context ) @@ -522,7 +523,7 @@ def format_percent(value): names[row.username] = row.first_name + " " + row.last_name # pt = pt.drop(columns=["username"], axis=1) - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # rename the columns in cols to cols_plus_points rename_dict = {old: new for old, new in zip(cols, display_cols)} pt = pt.rename(columns=rename_dict) @@ -1230,7 +1231,7 @@ async def get_builder( return RedirectResponse(url="/") reactdir = pathlib.Path(__file__).parent.parent / "react" - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() wp_imports = get_webpack_static_imports(course) react_imports = get_react_imports(reactdir) course_attrs = await fetch_all_course_attributes(course.id) @@ -1337,7 +1338,7 @@ async def make_invoice_request( is_instructor=user_is_instructor, referer=referer, ) - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() response = templates.TemplateResponse("assignment/instructor/invoice.html", context) return response @@ -1560,7 +1561,7 @@ async def do_assignment_summary( "is_instructor": True, "student_page": False, } - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() response = templates.TemplateResponse( "assignment/instructor/assignment_summary.html", context ) @@ -1743,7 +1744,7 @@ async def get_add_token_page( total_tokens = len(tokens) - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() context = { "course": course, "user": user, diff --git a/bases/rsptx/assignment_server_api/routers/peer.py b/bases/rsptx/assignment_server_api/routers/peer.py index bf9a3cc62..c2363b27a 100644 --- a/bases/rsptx/assignment_server_api/routers/peer.py +++ b/bases/rsptx/assignment_server_api/routers/peer.py @@ -21,7 +21,6 @@ # ------------------- from fastapi import APIRouter, Body, Depends, Request from fastapi.responses import HTMLResponse, JSONResponse -from fastapi.templating import Jinja2Templates from rsptx.auth.session import auth_manager from rsptx.configuration import settings from rsptx.db.async_session import async_session @@ -52,7 +51,7 @@ from rsptx.response_helpers.core import ( get_webpack_static_imports, ) -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates # Analogy themes for async LLM mode # ================================== @@ -98,7 +97,7 @@ async def get_peer_instructor( Display the peer instruction instructor interface showing all peer assignments. """ rslogger.info(f"Rendering peer instructor page for course: {course.course_name}") - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Fetch all peer assignments for the course all_assignments = await fetch_assignments(course.course_name, fetch_all=True) @@ -140,7 +139,7 @@ async def get_peer_dashboard( This is where instructors control the flow of peer instruction. """ rslogger.info(f"Peer dashboard for assignment {assignment_id}, next={next}") - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Fetch the assignment assignment = await fetch_one_assignment(assignment_id) @@ -285,7 +284,7 @@ async def get_peer_extra( Meant to be opened on a separate device so students can't see it. """ rslogger.info(f"Peer extra info for assignment {assignment_id}") - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() assignment = await fetch_one_assignment(assignment_id) questions_result = await fetch_assignment_questions(assignment_id) @@ -353,7 +352,7 @@ async def get_peer_student( Display the peer instruction student interface showing available peer assignments. """ rslogger.info(f"Rendering peer student page for user: {user.username}") - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Fetch visible peer assignments for the student all_assignments = await fetch_assignments( @@ -393,7 +392,7 @@ async def get_peer_question( Display the current peer instruction question for in-class participation. """ rslogger.info(f"Peer question for assignment {assignment_id}, user {user.username}") - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() # Fetch the assignment and its questions assignment = await fetch_one_assignment(assignment_id) @@ -516,7 +515,7 @@ async def get_peer_async( rslogger.info( f"Peer async for assignment {assignment_id}, question {question_num}, user {user.username}" ) - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() assignment = await fetch_one_assignment(assignment_id) if not assignment: diff --git a/bases/rsptx/assignment_server_api/routers/student.py b/bases/rsptx/assignment_server_api/routers/student.py index e2f8ff896..32af10dd5 100644 --- a/bases/rsptx/assignment_server_api/routers/student.py +++ b/bases/rsptx/assignment_server_api/routers/student.py @@ -14,7 +14,6 @@ # Standard library # ---------------- import csv -import datetime import io from typing import Optional import json @@ -30,7 +29,6 @@ RedirectResponse, StreamingResponse, ) -from fastapi.templating import Jinja2Templates from pydantic import BaseModel # Local application imports @@ -67,7 +65,11 @@ from rsptx.db.models import GradeValidator, UseinfoValidation, CoursesValidator from rsptx.db.crud.assignment import is_assignment_visible_to_students from rsptx.auth.session import auth_manager, is_instructor -from rsptx.templates import template_folder, get_jinja_templates +from rsptx.templates import ( + format_course_datetime, + get_jinja_templates, + get_shared_templates, +) from rsptx.response_helpers import construct_course_url, safe_join from rsptx.response_helpers.core import ( make_json_response, @@ -92,7 +94,6 @@ async def get_assignments( request: Request, user=Depends(auth_manager), response_class=HTMLResponse, - RS_info: Optional[str] = Cookie(None), ): """Create the chooseAssignment page for the user. @@ -139,25 +140,16 @@ async def get_assignments( ] assignments = list(assignments) + exception_assignments - parsed_js = json.loads(RS_info) if RS_info else {} - timezoneoffset = parsed_js.get("tz_offset", None) + now = canonical_utcnow() def sort_key(assignment): - deadline = assignment.duedate - if timezoneoffset: - deadline = deadline + datetime.timedelta(hours=float(timezoneoffset)) - return ( - deadline < canonical_utcnow(), - abs((deadline - canonical_utcnow()).total_seconds()), - ) - else: - return ( - assignment.duedate < canonical_utcnow(), - abs((assignment.duedate - canonical_utcnow()).total_seconds()), - ) + # duedate is stored as naive UTC, so it is directly comparable to now. + return ( + assignment.duedate < now, + abs((assignment.duedate - now).total_seconds()), + ) # Sort assignments: upcoming assignments first (closest to current date), past due assignments last - now = canonical_utcnow() assignments.sort(key=sort_key) stats_list = await fetch_all_assignment_stats(course.course_name, user.id) stats = {} @@ -190,9 +182,9 @@ def sort_key(assignment): "Unsafe or invalid book_path computed for course %s; falling back to shared templates", course.course_name, ) - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() else: - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() context = dict( course=course, @@ -301,19 +293,27 @@ def _build_chapter_progress(chapters, subchapters, progress): return result -async def _build_assignment_grades(course_name, target_id, student_view): +async def _build_assignment_grades( + course_name, target_id, student_view, course_timezone=None +): """Build the per-assignment grade table (score, percent, class average). :param course_name: The course name. :param target_id: The auth_user id of the student being reported on. :param student_view: bool, True when a student is viewing their own report; unreleased assignments are shown as N/A in that case. + :param course_timezone: The course timezone used to render due dates, which + are stored as naive UTC. :return: dict keyed by assignment name. """ assignments = await fetch_assignments(course_name, fetch_all=True) grades = {} for assign in assignments: - due_date = assign.duedate.date().strftime("%m-%d-%Y") + # Shift into course-local time before taking the date: a late-evening + # deadline falls on the following day in UTC. + due_date = format_course_datetime( + assign.duedate, course_timezone, fmt="%m-%d-%Y", show_timezone=False + ) entry = { "score": "N/A", "pct": "N/A", @@ -411,13 +411,13 @@ async def studentreport( # Grades ------------------------------------------------------------------ grades = await _build_assignment_grades( - course.course_name, target_user.id, student_view + course.course_name, target_user.id, student_view, course.timezone ) # Recent activity --------------------------------------------------------- activity = await fetch_recent_useinfo(sid, course.course_name) - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() context = dict( request=request, course=course, @@ -928,13 +928,11 @@ async def doAssignment( else: is_graded = False - timezoneoffset = parsed_js.get("tz_offset", None) - timestamp = canonical_utcnow() + # duedate is stored as naive UTC, so it is directly comparable to timestamp. + # The template renders it in the course timezone via the course_datetime + # filter, so leave the datetime itself intact here. deadline = assignment.duedate - if timezoneoffset: - deadline = deadline + datetime.timedelta(hours=float(timezoneoffset)) - assignment.duedate = assignment.duedate.strftime("%a %d, %b %Y %I:%M %p") enforce_pastdue = False if assignment.enforce_due and timestamp > deadline: enforce_pastdue = True @@ -959,11 +957,11 @@ async def doAssignment( "Unsafe or invalid book_path computed for course %s; falling back to shared templates", course.course_name, ) - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() else: - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() - # templates = Jinja2Templates(directory=template_folder) + # templates = get_shared_templates() # reverse the order of the keys in the preambles dictionary so that the first key I added is now the last # this will ensure that when multiple preamble definitions are used the last one is from the current course preambles = dict((k, v) for k, v in reversed(preambles.items())) diff --git a/bases/rsptx/author_server_api/main.py b/bases/rsptx/author_server_api/main.py index 3d5334edc..9faa7acb8 100644 --- a/bases/rsptx/author_server_api/main.py +++ b/bases/rsptx/author_server_api/main.py @@ -26,7 +26,6 @@ from fastapi import Body, FastAPI, Request, Depends, status from fastapi.responses import JSONResponse, RedirectResponse, FileResponse from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates from celery.result import AsyncResult import pandas as pd from sqlalchemy import create_engine @@ -73,7 +72,7 @@ get_course_graph, ) from rsptx.auth.session import auth_manager -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates, template_folder logger = logging.getLogger("runestone") handler = logging.StreamHandler(sys.stdout) @@ -88,7 +87,7 @@ # We need to create a path that will work inside and outside of docker. base_dir = pathlib.Path(template_folder) app.mount("/static", StaticFiles(directory=base_dir / "staticAssets"), name="static") -templates = Jinja2Templates(directory=template_folder) +templates = get_shared_templates() add_exception_handlers(app) diff --git a/bases/rsptx/book_server_api/routers/books.py b/bases/rsptx/book_server_api/routers/books.py index d4cc1e6ec..94bbd1828 100644 --- a/bases/rsptx/book_server_api/routers/books.py +++ b/bases/rsptx/book_server_api/routers/books.py @@ -315,15 +315,8 @@ async def serve_page( activity_info = await fetch_page_activity_counts( chapter, subchapter, course_row.base_course, course_name, user.username ) - if not course_row.timezone: - if RS_info: - tz = json.loads(RS_info).get("timezone", "UTC") - else: - tz = "UTC" - else: - tz = course_row.timezone assignment__spec = await fetch_reading_assignment_spec( - chapter, subchapter, course_row.id, timezone=tz + chapter, subchapter, course_row.id ) if assignment__spec: activity_info["assignment_spec"] = dict(**assignment__spec._mapping) diff --git a/bases/rsptx/book_server_api/routers/course.py b/bases/rsptx/book_server_api/routers/course.py index 2b17be06c..7458a8ced 100644 --- a/bases/rsptx/book_server_api/routers/course.py +++ b/bases/rsptx/book_server_api/routers/course.py @@ -8,20 +8,17 @@ # # Standard library # ---------------- -import json import datetime # Third-party imports # ------------------- -from typing import Optional -from fastapi import APIRouter, Cookie, Request, Depends, status -from fastapi.templating import Jinja2Templates +from fastapi import APIRouter, Request, Depends, status # Local application imports # ------------------------- from rsptx.auth.session import auth_manager -from rsptx.templates import get_jinja_templates, template_folder +from rsptx.templates import get_jinja_templates, get_shared_templates from rsptx.db.crud import ( fetch_assignments, fetch_all_assignment_stats, @@ -58,9 +55,7 @@ @router.api_route("/index", methods=["GET", "POST"]) -async def index( - request: Request, user=Depends(auth_manager), RS_info: Optional[str] = Cookie(None) -): +async def index(request: Request, user=Depends(auth_manager)): """Fetch current course information Fetch current assignment information @@ -114,24 +109,16 @@ async def index( assignments = list(assignments) + exception_assignments assignments = adjust_deadlines(assignments, accommodations) - parsed_js = json.loads(RS_info) if RS_info else {} - timezoneoffset = parsed_js.get("tz_offset", None) + now = canonical_utcnow() def sort_key(assignment): - deadline = assignment.duedate - if timezoneoffset: - deadline = deadline + datetime.timedelta(hours=float(timezoneoffset)) - return ( - deadline < canonical_utcnow(), - abs((deadline - canonical_utcnow()).total_seconds()), - ) - else: - return ( - assignment.duedate < canonical_utcnow(), - abs((assignment.duedate - canonical_utcnow()).total_seconds()), - ) + # duedate is stored as naive UTC, so it is directly comparable to now. + # Upcoming assignments sort first, closest deadline first. + return ( + assignment.duedate < now, + abs((assignment.duedate - now).total_seconds()), + ) - now = canonical_utcnow() assignments.sort(key=sort_key) stats_list = await fetch_all_assignment_stats(course_name, user.id) @@ -160,9 +147,9 @@ def sort_key(assignment): if book_path: templates = get_jinja_templates(book_path) else: - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() else: - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() return templates.TemplateResponse( "book/course/current_course.html", diff --git a/bases/rsptx/book_server_api/routers/rslogging.py b/bases/rsptx/book_server_api/routers/rslogging.py index 8b59a5eef..3c89db83e 100644 --- a/bases/rsptx/book_server_api/routers/rslogging.py +++ b/bases/rsptx/book_server_api/routers/rslogging.py @@ -188,16 +188,7 @@ async def log_book_event( ans_idx = await create_answer_table_entry(valid_table, entry.event) rslogger.debug(ans_idx) if entry.event != "timedExam" and entry.event != "selectquestion": - course = await fetch_course(user.course_name) - if course.timezone: - tz = course.timezone - else: - if hasattr(request.state, "timezone"): - tz = request.state.timezone - rslogger.debug(f"Using timezone {tz} from request state") - else: - tz = "UTC" - scoreSpec = await grade_submission(user, entry, tz) + scoreSpec = await grade_submission(user, entry) response_dict.update(scoreSpec.model_dump()) if idx: diff --git a/components/rsptx/db/crud/scoring.py b/components/rsptx/db/crud/scoring.py index 1c3b743b6..591ff2d62 100644 --- a/components/rsptx/db/crud/scoring.py +++ b/components/rsptx/db/crud/scoring.py @@ -1,7 +1,8 @@ import datetime from typing import List, Optional from sqlalchemy import select, and_, or_ -from zoneinfo import ZoneInfo + +from rsptx.response_helpers.core import canonical_utcnow from ..models import ( Assignment, @@ -47,7 +48,6 @@ async def is_assigned( course_id: int, assignment_id: Optional[int] = None, accommodation: Optional[DeadlineExceptionValidator] = None, - timezone: Optional[str] = "UTC", ) -> schemas.ScoringSpecification: """ Check if a question is part of an assignment. @@ -78,8 +78,7 @@ async def is_assigned( visible_exception = False if accommodation and accommodation.visible: visible_exception = True - tz = ZoneInfo(timezone) - course_tz_now = datetime.datetime.now(tz) + now = canonical_utcnow() async with async_session() as session: res = await session.execute(query) for row in res: @@ -97,7 +96,7 @@ async def is_assigned( ) if accommodation and accommodation.duedate: row.Assignment.duedate += datetime.timedelta(days=accommodation.duedate) - if course_tz_now <= row.Assignment.duedate.replace(tzinfo=tz): + if now <= row.Assignment.duedate: if is_assignment_visible_to_students(row.Assignment): scoringSpec.assigned = True return scoringSpec @@ -116,7 +115,6 @@ async def fetch_reading_assignment_spec( chapter: str, subchapter: str, course_id: int, - timezone: Optional[str] = "UTC", ) -> Optional[int]: """ Check if a reading assignment is assigned for a given chapter and subchapter. @@ -126,11 +124,6 @@ async def fetch_reading_assignment_spec( :param course_id: int, the id of the course :return: The number of required activities or None if not found """ - tz = ZoneInfo(timezone) - course_tz_now = datetime.datetime.now(tz) - course_tz_now = course_tz_now.replace(tzinfo=None) - from rsptx.response_helpers.core import canonical_utcnow - now = canonical_utcnow() # Visibility clause that respects visible_on and hidden_on scheduling vclause = or_( @@ -175,7 +168,7 @@ async def fetch_reading_assignment_spec( Question.subchapter == subchapter, vclause, or_( - Assignment.duedate > course_tz_now, + Assignment.duedate > now, Assignment.enforce_due == False, # noqa: E712 ), ) diff --git a/components/rsptx/exceptions/core.py b/components/rsptx/exceptions/core.py index 0a22d6f0c..784ec2db2 100644 --- a/components/rsptx/exceptions/core.py +++ b/components/rsptx/exceptions/core.py @@ -7,7 +7,6 @@ from fastapi import Request, Response, status from fastapi.encoders import jsonable_encoder from fastapi.responses import JSONResponse, RedirectResponse -from fastapi.templating import Jinja2Templates from pydantic import ValidationError @@ -16,7 +15,7 @@ from rsptx.db.crud import create_traceback from rsptx.logging import rslogger from rsptx.response_helpers.core import canonical_utcnow -from rsptx.templates import template_folder +from rsptx.templates import get_shared_templates def add_exception_handlers(app): @@ -146,7 +145,7 @@ async def generic_error_handler(request: Request, exc: Exception): "timestamp": date, } - templates = Jinja2Templates(directory=template_folder) + templates = get_shared_templates() return templates.TemplateResponse("error_page.html", context) else: return JSONResponse( diff --git a/components/rsptx/grading_helpers/core.py b/components/rsptx/grading_helpers/core.py index 8d5c9f880..12a9c2867 100644 --- a/components/rsptx/grading_helpers/core.py +++ b/components/rsptx/grading_helpers/core.py @@ -1,6 +1,5 @@ from typing import Union, List from datetime import timedelta -from zoneinfo import ZoneInfo from rsptx.db.models import AuthUserValidator, DeadlineExceptionValidator from rsptx.db.crud import ( is_assigned, @@ -35,7 +34,7 @@ async def grade_submission( - user: AuthUserValidator, submission: LogItemIncoming, timezone: str = "UTC" + user: AuthUserValidator, submission: LogItemIncoming ) -> ScoringSpecification: """ Grade a submission and store the results in the database. @@ -64,7 +63,6 @@ async def grade_submission( user.course_id, submission.assignment_id, accommodation=accommodation, - timezone=timezone, ) # Skip scoring for interaction events these are just logs that the student # did something with a selectquestion. This is required to get accurate grades @@ -337,9 +335,7 @@ def adjust_deadlines( return assignment_list -async def has_late_submission( - username: str, assignment_id: int, timezone: str = "UTC" -) -> bool: +async def has_late_submission(username: str, assignment_id: int) -> bool: """ Determine whether a student has saved work for any question on an assignment after its deadline. @@ -356,8 +352,6 @@ async def has_late_submission( :param username: The student's username (``auth_user.username`` / ``sid``). :param assignment_id: The id of the assignment to check. - :param timezone: The course timezone used to interpret the due date, which - is stored in course-local time. :return: True if the student saved work after the enforced (and accommodated) deadline, False otherwise. """ @@ -376,11 +370,6 @@ async def has_late_submission( if accommodation and accommodation.duedate: deadline += timedelta(days=accommodation.duedate) - # The due date is stored in the course's local timezone, while useinfo - # timestamps are stored as naive UTC, so convert before comparing. - tz = ZoneInfo(timezone) - deadline_utc = ( - deadline.replace(tzinfo=tz).astimezone(ZoneInfo("UTC")).replace(tzinfo=None) - ) - - return await has_submissions_after_deadline(username, assignment_id, deadline_utc) + # Both the due date and the useinfo timestamps are naive UTC, so they are + # directly comparable. + return await has_submissions_after_deadline(username, assignment_id, deadline) diff --git a/components/rsptx/grading_helpers/regrade.py b/components/rsptx/grading_helpers/regrade.py index dfa862577..029f5593c 100644 --- a/components/rsptx/grading_helpers/regrade.py +++ b/components/rsptx/grading_helpers/regrade.py @@ -98,6 +98,11 @@ async def _fetch_answer_rows(tbl, div_id: str, course_name: str, sid: str): def _effective_deadline(assignment: AssignmentValidator, accommodation): + """Return the assignment deadline as naive UTC, with any accommodation applied. + + ``Assignment.duedate`` is stored as naive UTC, the same as the answer table + timestamps it gets compared against, so no timezone conversion belongs here. + """ deadline = assignment.duedate if deadline is None: return None diff --git a/components/rsptx/lti1p3/core.py b/components/rsptx/lti1p3/core.py index c9ebcc507..bddeafac7 100644 --- a/components/rsptx/lti1p3/core.py +++ b/components/rsptx/lti1p3/core.py @@ -76,7 +76,14 @@ def update_line_item_from_assignment( get_assignment_score_resource_id(rs_course, rs_assignment) ) if push_duedate: - line_item.set_end_date_time(rs_assignment.duedate.isoformat()) + # duedate is stored as naive UTC. Send it with an explicit offset, + # matching the format time_now() uses -- without one the LMS is free to + # read it as its own local time and silently shift the deadline. + line_item.set_end_date_time( + rs_assignment.duedate.replace(tzinfo=datetime.timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) line_item.set_tag("grade") line_item.set_score_maximum(rs_assignment.points if use_pts else 100) return line_item diff --git a/components/rsptx/templates/__init__.py b/components/rsptx/templates/__init__.py index 70d31cf7b..44e60ec16 100644 --- a/components/rsptx/templates/__init__.py +++ b/components/rsptx/templates/__init__.py @@ -1,7 +1,19 @@ from pathlib import Path from rsptx.templates import core -from rsptx.templates.core import get_jinja_templates +from rsptx.templates.core import ( + format_course_datetime, + get_jinja_templates, + get_shared_templates, + install_filters, +) -__all__ = ["core", "get_jinja_templates", "template_folder"] +__all__ = [ + "core", + "format_course_datetime", + "get_jinja_templates", + "get_shared_templates", + "install_filters", + "template_folder", +] template_folder = Path(__file__).parent.absolute() diff --git a/components/rsptx/templates/assignment/instructor/peer_instructor.html b/components/rsptx/templates/assignment/instructor/peer_instructor.html index 1a34979fa..9ee729502 100644 --- a/components/rsptx/templates/assignment/instructor/peer_instructor.html +++ b/components/rsptx/templates/assignment/instructor/peer_instructor.html @@ -130,7 +130,7 @@

Course: {{ course.course_name }}

{{ assignment.name }} - Review - {{ assignment.duedate.strftime('%Y-%m-%d %H:%M') if assignment.duedate else 'N/A' }} + {{ assignment.duedate | course_datetime(course.timezone, '%Y-%m-%d %H:%M') if assignment.duedate else 'N/A' }} {{ assignment.description or '' }} {% endfor %} diff --git a/components/rsptx/templates/assignment/student/assignment_block.html b/components/rsptx/templates/assignment/student/assignment_block.html index 83f3e51c0..cfa4276d8 100644 --- a/components/rsptx/templates/assignment/student/assignment_block.html +++ b/components/rsptx/templates/assignment/student/assignment_block.html @@ -47,7 +47,7 @@ {% endif %} - {{assignment.duedate.strftime("%b %d, %Y %I:%M %p")}} + {{ assignment.duedate | course_datetime(course.timezone) }} {% if assignment.id in stats and stats[assignment.id].score != None %} {{ stats[assignment.id].score | round(2) }} / {{ assignment.points }} diff --git a/components/rsptx/templates/assignment/student/doAssignment.html b/components/rsptx/templates/assignment/student/doAssignment.html index 3a84e0ce5..bfedb43b3 100644 --- a/components/rsptx/templates/assignment/student/doAssignment.html +++ b/components/rsptx/templates/assignment/student/doAssignment.html @@ -40,7 +40,7 @@

Assignment: {{ assignm class="assignment-due-date notdue" {% endif %} > - Due: {{ assignment['duedate'] }} + Due: {{ assignment['duedate'] | course_datetime(course.timezone, "%a %d, %b %Y %I:%M %p") }}

{% if enforce_pastdue %} (Past due and no longer scoring submissions) diff --git a/components/rsptx/templates/assignment/student/peer_student.html b/components/rsptx/templates/assignment/student/peer_student.html index 603423bb0..515f40170 100644 --- a/components/rsptx/templates/assignment/student/peer_student.html +++ b/components/rsptx/templates/assignment/student/peer_student.html @@ -122,7 +122,7 @@

Course: {{ course.course_name }}

Not Available {% endif %} - {{ assignment.duedate.strftime('%Y-%m-%d %H:%M') if assignment.duedate else 'N/A' }} + {{ assignment.duedate | course_datetime(course.timezone, '%Y-%m-%d %H:%M') if assignment.duedate else 'N/A' }} {{ assignment.description or '' }} {% endfor %} diff --git a/components/rsptx/templates/core.py b/components/rsptx/templates/core.py index fe6b6e4de..eb506968a 100644 --- a/components/rsptx/templates/core.py +++ b/components/rsptx/templates/core.py @@ -1,12 +1,87 @@ +import datetime from pathlib import Path +from typing import Optional, Union +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import jinja2 from fastapi.templating import Jinja2Templates +template_folder = Path(__file__).parent.absolute() + +# Assignment due dates render as a date and time with no seconds; the timezone +# abbreviation is appended separately by format_course_datetime. +DEFAULT_DATETIME_FORMAT = "%b %d, %Y %I:%M %p" + + +def _resolve_timezone(name: Optional[str]) -> datetime.tzinfo: + """Turn a course timezone name into a tzinfo, falling back to UTC. + + A course with no timezone is treated as UTC, matching the due date + migration. An unrecognized name also falls back rather than raising -- a + bad timezone string should not take a page down. + """ + if not name: + return datetime.timezone.utc + try: + return ZoneInfo(name) + except (ZoneInfoNotFoundError, ValueError): + return datetime.timezone.utc + + +def format_course_datetime( + value: Union[datetime.datetime, None], + course_timezone: Optional[str] = None, + fmt: str = DEFAULT_DATETIME_FORMAT, + show_timezone: bool = True, +) -> str: + """Render a naive UTC datetime as wall clock time in the course timezone. + + Datetimes are stored as naive UTC throughout the database. A deadline is a + property of the course rather than of whoever is looking at it, so it is + displayed in the course's own timezone, with the abbreviation shown so the + reader knows which clock it refers to. + + Registered as the ``course_datetime`` Jinja filter:: + + {{ assignment.duedate | course_datetime(course.timezone) }} + """ + if value is None: + return "" + if not isinstance(value, datetime.datetime): + # Already formatted upstream, or not a datetime at all -- pass through. + return str(value) + + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.timezone.utc) + local = value.astimezone(_resolve_timezone(course_timezone)) + + rendered = local.strftime(fmt) + if show_timezone: + abbreviation = local.strftime("%Z") + if abbreviation: + rendered = f"{rendered} {abbreviation}" + return rendered + + +def install_filters(env: jinja2.Environment) -> jinja2.Environment: + """Register Runestone's shared Jinja filters on an environment.""" + env.filters["course_datetime"] = format_course_datetime + return env + + +def get_shared_templates() -> Jinja2Templates: + """Return Jinja templates for the shared template folder. + + Use this instead of constructing ``Jinja2Templates`` directly so that + every server gets the shared filters. + """ + templates = Jinja2Templates(directory=template_folder) + install_filters(templates.env) + return templates + def get_jinja_templates(book_path: str) -> Jinja2Templates: """Return Jinja templates that search book-specific and shared paths.""" - template_folder = Path(__file__).parent.absolute() loader = jinja2.ChoiceLoader( [ jinja2.FileSystemLoader(book_path), @@ -17,4 +92,5 @@ def get_jinja_templates(book_path: str) -> Jinja2Templates: loader=loader, autoescape=jinja2.select_autoescape(["html", "xml"]), ) + install_filters(env) return Jinja2Templates(env=env) diff --git a/migrations/versions/c4e8a1f7b2d9_duedate_to_utc.py b/migrations/versions/c4e8a1f7b2d9_duedate_to_utc.py new file mode 100644 index 000000000..ea98aca20 --- /dev/null +++ b/migrations/versions/c4e8a1f7b2d9_duedate_to_utc.py @@ -0,0 +1,181 @@ +"""store assignment duedate in UTC + +Revision ID: c4e8a1f7b2d9 +Revises: 3bfddd662428 +Create Date: 2026-07-27 10:12:04.331902 + +``assignments.duedate`` has always been a naive datetime holding *course-local* +wall clock time, while ``visible_on``, ``hidden_on`` and every answer timestamp +are naive UTC. This migration shifts ``duedate`` to naive UTC so the column is +consistent with the rest of the schema and no conversion is needed at grading +time. + +Only courses with an explicit ``courses.timezone`` are shifted. The column is +nullable and was added in ``8f857bdfef19`` (2025-10-07) without a backfill, so +most legacy courses are NULL and have no defensible source timezone for their +due dates. Those rows are left byte-identical and their timezone is backfilled +to ``'UTC'``, which matches the fallback the application already used when no +timezone and no browser cookie were available. + +The ids of the courses whose timezone was backfilled are recorded in +``duedate_utc_tz_backfill`` so ``downgrade()`` can restore NULL for exactly +those courses and no others. That table is dropped by ``downgrade()``. + +``downgrade()`` recomputes the local time from the stored UTC value rather than +restoring a snapshot, so assignments created or edited after the upgrade are +converted correctly too. The round trip is exact unless a course's timezone is +changed while the upgrade is in effect. +""" + +import logging +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c4e8a1f7b2d9" +down_revision: Union[str, None] = "3bfddd662428" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +logger = logging.getLogger("alembic.runtime.migration") + +BACKFILL_TABLE = "duedate_utc_tz_backfill" + +# Postgres ``AT TIME ZONE`` is direction sensitive: +# naive timestamp AT TIME ZONE 'zone' -> timestamptz (reads the naive value as +# wall clock in 'zone') +# timestamptz AT TIME ZONE 'zone' -> naive timestamp (renders the instant +# as wall clock in 'zone') +# so local -> UTC is (duedate AT TIME ZONE course_tz) AT TIME ZONE 'UTC'. + +LOCAL_TO_UTC = sa.text( + """ + UPDATE assignments a + SET duedate = (a.duedate AT TIME ZONE c.timezone) AT TIME ZONE 'UTC' + FROM courses c + WHERE a.course = c.id + AND a.duedate IS NOT NULL + AND c.timezone IS NOT NULL + AND c.timezone <> 'UTC' + """ +) + +UTC_TO_LOCAL = sa.text( + """ + UPDATE assignments a + SET duedate = (a.duedate AT TIME ZONE 'UTC') AT TIME ZONE c.timezone + FROM courses c + WHERE a.course = c.id + AND a.duedate IS NOT NULL + AND c.timezone IS NOT NULL + AND c.timezone <> 'UTC' + """ +) + +# Only courses that actually own assignments can break the conversion, so a bad +# timezone on an empty course is not worth blocking a deploy over. +BAD_TIMEZONES = sa.text( + """ + SELECT c.id, c.course_name, c.timezone + FROM courses c + WHERE c.timezone IS NOT NULL + AND EXISTS (SELECT 1 FROM assignments a WHERE a.course = c.id) + AND NOT EXISTS ( + SELECT 1 FROM pg_timezone_names t + WHERE lower(t.name) = lower(c.timezone) + ) + ORDER BY c.id + """ +) + + +def _assert_timezones_are_resolvable(conn) -> None: + """Fail with an actionable message instead of a bare Postgres error. + + ``AT TIME ZONE`` aborts the whole statement on an unrecognized zone name. + ``courses.timezone`` is validated against the IANA database when set through + the course settings UI, but the LTI and legacy paths do not go through that + validator, so check before touching any rows. + """ + bad = conn.execute(BAD_TIMEZONES).fetchall() + if bad: + detail = ", ".join( + f"course {r.id} ({r.course_name!r}) = {r.timezone!r}" for r in bad + ) + raise RuntimeError( + "Cannot convert assignment due dates to UTC: these courses have " + f"assignments and a timezone Postgres does not recognize: {detail}. " + "Fix or clear courses.timezone for them and re-run the migration." + ) + + +def _backfill_table_exists(conn) -> bool: + return ( + conn.execute( + sa.text("SELECT to_regclass(:name)"), {"name": BACKFILL_TABLE} + ).scalar() + is not None + ) + + +def upgrade() -> None: + conn = op.get_bind() + + _assert_timezones_are_resolvable(conn) + + shifted = conn.execute(LOCAL_TO_UTC).rowcount + logger.info("duedate -> UTC: shifted %s assignment(s)", shifted) + + # Record which courses we are about to backfill so the downgrade can put + # NULL back for exactly those, and not for courses that already said 'UTC'. + conn.execute( + sa.text( + f"CREATE TABLE IF NOT EXISTS {BACKFILL_TABLE} " + "(course_id integer PRIMARY KEY)" + ) + ) + conn.execute( + sa.text( + f"INSERT INTO {BACKFILL_TABLE} (course_id) " + "SELECT id FROM courses WHERE timezone IS NULL " + "ON CONFLICT (course_id) DO NOTHING" + ) + ) + backfilled = conn.execute( + sa.text("UPDATE courses SET timezone = 'UTC' WHERE timezone IS NULL") + ).rowcount + logger.info("duedate -> UTC: backfilled timezone on %s course(s)", backfilled) + + +def downgrade() -> None: + conn = op.get_bind() + + _assert_timezones_are_resolvable(conn) + + # Restore NULL first, so those courses are excluded from the conversion + # below exactly as they were excluded on the way up. + if _backfill_table_exists(conn): + restored = conn.execute( + sa.text( + "UPDATE courses SET timezone = NULL " + f"WHERE id IN (SELECT course_id FROM {BACKFILL_TABLE})" + ) + ).rowcount + logger.info( + "duedate -> local: restored NULL timezone on %s course(s)", restored + ) + else: + logger.warning( + "%s is missing; leaving courses.timezone as-is. Due dates will still " + "be converted back to course-local time.", + BACKFILL_TABLE, + ) + + shifted = conn.execute(UTC_TO_LOCAL).rowcount + logger.info("duedate -> local: shifted %s assignment(s)", shifted) + + op.execute(f"DROP TABLE IF EXISTS {BACKFILL_TABLE}") diff --git a/test/bases/rsptx/admin_server_api/test_analytics.py b/test/bases/rsptx/admin_server_api/test_analytics.py index b7431e939..10f2a2f41 100644 --- a/test/bases/rsptx/admin_server_api/test_analytics.py +++ b/test/bases/rsptx/admin_server_api/test_analytics.py @@ -5,6 +5,7 @@ import pandas as pd from rsptx.admin_server_api.routers.analytics import ( + _format_duedate, _pad_with_enrolled, _student_label, ) @@ -69,3 +70,34 @@ def test_pad_with_enrolled_no_roster_is_a_noop(): assert list(padded.columns) == ["Kussman, Erin (ekussman)"] assert padded["Kussman, Erin (ekussman)"].tolist() == [3] + + +# _format_duedate +# --------------- +# duedate is stored as naive UTC and these reports show a date only, so the +# value has to be shifted into the course timezone before the date is taken -- +# a late-evening deadline falls on the following day in UTC. + + +def test_format_duedate_shifts_into_the_course_timezone(): + # 2026-09-02 04:59 UTC is 2026-09-01 in Chicago. + stamp = pd.Timestamp("2026-09-02 04:59:00") + assert _format_duedate(stamp, "America/Chicago") == "2026-09-01" + + +def test_format_duedate_without_a_timezone_stays_utc(): + stamp = pd.Timestamp("2026-09-02 04:59:00") + assert _format_duedate(stamp, None) == "2026-09-02" + + +def test_format_duedate_handles_pandas_null(): + # pd.NaT is not caught by an `is None` check and would otherwise render as + # the string "NaT". + assert _format_duedate(pd.NaT, "America/Chicago") == "" + assert _format_duedate(None, "America/Chicago") == "" + + +def test_format_duedate_omits_any_timezone_label(): + stamp = pd.Timestamp("2026-09-02 04:59:00") + assert _format_duedate(stamp, "America/Chicago") == "2026-09-01" + assert "CDT" not in _format_duedate(stamp, "America/Chicago") diff --git a/test/bases/rsptx/admin_server_api/test_copy_assignment_dates.py b/test/bases/rsptx/admin_server_api/test_copy_assignment_dates.py new file mode 100644 index 000000000..6acf86b9f --- /dev/null +++ b/test/bases/rsptx/admin_server_api/test_copy_assignment_dates.py @@ -0,0 +1,160 @@ +"""Due date arithmetic when copying an assignment between terms. + +``_copy_one_assignment`` re-dates an assignment by its offset from the start of +term. ``duedate`` is stored as naive UTC while ``term_start_date`` is a bare +date, so the term start has to be anchored in the course timezone before the +two can be subtracted. Without that, the copy drifts by the course's UTC offset +and can land on the wrong day. +""" + +import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from zoneinfo import ZoneInfo + +from rsptx.admin_server_api.routers import instructor +from rsptx.admin_server_api.routers.instructor import _term_start_utc + +UTC = datetime.timezone.utc +CHICAGO = ZoneInfo("America/Chicago") + + +def _local_to_stored(year, month, day, hour, minute, tz): + """The naive UTC value the database holds for a local wall clock time.""" + return ( + datetime.datetime(year, month, day, hour, minute, tzinfo=tz) + .astimezone(UTC) + .replace(tzinfo=None) + ) + + +def _stored_to_local(stored, tz): + return stored.replace(tzinfo=UTC).astimezone(tz) + + +def _course(id, term_start_date, timezone): + return SimpleNamespace( + id=id, + course_name=f"course{id}", + term_start_date=term_start_date, + timezone=timezone, + ) + + +def _assignment(duedate): + return SimpleNamespace( + id=7, + name="Homework 1", + description="", + duedate=duedate, + points=10, + threshold_pct=None, + is_timed=False, + is_peer=False, + time_limit=None, + from_source=False, + nofeedback=False, + nopause=False, + released=True, + allow_self_autograde=False, + enforce_due=True, + peer_async_visible=False, + kind="Regular", + ) + + +async def _copy(source_course, target_course, assignment): + """Run the copy with its collaborators mocked; return the new duedate.""" + created = AsyncMock(return_value=SimpleNamespace(id=99)) + with ( + patch.object(instructor, "fetch_course", AsyncMock(return_value=source_course)), + patch.object( + instructor, "fetch_one_assignment", AsyncMock(return_value=assignment) + ), + patch.object(instructor, "create_assignment", created), + patch.object( + instructor, "fetch_assignment_questions", AsyncMock(return_value=[]) + ), + patch.object(instructor, "create_assignment_question", AsyncMock()), + ): + result = await instructor._copy_one_assignment( + source_course.course_name, assignment.id, target_course + ) + # The function swallows exceptions into a "failed: ..." string, so assert + # success rather than silently testing an error path. + assert result == "success", result + return created.call_args.args[0].duedate + + +# _term_start_utc +# --------------- + + +def test_term_start_anchors_midnight_in_the_course_timezone(): + # Midnight on 2026-08-24 in Chicago (CDT, UTC-5) is 05:00 UTC. + assert _term_start_utc(datetime.date(2026, 8, 24), "America/Chicago") == ( + datetime.datetime(2026, 8, 24, 5, 0) + ) + + +def test_term_start_uses_the_offset_in_effect_on_that_date(): + # January is CST (UTC-6), August is CDT (UTC-5). + assert _term_start_utc(datetime.date(2026, 1, 12), "America/Chicago").hour == 6 + assert _term_start_utc(datetime.date(2026, 8, 24), "America/Chicago").hour == 5 + + +@pytest.mark.parametrize("timezone", [None, "", "UTC"]) +def test_term_start_without_a_timezone_is_utc_midnight(timezone): + assert _term_start_utc(datetime.date(2026, 8, 24), timezone) == ( + datetime.datetime(2026, 8, 24, 0, 0) + ) + + +# _copy_one_assignment +# -------------------- + + +async def test_copy_preserves_local_wall_clock_across_a_dst_boundary(): + # Regression: a spring term start (CST) and a fall term start (CDT) used to + # produce 2026-09-02 00:59 local instead of 2026-09-01 23:59. + source = _course(1, datetime.date(2026, 1, 12), "America/Chicago") + target = _course(2, datetime.date(2026, 8, 24), "America/Chicago") + stored = _local_to_stored(2026, 1, 20, 23, 59, CHICAGO) + + new_duedate = await _copy(source, target, _assignment(stored)) + + local = _stored_to_local(new_duedate, CHICAGO) + assert local.strftime("%Y-%m-%d %H:%M") == "2026-09-01 23:59" + + +async def test_copy_preserves_the_offset_from_the_start_of_term(): + source = _course(1, datetime.date(2026, 1, 12), "America/Chicago") + target = _course(2, datetime.date(2026, 8, 24), "America/Chicago") + stored = _local_to_stored(2026, 1, 20, 23, 59, CHICAGO) + + new_duedate = await _copy(source, target, _assignment(stored)) + + # 8 days and change after the start of term, in both terms. + assert new_duedate - _term_start_utc( + target.term_start_date, target.timezone + ) == stored - _term_start_utc(source.term_start_date, source.timezone) + + +async def test_copy_with_no_course_timezone_behaves_as_utc(): + source = _course(1, datetime.date(2026, 1, 12), None) + target = _course(2, datetime.date(2026, 8, 24), None) + stored = datetime.datetime(2026, 1, 20, 23, 59) + + new_duedate = await _copy(source, target, _assignment(stored)) + + assert new_duedate == datetime.datetime(2026, 9, 1, 23, 59) + + +async def test_copy_keeps_the_duedate_when_a_term_start_is_missing(): + source = _course(1, None, "America/Chicago") + target = _course(2, datetime.date(2026, 8, 24), "America/Chicago") + stored = _local_to_stored(2026, 1, 20, 23, 59, CHICAGO) + + assert await _copy(source, target, _assignment(stored)) == stored diff --git a/test/components/rsptx/grading_helpers/test_core.py b/test/components/rsptx/grading_helpers/test_core.py index a3cfa2899..726a48a0a 100644 --- a/test/components/rsptx/grading_helpers/test_core.py +++ b/test/components/rsptx/grading_helpers/test_core.py @@ -51,10 +51,10 @@ async def test_returns_false_when_no_late_work(): async def test_deadline_passed_to_query_in_utc(): - # UTC course timezone: the naive duedate is used unchanged as the cutoff. + # duedate is stored as naive UTC and is used unchanged as the cutoff. a, d, h = _patch_crud(_assignment(duedate=datetime(2026, 6, 1, 23, 59, 0))) with a, d, h as has_submissions: - await core.has_late_submission("student1", 42, timezone="UTC") + await core.has_late_submission("student1", 42) args = has_submissions.call_args.args assert args[0] == "student1" assert args[1] == 42 @@ -69,13 +69,16 @@ async def test_accommodation_extends_deadline(): accommodation=accommodation, ) with a, d, h as has_submissions: - await core.has_late_submission("student1", 42, timezone="UTC") + await core.has_late_submission("student1", 42) assert has_submissions.call_args.args[2] == datetime(2026, 6, 4, 23, 59, 0) -async def test_due_date_converted_from_course_timezone_to_utc(): - # 23:59 on 2026-06-01 in New York (EDT, UTC-4) is 03:59 the next day in UTC. - a, d, h = _patch_crud(_assignment(duedate=datetime(2026, 6, 1, 23, 59, 0))) +async def test_due_date_is_not_shifted_by_any_timezone(): + # duedate is already UTC, so the cutoff must be passed through untouched no + # matter what the ambient local timezone of the process happens to be. This + # guards against someone reintroducing a course-local -> UTC conversion. + duedate = datetime(2026, 6, 1, 23, 59, 0) + a, d, h = _patch_crud(_assignment(duedate=duedate)) with a, d, h as has_submissions: - await core.has_late_submission("student1", 42, timezone="America/New_York") - assert has_submissions.call_args.args[2] == datetime(2026, 6, 2, 3, 59, 0) + await core.has_late_submission("student1", 42) + assert has_submissions.call_args.args[2] == duedate diff --git a/test/components/rsptx/grading_helpers/test_regrade_batch.py b/test/components/rsptx/grading_helpers/test_regrade_batch.py index 820fe6a98..7dcb62aec 100644 --- a/test/components/rsptx/grading_helpers/test_regrade_batch.py +++ b/test/components/rsptx/grading_helpers/test_regrade_batch.py @@ -1,3 +1,4 @@ +from datetime import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -160,3 +161,51 @@ async def test_rollup_uses_the_graded_course(): assert fetch_scores.await_args.args == (assignment.id, "testcourse", "student1") assert upsert.await_args.args[0].score == 7.0 assert lti.await_args.args[2] == 7.0 + + +# _effective_deadline +# ------------------- +# duedate is stored as naive UTC, the same frame as the answer timestamps it is +# compared against in regrade_one. This function previously returned a +# course-local duedate that was compared straight against UTC timestamps, so +# batch regrades silently used a cutoff that was off by the course's UTC +# offset. These tests pin the frame. + + +def _dated_assignment(duedate): + return SimpleNamespace(id=42, points=10, threshold_pct=None, duedate=duedate) + + +def test_effective_deadline_returns_the_duedate_unchanged(): + duedate = datetime(2026, 9, 2, 4, 59) + assert regrade._effective_deadline(_dated_assignment(duedate), None) == duedate + + +def test_effective_deadline_applies_accommodation_days(): + duedate = datetime(2026, 9, 2, 4, 59) + accommodation = SimpleNamespace(duedate=3) + assert regrade._effective_deadline( + _dated_assignment(duedate), accommodation + ) == datetime(2026, 9, 5, 4, 59) + + +def test_effective_deadline_ignores_accommodation_without_extra_days(): + duedate = datetime(2026, 9, 2, 4, 59) + for accommodation in (None, SimpleNamespace(duedate=None), SimpleNamespace()): + assert ( + regrade._effective_deadline(_dated_assignment(duedate), accommodation) + == duedate + ) + + +def test_effective_deadline_is_none_when_no_duedate(): + assert regrade._effective_deadline(_dated_assignment(None), None) is None + + +def test_effective_deadline_does_not_apply_a_timezone_shift(): + # Guards against reintroducing a course-local -> UTC conversion here. The + # cutoff must be usable as-is against naive UTC answer timestamps. + duedate = datetime(2026, 9, 2, 4, 59) + result = regrade._effective_deadline(_dated_assignment(duedate), None) + assert result.tzinfo is None + assert result == duedate diff --git a/test/components/rsptx/lti1p3/__init__.py b/test/components/rsptx/lti1p3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/components/rsptx/lti1p3/test_duedate_exchange.py b/test/components/rsptx/lti1p3/test_duedate_exchange.py new file mode 100644 index 000000000..d5de6ec1e --- /dev/null +++ b/test/components/rsptx/lti1p3/test_duedate_exchange.py @@ -0,0 +1,142 @@ +"""LTI 1.3 due date exchange. + +``assignments.duedate`` is stored as naive UTC. On the way in, an LMS timestamp +carrying an offset converts straight to UTC while a naive one is read as +course-local wall clock. On the way out the value must carry an explicit +offset, or the LMS is free to read it as its own local time. +""" + +import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from rsptx.lti1p3.core import update_line_item_from_assignment +from rsptx.lti1p3.pylti1p3.lineitem import LineItem + + +def _course(timezone="America/Chicago", id=1): + return SimpleNamespace(id=id, course_name="course1", timezone=timezone) + + +def _assignment(duedate=None, id=7): + return SimpleNamespace(id=id, name="Homework 1", duedate=duedate, points=10) + + +# Ingest +# ------ + + +async def _ingest(lms_string, course): + """Run update_rsassignment_from_lti and return the stored duedate.""" + from rsptx.admin_server_api.routers import lti1p3 + + assign = _assignment(duedate=datetime.datetime(2000, 1, 1)) + line_item = LineItem() + line_item.set_end_date_time(lms_string) + + with patch.object(lti1p3, "update_assignment", AsyncMock()) as update: + await lti1p3.update_rsassignment_from_lti(assign, line_item, {}, course) + + assert update.await_count == 1, "expected the assignment to be updated" + return assign.duedate + + +@pytest.mark.parametrize( + "lms_string,expected", + [ + # Explicit offset: converts straight to the same instant in UTC. + ("2026-09-01T23:59:00-05:00", datetime.datetime(2026, 9, 2, 4, 59)), + # Trailing Z is normalized before parsing. + ("2026-09-02T04:59:00Z", datetime.datetime(2026, 9, 2, 4, 59)), + # Naive: read as course-local (Chicago, CDT) then converted. + ("2026-09-01T23:59:00", datetime.datetime(2026, 9, 2, 4, 59)), + ], +) +async def test_ingest_stores_naive_utc(lms_string, expected): + assert await _ingest(lms_string, _course()) == expected + + +async def test_ingest_of_a_naive_time_without_a_course_timezone_is_utc(): + stored = await _ingest("2026-09-01T23:59:00", _course(timezone=None)) + assert stored == datetime.datetime(2026, 9, 1, 23, 59) + + +async def test_ingest_result_is_naive(): + # A tz-aware value would blow up later comparisons against naive UTC. + assert (await _ingest("2026-09-01T23:59:00-05:00", _course())).tzinfo is None + + +async def test_ingest_is_skipped_when_the_course_ignores_lti_dates(): + from rsptx.admin_server_api.routers import lti1p3 + + original = datetime.datetime(2026, 1, 1, 12, 0) + assign = _assignment(duedate=original) + line_item = LineItem() + line_item.set_end_date_time("2026-09-01T23:59:00-05:00") + + with patch.object(lti1p3, "update_assignment", AsyncMock()) as update: + await lti1p3.update_rsassignment_from_lti( + assign, line_item, {"ignore_lti_dates": "true"}, _course() + ) + + update.assert_not_awaited() + assert assign.duedate == original + + +async def test_ingest_ignores_an_unparseable_date(): + from rsptx.admin_server_api.routers import lti1p3 + + original = datetime.datetime(2026, 1, 1, 12, 0) + assign = _assignment(duedate=original) + line_item = LineItem() + line_item.set_end_date_time("not a date") + + with patch.object(lti1p3, "update_assignment", AsyncMock()) as update: + await lti1p3.update_rsassignment_from_lti(assign, line_item, {}, _course()) + + update.assert_not_awaited() + assert assign.duedate == original + + +# Push +# ---- + + +def test_push_sends_an_explicit_utc_offset(): + line_item = update_line_item_from_assignment( + LineItem(), + _assignment(duedate=datetime.datetime(2026, 9, 2, 4, 59)), + _course(), + push_duedate=True, + ) + assert line_item.get_end_date_time() == "2026-09-02T04:59:00Z" + + +def test_push_is_skipped_unless_requested(): + line_item = update_line_item_from_assignment( + LineItem(), + _assignment(duedate=datetime.datetime(2026, 9, 2, 4, 59)), + _course(), + push_duedate=False, + ) + assert line_item.get_end_date_time() is None + + +# Round trip +# ---------- + + +async def test_ingest_then_push_preserves_the_instant(): + lms_string = "2026-09-01T23:59:00-05:00" + stored = await _ingest(lms_string, _course()) + + line_item = update_line_item_from_assignment( + LineItem(), _assignment(duedate=stored), _course(), push_duedate=True + ) + sent = line_item.get_end_date_time() + + assert datetime.datetime.fromisoformat( + sent.replace("Z", "+00:00") + ) == datetime.datetime.fromisoformat(lms_string) diff --git a/test/components/rsptx/templates/test_core.py b/test/components/rsptx/templates/test_core.py index 800567c2f..392d3a5ab 100644 --- a/test/components/rsptx/templates/test_core.py +++ b/test/components/rsptx/templates/test_core.py @@ -2,7 +2,22 @@ import pytest +import datetime + +import jinja2 + + from rsptx.templates import core +from rsptx.templates.core import ( + format_course_datetime, + get_shared_templates, + install_filters, +) + +# A deadline of 11:59 PM on 2026-09-01 in America/Chicago (CDT, UTC-5) is +# stored as 2026-09-02 04:59 UTC. Every case below starts from that stored +# value, so a correct conversion has to give the wall clock back. +STORED_UTC = datetime.datetime(2026, 9, 2, 4, 59) def test_sample(): @@ -31,3 +46,103 @@ def test_editlibrary_displays_source_repository(github_url, expected): ) assert expected in rendered + + +@pytest.mark.parametrize( + "timezone,expected", + [ + ("America/Chicago", "Sep 01, 2026 11:59 PM CDT"), + ("Europe/Berlin", "Sep 02, 2026 06:59 AM CEST"), + ("Asia/Kolkata", "Sep 02, 2026 10:29 AM IST"), # half hour offset + ("Asia/Tokyo", "Sep 02, 2026 01:59 PM JST"), + ("UTC", "Sep 02, 2026 04:59 AM UTC"), + ], +) +def test_renders_stored_utc_in_the_course_timezone(timezone, expected): + assert format_course_datetime(STORED_UTC, timezone) == expected + + +def test_daylight_saving_is_taken_from_the_date_not_the_zone(): + # The same course is UTC-6 in January and UTC-5 in July. Both render as + # 11:59 PM local, which is only true if the offset comes from the date. + winter = datetime.datetime(2026, 1, 16, 5, 59) # 2026-01-15 23:59 CST + summer = datetime.datetime(2026, 7, 16, 4, 59) # 2026-07-15 23:59 CDT + assert ( + format_course_datetime(winter, "America/Chicago") == "Jan 15, 2026 11:59 PM CST" + ) + assert ( + format_course_datetime(summer, "America/Chicago") == "Jul 15, 2026 11:59 PM CDT" + ) + + +def test_a_course_with_no_timezone_is_treated_as_utc(): + # Matches the duedate migration, which backfills NULL to 'UTC'. + assert format_course_datetime(STORED_UTC, None) == "Sep 02, 2026 04:59 AM UTC" + assert format_course_datetime(STORED_UTC, "") == "Sep 02, 2026 04:59 AM UTC" + + +def test_unrecognized_timezone_falls_back_instead_of_raising(): + # courses.timezone is only validated when set through the settings UI, so a + # bad value must not take the page down. + assert ( + format_course_datetime(STORED_UTC, "Mars/Olympus") + == "Sep 02, 2026 04:59 AM UTC" + ) + + +def test_none_renders_as_empty_string(): + assert format_course_datetime(None, "America/Chicago") == "" + + +def test_non_datetime_passes_through(): + # Some callers hand in a value that was already formatted upstream. + assert format_course_datetime("Sep 01", "America/Chicago") == "Sep 01" + + +def test_custom_format_is_honored(): + assert ( + format_course_datetime(STORED_UTC, "America/Chicago", fmt="%Y-%m-%d %H:%M") + == "2026-09-01 23:59 CDT" + ) + + +def test_show_timezone_false_omits_the_abbreviation(): + assert ( + format_course_datetime( + STORED_UTC, "America/Chicago", fmt="%Y-%m-%d", show_timezone=False + ) + == "2026-09-01" + ) + + +def test_date_only_display_still_shifts_the_day(): + # The reason date-only fields cannot skip the conversion: this deadline is + # September 2nd in UTC but September 1st for the course. + assert format_course_datetime( + STORED_UTC, "America/Chicago", fmt="%Y-%m-%d", show_timezone=False + ) != STORED_UTC.strftime("%Y-%m-%d") + + +def test_aware_datetime_is_not_double_shifted(): + aware = STORED_UTC.replace(tzinfo=datetime.timezone.utc) + assert format_course_datetime(aware, "America/Chicago") == format_course_datetime( + STORED_UTC, "America/Chicago" + ) + + +def test_install_filters_registers_course_datetime(): + env = jinja2.Environment() + assert "course_datetime" not in env.filters + install_filters(env) + assert env.filters["course_datetime"] is format_course_datetime + + +def test_shared_templates_have_the_filter_registered(): + # Every server builds templates through this factory, so the filter has to + # be present or `| course_datetime` raises at render time. + env = get_shared_templates().env + assert "course_datetime" in env.filters + rendered = env.from_string("{{ d | course_datetime(tz) }}").render( + d=STORED_UTC, tz="America/Chicago" + ) + assert rendered == "Sep 01, 2026 11:59 PM CDT" diff --git a/test/migrations/test_duedate_to_utc.py b/test/migrations/test_duedate_to_utc.py new file mode 100644 index 000000000..f05e8203e --- /dev/null +++ b/test/migrations/test_duedate_to_utc.py @@ -0,0 +1,221 @@ +"""Round trip for the duedate -> UTC migration (c4e8a1f7b2d9). + +Runs the real ``upgrade()``/``downgrade()`` bodies against the test database +inside a transaction that is always rolled back, with a stand-in for alembic's +``op``. Postgres DDL is transactional, so the backfill table the migration +creates disappears with the rollback too. +""" + +import datetime +import importlib.util +import os +import sys +from pathlib import Path + +import pytest +import sqlalchemy as sa + +MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "versions" + / "c4e8a1f7b2d9_duedate_to_utc.py" +) + +# (label, timezone, local wall clock, expected stored UTC) +CASES = [ + ("chicago-winter", "America/Chicago", "2026-01-15 23:59:00", "2026-01-16 05:59:00"), + ("chicago-summer", "America/Chicago", "2026-07-15 23:59:00", "2026-07-16 04:59:00"), + ("berlin", "Europe/Berlin", "2026-03-01 12:00:00", "2026-03-01 11:00:00"), + ("kolkata", "Asia/Kolkata", "2026-05-10 09:30:00", "2026-05-10 04:00:00"), + ("tokyo", "Asia/Tokyo", "2026-02-01 08:00:00", "2026-01-31 23:00:00"), + ("explicit-utc", "UTC", "2026-04-01 17:00:00", "2026-04-01 17:00:00"), + ("null-tz", None, "2026-04-01 17:00:00", "2026-04-01 17:00:00"), +] + + +@pytest.fixture +def conn(): + """A connection in a transaction that is never committed.""" + url = os.environ["TEST_DBURL"] + engine = sa.create_engine(url, future=True) + connection = engine.connect() + try: + yield connection + finally: + connection.rollback() + connection.close() + engine.dispose() + + +@pytest.fixture +def migration(conn): + """The migration module, with ``op`` bound to the test connection.""" + spec = importlib.util.spec_from_file_location("duedate_mig", MIGRATION) + mod = importlib.util.module_from_spec(spec) + sys.modules["duedate_mig"] = mod + spec.loader.exec_module(mod) + + class FakeOp: + @staticmethod + def get_bind(): + return conn + + @staticmethod + def execute(stmt): + return conn.execute(sa.text(stmt) if isinstance(stmt, str) else stmt) + + mod.op = FakeOp + return mod + + +@pytest.fixture +def seeded(conn): + """Insert one course per timezone plus its assignments. Returns ids.""" + ids = {} + course_ids = {} + for label, tz, local, _expected in CASES: + if tz not in course_ids: + course_ids[tz] = conn.execute( + sa.text( + "INSERT INTO courses (course_name, base_course, timezone, " + " term_start_date, login_required, allow_pairs, " + " downloads_enabled, courselevel, institution) " + "VALUES (:n, :n, :tz, '2026-01-01', 'F', 'F', 'F', '', '') " + "RETURNING id" + ), + {"n": f"duedate-utc-test-{tz or 'null'}", "tz": tz}, + ).scalar() + ids[label] = conn.execute( + sa.text( + "INSERT INTO assignments (course, name, duedate, visible, " + " released, from_source, points) " + "VALUES (:c, :n, :d, 'F', 'F', 'F', 10) RETURNING id" + ), + { + "c": course_ids[tz], + "n": f"duedate-utc-test-{label}", + "d": datetime.datetime.fromisoformat(local), + }, + ).scalar() + return ids, course_ids + + +def _duedates(conn, ids): + rows = conn.execute( + sa.text("SELECT id, duedate FROM assignments WHERE id = ANY(:ids)"), + {"ids": list(ids.values())}, + ).fetchall() + by_id = {r.id: r.duedate for r in rows} + return {label: by_id[aid] for label, aid in ids.items()} + + +def test_upgrade_converts_course_local_to_utc(conn, migration, seeded): + ids, _ = seeded + migration.upgrade() + after = _duedates(conn, ids) + for label, _tz, _local, expected in CASES: + assert after[label] == datetime.datetime.fromisoformat(expected), label + + +def test_upgrade_leaves_utc_and_null_timezone_courses_untouched(conn, migration, seeded): + ids, _ = seeded + before = _duedates(conn, ids) + migration.upgrade() + after = _duedates(conn, ids) + assert after["explicit-utc"] == before["explicit-utc"] + assert after["null-tz"] == before["null-tz"] + + +def test_upgrade_backfills_null_timezone_to_utc(conn, migration, seeded): + _, course_ids = seeded + null_course = course_ids[None] + assert conn.execute( + sa.text("SELECT timezone FROM courses WHERE id = :id"), {"id": null_course} + ).scalar() is None + + migration.upgrade() + + assert ( + conn.execute( + sa.text("SELECT timezone FROM courses WHERE id = :id"), {"id": null_course} + ).scalar() + == "UTC" + ) + assert conn.execute( + sa.text( + "SELECT 1 FROM duedate_utc_tz_backfill WHERE course_id = :id" + ), + {"id": null_course}, + ).scalar() == 1 + + +def test_upgrade_records_only_backfilled_courses(conn, migration, seeded): + _, course_ids = seeded + migration.upgrade() + recorded = { + r.course_id + for r in conn.execute( + sa.text("SELECT course_id FROM duedate_utc_tz_backfill") + ) + } + assert course_ids[None] in recorded + # A course that already said 'UTC' must not be recorded, or the downgrade + # would wrongly set its timezone back to NULL. + assert course_ids["UTC"] not in recorded + + +def test_downgrade_restores_duedates_and_timezones(conn, migration, seeded): + ids, course_ids = seeded + before = _duedates(conn, ids) + + migration.upgrade() + migration.downgrade() + + assert _duedates(conn, ids) == before + assert conn.execute( + sa.text("SELECT timezone FROM courses WHERE id = :id"), + {"id": course_ids[None]}, + ).scalar() is None + assert conn.execute( + sa.text("SELECT timezone FROM courses WHERE id = :id"), + {"id": course_ids["America/Chicago"]}, + ).scalar() == "America/Chicago" + + +def test_downgrade_drops_the_backfill_table(conn, migration, seeded): + migration.upgrade() + assert conn.execute( + sa.text("SELECT to_regclass('duedate_utc_tz_backfill')") + ).scalar() is not None + migration.downgrade() + assert conn.execute( + sa.text("SELECT to_regclass('duedate_utc_tz_backfill')") + ).scalar() is None + + +def test_unresolvable_timezone_on_a_course_with_assignments_is_rejected( + conn, migration, seeded +): + _, course_ids = seeded + conn.execute( + sa.text("UPDATE courses SET timezone = 'Mars/Olympus' WHERE id = :id"), + {"id": course_ids["America/Chicago"]}, + ) + with pytest.raises(RuntimeError, match="Mars/Olympus"): + migration.upgrade() + + +def test_unresolvable_timezone_on_a_course_without_assignments_does_not_block( + conn, migration, seeded +): + conn.execute( + sa.text( + "INSERT INTO courses (course_name, base_course, timezone, " + " term_start_date, login_required, allow_pairs, " + " downloads_enabled, courselevel, institution) " + "VALUES ('duedate-utc-test-empty', 'duedate-utc-test-empty', " + " 'Mars/Olympus', '2026-01-01', 'F', 'F', 'F', '', '')" + ) + ) + migration.upgrade() # must not raise From 503e0a145cc3143a05ab2d45ed3666331441071b Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Mon, 27 Jul 2026 19:43:02 -0500 Subject: [PATCH 2/3] Show due dates on the reader's clock, warn instructors on a timezone 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