Skip to content

Commit 0a68aa6

Browse files
committed
feat: add Send Back for Correction workflow decision (v0.32.0)
- WorkflowStage.allow_send_back BooleanField (opt-in per stage) - ApprovalTask.status += 'returned' (non-terminal, no rejection hooks) - AuditLog.action += 'send_back' - workflow_engine: handle_send_back() + handle_sub_workflow_send_back() - views: approve_submission accepts decision=send_back with target stage + reason - approve.html: collapsible Send Back panel with stage dropdown (hidden when no prior allow_send_back stages) - Migration 0055_add_send_back
1 parent 5db8112 commit 0a68aa6

8 files changed

Lines changed: 945 additions & 18 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.32.0] - 2026-03-17
11+
12+
### Added
13+
- **Send Back for Correction** — a new third decision path on the approval screen that returns a staged workflow to any prior stage without terminating the submission.
14+
- `WorkflowStage.allow_send_back` (`BooleanField`, default `False`) — opt-in per stage via Django Admin; only stages with this flag enabled appear as send-back targets for downstream approvers.
15+
- `ApprovalTask.status` extended with `"returned"` (Returned for Correction) — records the closed task without triggering rejection hooks.
16+
- `AuditLog.action` extended with `"send_back"` — full audit trail entry written on every send-back with target stage name and reason.
17+
- `handle_send_back(submission, task, target_stage)` in `workflow_engine.py` — cancels sibling pending tasks at the current stage, re-creates tasks at the target stage via the existing `_create_stage_tasks` helper; `FormSubmission.status` remains `pending_approval` throughout.
18+
- `handle_sub_workflow_send_back(task, target_stage)` — identical logic scoped to a `SubWorkflowInstance` using `_create_sub_workflow_stage_tasks`.
19+
- `approve_submission` view updated to accept `decision=send_back`; validates target stage belongs to the same workflow, has a lower `order`, and that a non-empty reason was supplied.
20+
- `approve.html` — collapsible **Send Back for Correction** card (Bootstrap warning colour, collapsed by default) injected after the main decision buttons in both the step-fields and standard approval forms; hidden when no prior `allow_send_back` stages exist.
21+
- Migration `0055_add_send_back` covering all model changes above.
22+
1023
## [0.14.12] - 2026-03-06
1124

1225
### Fixed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""
2+
Migration 0055 – Send-Back for Correction feature.
3+
4+
Changes:
5+
* ApprovalTask.status – add "returned" choice (VARCHAR only, no constraint change).
6+
* WorkflowStage.allow_send_back – new BooleanField(default=False).
7+
* AuditLog.action – add "send_back" choice (VARCHAR only).
8+
"""
9+
10+
from django.db import migrations, models
11+
12+
13+
class Migration(migrations.Migration):
14+
15+
dependencies = [
16+
("django_forms_workflows", "0054_rename_approved_pending_to_pending_approval"),
17+
]
18+
19+
operations = [
20+
# WorkflowStage.allow_send_back
21+
migrations.AddField(
22+
model_name="workflowstage",
23+
name="allow_send_back",
24+
field=models.BooleanField(
25+
default=False,
26+
help_text=(
27+
"Allow approvers at a later stage to return the submission to this "
28+
"stage for correction, without terminating the workflow. When enabled, "
29+
"this stage will appear as a 'Send Back' target option for all "
30+
"subsequent stages."
31+
),
32+
),
33+
),
34+
# ApprovalTask.status – extend choices to include "returned"
35+
migrations.AlterField(
36+
model_name="approvaltask",
37+
name="status",
38+
field=models.CharField(
39+
choices=[
40+
("pending", "Pending"),
41+
("approved", "Approved"),
42+
("rejected", "Rejected"),
43+
("returned", "Returned for Correction"),
44+
("expired", "Expired"),
45+
("skipped", "Skipped"),
46+
],
47+
default="pending",
48+
max_length=20,
49+
),
50+
),
51+
# AuditLog.action – extend choices to include "send_back"
52+
migrations.AlterField(
53+
model_name="auditlog",
54+
name="action",
55+
field=models.CharField(
56+
choices=[
57+
("create", "Created"),
58+
("update", "Updated"),
59+
("delete", "Deleted"),
60+
("submit", "Submitted"),
61+
("approve", "Approved"),
62+
("reject", "Rejected"),
63+
("send_back", "Returned for Correction"),
64+
("withdraw", "Withdrawn"),
65+
("assign", "Assigned"),
66+
("comment", "Commented"),
67+
],
68+
max_length=20,
69+
),
70+
),
71+
]
72+

django_forms_workflows/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,14 @@ class WorkflowStage(models.Model):
795795
"group assignment if the field is empty or no matching user is found."
796796
),
797797
)
798+
allow_send_back = models.BooleanField(
799+
default=False,
800+
help_text=(
801+
"Allow approvers at a later stage to return the submission to this stage "
802+
"for correction, without terminating the workflow. When enabled, this stage "
803+
"will appear as a 'Send Back' target option for all subsequent stages."
804+
),
805+
)
798806

799807
class Meta:
800808
ordering = ["order"]
@@ -1411,6 +1419,7 @@ class ApprovalTask(models.Model):
14111419
("pending", "Pending"),
14121420
("approved", "Approved"),
14131421
("rejected", "Rejected"),
1422+
("returned", "Returned for Correction"),
14141423
("expired", "Expired"),
14151424
("skipped", "Skipped"),
14161425
]
@@ -1510,6 +1519,7 @@ class AuditLog(models.Model):
15101519
("submit", "Submitted"),
15111520
("approve", "Approved"),
15121521
("reject", "Rejected"),
1522+
("send_back", "Returned for Correction"),
15131523
("withdraw", "Withdrawn"),
15141524
("assign", "Assigned"),
15151525
("comment", "Commented"),

django_forms_workflows/templates/django_forms_workflows/approve.html

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,54 @@ <h6 class="mb-3"><i class="bi bi-clipboard-check"></i> Your Decision</h6>
277277
</div>
278278
</form>
279279

280+
{% if send_back_stages %}
281+
<!-- Send Back for Correction -->
282+
<div class="card mb-4 border-warning">
283+
<div class="card-header bg-warning bg-opacity-10 border-warning">
284+
<button class="btn btn-link text-warning-emphasis fw-semibold p-0 text-decoration-none w-100 text-start"
285+
type="button"
286+
data-bs-toggle="collapse"
287+
data-bs-target="#sendBackPanel"
288+
aria-expanded="false"
289+
aria-controls="sendBackPanel">
290+
<i class="bi bi-arrow-return-left me-2"></i>
291+
Send Back for Correction
292+
<i class="bi bi-chevron-down float-end mt-1"></i>
293+
</button>
294+
</div>
295+
<div class="collapse" id="sendBackPanel">
296+
<div class="card-body">
297+
<p class="text-muted small mb-3">
298+
Use this option when data entered by a previous stage approver needs to be corrected.
299+
The submission will <strong>not</strong> be rejected — it will be returned to the
300+
selected stage so the approver there can revise their entries and re-approve.
301+
</p>
302+
<form method="post" data-loading-message="Sending back for correction…">
303+
{% csrf_token %}
304+
<input type="hidden" name="decision" value="send_back">
305+
<div class="mb-3">
306+
<label for="send_back_stage_id" class="form-label fw-semibold">Return to stage <span class="text-danger">*</span></label>
307+
<select class="form-select" id="send_back_stage_id" name="send_back_stage_id" required>
308+
<option value="">— select a stage —</option>
309+
{% for stage in send_back_stages %}
310+
<option value="{{ stage.id }}">{{ stage.name }}</option>
311+
{% endfor %}
312+
</select>
313+
</div>
314+
<div class="mb-3">
315+
<label for="send_back_reason" class="form-label fw-semibold">Reason for correction <span class="text-danger">*</span></label>
316+
<textarea class="form-control" id="send_back_reason" name="send_back_reason" rows="3"
317+
placeholder="Describe what needs to be corrected and why…" required></textarea>
318+
</div>
319+
<button type="submit" class="btn btn-warning">
320+
<i class="bi bi-arrow-return-left"></i> Send Back for Correction
321+
</button>
322+
</form>
323+
</div>
324+
</div>
325+
</div>
326+
{% endif %}
327+
280328
<!-- Future Steps Preview (greyed out) -->
281329
{% for step in approval_steps %}
282330
{% if step.number > current_step_number %}
@@ -358,6 +406,55 @@ <h5 class="mb-0"><i class="bi bi-clipboard-check"></i> Your Decision</h5>
358406
</form>
359407
</div>
360408
</div>
409+
410+
{% if send_back_stages %}
411+
<!-- Send Back for Correction -->
412+
<div class="card mb-4 border-warning">
413+
<div class="card-header bg-warning bg-opacity-10 border-warning">
414+
<button class="btn btn-link text-warning-emphasis fw-semibold p-0 text-decoration-none w-100 text-start"
415+
type="button"
416+
data-bs-toggle="collapse"
417+
data-bs-target="#sendBackPanelStd"
418+
aria-expanded="false"
419+
aria-controls="sendBackPanelStd">
420+
<i class="bi bi-arrow-return-left me-2"></i>
421+
Send Back for Correction
422+
<i class="bi bi-chevron-down float-end mt-1"></i>
423+
</button>
424+
</div>
425+
<div class="collapse" id="sendBackPanelStd">
426+
<div class="card-body">
427+
<p class="text-muted small mb-3">
428+
Use this option when data entered by a previous stage approver needs to be corrected.
429+
The submission will <strong>not</strong> be rejected — it will be returned to the
430+
selected stage so the approver there can revise their entries and re-approve.
431+
</p>
432+
<form method="post" data-loading-message="Sending back for correction…">
433+
{% csrf_token %}
434+
<input type="hidden" name="decision" value="send_back">
435+
<div class="mb-3">
436+
<label for="send_back_stage_id_std" class="form-label fw-semibold">Return to stage <span class="text-danger">*</span></label>
437+
<select class="form-select" id="send_back_stage_id_std" name="send_back_stage_id" required>
438+
<option value="">— select a stage —</option>
439+
{% for stage in send_back_stages %}
440+
<option value="{{ stage.id }}">{{ stage.name }}</option>
441+
{% endfor %}
442+
</select>
443+
</div>
444+
<div class="mb-3">
445+
<label for="send_back_reason_std" class="form-label fw-semibold">Reason for correction <span class="text-danger">*</span></label>
446+
<textarea class="form-control" id="send_back_reason_std" name="send_back_reason" rows="3"
447+
placeholder="Describe what needs to be corrected and why…" required></textarea>
448+
</div>
449+
<button type="submit" class="btn btn-warning">
450+
<i class="bi bi-arrow-return-left"></i> Send Back for Correction
451+
</button>
452+
</form>
453+
</div>
454+
</div>
455+
</div>
456+
{% endif %}
457+
361458
{% endif %}
362459
</div>
363460
</div>

django_forms_workflows/views.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -976,10 +976,76 @@ def approve_submission(request, task_id):
976976
decision = request.POST.get("decision")
977977
comments = request.POST.get("comments", "")
978978

979-
if decision not in ["approve", "reject"]:
979+
if decision not in ["approve", "reject", "send_back"]:
980980
messages.error(request, "Invalid decision.")
981981
return redirect("forms_workflows:approve_submission", task_id=task_id)
982982

983+
# Send-back path — no approval-step-field validation needed.
984+
if decision == "send_back":
985+
send_back_reason = request.POST.get("send_back_reason", "").strip()
986+
send_back_stage_id = request.POST.get("send_back_stage_id", "").strip()
987+
if not send_back_reason:
988+
messages.error(
989+
request, "A reason is required when sending back for correction."
990+
)
991+
return redirect("forms_workflows:approve_submission", task_id=task_id)
992+
if not send_back_stage_id:
993+
messages.error(request, "Please select a stage to send back to.")
994+
return redirect("forms_workflows:approve_submission", task_id=task_id)
995+
996+
from .models import WorkflowStage
997+
from .workflow_engine import handle_send_back, handle_sub_workflow_send_back
998+
999+
try:
1000+
target_stage = WorkflowStage.objects.get(pk=send_back_stage_id)
1001+
except WorkflowStage.DoesNotExist:
1002+
messages.error(request, "Invalid target stage selected.")
1003+
return redirect("forms_workflows:approve_submission", task_id=task_id)
1004+
1005+
# Verify the target stage belongs to the same workflow and is truly prior.
1006+
current_stage = task.workflow_stage
1007+
if (
1008+
current_stage is None
1009+
or target_stage.workflow_id != current_stage.workflow_id
1010+
):
1011+
messages.error(
1012+
request, "Target stage does not belong to this workflow."
1013+
)
1014+
return redirect("forms_workflows:approve_submission", task_id=task_id)
1015+
if target_stage.order >= current_stage.order:
1016+
messages.error(request, "You can only send back to a prior stage.")
1017+
return redirect("forms_workflows:approve_submission", task_id=task_id)
1018+
1019+
task.status = "returned"
1020+
task.decision = "send_back"
1021+
task.comments = send_back_reason
1022+
task.completed_by = request.user
1023+
task.completed_at = timezone.now()
1024+
task.save()
1025+
1026+
if task.sub_workflow_instance_id:
1027+
handle_sub_workflow_send_back(task, target_stage)
1028+
else:
1029+
handle_send_back(submission, task, target_stage)
1030+
1031+
AuditLog.objects.create(
1032+
action="send_back",
1033+
object_type="FormSubmission",
1034+
object_id=submission.id,
1035+
user=request.user,
1036+
user_ip=get_client_ip(request),
1037+
changes={
1038+
"task_id": task.id,
1039+
"target_stage": target_stage.name,
1040+
"reason": send_back_reason,
1041+
},
1042+
)
1043+
messages.success(
1044+
request,
1045+
f'Submission returned to "{target_stage.name}" for correction.',
1046+
)
1047+
return redirect("forms_workflows:approval_inbox")
1048+
9831049
# If there are approval step fields and decision is approve, validate them
9841050
if has_approval_step_fields and decision == "approve":
9851051
from .forms import ApprovalStepForm
@@ -1070,7 +1136,8 @@ def approve_submission(request, task_id):
10701136
changes={"task_id": task.id, "comments": comments},
10711137
)
10721138

1073-
messages.success(request, f"Submission {decision}d successfully.")
1139+
action_label = "approved" if decision == "approve" else "rejected"
1140+
messages.success(request, f"Submission {action_label} successfully.")
10741141
return redirect("forms_workflows:approval_inbox")
10751142

10761143
# GET request - create the approval step form if needed
@@ -1164,6 +1231,28 @@ def approve_submission(request, task_id):
11641231
else ""
11651232
)
11661233

1234+
# Build the list of prior stages the approver may send back to.
1235+
# For sub-workflow tasks we look at the sub-workflow's own stage set.
1236+
send_back_stages: list = []
1237+
if task.workflow_stage and task.workflow_stage.allow_send_back is not None:
1238+
# current task's stage is NOT a send-back target for itself; only
1239+
# stages with a *lower* order value and allow_send_back=True qualify.
1240+
current_order = task.workflow_stage.order
1241+
if task.sub_workflow_instance_id:
1242+
sub_wf = task.sub_workflow_instance.definition.sub_workflow
1243+
send_back_stages = list(
1244+
sub_wf.stages.filter(
1245+
allow_send_back=True, order__lt=current_order
1246+
).order_by("order")
1247+
)
1248+
else:
1249+
wf_for_send_back = task.workflow_stage.workflow
1250+
send_back_stages = list(
1251+
wf_for_send_back.stages.filter(
1252+
allow_send_back=True, order__lt=current_order
1253+
).order_by("order")
1254+
)
1255+
11671256
return render(
11681257
request,
11691258
"django_forms_workflows/approve.html",
@@ -1187,6 +1276,8 @@ def approve_submission(request, task_id):
11871276
"approve_label": approve_label,
11881277
# sub-workflow context (None for regular tasks)
11891278
"sub_workflow_instance": task.sub_workflow_instance,
1279+
# send-back targets (empty list → panel hidden in template)
1280+
"send_back_stages": send_back_stages,
11901281
},
11911282
)
11921283

0 commit comments

Comments
 (0)