Skip to content

Commit b74a906

Browse files
committed
chore: release 0.38.5
1 parent f019713 commit b74a906

8 files changed

Lines changed: 168 additions & 4 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.38.5] - 2026-03-30
11+
12+
### Fixed
13+
- **Send-back stage actions missing from approval workflows** — The visual workflow builder now round-trips the stage-level `allow_send_back` flag, so stages configured as send-back targets persist correctly and the approval UI once again shows **Send Back for Correction** where applicable. Older saved visual workflow JSON is backfilled from `WorkflowStage.allow_send_back` to avoid silently losing the button on existing workflows.
14+
- **Submission PDF visibility/privacy alignment** — Reviewer-group members are now treated like other elevated users for `post_approval` PDF access, and submitter-only PDF views now respect `WorkflowDefinition.hide_approval_history` so approval-step details are hidden consistently in generated PDFs.
15+
1016
## [0.38.0] - 2026-03-27
1117

1218
### Added

django_forms_workflows/static/django_forms_workflows/js/workflow-builder.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ class WorkflowBuilder {
335335
order: 1,
336336
approval_logic: 'all',
337337
requires_manager_approval: false,
338+
allow_send_back: false,
338339
approve_label: '',
339340
approval_groups: [],
340341
};
@@ -542,6 +543,18 @@ class WorkflowBuilder {
542543
</div>
543544
</div>
544545
546+
<div class="mb-3">
547+
<div class="form-check form-switch">
548+
<input class="form-check-input" type="checkbox" id="stage_allow_send_back_${node.id}"
549+
name="allow_send_back" ${data.allow_send_back ? 'checked' : ''}
550+
onchange="workflowBuilder.updateStageConfig('${node.id}')">
551+
<label class="form-check-label" for="stage_allow_send_back_${node.id}">
552+
<i class="bi bi-arrow-return-left"></i> <strong>Allow Send Back to This Stage</strong>
553+
</label>
554+
</div>
555+
<small class="text-muted">Later stages can return submissions here for correction without rejecting the workflow.</small>
556+
</div>
557+
545558
<hr />
546559
547560
<div class="mb-3">
@@ -1110,6 +1123,7 @@ class WorkflowBuilder {
11101123
node.data.order = parseInt(container.querySelector('input[name="order"]').value) || 1;
11111124
node.data.approve_label = container.querySelector('input[name="approve_label"]').value;
11121125
node.data.requires_manager_approval = container.querySelector(`#stage_requires_manager_${nodeId}`).checked;
1126+
node.data.allow_send_back = container.querySelector(`#stage_allow_send_back_${nodeId}`).checked;
11131127

11141128
const groupSelect = container.querySelector(`#stage_groups_${nodeId}`);
11151129
node.data.approval_groups = Array.from(groupSelect.selectedOptions).map(opt => ({
@@ -1328,6 +1342,7 @@ class WorkflowBuilder {
13281342
case 'stage':
13291343
const stageParts = [];
13301344
if (node.data.requires_manager_approval) stageParts.push('Manager');
1345+
if (node.data.allow_send_back) stageParts.push('Send Back target');
13311346
if (node.data.approval_groups && node.data.approval_groups.length > 0) {
13321347
const gc = node.data.approval_groups.length;
13331348
stageParts.push(`${gc} group${gc > 1 ? 's' : ''} (${node.data.approval_logic || 'all'})`);

django_forms_workflows/templates/django_forms_workflows/submission_pdf.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ <h3 style="margin-top: 20px;">Attachments</h3>
259259
</ul>
260260
{% endif %}
261261

262+
{% if not hide_approval_history %}
262263
{% for section in approval_step_sections %}
263264
<div class="step-section-header">
264265
<span class="step-name-cell">{{ section.step_name }}</span>
@@ -291,6 +292,7 @@ <h3 style="margin-top: 20px;">Attachments</h3>
291292
</table>
292293
{% endif %}
293294
{% endfor %}
295+
{% endif %}
294296

295297
</body>
296298
</html>

django_forms_workflows/views.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1923,12 +1923,15 @@ def submission_pdf(request, submission_id):
19231923
form_def = submission.form_definition
19241924

19251925
# --- permission check (same as submission_detail) ---
1926+
is_reviewer = request.user.groups.filter(
1927+
id__in=form_def.reviewer_groups.all()
1928+
).exists()
19261929
can_view = (
19271930
submission.submitter == request.user
19281931
or request.user.is_superuser
19291932
or user_can_approve(request.user, submission)
19301933
or request.user.groups.filter(id__in=form_def.admin_groups.all()).exists()
1931-
or request.user.groups.filter(id__in=form_def.reviewer_groups.all()).exists()
1934+
or is_reviewer
19321935
)
19331936
if not can_view:
19341937
return HttpResponseForbidden(
@@ -1941,18 +1944,32 @@ def submission_pdf(request, submission_id):
19411944
return HttpResponseForbidden("PDF generation is not enabled for this form.")
19421945

19431946
if pdf_setting == "post_approval" and submission.status != "approved":
1944-
# Approvers, admins, and superusers may download pending submissions.
1945-
# Only the submitter themselves must wait until the form is approved.
1947+
# Approvers, admins, reviewers, and superusers may download pending
1948+
# submissions. Only the submitter themselves must wait until approved.
19461949
is_elevated = (
19471950
request.user.is_superuser
19481951
or user_can_approve(request.user, submission)
19491952
or request.user.groups.filter(id__in=form_def.admin_groups.all()).exists()
1953+
or is_reviewer
19501954
)
19511955
if not is_elevated:
19521956
return HttpResponseForbidden(
19531957
"PDF is only available after the submission has been approved."
19541958
)
19551959

1960+
# --- privacy: mirror hide_approval_history logic from submission_detail ---
1961+
workflow = getattr(form_def, "workflow", None)
1962+
is_submitter_only = (
1963+
submission.submitter == request.user
1964+
and not request.user.is_superuser
1965+
and not user_can_approve(request.user, submission)
1966+
and not request.user.groups.filter(id__in=form_def.admin_groups.all()).exists()
1967+
and not is_reviewer
1968+
)
1969+
hide_approval_history = bool(
1970+
workflow and workflow.hide_approval_history and is_submitter_only
1971+
)
1972+
19561973
# --- build width-aware row groups for PDF layout ---
19571974
pdf_rows = _build_pdf_rows(submission)
19581975
approval_step_sections = _build_approval_step_sections(submission)
@@ -1967,6 +1984,7 @@ def submission_pdf(request, submission_id):
19671984
"form_def": form_def,
19681985
"pdf_rows": pdf_rows,
19691986
"approval_step_sections": approval_step_sections,
1987+
"hide_approval_history": hide_approval_history,
19701988
"resolved_attachments": _resolve_attachments(submission.attachments),
19711989
"request": request,
19721990
},

django_forms_workflows/workflow_builder_views.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,24 @@ def convert_workflow_to_visual(workflow, form_definition):
166166
167167
Reads WorkflowStage records to build stage nodes.
168168
"""
169+
stage_defaults = {
170+
stage.id: {"allow_send_back": stage.allow_send_back}
171+
for stage in workflow.stages.all()
172+
}
173+
169174
# Check if visual workflow data exists AND has the correct format (nodes array)
170175
if workflow.visual_workflow_data:
171176
visual_data = workflow.visual_workflow_data
172177
# Check if it has the new format with nodes array
173178
if isinstance(visual_data, dict) and "nodes" in visual_data:
179+
for node in visual_data.get("nodes", []):
180+
if node.get("type") != "stage":
181+
continue
182+
stage_id = node.get("data", {}).get("stage_id")
183+
defaults = stage_defaults.get(stage_id)
184+
if defaults:
185+
for key, value in defaults.items():
186+
node.setdefault("data", {}).setdefault(key, value)
174187
logger.info("Loading saved visual workflow data (new format)")
175188
return visual_data
176189
# Old format (e.g., stages array) - regenerate
@@ -295,6 +308,7 @@ def convert_workflow_to_visual(workflow, form_definition):
295308
"order": stage.order,
296309
"approval_logic": stage.approval_logic,
297310
"requires_manager_approval": stage.requires_manager_approval,
311+
"allow_send_back": stage.allow_send_back,
298312
"approve_label": stage.approve_label or "",
299313
"approval_groups": [
300314
{"id": g.id, "name": g.name} for g in stage_groups
@@ -326,6 +340,7 @@ def convert_workflow_to_visual(workflow, form_definition):
326340
"order": stage.order,
327341
"approval_logic": stage.approval_logic,
328342
"requires_manager_approval": stage.requires_manager_approval,
343+
"allow_send_back": stage.allow_send_back,
329344
"approve_label": stage.approve_label or "",
330345
"approval_groups": [
331346
{"id": g.id, "name": g.name} for g in stage_groups
@@ -550,6 +565,7 @@ def convert_visual_to_workflow(workflow_data, form_definition):
550565
"order": sdata.get("order", idx),
551566
"approval_logic": sdata.get("approval_logic", "all"),
552567
"requires_manager_approval": sdata.get("requires_manager_approval", False),
568+
"allow_send_back": sdata.get("allow_send_back", False),
553569
"approve_label": sdata.get("approve_label", ""),
554570
}
555571

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.4"
3+
version = "0.38.5"
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_builders.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ def test_generates_layout_with_stages(
5252
self, staged_workflow, form_definition, approval_group, second_approval_group
5353
):
5454
"""Staged workflow generates stage nodes (not legacy approval_config)."""
55+
first_stage = staged_workflow.stages.order_by("order").first()
56+
first_stage.allow_send_back = True
57+
first_stage.save(update_fields=["allow_send_back"])
58+
5559
result = convert_workflow_to_visual(staged_workflow, form_definition)
5660
types = [n["type"] for n in result["nodes"]]
5761

@@ -67,6 +71,33 @@ def test_generates_layout_with_stages(
6771
assert len(stage_nodes) == 2
6872
assert stage_nodes[0]["data"]["name"] == "Manager Review"
6973
assert stage_nodes[1]["data"]["name"] == "Finance Review"
74+
assert stage_nodes[0]["data"]["allow_send_back"] is True
75+
76+
def test_backfills_send_back_flag_from_saved_visual_data(
77+
self, staged_workflow, form_definition
78+
):
79+
stage = staged_workflow.stages.order_by("order").first()
80+
stage.allow_send_back = True
81+
stage.save(update_fields=["allow_send_back"])
82+
staged_workflow.visual_workflow_data = {
83+
"nodes": [
84+
{
85+
"id": "node_1",
86+
"type": "stage",
87+
"data": {
88+
"stage_id": stage.id,
89+
"name": stage.name,
90+
"order": stage.order,
91+
},
92+
}
93+
],
94+
"connections": [],
95+
}
96+
staged_workflow.save(update_fields=["visual_workflow_data"])
97+
98+
result = convert_workflow_to_visual(staged_workflow, form_definition)
99+
100+
assert result["nodes"][0]["data"]["allow_send_back"] is True
70101

71102
def test_workflow_with_no_stages_has_no_stage_nodes(self, form_definition, db):
72103
"""Workflow without stages produces no stage nodes."""
@@ -116,6 +147,7 @@ def test_creates_stages(self, form_definition, approval_group):
116147
"order": 1,
117148
"approval_logic": "all",
118149
"requires_manager_approval": True,
150+
"allow_send_back": True,
119151
"approve_label": "Sign Off",
120152
"approval_groups": [
121153
{"id": approval_group.id, "name": approval_group.name}
@@ -130,6 +162,7 @@ def test_creates_stages(self, form_definition, approval_group):
130162
assert stages[0].name == "Review"
131163
assert stages[0].approval_logic == "all"
132164
assert stages[0].requires_manager_approval is True
165+
assert stages[0].allow_send_back is True
133166
assert stages[0].approve_label == "Sign Off"
134167
assert list(stages[0].approval_groups.values_list("id", flat=True)) == [
135168
approval_group.id
@@ -253,6 +286,7 @@ def test_save_creates_stages(
253286
"order": 1,
254287
"approval_logic": "all",
255288
"requires_manager_approval": False,
289+
"allow_send_back": True,
256290
"approve_label": "Confirm",
257291
"approval_groups": [
258292
{
@@ -279,6 +313,7 @@ def test_save_creates_stages(
279313
stages = list(wf.stages.order_by("order"))
280314
assert len(stages) == 1
281315
assert stages[0].name == "Sign Off"
316+
assert stages[0].allow_send_back is True
282317

283318

284319
# ── Form builder view tests ─────────────────────────────────────────────────

tests/test_views.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,78 @@ def test_approval_step_sections_in_context(
260260
resp = client.get(url)
261261
assert "approval_step_sections" in resp.context
262262

263+
def test_send_back_option_shown_for_prior_send_back_stage(
264+
self,
265+
client,
266+
form_with_fields,
267+
user,
268+
approver_user,
269+
approval_group,
270+
second_approval_group,
271+
):
272+
from django_forms_workflows.models import WorkflowDefinition, WorkflowStage
273+
274+
wf = WorkflowDefinition.objects.create(
275+
form_definition=form_with_fields, requires_approval=True
276+
)
277+
stage1 = WorkflowStage.objects.create(
278+
workflow=wf,
279+
name="Manager Review",
280+
order=1,
281+
approval_logic="all",
282+
allow_send_back=True,
283+
)
284+
stage1.approval_groups.add(approval_group)
285+
stage2 = WorkflowStage.objects.create(
286+
workflow=wf,
287+
name="Finance Review",
288+
order=2,
289+
approval_logic="all",
290+
)
291+
stage2.approval_groups.add(second_approval_group)
292+
293+
sub = FormSubmission.objects.create(
294+
form_definition=form_with_fields,
295+
submitter=user,
296+
form_data={
297+
"full_name": "Test User",
298+
"email": "test@example.com",
299+
"department": "it",
300+
"amount": "500.00",
301+
"notes": "Review please",
302+
},
303+
status="pending_approval",
304+
)
305+
306+
ApprovalTask.objects.create(
307+
submission=sub,
308+
assigned_group=approval_group,
309+
step_name="Manager Review",
310+
status="approved",
311+
stage_number=1,
312+
workflow_stage=stage1,
313+
)
314+
task = ApprovalTask.objects.create(
315+
submission=sub,
316+
assigned_group=second_approval_group,
317+
step_name="Finance Review",
318+
status="pending",
319+
stage_number=2,
320+
workflow_stage=stage2,
321+
)
322+
323+
approver_user.groups.add(second_approval_group)
324+
client.force_login(approver_user)
325+
url = reverse("forms_workflows:approve_submission", args=[task.pk])
326+
resp = client.get(url)
327+
328+
assert resp.status_code == 200
329+
assert "send_back_stages" in resp.context
330+
assert [stage.name for stage in resp.context["send_back_stages"]] == [
331+
"Manager Review"
332+
]
333+
assert b"Send Back for Correction" in resp.content
334+
263335

264336
# ── save_draft ──────────────────────────────────────────────────────────
265337

0 commit comments

Comments
 (0)