Skip to content

Commit f636cc8

Browse files
committed
feat: unified NotificationRule model replacing three legacy notification mechanisms
Introduces NotificationRule — a single, generic model that replaces WorkflowNotification, StageFormFieldNotification, and the notify_assignee_on_final_decision stage flag. All six recipient sources (submitter, email field, static emails, stage assignees, stage groups, arbitrary groups) are available on every event type. The optional stage FK scopes stage-aware sources to a specific stage or includes all stages when null. New event types: submission_received, approval_request, stage_decision, workflow_approved, workflow_denied, form_withdrawn. Includes: - NotificationRule model with full validation and __str__ - Data migration (0075) from all three legacy sources - Unified send_notification_rules Celery task - Updated workflow_engine dispatch with stage_decision triggers - NotificationRuleInline in admin - Comprehensive docs/NOTIFICATIONS.md - Updated tests Legacy models retained for backward compatibility; both paths dispatch in parallel until legacy models are dropped.
1 parent e2bf59e commit f636cc8

12 files changed

Lines changed: 1041 additions & 448 deletions

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.43.0] - 2026-03-31
11+
12+
### Added
13+
- **Unified `NotificationRule` model** — Replaces `WorkflowNotification`, `StageFormFieldNotification`, and `WorkflowStage.notify_assignee_on_final_decision` with a single, generic model. All six recipient sources (submitter, email field, static emails, stage assignees, stage groups, arbitrary groups) are available on every event type, letting admins configure any combination they need.
14+
- **New event types**`stage_decision` (fires when an individual stage completes), `workflow_approved`, `workflow_denied`, `form_withdrawn` replace the legacy `approval_notification`, `rejection_notification`, `withdrawal_notification` names.
15+
- **Group notifications**`notify_stage_groups` includes all users in a stage's approval groups; `notify_groups` M2M allows notifying arbitrary Django groups independent of stage assignment.
16+
- **Stage-scoped vs. workflow-scoped rules** — Setting the optional `stage` FK scopes `notify_stage_assignees` and `notify_stage_groups` to a specific stage. Leaving it null includes all stages.
17+
- **Unified `send_notification_rules` Celery task** — Single dispatch entry point for all `NotificationRule` records, with full conditions evaluation, recipient resolution, and template rendering.
18+
- **Data migration `0075`** — Automatically migrates all existing `WorkflowNotification`, `StageFormFieldNotification`, and `notify_assignee_on_final_decision` records into `NotificationRule`.
19+
- **Comprehensive notification documentation**`docs/NOTIFICATIONS.md` with architecture overview, model reference, 10 configuration scenarios, troubleshooting guide, and migration notes.
20+
21+
### Changed
22+
- **Legacy models retained**`WorkflowNotification` and `StageFormFieldNotification` remain for backward compatibility. The workflow engine dispatches both legacy and unified paths in parallel. Legacy models will be removed in a future release.
23+
- **Updated `PendingNotification` and `NotificationLog` event types** — Both models now include the new event names alongside legacy names for historical records.
24+
1025
## [0.42.2] - 2026-03-31
1126

1227
### Fixed

django_forms_workflows/admin.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
LDAPGroupProfile,
3535
ManagedFile,
3636
NotificationLog,
37+
NotificationRule,
3738
PostSubmissionAction,
3839
PrefillSource,
3940
StageApprovalGroup,
@@ -515,13 +516,55 @@ class WorkflowNotificationInline(nested_admin.NestedStackedInline):
515516
)
516517

517518

519+
class NotificationRuleInline(nested_admin.NestedStackedInline):
520+
"""Unified notification rule inline.
521+
522+
Replaces WorkflowNotificationInline and StageFormFieldNotificationInline
523+
with a single, generic inline that supports all event types and all
524+
recipient sources.
525+
"""
526+
527+
model = NotificationRule
528+
extra = 0
529+
fieldsets = (
530+
(
531+
None,
532+
{
533+
"fields": (
534+
("event", "stage"),
535+
(
536+
"notify_submitter",
537+
"notify_stage_assignees",
538+
"notify_stage_groups",
539+
),
540+
("email_field", "static_emails"),
541+
"notify_groups",
542+
"subject_template",
543+
)
544+
},
545+
),
546+
(
547+
"Conditions (optional)",
548+
{
549+
"classes": ("collapse",),
550+
"description": (
551+
"Leave blank to always send. "
552+
"Use the same JSON format as stage trigger_conditions — "
553+
'e.g. <code>{"operator":"AND","conditions":[{"field":"department","operator":"equals","value":"Graduate"}]}</code>'
554+
),
555+
"fields": ("conditions",),
556+
},
557+
),
558+
)
559+
560+
518561
class WorkflowDefinitionInline(nested_admin.NestedStackedInline):
519562
"""Inline for editing a WorkflowDefinition directly from the FormDefinition
520563
change page."""
521564

522565
model = WorkflowDefinition
523566
extra = 0
524-
inlines = [WorkflowStageInline, WorkflowNotificationInline]
567+
inlines = [WorkflowStageInline, NotificationRuleInline, WorkflowNotificationInline]
525568
fields = [
526569
"name_label",
527570
"requires_approval",
@@ -1466,7 +1509,12 @@ def has_trigger_conditions(self, obj):
14661509

14671510
@admin.register(WorkflowDefinition)
14681511
class WorkflowDefinitionAdmin(nested_admin.NestedModelAdmin):
1469-
inlines = [WorkflowStageInline, WorkflowNotificationInline, ChangeHistoryInline]
1512+
inlines = [
1513+
WorkflowStageInline,
1514+
NotificationRuleInline,
1515+
WorkflowNotificationInline,
1516+
ChangeHistoryInline,
1517+
]
14701518
list_display = (
14711519
"form_definition",
14721520
"requires_approval",
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Generated by Django 6.0.3 on 2026-03-31 16:14
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+
("auth", "0012_alter_user_first_name_max_length"),
11+
("django_forms_workflows", "0073_add_stage_notify_assignee_on_final_decision"),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="NotificationRule",
17+
fields=[
18+
(
19+
"id",
20+
models.BigAutoField(
21+
auto_created=True,
22+
primary_key=True,
23+
serialize=False,
24+
verbose_name="ID",
25+
),
26+
),
27+
(
28+
"event",
29+
models.CharField(
30+
choices=[
31+
("submission_received", "Submission Received"),
32+
("approval_request", "Approval Request (stage activated)"),
33+
(
34+
"stage_decision",
35+
"Stage Decision (individual stage completed)",
36+
),
37+
("workflow_approved", "Workflow Approved (final decision)"),
38+
("workflow_denied", "Workflow Denied (final decision)"),
39+
("form_withdrawn", "Form Withdrawn"),
40+
],
41+
help_text="The workflow event that triggers this notification.",
42+
max_length=30,
43+
),
44+
),
45+
(
46+
"conditions",
47+
models.JSONField(
48+
blank=True,
49+
help_text="Optional conditions evaluated against form_data. Uses the same format as stage trigger_conditions. When set, the notification only fires if conditions are met.",
50+
null=True,
51+
),
52+
),
53+
(
54+
"subject_template",
55+
models.CharField(
56+
blank=True,
57+
default="",
58+
help_text="Custom email subject line. Supports {form_name} and {submission_id} placeholders. Leave blank for the default.",
59+
max_length=500,
60+
),
61+
),
62+
(
63+
"notify_submitter",
64+
models.BooleanField(
65+
default=False,
66+
help_text="Include the person who submitted the form.",
67+
),
68+
),
69+
(
70+
"email_field",
71+
models.CharField(
72+
blank=True,
73+
default="",
74+
help_text="Form field slug whose submitted value is an email address. Resolved from form_data at send time (varies per submission).",
75+
max_length=200,
76+
),
77+
),
78+
(
79+
"static_emails",
80+
models.CharField(
81+
blank=True,
82+
default="",
83+
help_text="Comma-separated fixed email addresses.",
84+
max_length=1000,
85+
),
86+
),
87+
(
88+
"notify_stage_assignees",
89+
models.BooleanField(
90+
default=False,
91+
help_text="Include dynamically-assigned approvers. When a stage is specified, only that stage's assignee is included. When no stage is specified, assignees from all stages are included.",
92+
),
93+
),
94+
(
95+
"notify_stage_groups",
96+
models.BooleanField(
97+
default=False,
98+
help_text="Include all users in the stage's approval groups. When a stage is specified, only that stage's groups are included. When no stage is specified, groups from all stages are included.",
99+
),
100+
),
101+
(
102+
"notify_groups",
103+
models.ManyToManyField(
104+
blank=True,
105+
help_text="Additional groups to notify, independent of stage assignment. All users in these groups with a non-empty email will receive the notification.",
106+
related_name="notification_rules",
107+
to="auth.group",
108+
),
109+
),
110+
(
111+
"stage",
112+
models.ForeignKey(
113+
blank=True,
114+
help_text="Optional. When set, scopes this rule to a specific stage. Recipient sources like 'Notify stage assignees' and 'Notify stage groups' will reference only this stage. When blank, they reference all stages in the workflow.",
115+
null=True,
116+
on_delete=django.db.models.deletion.CASCADE,
117+
related_name="notification_rules",
118+
to="django_forms_workflows.workflowstage",
119+
),
120+
),
121+
(
122+
"workflow",
123+
models.ForeignKey(
124+
on_delete=django.db.models.deletion.CASCADE,
125+
related_name="notification_rules",
126+
to="django_forms_workflows.workflowdefinition",
127+
),
128+
),
129+
],
130+
options={
131+
"verbose_name": "Notification Rule",
132+
"verbose_name_plural": "Notification Rules",
133+
"ordering": ["workflow", "event", "stage"],
134+
},
135+
),
136+
]
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""
2+
Data migration: populate NotificationRule from the three legacy sources.
3+
4+
1. WorkflowNotification → NotificationRule (stage=null)
5+
2. StageFormFieldNotification → NotificationRule (stage=notif.stage)
6+
3. WorkflowStage.notify_assignee_on_final_decision=True
7+
→ two NotificationRule records (workflow_approved + workflow_denied)
8+
with notify_stage_assignees=True, scoped to that stage.
9+
10+
Event name mapping (old → new):
11+
approval_notification → workflow_approved
12+
rejection_notification → workflow_denied
13+
withdrawal_notification → form_withdrawn
14+
submission_received → submission_received (unchanged)
15+
approval_request → approval_request (unchanged)
16+
"""
17+
18+
from django.db import migrations
19+
20+
21+
EVENT_MAP = {
22+
"approval_notification": "workflow_approved",
23+
"rejection_notification": "workflow_denied",
24+
"withdrawal_notification": "form_withdrawn",
25+
"submission_received": "submission_received",
26+
"approval_request": "approval_request",
27+
}
28+
29+
30+
def forwards(apps, schema_editor):
31+
NotificationRule = apps.get_model("django_forms_workflows", "NotificationRule")
32+
WorkflowNotification = apps.get_model(
33+
"django_forms_workflows", "WorkflowNotification"
34+
)
35+
StageFormFieldNotification = apps.get_model(
36+
"django_forms_workflows", "StageFormFieldNotification"
37+
)
38+
WorkflowStage = apps.get_model("django_forms_workflows", "WorkflowStage")
39+
40+
# 1. WorkflowNotification → NotificationRule (workflow-scoped)
41+
for wn in WorkflowNotification.objects.all():
42+
NotificationRule.objects.create(
43+
workflow_id=wn.workflow_id,
44+
stage=None,
45+
event=EVENT_MAP.get(wn.notification_type, wn.notification_type),
46+
conditions=wn.conditions,
47+
subject_template=wn.subject_template or "",
48+
notify_submitter=wn.notify_submitter,
49+
email_field=wn.email_field or "",
50+
static_emails=wn.static_emails or "",
51+
notify_stage_assignees=False,
52+
notify_stage_groups=False,
53+
)
54+
55+
# 2. StageFormFieldNotification → NotificationRule (stage-scoped)
56+
for sfn in StageFormFieldNotification.objects.select_related("stage").all():
57+
NotificationRule.objects.create(
58+
workflow_id=sfn.stage.workflow_id,
59+
stage_id=sfn.stage_id,
60+
event=EVENT_MAP.get(sfn.notification_type, sfn.notification_type),
61+
conditions=sfn.conditions,
62+
subject_template=sfn.subject_template or "",
63+
notify_submitter=False,
64+
email_field=sfn.email_field or "",
65+
static_emails=sfn.static_emails or "",
66+
notify_stage_assignees=False,
67+
notify_stage_groups=False,
68+
)
69+
70+
# 3. WorkflowStage.notify_assignee_on_final_decision → two rules
71+
for stage in WorkflowStage.objects.filter(
72+
notify_assignee_on_final_decision=True
73+
):
74+
for event in ("workflow_approved", "workflow_denied"):
75+
NotificationRule.objects.create(
76+
workflow_id=stage.workflow_id,
77+
stage_id=stage.id,
78+
event=event,
79+
conditions=None,
80+
subject_template="",
81+
notify_submitter=False,
82+
email_field="",
83+
static_emails="",
84+
notify_stage_assignees=True,
85+
notify_stage_groups=False,
86+
)
87+
88+
89+
class Migration(migrations.Migration):
90+
91+
dependencies = [
92+
("django_forms_workflows", "0074_add_notification_rule"),
93+
]
94+
95+
operations = [
96+
migrations.RunPython(forwards, migrations.RunPython.noop),
97+
]
98+
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Generated by Django 6.0.3 on 2026-03-31 16:29
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("django_forms_workflows", "0075_migrate_to_notification_rules"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="notificationlog",
15+
name="notification_type",
16+
field=models.CharField(
17+
choices=[
18+
("submission_received", "Submission Received"),
19+
("approval_request", "Approval Request"),
20+
("stage_decision", "Stage Decision"),
21+
("workflow_approved", "Workflow Approved"),
22+
("workflow_denied", "Workflow Denied"),
23+
("form_withdrawn", "Form Withdrawn"),
24+
("approval_reminder", "Approval Reminder"),
25+
("escalation", "Escalation"),
26+
("batched", "Batched Digest"),
27+
("submission_created", "Submission Received (legacy)"),
28+
("approval_notification", "Approved (legacy)"),
29+
("rejection_notification", "Rejected (legacy)"),
30+
("other", "Other"),
31+
],
32+
db_index=True,
33+
default="other",
34+
max_length=50,
35+
),
36+
),
37+
migrations.AlterField(
38+
model_name="pendingnotification",
39+
name="notification_type",
40+
field=models.CharField(
41+
choices=[
42+
("submission_received", "Submission Received"),
43+
("approval_request", "Approval Request"),
44+
("stage_decision", "Stage Decision"),
45+
("workflow_approved", "Workflow Approved"),
46+
("workflow_denied", "Workflow Denied"),
47+
("form_withdrawn", "Form Withdrawn"),
48+
("approval_notification", "Approval Notification (legacy)"),
49+
("rejection_notification", "Rejection Notification (legacy)"),
50+
("withdrawal_notification", "Withdrawal Notification (legacy)"),
51+
],
52+
max_length=30,
53+
),
54+
),
55+
]

0 commit comments

Comments
 (0)