Skip to content

Commit 129b931

Browse files
committed
Conditional workflow and stage triggers (trigger_conditions)
Add field-based conditional triggers to both WorkflowDefinition and WorkflowStage via a new trigger_conditions JSONField. Workflow-level conditions: Form submitted → if field A == X → Workflow 1 if field A == Y → Workflow 2 Stage-level conditions: Workflow 1 → Stage 1 (always) if field A == X → Stage 2a if field A == Y → Stage 2b New module: conditions.py with evaluate_conditions() evaluator. Admin UI, sync, and clone all updated. Migration 0048. Bump version to 0.27.0
1 parent f89fb65 commit 129b931

File tree

8 files changed

+239
-6
lines changed

8 files changed

+239
-6
lines changed

django_forms_workflows/admin.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ class WorkflowDefinitionInline(admin.StackedInline):
278278
fields = [
279279
"name_label",
280280
"requires_approval",
281+
"trigger_conditions",
281282
"hide_approval_history",
282283
("notify_on_submission", "notify_on_approval", "notify_on_rejection"),
283284
"additional_notify_emails",
@@ -529,6 +530,7 @@ def clone_forms(self, request, queryset):
529530
notification_cadence_time=wf.notification_cadence_time,
530531
notification_cadence_form_field=wf.notification_cadence_form_field,
531532
visual_workflow_data=wf.visual_workflow_data,
533+
trigger_conditions=wf.trigger_conditions,
532534
hide_approval_history=wf.hide_approval_history,
533535
allow_bulk_export=wf.allow_bulk_export,
534536
allow_bulk_pdf_export=wf.allow_bulk_pdf_export,
@@ -541,6 +543,7 @@ def clone_forms(self, request, queryset):
541543
approval_logic=stage.approval_logic,
542544
requires_manager_approval=stage.requires_manager_approval,
543545
approve_label=stage.approve_label,
546+
trigger_conditions=stage.trigger_conditions,
544547
)
545548
for sag in StageApprovalGroup.objects.filter(
546549
stage=stage
@@ -1108,6 +1111,7 @@ class WorkflowStageInline(admin.StackedInline):
11081111
"approval_logic",
11091112
"approve_label",
11101113
"requires_manager_approval",
1114+
"trigger_conditions",
11111115
)
11121116

11131117

@@ -1134,6 +1138,7 @@ class WorkflowDefinitionAdmin(admin.ModelAdmin):
11341138
"form_definition",
11351139
"name_label",
11361140
"requires_approval",
1141+
"trigger_conditions",
11371142
)
11381143
},
11391144
),
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""
2+
Evaluate trigger_conditions against form submission data.
3+
4+
Condition format (same as FormField.conditional_rules):
5+
6+
{
7+
"operator": "AND" | "OR",
8+
"conditions": [
9+
{
10+
"field": "field_name",
11+
"operator": "equals" | "not_equals" | "gt" | "lt" |
12+
"gte" | "lte" | "contains" | "in",
13+
"value": <expected_value>
14+
},
15+
...
16+
]
17+
}
18+
19+
``evaluate_conditions(conditions, data)`` returns ``True`` when the
20+
submission data satisfies the rule set, or when ``conditions`` is
21+
``None`` / empty (unconditional).
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import logging
27+
from decimal import Decimal, InvalidOperation
28+
from typing import Any
29+
30+
logger = logging.getLogger(__name__)
31+
32+
33+
def _coerce_numeric(val: Any) -> Decimal | None:
34+
"""Try to coerce a value to Decimal for numeric comparisons."""
35+
if val is None:
36+
return None
37+
try:
38+
return Decimal(str(val))
39+
except (InvalidOperation, ValueError, TypeError):
40+
return None
41+
42+
43+
def _evaluate_single(condition: dict, data: dict) -> bool:
44+
"""Evaluate a single condition dict against submission data."""
45+
field = condition.get("field", "")
46+
operator = condition.get("operator", "equals")
47+
expected = condition.get("value")
48+
49+
actual = data.get(field)
50+
51+
# Normalise to strings for simple comparisons
52+
actual_str = str(actual).strip() if actual is not None else ""
53+
expected_str = str(expected).strip() if expected is not None else ""
54+
55+
if operator == "equals":
56+
return actual_str.lower() == expected_str.lower()
57+
58+
if operator == "not_equals":
59+
return actual_str.lower() != expected_str.lower()
60+
61+
if operator == "contains":
62+
return expected_str.lower() in actual_str.lower()
63+
64+
if operator == "in":
65+
# expected should be a list; check if actual is in it
66+
if isinstance(expected, list):
67+
return actual_str.lower() in [str(v).strip().lower() for v in expected]
68+
# Fallback: comma-separated string
69+
return actual_str.lower() in [
70+
v.strip().lower() for v in expected_str.split(",")
71+
]
72+
73+
# Numeric comparisons
74+
actual_num = _coerce_numeric(actual)
75+
expected_num = _coerce_numeric(expected)
76+
if actual_num is None or expected_num is None:
77+
logger.debug(
78+
"Non-numeric comparison attempted: field=%s op=%s actual=%r expected=%r",
79+
field,
80+
operator,
81+
actual,
82+
expected,
83+
)
84+
return False
85+
86+
if operator == "gt":
87+
return actual_num > expected_num
88+
if operator == "lt":
89+
return actual_num < expected_num
90+
if operator == "gte":
91+
return actual_num >= expected_num
92+
if operator == "lte":
93+
return actual_num <= expected_num
94+
95+
logger.warning("Unknown condition operator: %s", operator)
96+
return False
97+
98+
99+
def evaluate_conditions(conditions: dict | None, data: dict) -> bool:
100+
"""Evaluate a trigger_conditions rule set against form data.
101+
102+
Returns ``True`` when:
103+
- ``conditions`` is ``None``, empty dict, or has no ``conditions`` list
104+
(unconditional — always matches)
105+
- All / any individual conditions pass (depending on the top-level operator)
106+
"""
107+
if not conditions:
108+
return True
109+
110+
condition_list = conditions.get("conditions")
111+
if not condition_list:
112+
return True
113+
114+
group_operator = conditions.get("operator", "AND").upper()
115+
116+
results = [_evaluate_single(c, data) for c in condition_list]
117+
118+
if group_operator == "OR":
119+
return any(results)
120+
# Default: AND
121+
return all(results)

django_forms_workflows/form_builder_views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ def form_builder_clone(request, form_id):
174174
notification_cadence_time=wf.notification_cadence_time,
175175
notification_cadence_form_field=wf.notification_cadence_form_field,
176176
visual_workflow_data=wf.visual_workflow_data,
177+
trigger_conditions=wf.trigger_conditions,
177178
hide_approval_history=wf.hide_approval_history,
178179
allow_bulk_export=wf.allow_bulk_export,
179180
allow_bulk_pdf_export=wf.allow_bulk_pdf_export,
@@ -186,6 +187,7 @@ def form_builder_clone(request, form_id):
186187
approval_logic=stage.approval_logic,
187188
requires_manager_approval=stage.requires_manager_approval,
188189
approve_label=stage.approve_label,
190+
trigger_conditions=stage.trigger_conditions,
189191
)
190192
for sag in StageApprovalGroup.objects.filter(stage=stage).order_by(
191193
"position"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 6.0.2 on 2026-03-13 23:29
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("django_forms_workflows", "0047_add_hide_approval_history"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="workflowdefinition",
15+
name="trigger_conditions",
16+
field=models.JSONField(
17+
blank=True,
18+
help_text='Conditions that must be met for this workflow to run. Format: {"operator": "AND|OR", "conditions": [{"field": "field_name", "operator": "equals|not_equals|gt|lt|gte|lte|contains|in", "value": "..."}]}',
19+
null=True,
20+
),
21+
),
22+
migrations.AddField(
23+
model_name="workflowstage",
24+
name="trigger_conditions",
25+
field=models.JSONField(
26+
blank=True,
27+
help_text='Conditions that must be met for this stage to run. When advancing from a prior stage, only stages whose conditions match the submission data will be entered. Format: {"operator": "AND|OR", "conditions": [{"field": "field_name", "operator": "equals|not_equals|gt|lt|gte|lte|contains|in", "value": "..."}]}',
28+
null=True,
29+
),
30+
),
31+
migrations.AlterField(
32+
model_name="stageapprovalgroup",
33+
name="id",
34+
field=models.BigAutoField(
35+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
36+
),
37+
),
38+
]

django_forms_workflows/models.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,19 @@ class WorkflowDefinition(models.Model):
651651
help_text="Visual workflow builder layout (nodes and connections)",
652652
)
653653

654+
# Conditional trigger — when set, this workflow only runs if the
655+
# submission data matches the conditions. None / empty = always run.
656+
trigger_conditions = models.JSONField(
657+
blank=True,
658+
null=True,
659+
help_text=(
660+
"Conditions that must be met for this workflow to run. "
661+
'Format: {"operator": "AND|OR", "conditions": '
662+
'[{"field": "field_name", "operator": "equals|not_equals|gt|lt|gte|lte|contains|in", '
663+
'"value": "..."}]}'
664+
),
665+
)
666+
654667
# Privacy
655668
hide_approval_history = models.BooleanField(
656669
default=False,
@@ -739,6 +752,20 @@ class WorkflowStage(models.Model):
739752
'(e.g. "Complete", "Confirm", "Sign Off"). Defaults to "Approve" when blank.'
740753
),
741754
)
755+
# Conditional trigger — when set, this stage only runs if the
756+
# submission data matches the conditions. None / empty = always run.
757+
trigger_conditions = models.JSONField(
758+
blank=True,
759+
null=True,
760+
help_text=(
761+
"Conditions that must be met for this stage to run. "
762+
"When advancing from a prior stage, only stages whose conditions "
763+
"match the submission data will be entered. "
764+
'Format: {"operator": "AND|OR", "conditions": '
765+
'[{"field": "field_name", "operator": "equals|not_equals|gt|lt|gte|lte|contains|in", '
766+
'"value": "..."}]}'
767+
),
768+
)
742769

743770
class Meta:
744771
ordering = ["order"]

django_forms_workflows/sync_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ def _serialize_workflow_stage(stage):
233233
"approval_logic": stage.approval_logic,
234234
"approval_groups": _group_names(stage.approval_groups),
235235
"requires_manager_approval": stage.requires_manager_approval,
236+
"trigger_conditions": stage.trigger_conditions,
236237
}
237238

238239

@@ -256,6 +257,7 @@ def _serialize_workflow(wf):
256257
return None
257258
return {
258259
"requires_approval": wf.requires_approval,
260+
"trigger_conditions": wf.trigger_conditions,
259261
"approval_deadline_days": wf.approval_deadline_days,
260262
"send_reminder_after_days": wf.send_reminder_after_days,
261263
"auto_approve_after_days": wf.auto_approve_after_days,

django_forms_workflows/workflow_engine.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from django.db import transaction
3535
from django.utils import timezone
3636

37+
from .conditions import evaluate_conditions
3738
from .models import (
3839
ApprovalTask,
3940
FormSubmission,
@@ -350,7 +351,9 @@ def _advance_to_next_stage(
350351
).exists():
351352
return # A parallel branch is still in progress
352353

353-
# Find stages at the next order level within this workflow track.
354+
# Find stages at the next order level within this workflow track,
355+
# filtering by trigger_conditions against the submission data.
356+
form_data = submission.form_data or {}
354357
future_stages = [s for s in stages if s.order > current_order]
355358
if not future_stages:
356359
# This workflow track is complete. Check whether ALL tracks are done
@@ -359,7 +362,31 @@ def _advance_to_next_stage(
359362
return
360363

361364
next_order = future_stages[0].order
362-
for stage in (s for s in future_stages if s.order == next_order):
365+
eligible_stages = [
366+
s
367+
for s in future_stages
368+
if s.order == next_order
369+
and evaluate_conditions(s.trigger_conditions, form_data)
370+
]
371+
if not eligible_stages:
372+
# No eligible stages at the next order — check further orders
373+
# or finalize if nothing remains.
374+
remaining = [
375+
s
376+
for s in future_stages
377+
if s.order > next_order
378+
and evaluate_conditions(s.trigger_conditions, form_data)
379+
]
380+
if remaining:
381+
# Jump to the next eligible order level
382+
jump_order = remaining[0].order
383+
for stage in (s for s in remaining if s.order == jump_order):
384+
_create_stage_tasks(submission, stage, due_date=due_date)
385+
else:
386+
_try_finalize_all_tracks(submission)
387+
return
388+
389+
for stage in eligible_stages:
363390
_create_stage_tasks(submission, stage, due_date=due_date)
364391

365392

@@ -394,8 +421,14 @@ def create_workflow_tasks(submission: FormSubmission) -> None:
394421
except Exception as e:
395422
logger.error("Error spawning sub-workflows on submission: %s", e, exc_info=True)
396423

397-
# Filter to workflows that actually require approval
398-
approval_workflows = [w for w in workflows if w.requires_approval]
424+
# Filter to workflows that require approval AND whose trigger
425+
# conditions (if any) match the submission data.
426+
form_data = submission.form_data or {}
427+
approval_workflows = [
428+
w
429+
for w in workflows
430+
if w.requires_approval and evaluate_conditions(w.trigger_conditions, form_data)
431+
]
399432
if not approval_workflows:
400433
_finalize_submission(submission)
401434
return
@@ -412,7 +445,12 @@ def create_workflow_tasks(submission: FormSubmission) -> None:
412445
continue # No stages configured for this track
413446

414447
first_order = stages[0].order
415-
first_order_stages = [s for s in stages if s.order == first_order]
448+
first_order_stages = [
449+
s
450+
for s in stages
451+
if s.order == first_order
452+
and evaluate_conditions(s.trigger_conditions, form_data)
453+
]
416454
for stage in first_order_stages:
417455
groups = list(stage.approval_groups.all())
418456
if not stage.requires_manager_approval and not groups:

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.26.1"
3+
version = "0.27.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)