Notifications: days counted in the server's time zone, a raw ISO timestamp for interviews, the status label in the changer's language #385
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#385
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
mainat0a5cad587: still present. The code is now atjobs/models.py:736(state_condition) andjobs/models.py:821-823(undecided()), and the scheduler pass callsannounce_closing_postingsatnotifications/management/commands/send_due_reminders.py:195; the other locations have not moved.Related to #223 (closed), which made
notifywrite 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 beforenotifyran.notifications/slow.py:9-14states 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 intranslation.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, elsesite.default_time_zone()). The scheduler and the worker activate none, so they work insettings.TIME_ZONE(POSTULO_TIME_ZONE, defaultEurope/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
closing_for(jobs/closing.py:55-66) andannounce_closing_postings(jobs/closing.py:69-107), bothtoday = 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 throughundecided()(jobs/models.py:818-820). Called from the scheduler pass innotifications/management/commands/send_due_reminders.py:193.Europe/Paris, the person's profile saysAmerica/Los_Angeles, their notice is three days and a listing closes on 2 October.closing_announced_for), so nothing follows on their real last day.closes_at__gte=todaywindow and counted as shut byundecided(), so it is not announced at all.2. The interview message's body is a machine timestamp
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-232in postulo-apprise).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 by2026-11-02T15:00:00+00:00for a 10:00 interview in New York. The offset is whichever one the value carried when the errand was queued, not necessarily theirs.3. The status-change message carries a label translated before
notifyranchange_status(applications/services.py:194-207,status=str(Status(new_status).label));a_status_changed(notifications/slow.py:159-178), which printspayload.get("status", "")in the title.change_statusis called. A French reader whose MCP agent sendsAccept-Language: engets "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 theLANGUAGE_CODElabel. Same default-off switch as point 2.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
zone_for(user)besidelanguage_forinnotifications/service.py: the profile'stime_zone, elsesite.default_time_zone(), elsesettings.TIME_ZONE, falling back to the server zone for a namezoneinfodoes not know, as the middleware does. Innotify, wrap the build intimezone.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.announce_closing_postings, work outtodayper owner (timezone.localdate(timezone=zone_for(owner))), pass it toclosing_forand_announcement, and build the owner's queryset inside the sametimezone.overrideso thatundecided()agrees. Widen the owners pre-filter atjobs/closing.py:80by a day, since the server's date can be a day ahead of an owner's.an_interview_was_scheduled, parsestarts_atand word the body withformats.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 indatafor machines. The webhook notifier also sendsbody(plugins/webhook/__init__.py:115), so note the change for anyone parsing it rather thandata.starts_at.a_status_changed, look the label up frompayload["to_status"]inside the builder, and stop sending a pre-translatedstatusfromchange_status(fall back topayload["status"]for errands queued before the upgrade).Done when
tests/test_deadlines.pygives the person anAmerica/Los_Angelesprofile on aEurope/Parisinstance, 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.tests/test_notifications.pydelivers aninterview_schedulederrand to a person inAmerica/New_Yorkreadingfrand asserts the body is the local, formatted time and not an ISO string. It fails today.tests/test_notifications.pysendsstatus_changedfrom a request underento a person readingfrand asserts the French label in the title. It fails today.test_the_builders_word_the_three_eventsintests/test_webhooks.pyasserts the body and the label, not only the key and the title.