Skip to content

Commit 8a92686

Browse files
committed
feat: custom body templates render reviewer comments + auto-detect finalizing task
Two new pieces of context for any NotificationRule body_template string (also added to the file-template context, harmless extras): - `task` — the finalizing/rejecting ApprovalTask. For workflow_approved and workflow_denied this is auto-detected from the submission's task history when no explicit task_id is passed: latest-approved task for workflow_approved, latest-rejected task for workflow_denied. So `{{ task.comments }}` and `{{ task.workflow_stage.name }}` work in custom bodies for those events without the engine threading the task through, and the admin "Retry failed" action gets the right task too. - `public_comments` — denormalized list of {stage_name, status, actor, comment} dicts for every approved/rejected task with a non-empty comment, ordered by workflow flow (stage_number → step_number → completed_at). Lets a custom body do `{% for c in public_comments %}{{ c.stage_name }}: {{ c.comment }} {% endfor %}` without iterating the ApprovalTask model. Pending tasks and tasks without comments are excluded. Also drops the over-aggressive _skip_stage_groups early-out in _collect_notification_recipients. The per-stage filter shipped in 0.74.9 (assigned_group set, assigned_to NULL) is strictly more correct: for the per-task approval_request use case it produces the same outcome as the early-out (the eff_stage's task with assigned_to set is excluded by the filter, so no group recipients), and for workflow-level events it correctly considers OTHER stages on the workflow that the early-out would have suppressed once auto-detection picks a finalizing task that happens to be dynamic-assigned. 4 new tests cover: - public_comments loop renders in workflow order - {{ task.comments }} / {{ task.workflow_stage.name }} resolve via auto-detect for workflow_denied - Symmetric auto-detect for workflow_approved - public_comments excludes pending tasks and empty-comment tasks (Reassign view fix shipped in the prior commit.)
1 parent 1d6d0a2 commit 8a92686

4 files changed

Lines changed: 357 additions & 15 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.74.10] - 2026-04-30
11+
12+
### Added
13+
- **Custom NotificationRule body templates can now render reviewer
14+
comments.** Two new pieces of context are available to any body
15+
string (in addition to the existing `submission`, `form_data`,
16+
`submission_url`, `approval_url`, `hide_approval_history`):
17+
- `task` — the finalizing/rejecting task. For `workflow_approved`
18+
and `workflow_denied` it's auto-detected when the caller didn't
19+
pass a `task_id`, so `{{ task.comments }}` and
20+
`{{ task.workflow_stage.name }}` work in custom bodies for those
21+
events. The admin "Retry failed" action benefits from the same
22+
auto-detection.
23+
- `public_comments` — denormalized list of dicts with
24+
`{stage_name, status, actor, comment}` for every approved/rejected
25+
task with a non-empty comment, ordered by workflow flow
26+
(stage → step → completed_at). Lets a custom body do
27+
`{% for c in public_comments %}{{ c.stage_name }}:
28+
{{ c.comment }}{% endfor %}` without iterating the model.
29+
Pending tasks and tasks without comments are excluded.
30+
31+
### Fixed
32+
- **`reassign_task` clears `assigned_group` when switching to an
33+
individual.** The view set `task.assigned_to = new_assignee` and
34+
saved only that field, leaving `task.assigned_group` populated from
35+
the original group-fallback assignment. Two consequences:
36+
the inbox AJAX serializer mis-attributed reassigned tasks to the
37+
group (visible "Online Operations" instead of "Jane Doe" after
38+
reassignment), and `notify_stage_groups` resolution (after the
39+
0.74.9 fix) misclassified the stage as both dynamic and group at
40+
once. Fix clears `assigned_group` and records the cleared value in
41+
the AuditLog `changes`. Defensive change in the inbox serializer:
42+
prefer `assigned_to` over `assigned_group` when both are set, so
43+
any task already reassigned before the fix renders correctly.
44+
- **`notify_stage_groups` over-aggressive early-out removed.** A prior
45+
guard skipped ALL group resolution when the triggering task had
46+
`assigned_to` set. With the new auto-detection of the finalizing
47+
task on `workflow_approved`/`workflow_denied` events, this could
48+
silently swallow group notifications for OTHER stages on the
49+
workflow. The per-stage filter shipped in 0.74.9
50+
(`assigned_group_id IS NOT NULL AND assigned_to_id IS NULL`) is
51+
strictly more correct and supersedes the early-out for both
52+
per-task and workflow-level cases.
53+
1054
## [0.74.9] - 2026-04-30
1155

1256
### Fixed

django_forms_workflows/tasks.py

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,20 +1176,17 @@ def _effective_stage_id():
11761176
# top of that double-notifies (and confuses the group, who never
11771177
# held the task).
11781178
#
1179-
# The pre-existing `_skip_stage_groups` early-out (when the triggering
1180-
# task has assigned_to) is preserved for the approval_request / use_
1181-
# triggering_stage path — it's still the right behaviour there. For
1182-
# workflow-level events (workflow_approved/denied/form_withdrawn) no
1183-
# triggering task is passed, so that gate is false and the per-stage
1184-
# filter below does the work.
1185-
_skip_stage_groups = (
1186-
task is not None and getattr(task, "assigned_to_id", None) is not None
1187-
)
1188-
if (
1189-
getattr(notif, "notify_stage_groups", False)
1190-
and submission is not None
1191-
and not _skip_stage_groups
1192-
):
1179+
# The per-stage filter below is strictly more correct than the prior
1180+
# `_skip_stage_groups` early-out and supersedes it. For the per-task
1181+
# approval_request use case: when use_triggering_stage=True, eff_stage
1182+
# is set to the triggering stage; if that stage's task has
1183+
# assigned_to set, the assigned_group_id__isnull=False filter excludes
1184+
# it → no group recipients (same outcome as the early-out). For
1185+
# workflow-level events the early-out was over-aggressive — once
1186+
# task auto-detection in send_notification_rules picks a finalizing
1187+
# task, that task's assigned_to status incorrectly suppressed group
1188+
# resolution for OTHER stages on the workflow.
1189+
if getattr(notif, "notify_stage_groups", False) and submission is not None:
11931190
from django.contrib.auth import get_user_model
11941191

11951192
from .models import StageApprovalGroup
@@ -1361,6 +1358,32 @@ def send_notification_rules(
13611358
).get(id=submission_id)
13621359
form_data = submission.form_data or {}
13631360
form_name = submission.form_definition.name
1361+
1362+
# Auto-detect the finalizing/rejecting task for workflow-level events
1363+
# so custom NotificationRule.body_template strings can address
1364+
# ``{{ task.comments }}`` / ``{{ task.workflow_stage.name }}`` without
1365+
# the caller having to know which task drove the decision. Also lets
1366+
# the admin "Retry failed" action re-fire these events without losing
1367+
# the task context. Only applies when no explicit ``task_id`` was
1368+
# passed; an explicit task always wins.
1369+
if not task_id:
1370+
if event == "workflow_approved":
1371+
_finalizing = (
1372+
submission.approval_tasks.filter(status="approved")
1373+
.order_by("-completed_at")
1374+
.first()
1375+
)
1376+
if _finalizing:
1377+
task_id = _finalizing.id
1378+
elif event == "workflow_denied":
1379+
_rejecting = (
1380+
submission.approval_tasks.filter(status="rejected")
1381+
.order_by("-completed_at")
1382+
.first()
1383+
)
1384+
if _rejecting:
1385+
task_id = _rejecting.id
1386+
13641387
submission_url, approval_url = _build_form_field_notification_context(
13651388
submission,
13661389
ApprovalTask.objects.get(id=task_id) if task_id else None,
@@ -1377,6 +1400,34 @@ def send_notification_rules(
13771400
except ApprovalTask.DoesNotExist:
13781401
pass
13791402

1403+
# Build a denormalized "public_comments" list for the template context
1404+
# (file templates and per-rule body_template overrides alike). Each
1405+
# entry has stage_name / status / actor / comment so a custom body can
1406+
# do ``{% for c in public_comments %}{{ c.stage_name }}: {{ c.comment }}{% endfor %}``
1407+
# without knowing the model. Ordered by workflow flow (stage → step →
1408+
# completion time) so the audit reads top-to-bottom in the email.
1409+
_comments_qs = (
1410+
submission.approval_tasks.select_related("workflow_stage", "completed_by")
1411+
.filter(status__in=["approved", "rejected"])
1412+
.exclude(comments="")
1413+
.order_by("stage_number", "step_number", "completed_at")
1414+
)
1415+
public_comments: list[dict] = []
1416+
for _t in _comments_qs:
1417+
_actor = ""
1418+
if _t.completed_by:
1419+
_actor = _t.completed_by.get_full_name() or _t.completed_by.username
1420+
public_comments.append(
1421+
{
1422+
"stage_name": (
1423+
_t.workflow_stage.name if _t.workflow_stage_id else _t.step_name
1424+
),
1425+
"status": _t.status,
1426+
"actor": _actor,
1427+
"comment": _t.comments,
1428+
}
1429+
)
1430+
13801431
rules_qs = (
13811432
NotificationRule.objects.filter(event=event)
13821433
.select_related("workflow", "stage")
@@ -1540,6 +1591,12 @@ def send_notification_rules(
15401591
"submission_url": submission_url,
15411592
"approval_url": approval_url,
15421593
"hide_approval_history": hide_approval_history,
1594+
# Denormalized list of {stage_name, status, actor, comment}
1595+
# entries for every approved/rejected task with a non-empty
1596+
# public comment. Lets per-rule body_template overrides do
1597+
# ``{% for c in public_comments %}{{ c.stage_name }}:
1598+
# {{ c.comment }}{% endfor %}`` without iterating the model.
1599+
"public_comments": public_comments,
15431600
}
15441601
if task_id:
15451602
try:

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.74.9"
3+
version = "0.74.10"
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)