Skip to content

Commit fe6f41f

Browse files
committed
feat: add reviewer_groups permission level to FormDefinition
Adds a new reviewer_groups M2M field that grants read-only access to all submissions and full approval history for a form, without admin privileges. - reviewer_groups members see all non-draft submissions for their forms in My Submissions so they can navigate without needing a direct URL - Full approval history always visible to reviewers (respects hide_approval_history the same way approvers and admins do) - Permission enforced in submission_detail, sub_workflow_detail, submission_pdf, bulk_export_submissions, bulk_export_submissions_pdf, user_can_view_submission - Django Admin: filter_horizontal widget in Access Control fieldset; preserved when cloning a form - Migration 0064_add_reviewer_groups adds the join table Bumps version to 0.37.0
1 parent b81a663 commit fe6f41f

7 files changed

Lines changed: 118 additions & 9 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.37.0] - 2026-03-26
11+
12+
### Added
13+
- **`reviewer_groups` permission level on `FormDefinition`** — a new M2M field that grants named groups read-only access to all submissions and their full approval history for a form, without the management capabilities of `admin_groups`.
14+
- Members of a `reviewer_groups` group will see all submitted/pending/approved/rejected/withdrawn submissions for that form under **My Submissions** (alongside their own), so they can navigate to them without needing a direct URL.
15+
- The full approval history is always visible to reviewers even when `hide_approval_history` is enabled on the workflow (matching the behaviour for approvers and admins).
16+
- Access is enforced consistently across `submission_detail`, `sub_workflow_detail`, `submission_pdf`, `bulk_export_submissions`, `bulk_export_submissions_pdf`, and the `user_can_view_submission` utility.
17+
- The field is exposed in Django Admin under the **Access Control** fieldset with a horizontal filter widget, and is preserved when cloning a form.
18+
- Migration `0064_add_reviewer_groups` adds the underlying join table.
19+
1020
## [0.36.5] - 2026-03-26
1121

1222
### Fixed

django_forms_workflows/admin.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,12 @@ class FormDefinitionAdmin(nested_admin.NestedModelAdmin):
398398
prepopulated_fields = {"slug": ("name",)}
399399
readonly_fields = ("created_at", "updated_at", "created_by")
400400
inlines = [FormFieldInline, WorkflowDefinitionInline]
401-
filter_horizontal = ("submit_groups", "view_groups", "admin_groups")
401+
filter_horizontal = (
402+
"submit_groups",
403+
"view_groups",
404+
"admin_groups",
405+
"reviewer_groups",
406+
)
402407
change_form_template = "admin/django_forms_workflows/formdef_change_form.html"
403408
actions = ["clone_forms", "diff_forms", "export_as_json"]
404409

@@ -424,6 +429,7 @@ class FormDefinitionAdmin(nested_admin.NestedModelAdmin):
424429
"submit_groups",
425430
"view_groups",
426431
"admin_groups",
432+
"reviewer_groups",
427433
),
428434
},
429435
),
@@ -614,6 +620,7 @@ def clone_forms(self, request, queryset):
614620
cloned_form.submit_groups.set(form.submit_groups.all())
615621
cloned_form.view_groups.set(form.view_groups.all())
616622
cloned_form.admin_groups.set(form.admin_groups.all())
623+
cloned_form.reviewer_groups.set(form.reviewer_groups.all())
617624

618625
# Clone workflows, stages, and stage approval groups
619626
for wf in form.workflows.all():
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import django.db.models.deletion
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
7+
dependencies = [
8+
("django_forms_workflows", "0063_formdefinition_api_enabled_apitoken"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="formdefinition",
14+
name="reviewer_groups",
15+
field=models.ManyToManyField(
16+
blank=True,
17+
help_text=(
18+
"Groups that can view all submissions and full approval history "
19+
"for this form. Unlike admin groups, reviewers cannot manage the "
20+
"form itself."
21+
),
22+
related_name="can_review_forms",
23+
to="auth.group",
24+
),
25+
),
26+
]
27+

django_forms_workflows/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ class FormDefinition(models.Model):
156156
blank=True,
157157
help_text="Groups that can view all submissions",
158158
)
159+
reviewer_groups = models.ManyToManyField(
160+
Group,
161+
related_name="can_review_forms",
162+
blank=True,
163+
help_text=(
164+
"Groups that can view all submissions and full approval history for this form. "
165+
"Unlike admin groups, reviewers cannot manage the form itself."
166+
),
167+
)
159168

160169
# Behavior
161170
allow_save_draft = models.BooleanField(

django_forms_workflows/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,12 @@ def user_can_view_submission(user, submission: FormSubmission) -> bool:
264264
).exists():
265265
return True
266266

267+
# Reviewers can view submissions and approval history
268+
if user.groups.filter(
269+
id__in=submission.form_definition.reviewer_groups.all()
270+
).exists():
271+
return True
272+
267273
return False
268274

269275

django_forms_workflows/views.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,25 @@ def my_submissions(request):
417417
``category_counts`` list so the UI can render a filter-pill bar showing
418418
how many submissions belong to each category.
419419
"""
420-
base_submissions = FormSubmission.objects.filter(submitter=request.user)
420+
# Include submissions the user owns OR submissions for forms they can review.
421+
reviewer_form_ids = (
422+
FormDefinition.objects.filter(reviewer_groups__in=request.user.groups.all())
423+
.values_list("id", flat=True)
424+
.distinct()
425+
)
426+
base_submissions = FormSubmission.objects.filter(
427+
models.Q(submitter=request.user)
428+
| models.Q(
429+
form_definition__in=reviewer_form_ids,
430+
status__in=[
431+
"submitted",
432+
"pending_approval",
433+
"approved",
434+
"rejected",
435+
"withdrawn",
436+
],
437+
)
438+
).distinct()
421439

422440
# --- Category counts (for the filter bar) ---
423441
raw_counts = (
@@ -542,14 +560,17 @@ def submission_detail(request, submission_id):
542560
"""View submission details"""
543561
submission = get_object_or_404(FormSubmission, id=submission_id)
544562

545-
# Check permissions - user must be submitter, approver, or admin
563+
# Check permissions - user must be submitter, approver, admin, or reviewer
564+
form_def_early = submission.form_definition
565+
is_reviewer = request.user.groups.filter(
566+
id__in=form_def_early.reviewer_groups.all()
567+
).exists()
546568
can_view = (
547569
submission.submitter == request.user
548570
or request.user.is_superuser
549571
or user_can_approve(request.user, submission)
550-
or request.user.groups.filter(
551-
id__in=submission.form_definition.admin_groups.all()
552-
).exists()
572+
or request.user.groups.filter(id__in=form_def_early.admin_groups.all()).exists()
573+
or is_reviewer
553574
)
554575

555576
if not can_view:
@@ -743,15 +764,17 @@ def submission_detail(request, submission_id):
743764
request.user.is_superuser
744765
or user_can_approve(request.user, submission)
745766
or request.user.groups.filter(id__in=form_def.admin_groups.all()).exists()
767+
or is_reviewer
746768
)
747769

748770
# Privacy: hide approval history from the submitter when configured.
749-
# Approvers and admins always see the full history.
771+
# Approvers, admins, and reviewers always see the full history.
750772
is_submitter_only = (
751773
submission.submitter == request.user
752774
and not request.user.is_superuser
753775
and not user_can_approve(request.user, submission)
754776
and not request.user.groups.filter(id__in=form_def.admin_groups.all()).exists()
777+
and not is_reviewer
755778
)
756779
hide_approval_history = bool(
757780
workflow and workflow.hide_approval_history and is_submitter_only
@@ -1424,6 +1447,9 @@ def sub_workflow_detail(request, instance_id):
14241447
or request.user.groups.filter(
14251448
id__in=submission.form_definition.admin_groups.all()
14261449
).exists()
1450+
or request.user.groups.filter(
1451+
id__in=submission.form_definition.reviewer_groups.all()
1452+
).exists()
14271453
)
14281454
if not can_view:
14291455
messages.error(request, "You don't have permission to view this.")
@@ -1789,6 +1815,7 @@ def submission_pdf(request, submission_id):
17891815
or request.user.is_superuser
17901816
or user_can_approve(request.user, submission)
17911817
or request.user.groups.filter(id__in=form_def.admin_groups.all()).exists()
1818+
or request.user.groups.filter(id__in=form_def.reviewer_groups.all()).exists()
17921819
)
17931820
if not can_view:
17941821
return HttpResponseForbidden(
@@ -2555,6 +2582,9 @@ def bulk_export_submissions(request):
25552582
or request.user.groups.filter(
25562583
id__in=sub.form_definition.admin_groups.all()
25572584
).exists()
2585+
or request.user.groups.filter(
2586+
id__in=sub.form_definition.reviewer_groups.all()
2587+
).exists()
25582588
)
25592589
if can_view:
25602590
allowed.append(sub)
@@ -2738,6 +2768,9 @@ def bulk_export_submissions_pdf(request):
27382768
or request.user.groups.filter(
27392769
id__in=sub.form_definition.admin_groups.all()
27402770
).exists()
2771+
or request.user.groups.filter(
2772+
id__in=sub.form_definition.reviewer_groups.all()
2773+
).exists()
27412774
)
27422775
if can_view:
27432776
allowed.append(sub)
@@ -3145,7 +3178,24 @@ def my_submissions_ajax(request):
31453178
category_slug = params.get("category", "").strip()
31463179
form_slug = params.get("form", "").strip()
31473180

3148-
qs = FormSubmission.objects.filter(submitter=request.user)
3181+
reviewer_form_ids = (
3182+
FormDefinition.objects.filter(reviewer_groups__in=request.user.groups.all())
3183+
.values_list("id", flat=True)
3184+
.distinct()
3185+
)
3186+
qs = FormSubmission.objects.filter(
3187+
models.Q(submitter=request.user)
3188+
| models.Q(
3189+
form_definition__in=reviewer_form_ids,
3190+
status__in=[
3191+
"submitted",
3192+
"pending_approval",
3193+
"approved",
3194+
"rejected",
3195+
"withdrawn",
3196+
],
3197+
)
3198+
).distinct()
31493199
records_total = qs.count()
31503200

31513201
if category_slug:

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.36.5"
3+
version = "0.37.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)