API: a time with no offset makes interviews answer 500 and moves a reminder into the server's zone #567

Closed
opened 2026-10-01 10:47:41 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, by reading the API's input schemas and posting times without an offset through the test client.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at api/api.py:398-412 (/me); the schemas, the routers and the service are where they were.

Every time the API accepts is declared as a plain dt.datetime, so pydantic turns "2026-11-02T10:00:00" into a naive datetime, and nothing afterwards refuses it or makes it aware. Where a service compares it with timezone.now(), the call crashes with a 500. Where it goes straight into the database, Django reads it in settings.TIME_ZONE and saves it with a warning. That is not the token owner's zone, nor the default set in Server settings, and the response echoes the naive value back, so the client never sees the shift.

  • Where:
    • api/schemas.py:394-413: InterviewIn.starts_at/ends_at and InterviewPatch.starts_at/ends_at.
    • api/schemas.py:504-532: EventIn.occurred_at, ReminderIn.due_at and ReminderPatch.due_at.
    • api/schemas.py:243: ListingEventIn.occurred_at.
    • api/routers/interviews.py:109-155: add_interview and change_interview compare the times (:115, :146) and pass them to schedule_interview/reschedule_interview.
    • applications/services.py:409-410: if ends_at < now.
    • api/routers/reminders.py:44-47: add_reminder creates the row with the raw value.
  • What happens:
    • POST /api/v1/interviews with "starts_at": "2026-11-02T10:00:00" raises TypeError: can't compare offset-naive and offset-aware datetimes at services.py:410 and answers the HTML 500 page rather than a problem document. PATCH /api/v1/interviews/{id} fails the same way, as does a POST that mixes an aware and a naive time (interviews.py:115).
    • POST /api/v1/reminders with "due_at": "2026-11-02T10:00:00" answers 201 and echoes "2026-11-02T10:00:00". The row holds 09:00 UTC, which is 04:00 in New York, so a reminder asked for at 10:00 by somebody in New York fires six hours early.
    • POST /api/v1/applications/{id}/events and the listing-history call store occurred_at the same way.
  • Why it matters: postulo-mcp's add_reminder (src/postulo_mcp/server.py:290-294) passes the model's string straight through. Its docstring suggests a Z, but nothing enforces one. /me (api/api.py:396-410) says nothing about the person's zone, so a client has nothing to build an aware moment from.
  • Fix: declare these inputs as pydantic AwareDatetime, so a missing offset is a 422 problem document that names the field. Do the same for the updated_since query parameters. If local times are to be accepted instead, make them aware in the token owner's zone (profile.time_zone, else site.default_time_zone()) in one shared validator, never in settings.TIME_ZONE. Either way, return time_zone from /me so a client can send the right offset.

Done when

  • tests/test_api.py: a starts_at with no offset on POST and PATCH /api/v1/interviews is a 422 problem document naming the field, never a 500. A due_at with no offset on POST /api/v1/reminders is refused the same way, or stored as 10:00 in the owner's zone.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, by reading the API's input schemas and posting times without an offset through the test client. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `api/api.py:398-412` (`/me`); the schemas, the routers and the service are where they were. Every time the API accepts is declared as a plain `dt.datetime`, so pydantic turns `"2026-11-02T10:00:00"` into a naive datetime, and nothing afterwards refuses it or makes it aware. Where a service compares it with `timezone.now()`, the call crashes with a 500. Where it goes straight into the database, Django reads it in `settings.TIME_ZONE` and saves it with a warning. That is not the token owner's zone, nor the default set in Server settings, and the response echoes the naive value back, so the client never sees the shift. - **Where:** - `api/schemas.py:394-413`: `InterviewIn.starts_at`/`ends_at` and `InterviewPatch.starts_at`/`ends_at`. - `api/schemas.py:504-532`: `EventIn.occurred_at`, `ReminderIn.due_at` and `ReminderPatch.due_at`. - `api/schemas.py:243`: `ListingEventIn.occurred_at`. - `api/routers/interviews.py:109-155`: `add_interview` and `change_interview` compare the times (`:115`, `:146`) and pass them to `schedule_interview`/`reschedule_interview`. - `applications/services.py:409-410`: `if ends_at < now`. - `api/routers/reminders.py:44-47`: `add_reminder` creates the row with the raw value. - **What happens:** - `POST /api/v1/interviews` with `"starts_at": "2026-11-02T10:00:00"` raises `TypeError: can't compare offset-naive and offset-aware datetimes` at `services.py:410` and answers the HTML 500 page rather than a problem document. `PATCH /api/v1/interviews/{id}` fails the same way, as does a POST that mixes an aware and a naive time (`interviews.py:115`). - `POST /api/v1/reminders` with `"due_at": "2026-11-02T10:00:00"` answers 201 and echoes `"2026-11-02T10:00:00"`. The row holds 09:00 UTC, which is 04:00 in New York, so a reminder asked for at 10:00 by somebody in New York fires six hours early. - `POST /api/v1/applications/{id}/events` and the listing-history call store `occurred_at` the same way. - **Why it matters:** postulo-mcp's `add_reminder` (`src/postulo_mcp/server.py:290-294`) passes the model's string straight through. Its docstring suggests a `Z`, but nothing enforces one. `/me` (`api/api.py:396-410`) says nothing about the person's zone, so a client has nothing to build an aware moment from. - **Fix:** declare these inputs as pydantic `AwareDatetime`, so a missing offset is a 422 problem document that names the field. Do the same for the `updated_since` query parameters. If local times are to be accepted instead, make them aware in the token owner's zone (`profile.time_zone`, else `site.default_time_zone()`) in one shared validator, never in `settings.TIME_ZONE`. Either way, return `time_zone` from `/me` so a client can send the right offset. ## Done when - `tests/test_api.py`: a `starts_at` with no offset on `POST` and `PATCH /api/v1/interviews` is a 422 problem document naming the field, never a 500. A `due_at` with no offset on `POST /api/v1/reminders` is refused the same way, or stored as 10:00 in the owner's zone.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:47:41 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#567
No description provided.