Skip to content

Commit d025b07

Browse files
committed
feat: add triggering stage option and workflow-scoped notification rules
- Add use_triggering_stage boolean to NotificationRule that auto-scopes stage-based recipients to the triggering task's stage at runtime - Scope notification rule dispatch to the specific workflow owning the triggering task instead of firing rules from all workflows on the form - Add model validation: use_triggering_stage and explicit stage FK are mutually exclusive - Update admin fieldsets to expose use_triggering_stage - Add migration 0087 - Bump version to 0.61.0
1 parent bccc12c commit d025b07

7 files changed

Lines changed: 435 additions & 18 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.61.0] - 2026-04-02
11+
12+
### Added
13+
- **Triggering Stage option** for notification rules — new `use_triggering_stage` boolean
14+
auto-scopes the rule to whichever stage fired the event at runtime, eliminating
15+
the need to create a separate rule per stage.
16+
- **Workflow-scoped notification rules** — when a notification event is associated with
17+
a specific task, rules are now filtered to only the task's workflow instead of firing
18+
rules from every workflow attached to the form definition.
19+
- Migration `0087`.
20+
1021
## [0.60.0] - 2026-04-02
1122

1223
### Added

django_forms_workflows/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ class NotificationRuleInline(nested_admin.NestedStackedInline):
516516
None,
517517
{
518518
"fields": (
519-
("event", "stage"),
519+
("event", "stage", "use_triggering_stage"),
520520
(
521521
"notify_submitter",
522522
"notify_stage_assignees",
@@ -1736,7 +1736,7 @@ class NotificationRuleAdmin(admin.ModelAdmin):
17361736
{
17371737
"fields": (
17381738
"workflow",
1739-
("event", "stage"),
1739+
("event", "stage", "use_triggering_stage"),
17401740
(
17411741
"notify_submitter",
17421742
"notify_stage_assignees",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 5.2.7 on 2026-04-02
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", "0086_add_embed_enabled"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="notificationrule",
16+
name="use_triggering_stage",
17+
field=models.BooleanField(
18+
default=False,
19+
help_text=(
20+
"When checked, automatically scopes this rule to whichever "
21+
"stage triggered the event at runtime. This avoids needing "
22+
"to create a separate rule per stage. Overrides the Stage "
23+
"dropdown above."
24+
),
25+
),
26+
),
27+
migrations.AlterField(
28+
model_name="notificationrule",
29+
name="stage",
30+
field=models.ForeignKey(
31+
blank=True,
32+
help_text=(
33+
"Optional. When set, scopes this rule to a specific stage. "
34+
"Recipient sources like 'Notify stage assignees' and "
35+
"'Notify stage groups' will reference only this stage. "
36+
"When blank, they reference all stages in the workflow. "
37+
"Ignored when 'Use triggering stage' is checked."
38+
),
39+
null=True,
40+
on_delete=django.db.models.deletion.CASCADE,
41+
related_name="notification_rules",
42+
to="django_forms_workflows.workflowstage",
43+
),
44+
),
45+
]

django_forms_workflows/models.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,7 +1318,17 @@ class NotificationRule(models.Model):
13181318
"Optional. When set, scopes this rule to a specific stage. "
13191319
"Recipient sources like 'Notify stage assignees' and "
13201320
"'Notify stage groups' will reference only this stage. "
1321-
"When blank, they reference all stages in the workflow."
1321+
"When blank, they reference all stages in the workflow. "
1322+
"Ignored when 'Use triggering stage' is checked."
1323+
),
1324+
)
1325+
use_triggering_stage = models.BooleanField(
1326+
default=False,
1327+
help_text=(
1328+
"When checked, automatically scopes this rule to whichever "
1329+
"stage triggered the event at runtime. This avoids needing "
1330+
"to create a separate rule per stage. Overrides the Stage "
1331+
"dropdown above."
13221332
),
13231333
)
13241334
event = models.CharField(
@@ -1401,6 +1411,12 @@ class Meta:
14011411
def clean(self):
14021412
from django.core.exceptions import ValidationError
14031413

1414+
if self.use_triggering_stage and self.stage_id:
1415+
raise ValidationError(
1416+
"'Use triggering stage' and a specific 'Stage' are mutually "
1417+
"exclusive. Either pick a stage or check 'Use triggering stage'."
1418+
)
1419+
14041420
has_recipients = (
14051421
self.notify_submitter
14061422
or self.email_field
@@ -1429,7 +1445,12 @@ def __str__(self) -> str:
14291445
if self.notify_stage_groups:
14301446
parts.append("stage-groups")
14311447
target = ", ".join(parts) if parts else "groups"
1432-
stage_label = f" [{self.stage.name}]" if self.stage_id else ""
1448+
if self.use_triggering_stage:
1449+
stage_label = " [triggering stage]"
1450+
elif self.stage_id:
1451+
stage_label = f" [{self.stage.name}]"
1452+
else:
1453+
stage_label = ""
14331454
return f"{self.get_event_display()}{stage_label}{target}"
14341455

14351456

django_forms_workflows/tasks.py

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,7 @@ def _get_form_field_email(form_data: dict, email_field: str) -> str | None:
971971

972972

973973
def _collect_notification_recipients(
974-
notif, form_data: dict, submission=None
974+
notif, form_data: dict, submission=None, task=None
975975
) -> list[str]:
976976
"""Return the deduplicated list of recipient emails for a notification rule.
977977
@@ -983,6 +983,10 @@ def _collect_notification_recipients(
983983
4. notify_stage_assignees → ApprovalTask.assigned_to.email
984984
5. notify_stage_groups → all users in the stage's approval groups
985985
6. notify_groups (M2M) → all users in explicitly-listed groups
986+
987+
When ``use_triggering_stage`` is True and a *task* is provided, the
988+
task's ``workflow_stage`` is used to scope stage-based recipients
989+
instead of ``notif.stage``.
986990
"""
987991
recipients: list[str] = []
988992

@@ -1004,6 +1008,18 @@ def _add(email: str | None) -> None:
10041008
if addr and "@" in addr:
10051009
_add(addr)
10061010

1011+
# Resolve the effective stage for stage-scoped recipient sources.
1012+
# Priority: use_triggering_stage (from task) > explicit stage FK > all stages.
1013+
_triggering_stage_id = None
1014+
if getattr(notif, "use_triggering_stage", False) and task is not None:
1015+
_triggering_stage_id = getattr(task, "workflow_stage_id", None)
1016+
1017+
def _effective_stage_id():
1018+
"""Return the stage ID to scope to, or None for all stages."""
1019+
if _triggering_stage_id:
1020+
return _triggering_stage_id
1021+
return getattr(notif, "stage_id", None)
1022+
10071023
# 4. Stage assignees (NotificationRule only)
10081024
if getattr(notif, "notify_stage_assignees", False) and submission is not None:
10091025
qs = (
@@ -1012,9 +1028,9 @@ def _add(email: str | None) -> None:
10121028
.exclude(workflow_stage__assignee_form_field__isnull=True)
10131029
.exclude(workflow_stage__assignee_form_field="")
10141030
)
1015-
# If rule is stage-scoped, limit to that stage
1016-
if getattr(notif, "stage_id", None):
1017-
qs = qs.filter(workflow_stage_id=notif.stage_id)
1031+
eff_stage = _effective_stage_id()
1032+
if eff_stage:
1033+
qs = qs.filter(workflow_stage_id=eff_stage)
10181034
for email in qs.values_list("assigned_to__email", flat=True):
10191035
_add(email)
10201036

@@ -1023,9 +1039,9 @@ def _add(email: str | None) -> None:
10231039
from django.contrib.auth import get_user_model
10241040

10251041
user_model = get_user_model()
1026-
# Determine which stages to include
1027-
if getattr(notif, "stage_id", None):
1028-
stage_ids = [notif.stage_id]
1042+
eff_stage = _effective_stage_id()
1043+
if eff_stage:
1044+
stage_ids = [eff_stage]
10291045
else:
10301046
# All stages in this workflow
10311047
from .models import WorkflowStage
@@ -1152,15 +1168,29 @@ def send_notification_rules(
11521168
workflow = getattr(submission.form_definition, "workflow", None)
11531169
hide_approval_history = bool(getattr(workflow, "hide_approval_history", False))
11541170

1155-
rules = (
1156-
NotificationRule.objects.filter(
1157-
workflow__form_definition=submission.form_definition,
1158-
event=event,
1159-
)
1171+
# Resolve the task (if any) to scope rules to its workflow
1172+
task = None
1173+
if task_id:
1174+
try:
1175+
task = ApprovalTask.objects.select_related("workflow_stage").get(id=task_id)
1176+
except ApprovalTask.DoesNotExist:
1177+
pass
1178+
1179+
rules_qs = (
1180+
NotificationRule.objects.filter(event=event)
11601181
.select_related("workflow", "stage")
11611182
.prefetch_related("notify_groups")
11621183
)
11631184

1185+
# Scope rules to the specific workflow that owns the triggering task,
1186+
# rather than firing rules from every workflow on the form.
1187+
if task and getattr(task, "workflow_stage_id", None):
1188+
rules_qs = rules_qs.filter(workflow_id=task.workflow_stage.workflow_id)
1189+
else:
1190+
rules_qs = rules_qs.filter(workflow__form_definition=submission.form_definition)
1191+
1192+
rules = rules_qs
1193+
11641194
default_subject_tpl = _EVENT_DEFAULT_SUBJECTS.get(
11651195
event, "{form_name} (ID {submission_id})"
11661196
)
@@ -1190,7 +1220,7 @@ def send_notification_rules(
11901220

11911221
# Resolve recipients
11921222
recipients = _collect_notification_recipients(
1193-
rule, form_data, submission=submission
1223+
rule, form_data, submission=submission, task=task
11941224
)
11951225
if not recipients:
11961226
logger.info(

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.60.0"
3+
version = "0.61.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)