Skip to content

Commit bb3562d

Browse files
committed
fix: stage group dedup, sub-workflow final notifications, README credits
- notify_stage_groups skipped when use_triggering_stage=True and the task has assigned_to set — prevents duplicate notifications when an individual was resolved via assignee_form_field - _promote_parent_if_complete now fires _notify_final_approval when all sub-workflows finish and parent is promoted to approved - _reject_sub_workflow fires _notify_rejection when a rejected child propagates rejection to the parent - Added Logan Nickerson to README Credits Bump version to 0.62.1.
1 parent 2219b7e commit bb3562d

10 files changed

Lines changed: 631 additions & 45 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.62.1] - 2026-04-02
11+
12+
### Fixed
13+
- **`notify_stage_groups` no longer duplicates when `assigned_to` is resolved**
14+
when `use_triggering_stage=True` and the task has a specific individual assigned
15+
(via `assignee_form_field`), stage approval groups are now skipped. The individual
16+
was already resolved so the fallback group should not also be notified.
17+
- **Sub-workflow completion now fires final-decision notifications**
18+
`_promote_parent_if_complete` calls `_notify_final_approval` when all sub-workflows
19+
finish and the parent is promoted to approved. Similarly, `_reject_sub_workflow`
20+
calls `_notify_rejection` when a rejected child propagates to the parent.
21+
Previously, no notification was dispatched at either of these lifecycle points.
22+
23+
### Changed
24+
- Added Logan Nickerson to README Credits for testing and business requirements.
25+
26+
## [0.62.0] - 2026-04-02
27+
28+
### Changed
29+
- **NotificationRule is now the single notification system.** The built-in
30+
`send_approval_request` task is no longer dispatched from the workflow engine.
31+
All notification behaviour is configured exclusively through NotificationRule
32+
records.
33+
- **Reminders ported to rules.** `check_approval_deadlines` now dispatches
34+
`approval_reminder` via `send_notification_rules` instead of calling the
35+
hardcoded `send_approval_reminder` task.
36+
37+
### Added
38+
- **New event types**: `approval_reminder` and `escalation` added to
39+
NotificationRule.EVENT_TYPES.
40+
- **`body_template` field** on NotificationRule — custom HTML email body
41+
rendered as a Django template string. When set, overrides the built-in
42+
file-based template for that event. Supports all context variables
43+
(submission, form_data, approver, task, approval_url, etc.).
44+
- **Data migration 0088** — auto-creates `approval_request` NotificationRule
45+
records (with `use_triggering_stage=True`, `notify_stage_assignees=True`,
46+
`notify_stage_groups=True`) for every workflow that doesn't already have one,
47+
preserving the behaviour of the removed built-in email. Also creates
48+
`approval_reminder` rules for workflows with `send_reminder_after_days` set.
49+
- `_send_html_email_from_string` helper for rendering inline template strings.
50+
1051
## [0.61.2] - 2026-04-02
1152

1253
### Fixed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ See [docs/ROADMAP.md](docs/ROADMAP.md) for the full prioritized roadmap with rat
421421
Built with ❤️ by the Django community.
422422

423423
Special thanks to:
424+
- Logan Nickerson for his time spent testing and helping with business requirements
424425
- [Django Crispy Forms](https://github.com/django-crispy-forms/django-crispy-forms)
425426
- [Celery](https://github.com/celery/celery)
426427
- [django-auth-ldap](https://github.com/django-auth-ldap/django-auth-ldap)

django_forms_workflows/admin.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,18 @@ class NotificationRuleInline(nested_admin.NestedStackedInline):
528528
)
529529
},
530530
),
531+
(
532+
"Email body (optional)",
533+
{
534+
"classes": ("collapse",),
535+
"description": (
536+
"Custom HTML email body. Rendered as a Django template "
537+
"with the full notification context. Leave blank to use "
538+
"the built-in template for this event type."
539+
),
540+
"fields": ("body_template",),
541+
},
542+
),
531543
(
532544
"Conditions (optional)",
533545
{
@@ -1748,6 +1760,18 @@ class NotificationRuleAdmin(admin.ModelAdmin):
17481760
)
17491761
},
17501762
),
1763+
(
1764+
"Email body (optional)",
1765+
{
1766+
"classes": ("collapse",),
1767+
"description": (
1768+
"Custom HTML email body. Rendered as a Django template "
1769+
"with the full notification context. Leave blank to use "
1770+
"the built-in template for this event type."
1771+
),
1772+
"fields": ("body_template",),
1773+
},
1774+
),
17511775
(
17521776
"Conditions (optional)",
17531777
{
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Generated by Django 5.2.7 on 2026-04-02
2+
3+
from django.db import migrations, models
4+
5+
6+
def create_default_notification_rules(apps, schema_editor):
7+
"""Auto-create NotificationRule records for workflows that relied on
8+
the built-in send_approval_request and send_approval_reminder tasks.
9+
10+
This ensures that removing the hardcoded email dispatch does not
11+
silently break existing workflows.
12+
"""
13+
WorkflowDefinition = apps.get_model(
14+
"django_forms_workflows", "WorkflowDefinition"
15+
)
16+
NotificationRule = apps.get_model(
17+
"django_forms_workflows", "NotificationRule"
18+
)
19+
20+
for wf in WorkflowDefinition.objects.all():
21+
# approval_request: replaces built-in send_approval_request
22+
if not NotificationRule.objects.filter(
23+
workflow=wf, event="approval_request"
24+
).exists():
25+
NotificationRule.objects.create(
26+
workflow=wf,
27+
event="approval_request",
28+
use_triggering_stage=True,
29+
notify_stage_assignees=True,
30+
notify_stage_groups=True,
31+
)
32+
33+
# approval_reminder: replaces built-in send_approval_reminder
34+
if (
35+
getattr(wf, "send_reminder_after_days", None)
36+
and not NotificationRule.objects.filter(
37+
workflow=wf, event="approval_reminder"
38+
).exists()
39+
):
40+
NotificationRule.objects.create(
41+
workflow=wf,
42+
event="approval_reminder",
43+
use_triggering_stage=True,
44+
notify_stage_assignees=True,
45+
)
46+
47+
48+
class Migration(migrations.Migration):
49+
50+
dependencies = [
51+
("django_forms_workflows", "0087_notificationrule_use_triggering_stage"),
52+
]
53+
54+
operations = [
55+
migrations.AddField(
56+
model_name="notificationrule",
57+
name="body_template",
58+
field=models.TextField(
59+
blank=True,
60+
default="",
61+
help_text=(
62+
"Custom email body (HTML). Rendered as a Django template with "
63+
"the full notification context (submission, form_data, approver, "
64+
"task, approval_url, submission_url, site_name, etc.). "
65+
"Leave blank to use the built-in template for this event type."
66+
),
67+
),
68+
),
69+
migrations.AlterField(
70+
model_name="notificationrule",
71+
name="event",
72+
field=models.CharField(
73+
choices=[
74+
("submission_received", "Submission Received"),
75+
("approval_request", "Approval Request (stage activated)"),
76+
("stage_decision", "Stage Decision (individual stage completed)"),
77+
("workflow_approved", "Workflow Approved (final decision)"),
78+
("workflow_denied", "Workflow Denied (final decision)"),
79+
("form_withdrawn", "Form Withdrawn"),
80+
("approval_reminder", "Approval Reminder"),
81+
("escalation", "Escalation"),
82+
],
83+
help_text="The workflow event that triggers this notification.",
84+
max_length=30,
85+
),
86+
),
87+
migrations.RunPython(
88+
create_default_notification_rules,
89+
reverse_code=migrations.RunPython.noop,
90+
),
91+
]

django_forms_workflows/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,8 @@ class NotificationRule(models.Model):
13011301
("workflow_approved", "Workflow Approved (final decision)"),
13021302
("workflow_denied", "Workflow Denied (final decision)"),
13031303
("form_withdrawn", "Form Withdrawn"),
1304+
("approval_reminder", "Approval Reminder"),
1305+
("escalation", "Escalation"),
13041306
]
13051307

13061308
workflow = models.ForeignKey(
@@ -1354,6 +1356,16 @@ class NotificationRule(models.Model):
13541356
"{submission_id} placeholders. Leave blank for the default."
13551357
),
13561358
)
1359+
body_template = models.TextField(
1360+
blank=True,
1361+
default="",
1362+
help_text=(
1363+
"Custom email body (HTML). Rendered as a Django template with "
1364+
"the full notification context (submission, form_data, approver, "
1365+
"task, approval_url, submission_url, site_name, etc.). "
1366+
"Leave blank to use the built-in template for this event type."
1367+
),
1368+
)
13571369

13581370
# ── Recipient sources (all optional, combined additively) ──
13591371

django_forms_workflows/tasks.py

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,71 @@ def _send_html_email(
149149
)
150150

151151

152+
def _send_html_email_from_string(
153+
subject: str,
154+
to: Iterable[str],
155+
body_template_string: str,
156+
context: dict,
157+
from_email: str | None = None,
158+
*,
159+
notification_type: str = "other",
160+
submission_id: int | None = None,
161+
) -> None:
162+
"""Like _send_html_email but renders an inline Django template string
163+
instead of loading a template file. Used when a NotificationRule has a
164+
custom ``body_template``.
165+
"""
166+
from django.template import Context, Template
167+
168+
to_list = [e for e in to if e]
169+
if not to_list:
170+
logger.info("Skipping email '%s' (no recipients)", subject)
171+
_write_notification_log(
172+
notification_type=notification_type,
173+
submission_id=submission_id,
174+
recipient_email="(none)",
175+
subject=subject,
176+
status="skipped",
177+
)
178+
return
179+
180+
context.setdefault("site_name", _site_name())
181+
182+
tpl = Template(body_template_string)
183+
html_body = tpl.render(Context(context))
184+
text_body = strip_tags(html_body)
185+
from_addr = from_email or getattr(
186+
settings, "DEFAULT_FROM_EMAIL", "no-reply@localhost"
187+
)
188+
189+
msg = EmailMultiAlternatives(
190+
subject=subject, body=text_body, from_email=from_addr, to=to_list
191+
)
192+
msg.attach_alternative(html_body, "text/html")
193+
try:
194+
msg.send(fail_silently=False)
195+
logger.info("Sent email '%s' to %s", subject, to_list)
196+
for recipient in to_list:
197+
_write_notification_log(
198+
notification_type=notification_type,
199+
submission_id=submission_id,
200+
recipient_email=recipient,
201+
subject=subject,
202+
status="sent",
203+
)
204+
except Exception as e: # pragma: no cover
205+
logger.exception("Failed sending email '%s' to %s: %s", subject, to_list, e)
206+
for recipient in to_list:
207+
_write_notification_log(
208+
notification_type=notification_type,
209+
submission_id=submission_id,
210+
recipient_email=recipient,
211+
subject=subject,
212+
status="failed",
213+
error_message=str(e),
214+
)
215+
216+
152217
def _write_notification_log(
153218
*,
154219
notification_type: str,
@@ -726,10 +791,12 @@ def check_approval_deadlines() -> str:
726791
)
727792
if now > reminder_time:
728793
try:
729-
send_approval_reminder.delay(task.id)
794+
send_notification_rules.delay(
795+
submission.id, "approval_reminder", task_id=task.id
796+
)
730797
except Exception:
731798
logger.debug(
732-
"Could not enqueue send_approval_reminder for task %s",
799+
"Could not enqueue approval_reminder for task %s",
733800
task.id,
734801
exc_info=True,
735802
)
@@ -1035,7 +1102,20 @@ def _effective_stage_id():
10351102
_add(email)
10361103

10371104
# 5. Stage approval groups (NotificationRule only)
1038-
if getattr(notif, "notify_stage_groups", False) and submission is not None:
1105+
# When using the triggering stage and the task already has a specific
1106+
# individual assigned (assigned_to), skip the group notification —
1107+
# the individual was resolved via assignee_form_field so the fallback
1108+
# group should not also be notified.
1109+
_skip_stage_groups = (
1110+
getattr(notif, "use_triggering_stage", False)
1111+
and task is not None
1112+
and getattr(task, "assigned_to_id", None) is not None
1113+
)
1114+
if (
1115+
getattr(notif, "notify_stage_groups", False)
1116+
and submission is not None
1117+
and not _skip_stage_groups
1118+
):
10391119
from django.contrib.auth import get_user_model
10401120

10411121
user_model = get_user_model()
@@ -1117,6 +1197,8 @@ def _build_form_field_notification_context(
11171197
"workflow_approved": "emails/approval_notification.html",
11181198
"workflow_denied": "emails/rejection_notification.html",
11191199
"form_withdrawn": "emails/withdrawal_notification.html",
1200+
"approval_reminder": "emails/approval_reminder.html",
1201+
"escalation": "emails/escalation_notification.html",
11201202
}
11211203

11221204
_EVENT_DEFAULT_SUBJECTS: dict[str, str] = {
@@ -1126,6 +1208,8 @@ def _build_form_field_notification_context(
11261208
"workflow_approved": "Submission Approved: {form_name} (ID {submission_id})",
11271209
"workflow_denied": "Submission Rejected: {form_name} (ID {submission_id})",
11281210
"form_withdrawn": "Submission Withdrawn: {form_name} (ID {submission_id})",
1211+
"approval_reminder": "Reminder: Approval Pending for {form_name} (ID {submission_id})",
1212+
"escalation": "Escalation: {form_name} (ID {submission_id})",
11291213
}
11301214

11311215

@@ -1150,8 +1234,8 @@ def send_notification_rules(
11501234
event: The NotificationRule event type (e.g. ``workflow_approved``).
11511235
task_id: Optional ApprovalTask ID (used for approval_request context).
11521236
"""
1153-
template = _EVENT_TEMPLATE_MAP.get(event)
1154-
if not template:
1237+
default_template = _EVENT_TEMPLATE_MAP.get(event)
1238+
if not default_template:
11551239
logger.warning("send_notification_rules: unknown event '%s'", event)
11561240
return
11571241

@@ -1298,16 +1382,28 @@ def send_notification_rules(
12981382
scheduled_for,
12991383
)
13001384
else:
1385+
# Determine template: per-rule body_template overrides file.
1386+
rule_body = getattr(rule, "body_template", "") or ""
13011387
for recipient in recipients:
13021388
ctx = {**base_context}
13031389
recipient_user = _users_by_email.get(recipient)
13041390
if recipient_user:
13051391
ctx["approver"] = recipient_user
1306-
_send_html_email(
1307-
subject,
1308-
[recipient],
1309-
template,
1310-
ctx,
1311-
notification_type=event,
1312-
submission_id=submission_id,
1313-
)
1392+
if rule_body.strip():
1393+
_send_html_email_from_string(
1394+
subject,
1395+
[recipient],
1396+
rule_body,
1397+
ctx,
1398+
notification_type=event,
1399+
submission_id=submission_id,
1400+
)
1401+
else:
1402+
_send_html_email(
1403+
subject,
1404+
[recipient],
1405+
default_template,
1406+
ctx,
1407+
notification_type=event,
1408+
submission_id=submission_id,
1409+
)

0 commit comments

Comments
 (0)