Skip to content

Commit 97b1c31

Browse files
committed
feat: extend batching to all WorkflowNotification event types (v0.38.0)
Previously notification_cadence only batched submission_received and approval_request. The other three conclusion events always fired immediately. Now all four WorkflowNotification event types respect the cadence setting. workflow_engine.py: - Add _notify_workflow_notification_with_cadence() shared helper that checks cadence, queues via _queue_workflow_level_notifications when non-immediate, and falls back to immediate on error - _notify_submission_created: stage form-field notifications always fire immediately; WorkflowNotification path goes through new helper - _notify_final_approval, _notify_rejection: same pattern via helper views.py: - Withdrawal routes through _notify_workflow_notification_with_cadence tasks.py: - Replace _queue_submission_notifications with generalized _queue_workflow_level_notifications(submission, workflow, type) resolving WorkflowNotification rules with full condition and recipient evaluation - Add _dispatch_conclusion_digest for approval/rejection/withdrawal - send_batched_notifications handles all five notification types models.py: - PendingNotification.NOTIFICATION_TYPES expanded 337 tests pass. Bump 0.37.17 to 0.38.0
1 parent deb10d2 commit 97b1c31

6 files changed

Lines changed: 158 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.38.0] - 2026-03-27
11+
12+
### Added
13+
- **Batching for all WorkflowNotification event types**`notification_cadence` now applies to every workflow-conclusion event (`approval_notification`, `rejection_notification`, `withdrawal_notification`) in addition to the existing `submission_received`. Previously only `submission_received` and `approval_request` were batched; the other three always fired immediately regardless of cadence.
14+
- **`_queue_workflow_level_notifications(submission, workflow, notification_type)`** — replaces the old `_queue_submission_notifications` helper. Resolves every matching `WorkflowNotification` rule (evaluating conditions and all three recipient sources — `notify_submitter`, `email_field`, `static_emails`) and creates one `PendingNotification` row per resolved recipient, correctly honouring per-rule conditions and the full recipient resolution logic.
15+
- **`_dispatch_conclusion_digest`** — new batch-dispatch function for `approval_notification`, `rejection_notification`, and `withdrawal_notification` digests, reusing the existing `notification_digest.html` template with a verb/label context (`approved`/`rejected`/`withdrawn`).
16+
- **`_notify_workflow_notification_with_cadence(submission, notification_type)`** — shared helper in `workflow_engine.py` that checks `notification_cadence` and either queues via `_queue_workflow_level_notifications` or fires `send_workflow_definition_notifications` immediately. Used by `_notify_submission_created`, `_notify_final_approval`, `_notify_rejection`, and the withdrawal view.
17+
18+
### Changed
19+
- `PendingNotification.NOTIFICATION_TYPES` expanded with `approval_notification`, `rejection_notification`, `withdrawal_notification`.
20+
- `send_batched_notifications` dispatch block handles all five notification types; unknown types are logged and skipped rather than silently dropped.
21+
- Stage `StageFormFieldNotification` events always fire immediately regardless of cadence — this is unchanged and intentional.
22+
1023
## [0.37.17] - 2026-03-27
1124

1225
### Fixed

django_forms_workflows/models.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,16 +1144,23 @@ class PendingNotification(models.Model):
11441144
"""
11451145
Queue of notifications waiting to be sent as part of a batch digest.
11461146
1147-
When a WorkflowDefinition has a non-immediate notification_cadence, incoming
1148-
approval-request and submission-received events are stored here instead of
1149-
being emailed immediately. The ``send_batched_notifications`` periodic task
1150-
finds due records, groups them by recipient + type, and sends a single digest
1151-
email per group.
1147+
When a WorkflowDefinition has a non-immediate notification_cadence, all
1148+
WorkflowNotification-level events (submission_received, approval_notification,
1149+
rejection_notification, withdrawal_notification) and approval_request events
1150+
are stored here instead of being emailed immediately.
1151+
1152+
The ``send_batched_notifications`` periodic task finds due records, groups them
1153+
by (recipient_email, notification_type, workflow_id), and sends one digest email
1154+
per group. Stage form-field notifications (StageFormFieldNotification) always
1155+
fire immediately and are not stored here.
11521156
"""
11531157

11541158
NOTIFICATION_TYPES = [
11551159
("submission_received", "Submission Received"),
11561160
("approval_request", "Approval Request"),
1161+
("approval_notification", "Approval Notification"),
1162+
("rejection_notification", "Rejection Notification"),
1163+
("withdrawal_notification", "Withdrawal Notification"),
11571164
]
11581165

11591166
workflow = models.ForeignKey(

django_forms_workflows/tasks.py

Lines changed: 106 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -245,29 +245,58 @@ def _at_time(base_dt):
245245
return _at_time(now + timedelta(days=1))
246246

247247

248-
def _queue_submission_notifications(submission, workflow) -> None:
249-
"""Queue a submission_received PendingNotification for each recipient.
248+
def _queue_workflow_level_notifications(
249+
submission, workflow, notification_type: str
250+
) -> None:
251+
"""Queue a PendingNotification row per resolved recipient for a WorkflowNotification event.
250252
251-
Recipients are resolved from WorkflowNotification rows with notify_submitter=True
252-
or static_emails/email_field configured. The legacy notify_on_* flags and
253-
additional_notify_emails have been removed; all submitter notifications are now
254-
configured via WorkflowNotification rows (created by migration 0067).
253+
Evaluates every WorkflowNotification rule attached to *workflow* for
254+
*notification_type*, respects conditions, and resolves recipients via
255+
``_collect_notification_recipients`` (notify_submitter + email_field + static_emails).
256+
One PendingNotification row is created per recipient per matching rule so that
257+
``send_batched_notifications`` can group them into a single digest email.
255258
"""
259+
from .conditions import evaluate_conditions
260+
256261
scheduled_for = _compute_scheduled_for(workflow, submission)
257-
submitter_email = getattr(getattr(submission, "submitter", None), "email", "")
258-
recipients: list[str] = []
259-
if submitter_email:
260-
recipients.append(submitter_email)
261-
for email in recipients:
262-
PendingNotification.objects.create(
263-
workflow=workflow,
264-
notification_type="submission_received",
265-
submission=submission,
266-
recipient_email=email,
267-
scheduled_for=scheduled_for,
262+
form_data = submission.form_data or {}
263+
264+
notifications = WorkflowNotification.objects.filter(
265+
workflow=workflow,
266+
notification_type=notification_type,
267+
)
268+
269+
queued = 0
270+
for notif in notifications:
271+
if notif.conditions:
272+
try:
273+
if not evaluate_conditions(notif.conditions, form_data):
274+
continue
275+
except Exception:
276+
logger.warning(
277+
"WorkflowNotification %s: condition error during batch queueing; skipping.",
278+
notif.id,
279+
exc_info=True,
280+
)
281+
continue
282+
283+
recipients = _collect_notification_recipients(
284+
notif, form_data, submission=submission
268285
)
286+
for email in recipients:
287+
PendingNotification.objects.create(
288+
workflow=workflow,
289+
notification_type=notification_type,
290+
submission=submission,
291+
recipient_email=email,
292+
scheduled_for=scheduled_for,
293+
)
294+
queued += 1
295+
269296
logger.info(
270-
"Queued submission_received batch notification for submission %s (due %s)",
297+
"Queued %d %s batch notification(s) for submission %s (due %s)",
298+
queued,
299+
notification_type,
271300
submission.id,
272301
scheduled_for,
273302
)
@@ -575,6 +604,21 @@ def send_batched_notifications() -> str:
575604
_dispatch_submission_digest(recipient_email, notifications)
576605
elif notification_type == "approval_request":
577606
_dispatch_approval_digest(recipient_email, notifications)
607+
elif notification_type in (
608+
"approval_notification",
609+
"rejection_notification",
610+
"withdrawal_notification",
611+
):
612+
_dispatch_conclusion_digest(
613+
recipient_email, notifications, notification_type
614+
)
615+
else:
616+
logger.warning(
617+
"send_batched_notifications: unknown type %r for %s; skipping.",
618+
notification_type,
619+
recipient_email,
620+
)
621+
continue
578622
ids = [n.id for n in notifications]
579623
PendingNotification.objects.filter(id__in=ids).update(sent=True)
580624
sent_count += len(ids)
@@ -664,6 +708,50 @@ def _dispatch_approval_digest(recipient_email: str, notifications: list) -> None
664708
)
665709

666710

711+
_CONCLUSION_VERB: dict[str, tuple[str, str]] = {
712+
"approval_notification": ("approved", "Approvals"),
713+
"rejection_notification": ("rejected", "Rejections"),
714+
"withdrawal_notification": ("withdrawn", "Withdrawals"),
715+
}
716+
717+
718+
def _dispatch_conclusion_digest(
719+
recipient_email: str, notifications: list, notification_type: str
720+
) -> None:
721+
"""Send a digest of approval/rejection/withdrawal events to one recipient."""
722+
sample = notifications[0]
723+
workflow = sample.workflow
724+
form_name = workflow.form_definition.name
725+
count = len(notifications)
726+
verb, label = _CONCLUSION_VERB.get(notification_type, ("processed", "Updates"))
727+
submissions = [
728+
{
729+
"submission": n.submission,
730+
"submission_url": _abs(
731+
reverse("forms_workflows:submission_detail", args=[n.submission.id])
732+
),
733+
}
734+
for n in notifications
735+
if n.submission
736+
]
737+
subject = f"{count} submission{'s' if count != 1 else ''} {verb}{form_name}"
738+
context = {
739+
"form_name": form_name,
740+
"count": count,
741+
"submissions": submissions,
742+
"notification_type": notification_type,
743+
"verb": verb,
744+
"label": label,
745+
}
746+
_send_html_email(
747+
subject,
748+
[recipient_email],
749+
"emails/notification_digest.html",
750+
context,
751+
notification_type="batched",
752+
)
753+
754+
667755
# ---------------------------------------------------------------------------
668756
# Form-Field Conditional Notification Tasks
669757
# ---------------------------------------------------------------------------

django_forms_workflows/views.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,11 +1745,13 @@ def withdraw_submission(request, submission_id):
17451745
comments="Submission withdrawn by submitter",
17461746
)
17471747

1748-
# Send withdrawal notifications via WorkflowNotification rules.
1748+
# Send withdrawal notifications — respects notification_cadence.
17491749
try:
1750-
from .workflow_engine import _notify_workflow_level_recipients
1750+
from .workflow_engine import _notify_workflow_notification_with_cadence
17511751

1752-
_notify_workflow_level_recipients(submission, "withdrawal_notification")
1752+
_notify_workflow_notification_with_cadence(
1753+
submission, "withdrawal_notification"
1754+
)
17531755
except Exception:
17541756
logger.warning(
17551757
"Could not dispatch withdrawal notifications for submission %s",

django_forms_workflows/workflow_engine.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,14 @@
5252
# ---- notification shims ----
5353

5454

55-
def _notify_submission_created(submission: FormSubmission) -> None:
55+
def _notify_workflow_notification_with_cadence(
56+
submission: FormSubmission, notification_type: str
57+
) -> None:
58+
"""Route WorkflowNotification rules through the batch queue or fire immediately.
59+
60+
Stage form-field notifications (StageFormFieldNotification) are always dispatched
61+
immediately by their own callers and are not affected by this function.
62+
"""
5663
workflow = getattr(submission.form_definition, "workflow", None)
5764
cadence = (
5865
getattr(workflow, "notification_cadence", "immediate")
@@ -62,22 +69,26 @@ def _notify_submission_created(submission: FormSubmission) -> None:
6269

6370
if cadence != "immediate" and workflow is not None:
6471
try:
65-
from .tasks import _queue_submission_notifications
72+
from .tasks import _queue_workflow_level_notifications
6673

67-
_queue_submission_notifications(submission, workflow)
74+
_queue_workflow_level_notifications(submission, workflow, notification_type)
6875
except Exception:
6976
logger.warning(
70-
"Failed to queue batched submission notification; falling back to immediate"
77+
"Failed to queue batched %s notification for submission %s; "
78+
"falling back to immediate.",
79+
notification_type,
80+
submission.id,
7181
)
72-
_notify_submission_created_immediate(submission)
82+
_notify_workflow_level_recipients(submission, notification_type)
7383
return
7484

75-
_notify_submission_created_immediate(submission)
85+
_notify_workflow_level_recipients(submission, notification_type)
7686

7787

78-
def _notify_submission_created_immediate(submission: FormSubmission) -> None:
88+
def _notify_submission_created(submission: FormSubmission) -> None:
89+
# Stage form-field notifications always fire immediately regardless of cadence.
7990
_notify_form_field_recipients_for_submission(submission, "submission_received")
80-
_notify_workflow_level_recipients(submission, "submission_received")
91+
_notify_workflow_notification_with_cadence(submission, "submission_received")
8192

8293

8394
def _notify_task_request(task: ApprovalTask) -> None:
@@ -114,12 +125,12 @@ def _notify_task_request_immediate(task: ApprovalTask) -> None:
114125

115126
def _notify_final_approval(submission: FormSubmission) -> None:
116127
_notify_form_field_recipients_for_submission(submission, "approval_notification")
117-
_notify_workflow_level_recipients(submission, "approval_notification")
128+
_notify_workflow_notification_with_cadence(submission, "approval_notification")
118129

119130

120131
def _notify_rejection(submission: FormSubmission) -> None:
121132
_notify_form_field_recipients_for_submission(submission, "rejection_notification")
122-
_notify_workflow_level_recipients(submission, "rejection_notification")
133+
_notify_workflow_notification_with_cadence(submission, "rejection_notification")
123134

124135

125136
def _notify_form_field_recipients_for_submission(

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-forms-workflows"
3-
version = "0.37.17"
3+
version = "0.38.0"
44
description = "Enterprise-grade, database-driven form builder with approval workflows and external data integration"
55
license = "LGPL-3.0-only"
66
readme = "README.md"

0 commit comments

Comments
 (0)