Skip to content

Commit ca189da

Browse files
matteiusclaude
andcommitted
feat: add role-based group separation for validation and reassignment
StageApprovalGroup now supports a `role` field (approval/validation/ reassignment) so stages can use different groups for assignee validation, reassignment eligibility, and fallback approval. Falls back to approval groups when no role-specific groups are configured (backward compatible). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0cbc715 commit ca189da

11 files changed

Lines changed: 297 additions & 25 deletions

File tree

django_forms_workflows/admin.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -477,8 +477,9 @@ class StageApprovalGroupInline(nested_admin.NestedTabularInline):
477477

478478
model = StageApprovalGroup
479479
extra = 1
480-
ordering = ("position",)
480+
ordering = ("role", "position")
481481
autocomplete_fields = ("group",)
482+
fields = ("group", "role", "position")
482483

483484

484485
class WorkflowStageInline(nested_admin.NestedStackedInline):
@@ -963,11 +964,12 @@ def clone_forms(self, request, queryset):
963964
)
964965
for sag in StageApprovalGroup.objects.filter(
965966
stage=stage
966-
).order_by("position"):
967+
).order_by("role", "position"):
967968
StageApprovalGroup.objects.create(
968969
stage=cloned_stage,
969970
group=sag.group,
970971
position=sag.position,
972+
role=sag.role,
971973
)
972974
# Clone SubWorkflowDefinition if present
973975
try:

django_forms_workflows/form_builder_views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,13 @@ def form_builder_clone(request, form_id):
187187
trigger_conditions=stage.trigger_conditions,
188188
)
189189
for sag in StageApprovalGroup.objects.filter(stage=stage).order_by(
190-
"position"
190+
"role", "position"
191191
):
192192
StageApprovalGroup.objects.create(
193193
stage=cloned_stage,
194194
group=sag.group,
195195
position=sag.position,
196+
role=sag.role,
196197
)
197198
# Clone SubWorkflowDefinition if present
198199
try:

django_forms_workflows/management/commands/seed_farm_demo.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,6 +1784,7 @@ def _set_stage_groups(self, stage, groups, sequential=False):
17841784
StageApprovalGroup.objects.update_or_create(
17851785
stage=stage,
17861786
group=group,
1787+
role="approval",
17871788
defaults={"position": position},
17881789
)
17891790

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Generated by Django 5.2.7 on 2026-04-13 17:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("auth", "0012_alter_user_first_name_max_length"),
10+
("django_forms_workflows", "0089_workflowstage_hide_comment_field"),
11+
]
12+
13+
operations = [
14+
migrations.AlterModelOptions(
15+
name="stageapprovalgroup",
16+
options={
17+
"ordering": ["role", "position"],
18+
"verbose_name": "Stage Approval Group",
19+
"verbose_name_plural": "Stage Approval Groups",
20+
},
21+
),
22+
migrations.AlterUniqueTogether(
23+
name="stageapprovalgroup",
24+
unique_together=set(),
25+
),
26+
migrations.AddField(
27+
model_name="stageapprovalgroup",
28+
name="role",
29+
field=models.CharField(
30+
choices=[
31+
("approval", "Approval (default)"),
32+
("validation", "Assignee validation"),
33+
("reassignment", "Reassignment pool"),
34+
],
35+
default="approval",
36+
help_text="Purpose of this group in the stage. 'Approval' groups receive fallback tasks and define the default pool. 'Validation' groups are checked when verifying a dynamic assignee's group membership (falls back to approval groups when none defined). 'Reassignment' groups define who may be reassigned to (falls back to approval groups when none defined).",
37+
max_length=20,
38+
),
39+
),
40+
migrations.AlterUniqueTogether(
41+
name="stageapprovalgroup",
42+
unique_together={("stage", "group", "role")},
43+
),
44+
]

django_forms_workflows/models.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,15 +1249,68 @@ class Meta:
12491249
def __str__(self) -> str:
12501250
return f"Stage {self.order}: {self.name}"
12511251

1252+
# ------------------------------------------------------------------
1253+
# Helpers to retrieve groups by role (with fallback to approval)
1254+
# ------------------------------------------------------------------
1255+
1256+
def _groups_by_role(self, role: str):
1257+
"""Return groups for *role*, falling back to approval groups if none."""
1258+
qs = Group.objects.filter(
1259+
stageapprovalgroup__stage=self,
1260+
stageapprovalgroup__role=role,
1261+
).order_by("stageapprovalgroup__position")
1262+
if qs.exists():
1263+
return qs
1264+
# Fallback: approval groups
1265+
return Group.objects.filter(
1266+
stageapprovalgroup__stage=self,
1267+
stageapprovalgroup__role="approval",
1268+
).order_by("stageapprovalgroup__position")
1269+
1270+
def get_validation_groups(self):
1271+
"""Groups used to validate a dynamic assignee's membership."""
1272+
return self._groups_by_role("validation")
1273+
1274+
def get_reassignment_groups(self):
1275+
"""Groups that define the eligible reassignment pool."""
1276+
return self._groups_by_role("reassignment")
1277+
1278+
def get_approval_groups(self):
1279+
"""Groups used for fallback task assignment / approval logic."""
1280+
return Group.objects.filter(
1281+
stageapprovalgroup__stage=self,
1282+
stageapprovalgroup__role="approval",
1283+
).order_by("stageapprovalgroup__position")
1284+
12521285

12531286
class StageApprovalGroup(models.Model):
12541287
"""
12551288
Through model for WorkflowStage ↔ Group M2M.
12561289
12571290
Stores a ``position`` so admins can control the order groups are
12581291
processed when approval_logic is ``"sequence"``.
1292+
1293+
The ``role`` field distinguishes the purpose of a group within the stage:
1294+
1295+
* **approval** – the default pool used for fallback task assignment and
1296+
approval logic (AND/OR/sequence).
1297+
* **validation** – used to check that a dynamically resolved assignee
1298+
belongs to an allowed group. When no validation groups are configured,
1299+
approval groups are used instead.
1300+
* **reassignment** – defines the eligible pool of users for task
1301+
reassignment. When no reassignment groups are configured, approval
1302+
groups are used instead.
1303+
1304+
The same Django ``Group`` may appear under multiple roles for a single
1305+
stage (e.g. both approval and reassignment).
12591306
"""
12601307

1308+
ROLE_CHOICES = [
1309+
("approval", "Approval (default)"),
1310+
("validation", "Assignee validation"),
1311+
("reassignment", "Reassignment pool"),
1312+
]
1313+
12611314
stage = models.ForeignKey(
12621315
WorkflowStage,
12631316
on_delete=models.CASCADE,
@@ -1270,15 +1323,30 @@ class StageApprovalGroup(models.Model):
12701323
default=0,
12711324
help_text="Order in which this group is processed (lower = first)",
12721325
)
1326+
role = models.CharField(
1327+
max_length=20,
1328+
choices=ROLE_CHOICES,
1329+
default="approval",
1330+
help_text=(
1331+
"Purpose of this group in the stage. "
1332+
"'Approval' groups receive fallback tasks and define the default pool. "
1333+
"'Validation' groups are checked when verifying a dynamic assignee's "
1334+
"group membership (falls back to approval groups when none defined). "
1335+
"'Reassignment' groups define who may be reassigned to "
1336+
"(falls back to approval groups when none defined)."
1337+
),
1338+
)
12731339

12741340
class Meta:
1275-
ordering = ["position"]
1276-
unique_together = [("stage", "group")]
1341+
ordering = ["role", "position"]
1342+
unique_together = [("stage", "group", "role")]
12771343
verbose_name = "Stage Approval Group"
12781344
verbose_name_plural = "Stage Approval Groups"
12791345

12801346
def __str__(self) -> str:
1281-
return f"{self.group.name} (pos {self.position})"
1347+
label = self.get_role_display() if self.role != "approval" else ""
1348+
suffix = f" [{label}]" if label else ""
1349+
return f"{self.group.name} (pos {self.position}){suffix}"
12821350

12831351

12841352
class NotificationRule(models.Model):

django_forms_workflows/sync_api.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,9 @@ def _serialize_workflow_stage(stage):
237237
# Serialize approval groups with their sequence position so that
238238
# "sequence" stages are restored in the correct order on import.
239239
approval_groups = [
240-
{"name": sag.group.name, "position": sag.position}
240+
{"name": sag.group.name, "position": sag.position, "role": sag.role}
241241
for sag in stage.stageapprovalgroup_set.select_related("group").order_by(
242-
"position"
242+
"role", "position"
243243
)
244244
]
245245
return {
@@ -792,11 +792,13 @@ def _import_single_workflow(
792792
if isinstance(entry, dict):
793793
group = _get_or_create_group(entry["name"])
794794
position = entry.get("position", pos)
795+
role = entry.get("role", "approval")
795796
else:
796797
group = _get_or_create_group(entry)
797798
position = pos
799+
role = "approval"
798800
StageApprovalGroup.objects.create(
799-
stage=stage, group=group, position=position
801+
stage=stage, group=group, position=position, role=role
800802
)
801803

802804
stage.notification_rules.all().delete()

django_forms_workflows/views.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1925,13 +1925,13 @@ def reassign_task(request, task_id):
19251925
messages.error(request, "Reassignment is not enabled for this approval step.")
19261926
return redirect("forms_workflows:approval_inbox")
19271927

1928-
# Permission: current assignee, any member of the stage's approval groups,
1929-
# or superuser can reassign.
1930-
stage_groups = list(stage.approval_groups.all())
1931-
stage_group_ids = {g.pk for g in stage_groups}
1928+
# Permission: current assignee, any member of the stage's reassignment
1929+
# groups (falls back to approval groups), or superuser can reassign.
1930+
reassign_groups = list(stage.get_reassignment_groups())
1931+
reassign_group_ids = {g.pk for g in reassign_groups}
19321932
can_reassign = (
19331933
task.assigned_to == request.user
1934-
or request.user.groups.filter(pk__in=stage_group_ids).exists()
1934+
or request.user.groups.filter(pk__in=reassign_group_ids).exists()
19351935
or request.user.is_superuser
19361936
)
19371937
if not can_reassign:
@@ -1950,11 +1950,11 @@ def reassign_task(request, task_id):
19501950
messages.error(request, "Selected user not found.")
19511951
return redirect("forms_workflows:reassign_task", task_id=task_id)
19521952

1953-
# Validate the new assignee is in one of the stage's approval groups
1954-
if not new_assignee.groups.filter(pk__in=stage_group_ids).exists():
1953+
# Validate the new assignee is in one of the stage's reassignment groups
1954+
if not new_assignee.groups.filter(pk__in=reassign_group_ids).exists():
19551955
messages.error(
19561956
request,
1957-
"The selected user is not a member of any approval group for this stage.",
1957+
"The selected user is not a member of any reassignment group for this stage.",
19581958
)
19591959
return redirect("forms_workflows:reassign_task", task_id=task_id)
19601960

@@ -1988,7 +1988,7 @@ def reassign_task(request, task_id):
19881988

19891989
# GET — show reassignment form with eligible users
19901990
eligible_users = (
1991-
user_model.objects.filter(groups__pk__in=stage_group_ids, is_active=True)
1991+
user_model.objects.filter(groups__pk__in=reassign_group_ids, is_active=True)
19921992
.distinct()
19931993
.order_by("last_name", "first_name", "username")
19941994
)

django_forms_workflows/workflow_builder_views.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,14 @@ def _normalize_trigger_conditions(raw_conditions):
129129

130130
def _serialize_stage_approval_groups(stage):
131131
return [
132-
{"id": sag.group_id, "name": sag.group.name, "position": sag.position}
132+
{
133+
"id": sag.group_id,
134+
"name": sag.group.name,
135+
"position": sag.position,
136+
"role": sag.role,
137+
}
133138
for sag in stage.stageapprovalgroup_set.select_related("group").order_by(
134-
"position", "group__name"
139+
"role", "position", "group__name"
135140
)
136141
]
137142

@@ -1245,6 +1250,7 @@ def convert_visual_to_workflow(workflow_data, form_definition, workflow=None):
12451250
stage=stage,
12461251
group_id=group_entry["id"],
12471252
position=group_entry.get("position", pos),
1253+
role=group_entry.get("role", "approval"),
12481254
)
12491255

12501256
stage_node_map[snode.get("id")] = stage

django_forms_workflows/workflow_engine.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -586,12 +586,14 @@ def _create_stage_tasks(
586586
# --- Dynamic assignment (form field → User lookup) ------------------------
587587
dynamic_assignee = _resolve_dynamic_assignee(submission, stage)
588588
if dynamic_assignee is not None:
589-
# Optionally validate the resolved user belongs to a stage approval group
589+
# Optionally validate the resolved user belongs to the stage's
590+
# validation groups (falls back to approval groups when none defined).
590591
if stage.validate_assignee_group and groups:
591-
group_ids = {g.pk for g in groups}
592-
if not dynamic_assignee.groups.filter(pk__in=group_ids).exists():
592+
validation_groups = list(stage.get_validation_groups())
593+
validation_group_ids = {g.pk for g in validation_groups}
594+
if not dynamic_assignee.groups.filter(pk__in=validation_group_ids).exists():
593595
logger.warning(
594-
"Dynamic assignee '%s' is not a member of any approval group "
596+
"Dynamic assignee '%s' is not a member of any validation group "
595597
"for stage '%s' (submission %s); falling back to group assignment.",
596598
dynamic_assignee.username,
597599
stage.name,

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.64.2"
3+
version = "0.65.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)