Scheduled Announcements¶
Organization announcements are no longer send-now-only. As of 1.64.0 a draft can be scheduled to fire at a future time — either an absolute timestamp or a time relative to the event's start or end — and event announcements can be re-delivered to attendees who sign up after the first send. Two Celery-beat sweeps do the work; the announcement model and the existing notification dispatcher do the rest.
This page documents the scheduling and resend mechanics. For the announcement notification itself (org_announcement) and the multi-channel delivery path, see Notifications.
Immediate send is unchanged
Sending an announcement now (POST /announcements/{id}/send) works exactly as before. Scheduling is an opt-in layer on top: a scheduled announcement is just a DRAFT that has been parked in SCHEDULED until a sweep decides it is due.
Status model¶
An Announcement (src/events/models/announcement.py) moves through three statuses:
| Status | Meaning |
|---|---|
DRAFT | Editable, never delivered. The default on create. |
SCHEDULED | Has a resolved future send time; waiting for the beat sweep. Still editable. |
SENT | Delivered (immutable except for the resend bookkeeping). |
stateDiagram-v2
[*] --> DRAFT: create_announcement
DRAFT --> SCHEDULED: schedule_announcement<br/>(/schedule)
SCHEDULED --> DRAFT: unschedule_announcement<br/>(/unschedule)
DRAFT --> SENT: send_announcement<br/>(/send, manual)
SCHEDULED --> SENT: send_scheduled_announcements<br/>(beat sweep, when due)
SENT --> SENT: resend_to_new_recipients<br/>(beat sweep, delta only)
SENT --> [*] Only DRAFT and SCHEDULED announcements can be updated or deleted; SENT is preserved for audit.
Scheduling: absolute vs relative¶
schedule_announcement (announcement_service.py) accepts exactly one of two modes. The choice is enforced both at the schema level (AnnouncementScheduleSchema) and in the model's clean().
| Mode | Fields | Resolves to |
|---|---|---|
| Absolute | scheduled_at (an AwareDatetime) | scheduled_at verbatim |
| Relative | schedule_anchor (event_start / event_end) + schedule_offset_minutes (signed) | anchor_time + offset |
schedule_offset_minutes is signed: -1440 means 24 hours before the anchor, +1440 means 24 hours after. The resolved time must be in the future at schedule time, or /schedule returns 422.
Invariants (enforced in Announcement.clean() and the service)¶
- Absolute and relative scheduling are mutually exclusive — provide one, not both.
- Relative scheduling requires both an anchor and an offset.
- Relative scheduling requires an event-targeted announcement (there is no anchor without an event).
Relative schedules auto-shift with the event¶
The live send time is not frozen at schedule time. It is recomputed on every sweep from the model's effective_send_at property:
@property
def effective_send_at(self) -> dt.datetime | None:
if self.schedule_anchor is None:
return self.scheduled_at # absolute: fixed
if self.event is None:
return None
anchor_time = self.event.start if self.schedule_anchor == self.ScheduleAnchor.EVENT_START else self.event.end
return anchor_time + dt.timedelta(minutes=self.schedule_offset_minutes or 0)
Move the event, and the announcement follows
An announcement scheduled for "1 hour before the doors open" stays correct if the organizer reschedules the event. Because the sweep reads effective_send_at fresh on every run, a relative schedule auto-shifts with event.start / event.end. Absolute schedules (scheduled_at) are fixed and never move.
Endpoints¶
Mounted on OrganizationAdminAnnouncementsController (src/events/controllers/organization_admin/announcements.py), under /organization-admin/{slug}/... with the send_announcements org permission.
| Method | Path | From → To | Purpose |
|---|---|---|---|
POST | /announcements/{id}/schedule | DRAFT → SCHEDULED | Park a draft with an absolute or relative schedule. Body is AnnouncementScheduleSchema. |
POST | /announcements/{id}/unschedule | SCHEDULED → DRAFT | Cancel a schedule and clear all schedule fields. |
POST | /announcements/{id}/send | DRAFT → SENT | Manual immediate send (unchanged). |
Both schedule endpoints map service ValueErrors (wrong status, unresolvable or past time, missing event for relative mode) to HTTP 422.
Delivery: background sweeps¶
Both sweeps live in src/events/announcement_tasks.py (split out of events.tasks to keep that module under the 1000-line limit; the registered task names are preserved so the beat schedule in migration 0082 keeps working). The worker registers them via EventsConfig.ready importing the module.
| Task | Cadence | Purpose |
|---|---|---|
events.send_scheduled_announcements | Every 5 min (migration 0082) | Send SCHEDULED announcements whose effective_send_at is now due. |
events.resend_announcements_to_new_signups | Every 15 min (migration 0082, offset to reduce lock contention) | Re-deliver SENT event announcements with resend_to_new_signups=True to attendees who joined since the last delivery. |
Scheduled-send sweep¶
send_scheduled_announcements snapshots candidate IDs, then re-fetches each under select_for_update (per the PgBouncer / server-side-cursor rule, #458), recomputes effective_send_at live (so relative schedules pick up any event move), and sends the due ones via announcement_service.send_announcement.
Overlapping runs are safe
send_announcement re-checks the status inside its own row-locked transaction, so a double-send caused by two overlapping beat runs is harmless — the second run sees the announcement is already SENT and skips it.
Resend to new sign-ups¶
An event announcement can opt into resend_to_new_signups=True. After the first send, the 15-minute sweep keeps re-delivering it to attendees who join later — until the event ends.
flowchart TD
Start([resend sweep, every 15 min]) --> Q["Query: status=SENT<br/>AND resend_to_new_signups=True<br/>AND event.end > now"]
Q --> Loop[For each candidate, SELECT FOR UPDATE]
Loop --> Current["current = get_recipients(announcement)"]
Current --> Notified["notified = users with an existing<br/>org_announcement notification<br/>for this announcement_id"]
Notified --> Delta{"new = current − notified<br/>non-empty?"}
Delta -->|"No"| Skip([no-op])
Delta -->|"Yes"| Deliver["_deliver_to_recipients(new)<br/>+ recipient_count += N"]
Deliver --> Done([resent]) Key properties of resend_to_new_recipients (announcement_service.py):
- No double-notify. The set of already-notified users is derived from the existing
org_announcementnotifications keyed oncontext__announcement_id— that notification ledger is the dedup mechanism. Onlycurrent − notifiedget a new notification. - Stop condition is the query. The sweep only considers announcements whose
event.end > now; once the event ends, the announcement simply drops out of the candidate set.resend_to_new_recipientsalso re-checksevent.end <= nowunder the lock as a guard. - Event-only.
resend_to_new_signupsrequires an event-targeted announcement (validated in bothclean()and the service) — "new sign-ups" only has meaning for an event's attendee set. - Row-locked + idempotent. Each candidate is processed under
select_for_update, andrecipient_countis bumped with anF()update for the delta actually delivered.
Recipients = event attendees
For an event announcement, recipients are users with an active or checked-in ticket or a YES RSVP (_get_event_recipients). As more people buy tickets or RSVP yes, the recipient set grows — which is exactly what the resend sweep picks up.
Lifecycle examples¶
Example 1: Relative schedule that survives a reschedule¶
- Organizer drafts "Doors update" targeting event E (
start = Sat 20:00). POST /schedulewithschedule_anchor=event_start,schedule_offset_minutes=-60.effective_send_atresolves toSat 19:00; it's in the future → statusSCHEDULED.- The next day the organizer moves E to
Sat 21:00. - The 5-minute sweep recomputes
effective_send_at = Sat 20:00. It does not fire at 19:00 (that time no longer resolves) — it fires at the new 20:00.
Example 2: Resend to late RSVPs¶
- Organizer sends "Bring your ID" for event E with
resend_to_new_signups=True. - 40 current attendees are notified; status
SENT,recipient_count=40. - Over the next two days, 12 more people RSVP yes.
- The 15-minute sweep computes
current=52,notified=40,new=12, delivers to the 12, and bumpsrecipient_countto 52. The original 40 are never re-notified. - Event E ends. The announcement drops out of the sweep's query; no further resends.
Example 3: Unschedule before it fires¶
- A
SCHEDULEDannouncement is parked for next week. - The organizer changes their mind and calls
POST /unschedule. - Status →
DRAFT;scheduled_at,schedule_anchor,schedule_offset_minutesare all cleared. The sweep no longer considers it.
Related reading¶
- Notifications — announcements deliver as
org_announcementnotifications through the standard multi-channel dispatcher. - Service Layer —
announcement_service.pyfollows the function-based service pattern. - Permissions — the
send_announcementsorg permission gates all of these endpoints.