Approval Workflow¶
A PreparedNotification is a rendered notification with a status. The status moves through a state machine that validates every transition and applies side effects, so the record shows not just what was sent but when it was approved, by whom, and when it was accepted for delivery.
The implementation is PreparedNotificationStateMachine in notices/validators.py.
States¶
| Status | Meaning |
|---|---|
draft |
Rendered but not approved. Content and recipients are still editable. |
ready |
Approved and queued. Recipients are frozen. |
sent |
Handed to the delivery system. |
delivered |
Confirmed as delivered. Terminal. |
failed |
Delivery failed. Can be retried. |
Transitions¶
| From | Allowed targets |
|---|---|
draft |
ready |
ready |
sent |
sent |
delivered, failed |
delivered |
none |
failed |
ready |
Anything else is rejected with the current status and the valid targets named in the error.
Three properties of this graph matter in practice.
There is no route back from ready to draft. Approval is one-way. A notification approved by mistake cannot be edited back into a draft; it has to be left unsent or deleted.
delivered is terminal, and failed returns to ready rather than to sent. A retry goes through approval again, which re-snapshots recipients and re-stamps the approval fields, so a retry after a contact was corrected picks up the new address.
There is no cancelled state. A notification that should not go out is deleted, not withdrawn.
Side effects¶
Each transition does more than write a status.
To ready. The recipient snapshot is recomputed from the contacts M2M, keeping only contacts with an email address. If the resulting snapshot is empty, the transition is refused:
Then approved_by is set to the requesting user and approved_at to the current time. Because this runs on every entry to ready, a retry from failed re-snapshots and re-stamps rather than preserving the original approval.
To sent. sent_at is set, but only if it is not already set. A second transition into sent cannot happen anyway, given the transition table.
To delivered. delivered_at is set on the same terms.
To failed. No side effects. Nothing records why it failed except the journal entry, if one is supplied.
The whole transition runs in a database transaction, so a refused approval leaves no partial snapshot behind.
Timestamps¶
By default a transition stamps the current time. A delivery system that batches, or polls on a delay, can supply the real time instead:
Two rules are enforced:
- A timestamp cannot be in the future.
- Ordering has to hold.
sent_atcannot precedeapproved_at, anddelivered_atcannot precedesent_at.
A violation is returned as a validation error against the timestamp field.
The ordering checks compare against the stored value, so they only apply when the earlier stamp exists. approved_at is always set by the transition into ready, so in normal use both checks are live.
One asymmetry is worth knowing: approved_at is always the real current time and ignores a supplied timestamp. Only sent_at and delivered_at honour it.
Journal entries¶
Any transition can carry a message, which creates a NetBox journal entry against the notification recording the status change and the message text. The entry's kind is derived from the target status:
| Target status | Journal kind |
|---|---|
ready |
info |
sent |
info |
delivered |
success |
failed |
warning |
No message means no journal entry. Nothing else is written, so a failed transition with no message leaves no record of the reason. A delivery system should always send one on failure.
Approving and resetting in the UI¶
A draft's detail page has two actions, both requiring notices.change_preparednotification:
- Approve moves a draft to
readythrough the state machine, so recipients are snapshotted and the approval is stamped exactly as for an APIPATCH. A draft with no recipients is refused with the message shown above. Approval is the only status change the web UI offers; every later transition is still driven through the REST API. - Reset to template re-renders a draft from its template, event and recipient group and discards manual edits. It only works on drafts linked to an event. The same action is available as
POST /api/plugins/notices/prepared-notifications/{id}/reset/, which returns the refreshed notification.
The modified flag¶
Generated drafts carry a modified flag. It is set when the draft's content (subject, bodies, headers, CSS, iCal) no longer matches what the generator rendered. Regeneration keeps modified drafts and re-renders unmodified ones; see Outgoing Notifications. A reset clears the flag.
The flag is filterable: ?modified=true on the list and the API. Notifications created by hand have no rendered baseline, so they are never flagged and never touched by generation.
Limitation: only content is compared. Editing just the recipients (contacts) is not detected, so such a draft still counts as untouched and the next regeneration replaces its recipients with the discovered ones.
Driving it over the API¶
Status is changed through the REST API only. The field is deliberately absent from the web UI, and the bulk edit view for prepared notifications is unmounted, because a direct write would bypass the recipient snapshot and the approval stamps described above. See Permissions.
Create as draft¶
A create is always draft. Posting any other status is refused:
The reason is that a create has no prior state, so the state machine never runs. A notification stored straight into ready would carry no recipient snapshot and no approval stamps, and since ready has no route back to draft, nothing downstream could repair it. Before this was enforced, such a record would be retried by the outbound poller indefinitely.
{
"template_id": 3,
"event_content_type": 91,
"event_id": 42,
"subject": "Scheduled maintenance on your circuit",
"body_text": "...",
"contact_ids": [12, 15]
}
Approve¶
Mark sent and delivered¶
Record a failure¶
A delivery loop¶
The plugin is the queue; the delivery system is the worker. A polling loop looks like this:
GET /api/plugins/notices/prepared-notifications/?status=readyfor work.- Send each notification using its
subject,body_text,body_html,headers,cssandical_content, addressed to the entries inrecipients. PATCHeach tosentwith the send time.PATCHtodeliveredon confirmation, or tofailedwith a message.
Read recipients, not contacts. The snapshot is the frozen list of addresses taken at approval; the M2M is the live set of contact records and may have moved since. See Recipient Discovery.
Once a notification reaches sent or delivered it also appears in the read-only sent notifications list and its own API endpoint:
That endpoint is a proxy over the same table, filtered to sent and delivered and restricted to GET, HEAD and OPTIONS.
Fields the state machine does not set¶
viewed_at exists on the model and appears on the detail page, but no code path writes it. It is read-only in both serializers and has no transition attached, so it stays empty. Treat it as reserved.