Skip to content

Commit dea6b74

Browse files
committed
feat: v0.11.0 — Batched notification digests with configurable cadence
- Add notification_cadence field to WorkflowDefinition with choices: immediate (default), daily, weekly, monthly, form_field_date - Add notification_cadence_day (0-6 for weekly DOW, 1-31 for monthly), notification_cadence_time (send time, default 08:00), notification_cadence_form_field (slug of date field for form_field_date mode) - Add PendingNotification model to queue batched notifications with scheduled_for, sent, recipient_email, notification_type FK fields - Add migration 0017_add_notification_batching - Add _compute_scheduled_for() helper to tasks.py - Add _queue_submission_notifications() and _queue_approval_request_notifications() queuing helpers to tasks.py - Add send_batched_notifications() periodic Celery task that dispatches digest emails grouped by (recipient, type, workflow) - Add _dispatch_submission_digest() and _dispatch_approval_digest() senders - Modify workflow_engine._notify_submission_created() and _notify_task_request() to check cadence: queue for batch or send immediately with graceful fallback - Add 'Notification Batching' fieldset to WorkflowDefinitionAdmin - Add notification_digest.html email template for both approval-request and submission-received digest types
1 parent fa215e1 commit dea6b74

7 files changed

Lines changed: 653 additions & 3 deletions

File tree

django_forms_workflows/admin.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,24 @@ class WorkflowDefinitionAdmin(admin.ModelAdmin):
544544
),
545545
},
546546
),
547+
(
548+
"Notification Batching",
549+
{
550+
"classes": ("collapse",),
551+
"description": (
552+
"Control <em>when</em> approval-request and submission-received "
553+
"notifications are sent. Non-immediate cadences queue notifications "
554+
"and send a single digest email when the schedule fires. "
555+
"Requires the <code>send_batched_notifications</code> Celery Beat task to be running."
556+
),
557+
"fields": (
558+
"notification_cadence",
559+
"notification_cadence_day",
560+
"notification_cadence_time",
561+
"notification_cadence_form_field",
562+
),
563+
},
564+
),
547565
(
548566
"Post-approval DB updates",
549567
{
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Generated migration for notification batching feature
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("django_forms_workflows", "0016_add_pdf_generation"),
11+
]
12+
13+
operations = [
14+
# --- WorkflowDefinition: notification cadence fields ---
15+
migrations.AddField(
16+
model_name="workflowdefinition",
17+
name="notification_cadence",
18+
field=models.CharField(
19+
choices=[
20+
("immediate", "Immediate (send right away)"),
21+
("daily", "Daily digest"),
22+
("weekly", "Weekly digest"),
23+
("monthly", "Monthly digest"),
24+
("form_field_date", "On date from a form field"),
25+
],
26+
default="immediate",
27+
help_text=(
28+
"When to send approval-request and submission notifications. "
29+
"Non-immediate options batch multiple notifications into a single digest email."
30+
),
31+
max_length=20,
32+
),
33+
),
34+
migrations.AddField(
35+
model_name="workflowdefinition",
36+
name="notification_cadence_day",
37+
field=models.IntegerField(
38+
blank=True,
39+
null=True,
40+
help_text=(
41+
"For weekly: day of week (0=Monday … 6=Sunday). "
42+
"For monthly: day of month (1–31)."
43+
),
44+
),
45+
),
46+
migrations.AddField(
47+
model_name="workflowdefinition",
48+
name="notification_cadence_time",
49+
field=models.TimeField(
50+
blank=True,
51+
null=True,
52+
help_text="Time of day to send batch digest (leave blank to use 08:00).",
53+
),
54+
),
55+
migrations.AddField(
56+
model_name="workflowdefinition",
57+
name="notification_cadence_form_field",
58+
field=models.SlugField(
59+
blank=True,
60+
help_text=(
61+
"For 'On date from a form field': the field slug whose date value "
62+
"determines when to send the digest."
63+
),
64+
),
65+
),
66+
# --- PendingNotification model ---
67+
migrations.CreateModel(
68+
name="PendingNotification",
69+
fields=[
70+
(
71+
"id",
72+
models.BigAutoField(
73+
auto_created=True,
74+
primary_key=True,
75+
serialize=False,
76+
verbose_name="ID",
77+
),
78+
),
79+
(
80+
"notification_type",
81+
models.CharField(
82+
choices=[
83+
("submission_received", "Submission Received"),
84+
("approval_request", "Approval Request"),
85+
],
86+
max_length=30,
87+
),
88+
),
89+
("recipient_email", models.EmailField()),
90+
(
91+
"scheduled_for",
92+
models.DateTimeField(
93+
db_index=True,
94+
help_text="When this notification should be included in a batch send.",
95+
),
96+
),
97+
("sent", models.BooleanField(db_index=True, default=False)),
98+
("created_at", models.DateTimeField(auto_now_add=True)),
99+
(
100+
"workflow",
101+
models.ForeignKey(
102+
on_delete=django.db.models.deletion.CASCADE,
103+
related_name="pending_notifications",
104+
to="django_forms_workflows.workflowdefinition",
105+
),
106+
),
107+
(
108+
"submission",
109+
models.ForeignKey(
110+
blank=True,
111+
null=True,
112+
on_delete=django.db.models.deletion.CASCADE,
113+
related_name="pending_notifications",
114+
to="django_forms_workflows.formsubmission",
115+
),
116+
),
117+
(
118+
"approval_task",
119+
models.ForeignKey(
120+
blank=True,
121+
null=True,
122+
on_delete=django.db.models.deletion.SET_NULL,
123+
related_name="pending_notifications",
124+
to="django_forms_workflows.approvaltask",
125+
),
126+
),
127+
],
128+
options={
129+
"verbose_name": "Pending Notification",
130+
"verbose_name_plural": "Pending Notifications",
131+
"ordering": ["scheduled_for", "notification_type"],
132+
},
133+
),
134+
]
135+

django_forms_workflows/models.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,45 @@ class WorkflowDefinition(models.Model):
622622
blank=True, help_text="Comma-separated emails for all notifications"
623623
)
624624

625+
# Notification Batching
626+
NOTIFICATION_CADENCE_CHOICES = [
627+
("immediate", "Immediate (send right away)"),
628+
("daily", "Daily digest"),
629+
("weekly", "Weekly digest"),
630+
("monthly", "Monthly digest"),
631+
("form_field_date", "On date from a form field"),
632+
]
633+
634+
notification_cadence = models.CharField(
635+
max_length=20,
636+
choices=NOTIFICATION_CADENCE_CHOICES,
637+
default="immediate",
638+
help_text=(
639+
"When to send approval-request and submission notifications. "
640+
"Non-immediate options batch multiple notifications into a single digest email."
641+
),
642+
)
643+
notification_cadence_day = models.IntegerField(
644+
null=True,
645+
blank=True,
646+
help_text=(
647+
"For weekly: day of week (0=Monday … 6=Sunday). "
648+
"For monthly: day of month (1–31)."
649+
),
650+
)
651+
notification_cadence_time = models.TimeField(
652+
null=True,
653+
blank=True,
654+
help_text="Time of day to send batch digest (leave blank to use 08:00).",
655+
)
656+
notification_cadence_form_field = models.SlugField(
657+
blank=True,
658+
help_text=(
659+
"For 'On date from a form field': the field slug whose date value "
660+
"determines when to send the digest."
661+
),
662+
)
663+
625664
# Post-Approval Database Updates (optional feature)
626665
enable_db_updates = models.BooleanField(
627666
default=False, help_text="Enable database updates after approval"
@@ -703,6 +742,62 @@ def __str__(self) -> str:
703742
return f"Stage {self.order}: {self.name}"
704743

705744

745+
class PendingNotification(models.Model):
746+
"""
747+
Queue of notifications waiting to be sent as part of a batch digest.
748+
749+
When a WorkflowDefinition has a non-immediate notification_cadence, incoming
750+
approval-request and submission-received events are stored here instead of
751+
being emailed immediately. The ``send_batched_notifications`` periodic task
752+
finds due records, groups them by recipient + type, and sends a single digest
753+
email per group.
754+
"""
755+
756+
NOTIFICATION_TYPES = [
757+
("submission_received", "Submission Received"),
758+
("approval_request", "Approval Request"),
759+
]
760+
761+
workflow = models.ForeignKey(
762+
WorkflowDefinition,
763+
on_delete=models.CASCADE,
764+
related_name="pending_notifications",
765+
)
766+
notification_type = models.CharField(max_length=30, choices=NOTIFICATION_TYPES)
767+
submission = models.ForeignKey(
768+
"FormSubmission",
769+
on_delete=models.CASCADE,
770+
null=True,
771+
blank=True,
772+
related_name="pending_notifications",
773+
)
774+
approval_task = models.ForeignKey(
775+
"ApprovalTask",
776+
on_delete=models.SET_NULL,
777+
null=True,
778+
blank=True,
779+
related_name="pending_notifications",
780+
)
781+
recipient_email = models.EmailField()
782+
scheduled_for = models.DateTimeField(
783+
help_text="When this notification should be included in a batch send.",
784+
db_index=True,
785+
)
786+
sent = models.BooleanField(default=False, db_index=True)
787+
created_at = models.DateTimeField(auto_now_add=True)
788+
789+
class Meta:
790+
verbose_name = "Pending Notification"
791+
verbose_name_plural = "Pending Notifications"
792+
ordering = ["scheduled_for", "notification_type"]
793+
794+
def __str__(self) -> str:
795+
return (
796+
f"{self.get_notification_type_display()}{self.recipient_email} "
797+
f"(due {self.scheduled_for:%Y-%m-%d %H:%M})"
798+
)
799+
800+
706801
class PostSubmissionAction(models.Model):
707802
"""
708803
Configurable post-submission actions to update external systems.

0 commit comments

Comments
 (0)