Notifications: days counted in the server's time zone, a raw ISO timestamp for interviews, the status label in the changer's language #385

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, reading how announcements are worded outside a request: notify, the slow builders, the closing-soon pass and what the services put in their payloads.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at jobs/models.py:736 (state_condition) and jobs/models.py:821-823 (undecided()), and the scheduler pass calls announce_closing_postings at notifications/management/commands/send_due_reminders.py:195; the other locations have not moved.

Related to #223 (closed), which made notify write every message in the recipient's language. This issue adds what that change left out: the recipient's time zone is never applied, and two builders print values that were resolved before notify ran. notifications/slow.py:9-14 states the rule ("the errand carries ingredients rather than sentences"); the three points below are where it is not followed.

notify (notifications/service.py:33-55) wraps the build in translation.override(language) and in nothing else. The only place a zone is activated is the request middleware (core/middleware.py:125-133: the profile's zone, else site.default_time_zone()). The scheduler and the worker activate none, so they work in settings.TIME_ZONE (POSTULO_TIME_ZONE, default Europe/Paris, config/settings/base.py:460) and ignore both the person's profile and the default an administrator chose under Server settings → Defaults.

1. The closing-soon notice counts days on the server's calendar

  • Where: closing_for (jobs/closing.py:55-66) and announce_closing_postings (jobs/closing.py:69-107), both today = at or timezone.localdate(); _announcement (jobs/closing.py:116-134, days = (posting.closes_at - today).days); state_condition (jobs/models.py:733, closes_at__lt=timezone.localdate()), reached through undecided() (jobs/models.py:818-820). Called from the scheduler pass in notifications/management/commands/send_due_reminders.py:193.
  • What happens: the instance runs in Europe/Paris, the person's profile says America/Los_Angeles, their notice is three days and a listing closes on 2 October.
    • The first pass after midnight in Paris on 29 September (15:00 on 28 September for them) announces it as closing "in 3 days" when it is four for the person.
    • A listing whose date is set or moved into the window late is worded against the wrong day too: at 16:30 on 1 October in Los Angeles (01:30 on 2 October in Paris) the message says "Analyst at Initech — today" for a listing that closes tomorrow for them. A date is announced once (closing_announced_for), so nothing follows on their real last day.
    • A listing that closes on the person's own "today" is, once Paris has passed midnight, outside the closes_at__gte=today window and counted as shut by undecided(), so it is not announced at all.
    • East of the server the error runs the other way.
  • Why it matters: the notice exists so that a closing date is not missed. It is a day off in either direction, and it disagrees with the dashboard, which runs in the person's zone, about the same listing.

2. The interview message's body is a machine timestamp

  • Where: an_interview_was_scheduled (notifications/slow.py:181-200, body=payload.get("starts_at", "")); _tell_interview (applications/services.py:593-609, starts_at=interview.starts_at.isoformat()). The body is printed as it is by the email notifier (plugins/email/__init__.py:75-81), the browser notifier (plugins/browser/__init__.py:163-168) and Apprise (src/postulo_apprise/notifier.py:231-232 in postulo-apprise).
  • What happens: the event is off by default for notifiers that reach a person (ABOUT_WHAT_YOU_DID, notifications/base.py:45), but every notifier's form has the switch. Somebody who turns on An interview is scheduled or moved on their Email connection, because a calendar sync or an agent moves interviews for them, receives "Interview moved: Analyst at Initech" followed by 2026-11-02T15:00:00+00:00 for a 10:00 interview in New York. The offset is whichever one the value carried when the errand was queued, not necessarily theirs.
  • Why it matters: the one line that says when the interview is cannot be read at a glance, and is not in the person's zone or date format.

3. The status-change message carries a label translated before notify ran

  • Where: change_status (applications/services.py:194-207, status=str(Status(new_status).label)); a_status_changed (notifications/slow.py:159-178), which prints payload.get("status", "") in the title.
  • What happens: the label is resolved in whatever language is active where change_status is called. A French reader whose MCP agent sends Accept-Language: en gets "Analyst at Initech: Interviewing", the English label inside an otherwise French notification. A change made by a background job, where no language is active, gets the LANGUAGE_CODE label. Same default-off switch as point 2.
  • Why it matters: these are exactly the flows #223 was about, a token request and a job with no request, and the message is in the wrong language in them.

tests/test_webhooks.py:322-340 (test_the_builders_word_the_three_events) feeds the builders "starts_at": "x" and "status": "Offer" and asserts only the key and the title, which is why neither point 2 nor point 3 is caught.

Fix

  • Add zone_for(user) beside language_for in notifications/service.py: the profile's time_zone, else site.default_time_zone(), else settings.TIME_ZONE, falling back to the server zone for a name zoneinfo does not know, as the middleware does. In notify, wrap the build in timezone.override(zone_for(user)) together with the language override. That serves every announcement, whether it runs in the scheduler, in the worker or inline in somebody's request.
  • In announce_closing_postings, work out today per owner (timezone.localdate(timezone=zone_for(owner))), pass it to closing_for and _announcement, and build the owner's queryset inside the same timezone.override so that undecided() agrees. Widen the owners pre-filter at jobs/closing.py:80 by a day, since the server's date can be a day ahead of an owner's.
  • In an_interview_was_scheduled, parse starts_at and word the body with formats.date_format(timezone.localtime(...), "DATETIME_FORMAT") inside the overrides; keep the body empty or as received when the value does not parse. The ISO values stay in data for machines. The webhook notifier also sends body (plugins/webhook/__init__.py:115), so note the change for anyone parsing it rather than data.starts_at.
  • In a_status_changed, look the label up from payload["to_status"] inside the builder, and stop sending a pre-translated status from change_status (fall back to payload["status"] for errands queued before the upgrade).

Done when

  • A test in tests/test_deadlines.py gives the person an America/Los_Angeles profile on a Europe/Paris instance, fixes the clock at 2026-10-01T23:30Z and asserts the notice for a listing added then, closing on 2 October, says "in 1 day", not "today". It fails today.
  • A test in tests/test_notifications.py delivers an interview_scheduled errand to a person in America/New_York reading fr and asserts the body is the local, formatted time and not an ISO string. It fails today.
  • A test in tests/test_notifications.py sends status_changed from a request under en to a person reading fr and asserts the French label in the title. It fails today.
  • test_the_builders_word_the_three_events in tests/test_webhooks.py asserts the body and the label, not only the key and the title.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading how announcements are worded outside a request: `notify`, the slow builders, the closing-soon pass and what the services put in their payloads. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `jobs/models.py:736` (`state_condition`) and `jobs/models.py:821-823` (`undecided()`), and the scheduler pass calls `announce_closing_postings` at `notifications/management/commands/send_due_reminders.py:195`; the other locations have not moved. Related to #223 (closed), which made `notify` write every message in the recipient's language. This issue adds what that change left out: the recipient's time zone is never applied, and two builders print values that were resolved before `notify` ran. `notifications/slow.py:9-14` states the rule ("the errand carries ingredients rather than sentences"); the three points below are where it is not followed. `notify` (`notifications/service.py:33-55`) wraps the build in `translation.override(language)` and in nothing else. The only place a zone is activated is the request middleware (`core/middleware.py:125-133`: the profile's zone, else `site.default_time_zone()`). The scheduler and the worker activate none, so they work in `settings.TIME_ZONE` (`POSTULO_TIME_ZONE`, default `Europe/Paris`, `config/settings/base.py:460`) and ignore both the person's profile and the default an administrator chose under *Server settings → Defaults*. ## 1. The closing-soon notice counts days on the server's calendar - **Where:** `closing_for` (`jobs/closing.py:55-66`) and `announce_closing_postings` (`jobs/closing.py:69-107`), both `today = at or timezone.localdate()`; `_announcement` (`jobs/closing.py:116-134`, `days = (posting.closes_at - today).days`); `state_condition` (`jobs/models.py:733`, `closes_at__lt=timezone.localdate()`), reached through `undecided()` (`jobs/models.py:818-820`). Called from the scheduler pass in `notifications/management/commands/send_due_reminders.py:193`. - **What happens:** the instance runs in `Europe/Paris`, the person's profile says `America/Los_Angeles`, their notice is three days and a listing closes on 2 October. - The first pass after midnight in Paris on 29 September (15:00 on 28 September for them) announces it as closing "in 3 days" when it is four for the person. - A listing whose date is set or moved into the window late is worded against the wrong day too: at 16:30 on 1 October in Los Angeles (01:30 on 2 October in Paris) the message says "Analyst at Initech — today" for a listing that closes tomorrow for them. A date is announced once (`closing_announced_for`), so nothing follows on their real last day. - A listing that closes on the person's own "today" is, once Paris has passed midnight, outside the `closes_at__gte=today` window and counted as shut by `undecided()`, so it is not announced at all. - East of the server the error runs the other way. - **Why it matters:** the notice exists so that a closing date is not missed. It is a day off in either direction, and it disagrees with the dashboard, which runs in the person's zone, about the same listing. ## 2. The interview message's body is a machine timestamp - **Where:** `an_interview_was_scheduled` (`notifications/slow.py:181-200`, `body=payload.get("starts_at", "")`); `_tell_interview` (`applications/services.py:593-609`, `starts_at=interview.starts_at.isoformat()`). The body is printed as it is by the email notifier (`plugins/email/__init__.py:75-81`), the browser notifier (`plugins/browser/__init__.py:163-168`) and Apprise (`src/postulo_apprise/notifier.py:231-232` in postulo-apprise). - **What happens:** the event is off by default for notifiers that reach a person (`ABOUT_WHAT_YOU_DID`, `notifications/base.py:45`), but every notifier's form has the switch. Somebody who turns on *An interview is scheduled or moved* on their Email connection, because a calendar sync or an agent moves interviews for them, receives "Interview moved: Analyst at Initech" followed by `2026-11-02T15:00:00+00:00` for a 10:00 interview in New York. The offset is whichever one the value carried when the errand was queued, not necessarily theirs. - **Why it matters:** the one line that says when the interview is cannot be read at a glance, and is not in the person's zone or date format. ## 3. The status-change message carries a label translated before `notify` ran - **Where:** `change_status` (`applications/services.py:194-207`, `status=str(Status(new_status).label)`); `a_status_changed` (`notifications/slow.py:159-178`), which prints `payload.get("status", "")` in the title. - **What happens:** the label is resolved in whatever language is active where `change_status` is called. A French reader whose MCP agent sends `Accept-Language: en` gets "Analyst at Initech: Interviewing", the English label inside an otherwise French notification. A change made by a background job, where no language is active, gets the `LANGUAGE_CODE` label. Same default-off switch as point 2. - **Why it matters:** these are exactly the flows #223 was about, a token request and a job with no request, and the message is in the wrong language in them. `tests/test_webhooks.py:322-340` (`test_the_builders_word_the_three_events`) feeds the builders `"starts_at": "x"` and `"status": "Offer"` and asserts only the key and the title, which is why neither point 2 nor point 3 is caught. ## Fix - Add `zone_for(user)` beside `language_for` in `notifications/service.py`: the profile's `time_zone`, else `site.default_time_zone()`, else `settings.TIME_ZONE`, falling back to the server zone for a name `zoneinfo` does not know, as the middleware does. In `notify`, wrap the build in `timezone.override(zone_for(user))` together with the language override. That serves every announcement, whether it runs in the scheduler, in the worker or inline in somebody's request. - In `announce_closing_postings`, work out `today` per owner (`timezone.localdate(timezone=zone_for(owner))`), pass it to `closing_for` and `_announcement`, and build the owner's queryset inside the same `timezone.override` so that `undecided()` agrees. Widen the owners pre-filter at `jobs/closing.py:80` by a day, since the server's date can be a day ahead of an owner's. - In `an_interview_was_scheduled`, parse `starts_at` and word the body with `formats.date_format(timezone.localtime(...), "DATETIME_FORMAT")` inside the overrides; keep the body empty or as received when the value does not parse. The ISO values stay in `data` for machines. The webhook notifier also sends `body` (`plugins/webhook/__init__.py:115`), so note the change for anyone parsing it rather than `data.starts_at`. - In `a_status_changed`, look the label up from `payload["to_status"]` inside the builder, and stop sending a pre-translated `status` from `change_status` (fall back to `payload["status"]` for errands queued before the upgrade). ## Done when - A test in `tests/test_deadlines.py` gives the person an `America/Los_Angeles` profile on a `Europe/Paris` instance, fixes the clock at 2026-10-01T23:30Z and asserts the notice for a listing added then, closing on 2 October, says "in 1 day", not "today". It fails today. - A test in `tests/test_notifications.py` delivers an `interview_scheduled` errand to a person in `America/New_York` reading `fr` and asserts the body is the local, formatted time and not an ISO string. It fails today. - A test in `tests/test_notifications.py` sends `status_changed` from a request under `en` to a person reading `fr` and asserts the French label in the title. It fails today. - `test_the_builders_word_the_three_events` in `tests/test_webhooks.py` asserts the body and the label, not only the key and the title.
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#385
No description provided.