Skip to content

Commit 1d4ca94

Browse files
committed
chore: release 0.38.6
1 parent b74a906 commit 1d4ca94

File tree

8 files changed

+165
-7
lines changed

8 files changed

+165
-7
lines changed

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.38.6] - 2026-03-30
11+
12+
### Fixed
13+
- **Workflow-level submission/final-decision notifications dispatched too early** — Submission-received and workflow-level final approval/rejection notifications are now dispatched on transaction commit so Celery does not race uncommitted submission/task state. This restores `submission_received` notifications for active workflows like Online Tuition Remission where approval requests were being sent but workflow-level notifications were missing from the notification log.
14+
- **Final decision recipients now include dynamic advisor/counselor assignees** — Final approval and rejection notifications now include direct assignees from workflow tasks backed by `assignee_form_field`, ensuring assigned advisors/counselors receive the final decision email alongside the submitter and any configured recipients.
15+
- **Approval comment help text clarified** — The approval page still warns that decision comments are public, but no longer tells end users to add a private field they cannot configure themselves.
16+
1017
## [0.38.5] - 2026-03-30
1118

1219
### Fixed

django_forms_workflows/tasks.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,7 @@ def _collect_notification_recipients(
770770
771771
Combines (in order):
772772
- submission.submitter.email if notif.notify_submitter is True and submission is provided.
773+
- Dynamic workflow assignee emails for final decision notifications.
773774
- The dynamic email read from form_data via notif.email_field (if set and valid).
774775
- All static emails from notif.static_emails (comma-separated, if set).
775776
"""
@@ -778,6 +779,20 @@ def _collect_notification_recipients(
778779
submitter_email = getattr(getattr(submission, "submitter", None), "email", None)
779780
if submitter_email and submitter_email not in recipients:
780781
recipients.append(submitter_email)
782+
if submission is not None and getattr(notif, "notification_type", None) in {
783+
"approval_notification",
784+
"rejection_notification",
785+
}:
786+
dynamic_assignee_emails = (
787+
submission.approval_tasks.select_related("assigned_to", "workflow_stage")
788+
.filter(assigned_to__isnull=False)
789+
.exclude(workflow_stage__assignee_form_field__isnull=True)
790+
.exclude(workflow_stage__assignee_form_field="")
791+
.values_list("assigned_to__email", flat=True)
792+
)
793+
for email in dynamic_assignee_emails:
794+
if email and email not in recipients:
795+
recipients.append(email)
781796
if notif.email_field:
782797
dynamic = _get_form_field_email(form_data, notif.email_field)
783798
if dynamic and dynamic not in recipients:

django_forms_workflows/templates/django_forms_workflows/approve.html

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,6 @@ <h6 class="mb-3"><i class="bi bi-clipboard-check"></i> Your Decision</h6>
346346
<div class="form-text text-muted">
347347
<i class="bi bi-info-circle"></i>
348348
This comment is <strong>visible to the submitter</strong> and appears on the public submission record.
349-
For internal notes, add a private field to this approval step's configuration instead.
350349
</div>
351350
</div>
352351
<div class="d-grid gap-2 d-md-block">
@@ -590,7 +589,6 @@ <h5 class="mb-0"><i class="bi bi-clipboard-check"></i> Your Decision</h5>
590589
<div class="form-text text-muted">
591590
<i class="bi bi-info-circle"></i>
592591
This comment is <strong>visible to the submitter</strong> and appears on the public submission record.
593-
For internal notes, add a private field to this approval step's configuration instead.
594592
</div>
595593
</div>
596594
<div class="d-grid gap-2 d-md-block">

django_forms_workflows/workflow_engine.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,14 +146,28 @@ def _notify_form_field_recipients_for_submission(
146146
"""
147147
try:
148148
from .tasks import send_submission_form_field_notifications
149-
150-
send_submission_form_field_notifications.delay(submission.id, notification_type)
151149
except Exception:
152150
logger.warning(
153151
"Could not dispatch form-field '%s' notifications for submission %s",
154152
notification_type,
155153
submission.id,
156154
)
155+
return
156+
157+
def _dispatch() -> None:
158+
try:
159+
send_submission_form_field_notifications.delay(
160+
submission.id, notification_type
161+
)
162+
except Exception:
163+
logger.warning(
164+
"Form-field '%s' notifications fell back to synchronous dispatch for submission %s",
165+
notification_type,
166+
submission.id,
167+
)
168+
send_submission_form_field_notifications(submission.id, notification_type)
169+
170+
transaction.on_commit(_dispatch)
157171

158172

159173
def _notify_workflow_level_recipients(
@@ -167,14 +181,28 @@ def _notify_workflow_level_recipients(
167181
"""
168182
try:
169183
from .tasks import send_workflow_definition_notifications
170-
171-
send_workflow_definition_notifications.delay(submission.id, notification_type)
172184
except Exception:
173185
logger.warning(
174186
"Could not dispatch WorkflowNotification '%s' rules for submission %s",
175187
notification_type,
176188
submission.id,
177189
)
190+
return
191+
192+
def _dispatch() -> None:
193+
try:
194+
send_workflow_definition_notifications.delay(
195+
submission.id, notification_type
196+
)
197+
except Exception:
198+
logger.warning(
199+
"WorkflowNotification '%s' fell back to synchronous dispatch for submission %s",
200+
notification_type,
201+
submission.id,
202+
)
203+
send_workflow_definition_notifications(submission.id, notification_type)
204+
205+
transaction.on_commit(_dispatch)
178206

179207

180208
def _due_date_for(workflow: WorkflowDefinition):

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.38.5"
3+
version = "0.38.6"
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"

tests/test_notifications.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
5+
from django_forms_workflows.models import (
6+
ApprovalTask,
7+
FormSubmission,
8+
WorkflowDefinition,
9+
WorkflowNotification,
10+
WorkflowStage,
11+
)
12+
from django_forms_workflows.tasks import send_workflow_definition_notifications
13+
14+
15+
@pytest.mark.parametrize(
16+
"notification_type,task_status",
17+
[
18+
("approval_notification", "approved"),
19+
("rejection_notification", "rejected"),
20+
],
21+
)
22+
def test_final_decision_notifications_include_dynamic_assignee(
23+
notification_type,
24+
task_status,
25+
form_definition,
26+
user,
27+
approver_user,
28+
):
29+
workflow = WorkflowDefinition.objects.create(
30+
form_definition=form_definition,
31+
requires_approval=True,
32+
)
33+
stage = WorkflowStage.objects.create(
34+
workflow=workflow,
35+
name="Advisor Review",
36+
order=1,
37+
approval_logic="all",
38+
assignee_form_field="advisor_email",
39+
assignee_lookup_type="email",
40+
)
41+
WorkflowNotification.objects.create(
42+
workflow=workflow,
43+
notification_type=notification_type,
44+
notify_submitter=True,
45+
)
46+
submission = FormSubmission.objects.create(
47+
form_definition=form_definition,
48+
submitter=user,
49+
form_data={"advisor_email": approver_user.email},
50+
status="approved"
51+
if notification_type == "approval_notification"
52+
else "rejected",
53+
)
54+
ApprovalTask.objects.create(
55+
submission=submission,
56+
step_name="Advisor Review",
57+
status=task_status,
58+
assigned_to=approver_user,
59+
workflow_stage=stage,
60+
stage_number=1,
61+
)
62+
63+
with patch("django_forms_workflows.tasks._send_html_email") as mock_send:
64+
send_workflow_definition_notifications(submission.id, notification_type)
65+
66+
recipients = [call.args[1][0] for call in mock_send.call_args_list]
67+
assert recipients == [user.email, approver_user.email]

tests/test_views.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,17 @@ def test_approve_get(self, client, approver_user, approval_setup):
222222
assert resp.status_code == 200
223223
assert b"Manager Review" in resp.content
224224

225+
def test_approve_comment_help_text_keeps_public_warning_only(
226+
self, client, approver_user, approval_setup
227+
):
228+
sub, task, _ = approval_setup
229+
client.force_login(approver_user)
230+
url = reverse("forms_workflows:approve_submission", args=[task.pk])
231+
resp = client.get(url)
232+
233+
assert b"visible to the submitter" in resp.content
234+
assert b"add a private field to this approval step" not in resp.content
235+
225236
def test_approve_post(self, client, approver_user, approval_setup):
226237
sub, task, _ = approval_setup
227238
client.force_login(approver_user)

tests/test_workflow_engine.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
WorkflowStage,
1717
)
1818
from django_forms_workflows.workflow_engine import (
19+
_notify_form_field_recipients_for_submission,
20+
_notify_workflow_level_recipients,
1921
create_workflow_tasks,
2022
handle_approval,
2123
handle_rejection,
@@ -67,6 +69,36 @@ def test_workflow_no_approval_required(
6769
assert sub.status == "approved"
6870

6971

72+
class TestNotificationDispatch:
73+
@patch(
74+
"django_forms_workflows.workflow_engine.transaction.on_commit",
75+
side_effect=lambda fn: fn(),
76+
)
77+
@patch("django_forms_workflows.tasks.send_workflow_definition_notifications.delay")
78+
def test_workflow_notifications_dispatch_on_commit(
79+
self, mock_delay, mock_on_commit, submission
80+
):
81+
_notify_workflow_level_recipients(submission, "submission_received")
82+
83+
mock_on_commit.assert_called_once()
84+
mock_delay.assert_called_once_with(submission.id, "submission_received")
85+
86+
@patch(
87+
"django_forms_workflows.workflow_engine.transaction.on_commit",
88+
side_effect=lambda fn: fn(),
89+
)
90+
@patch(
91+
"django_forms_workflows.tasks.send_submission_form_field_notifications.delay"
92+
)
93+
def test_form_field_notifications_dispatch_on_commit(
94+
self, mock_delay, mock_on_commit, submission
95+
):
96+
_notify_form_field_recipients_for_submission(submission, "submission_received")
97+
98+
mock_on_commit.assert_called_once()
99+
mock_delay.assert_called_once_with(submission.id, "submission_received")
100+
101+
70102
# ── Legacy flat mode (all) ────────────────────────────────────────────────
71103

72104

0 commit comments

Comments
 (0)