Dates of a moment taken in UTC: the day a store files a sent document under, and the retention report's #386

Closed
opened 2026-10-01 10:46:19 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, following aware datetimes to the places where they become a day.

Checked again on 2026-10-01 against main at 0a5cad587: still present.

Two places turn a stored moment into a day with .date(). With USE_TZ on, the moment comes back from the database in UTC, so the day is UTC's, not the person's. A CV sent late in the evening or just after midnight is filed in the archive under the wrong day, and the retention report names the wrong day a contact was recorded, beside a cutoff that is worked out in local time.

1. The day a store files a sent document under

  • Where: documents/stores.py:84 and :100, metadata_for: when = getattr(document, "archived_at", None) or document.created_at and sent_on=when.date() if origin == "render" and application is not None else None. In postulo-paperless, src/postulo_paperless/store.py:101 (compose_title) and :318 (put) use metadata.sent_on or metadata.created_at.date() for the title and Paperless's created field.
  • What happens: a CV rendered for an application at 00:30 on 3 October in Paris (22:30 UTC on the 2nd) reaches Paperless as created on 2 October and titled CV — Company — Role — 2026-10-02. One rendered at 21:00 on 2 October in New York is filed under 3 October. .date() on an aware datetime ignores the active zone, so it makes no difference that metadata_for also runs in the scheduler (documents/archiving.py:136), where no zone is active anyway. An upload has no sent_on, and the plugin falls back to created_at.date(), which has the same fault on its side.
  • Why it matters: the date is how a person finds "the CV I sent to X" in their archive again, and it disagrees with the application's own timeline in Postulo.

2. The retention report's "recorded" date

  • Where: core/gdpr.py:346 (retention_dry_run: the cutoff is timezone.make_aware(dt.datetime.combine(cutoff, dt.time.min)), local midnight) against core/gdpr.py:354 ("created_at": contact.created_at.date().isoformat(), the UTC day) and :367 ("cutoff": cutoff.isoformat()); shown at templates/server/data_protection.html:48-50 and :58.

  • What happens: a contact recorded at 00:30 local time is listed as recorded the previous day. Both dates also reach the page as ISO strings, so an administrator reading in German sees "recorded 2025-08-31" and "Records older than 2025-09-29" instead of dates in DATE_FORMAT. The template lint added by #225 looks at |date formats and cannot see a date that arrives as a string.

  • Fix: in metadata_for, take the day in the owner's zone: timezone.localtime(when, ZoneInfo(owner.profile.time_zone or site.default_time_zone())).date(), and give uploads a local date too (for example a created_on field on DocumentMetadata), so a store never has to call .date() on an aware datetime. Have postulo-paperless use that date. In retention_dry_run, return timezone.localdate(contact.created_at) and cutoff as date objects and format them in the template with |date:"DATE_FORMAT".

Done when

  • tests/test_stores.py has a test that renders a document at 22:30 UTC for an owner in Europe/Paris and asserts metadata_for(render).sent_on is the next day; the existing assertion metadata.sent_on == render.rendered_at.date() changes to the local day.
  • tests/test_gdpr.py has a test with a contact created at 23:30 UTC under an active Europe/Paris zone, asserting the report's date is the local one and a date object.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, following aware datetimes to the places where they become a day. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. Two places turn a stored moment into a day with `.date()`. With `USE_TZ` on, the moment comes back from the database in UTC, so the day is UTC's, not the person's. A CV sent late in the evening or just after midnight is filed in the archive under the wrong day, and the retention report names the wrong day a contact was recorded, beside a cutoff that is worked out in local time. ## 1. The day a store files a sent document under - **Where:** `documents/stores.py:84` and `:100`, `metadata_for`: `when = getattr(document, "archived_at", None) or document.created_at` and `sent_on=when.date() if origin == "render" and application is not None else None`. In postulo-paperless, `src/postulo_paperless/store.py:101` (`compose_title`) and `:318` (`put`) use `metadata.sent_on or metadata.created_at.date()` for the title and Paperless's `created` field. - **What happens:** a CV rendered for an application at 00:30 on 3 October in Paris (22:30 UTC on the 2nd) reaches Paperless as created on 2 October and titled `CV — Company — Role — 2026-10-02`. One rendered at 21:00 on 2 October in New York is filed under 3 October. `.date()` on an aware datetime ignores the active zone, so it makes no difference that `metadata_for` also runs in the scheduler (`documents/archiving.py:136`), where no zone is active anyway. An upload has no `sent_on`, and the plugin falls back to `created_at.date()`, which has the same fault on its side. - **Why it matters:** the date is how a person finds "the CV I sent to X" in their archive again, and it disagrees with the application's own timeline in Postulo. ## 2. The retention report's "recorded" date - **Where:** `core/gdpr.py:346` (`retention_dry_run`: the cutoff is `timezone.make_aware(dt.datetime.combine(cutoff, dt.time.min))`, local midnight) against `core/gdpr.py:354` (`"created_at": contact.created_at.date().isoformat()`, the UTC day) and `:367` (`"cutoff": cutoff.isoformat()`); shown at `templates/server/data_protection.html:48-50` and `:58`. - **What happens:** a contact recorded at 00:30 local time is listed as recorded the previous day. Both dates also reach the page as ISO strings, so an administrator reading in German sees "recorded 2025-08-31" and "Records older than 2025-09-29" instead of dates in `DATE_FORMAT`. The template lint added by #225 looks at `|date` formats and cannot see a date that arrives as a string. - **Fix:** in `metadata_for`, take the day in the owner's zone: `timezone.localtime(when, ZoneInfo(owner.profile.time_zone or site.default_time_zone())).date()`, and give uploads a local date too (for example a `created_on` field on `DocumentMetadata`), so a store never has to call `.date()` on an aware datetime. Have postulo-paperless use that date. In `retention_dry_run`, return `timezone.localdate(contact.created_at)` and `cutoff` as `date` objects and format them in the template with `|date:"DATE_FORMAT"`. ## Done when - `tests/test_stores.py` has a test that renders a document at 22:30 UTC for an owner in `Europe/Paris` and asserts `metadata_for(render).sent_on` is the next day; the existing assertion `metadata.sent_on == render.rendered_at.date()` changes to the local day. - `tests/test_gdpr.py` has a test with a contact created at 23:30 UTC under an active `Europe/Paris` zone, asserting the report's date is the local one and a `date` object.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:46:19 +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#386
No description provided.