diff --git a/CHANGELOG.md b/CHANGELOG.md index f730e12..d843c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- **`@staff_member_required` dropped from the form builder's views.** + All 13 views in `form_builder_views.py` no longer carry the decorator. + Admin URLs remain gated because `FormDefinitionAdmin.get_urls()` wraps every view in + `self.admin_site.admin_view(...)`, which enforces `is_staff`. + Any non-admin URL registrations (e.g. `form_builder_urls.py`) must now apply their own staff gating if needed. + change for admin routes; a new `test_..._requires_staff` test per view + confirms `admin_view()` alone still gates non-staff access. - **Form builder save logic factored into a standalone helper.** `form_builder_save`'s create/update logic for `FormDefinition` and its `FormField`s now lives in `save_form_definition_from_builder_data()`, diff --git a/django_forms_workflows/form_builder_views.py b/django_forms_workflows/form_builder_views.py index 921e872..0328406 100644 --- a/django_forms_workflows/form_builder_views.py +++ b/django_forms_workflows/form_builder_views.py @@ -9,7 +9,6 @@ import logging import uuid -from django.contrib.admin.views.decorators import staff_member_required from django.db import transaction from django.http import JsonResponse from django.shortcuts import get_object_or_404, render @@ -31,7 +30,6 @@ logger = logging.getLogger(__name__) -@staff_member_required @require_GET def form_builder_templates(request): """ @@ -64,7 +62,6 @@ def form_builder_templates(request): ) -@staff_member_required @require_GET def form_builder_load_template(request, template_id): """ @@ -85,7 +82,6 @@ def form_builder_load_template(request, template_id): ) -@staff_member_required @require_POST def form_builder_clone(request, form_id): """ @@ -228,7 +224,6 @@ def form_builder_clone(request, form_id): ) -@staff_member_required @require_GET def form_builder_view(request, form_id=None): """ @@ -273,7 +268,6 @@ def form_builder_view(request, form_id=None): return render(request, "admin/django_forms_workflows/form_builder.html", context) -@staff_member_required @require_GET def form_builder_load(request, form_id): """ @@ -358,7 +352,6 @@ def form_builder_load(request, form_id): return JsonResponse(form_data) -@staff_member_required @require_POST def form_builder_save(request): """ @@ -610,7 +603,6 @@ def save_form_definition_from_builder_data(data, user, form_definition=None): return form_definition, field_id_mapping -@staff_member_required @require_POST def form_builder_preview(request): """ @@ -771,7 +763,6 @@ def form_builder_preview(request): # --------------------------------------------------------------------------- -@staff_member_required @require_GET def document_template_list(request, form_id): """List document templates for a form.""" @@ -797,7 +788,6 @@ def document_template_list(request, form_id): ) -@staff_member_required @require_POST def document_template_save(request, form_id): """Create or update a document template.""" @@ -856,7 +846,6 @@ def document_template_save(request, form_id): ) -@staff_member_required @require_POST def document_template_delete(request, form_id, template_id): """Delete a document template.""" @@ -872,7 +861,6 @@ def document_template_delete(request, form_id, template_id): # --------------------------------------------------------------------------- -@staff_member_required @require_GET def shared_option_list_api(request): """List all shared option lists (for form builder dropdowns).""" @@ -893,7 +881,6 @@ def shared_option_list_api(request): ) -@staff_member_required @require_POST def shared_option_list_save(request): """Create or update a shared option list.""" @@ -936,7 +923,6 @@ def shared_option_list_save(request): ) -@staff_member_required @require_POST def shared_option_list_delete(request, list_id): """Delete a shared option list.""" diff --git a/tests/test_builders.py b/tests/test_builders.py index 9070263..9ac7ec2 100644 --- a/tests/test_builders.py +++ b/tests/test_builders.py @@ -1405,6 +1405,12 @@ def test_builder_view_accessible_to_superuser( resp = client.get(url) assert resp.status_code == 200 + def test_builder_new_view_requires_staff(self, client, user): + client.force_login(user) + url = _fb_url("builder/new/") + resp = client.get(url) + assert resp.status_code in (302, 403) + def test_load_returns_json(self, client, superuser, form_with_fields): client.login(username="admin", password="testpass123") url = _fb_url(f"builder/api/load/{form_with_fields.id}/") @@ -1415,6 +1421,12 @@ def test_load_returns_json(self, client, superuser, form_with_fields): assert "fields" in data assert len(data["fields"]) == 5 + def test_load_requires_staff(self, client, user, form_with_fields): + client.force_login(user) + url = _fb_url(f"builder/api/load/{form_with_fields.id}/") + resp = client.get(url) + assert resp.status_code in (302, 403) + def test_save_creates_fields(self, client, superuser, form_definition): client.login(username="admin", password="testpass123") url = _fb_url("builder/api/save/") @@ -1443,6 +1455,16 @@ def test_save_creates_fields(self, client, superuser, form_definition): assert resp.json()["success"] is True assert form_definition.fields.filter(field_name="new_field").exists() + def test_save_requires_staff(self, client, user, form_definition): + client.force_login(user) + url = _fb_url("builder/api/save/") + resp = client.post( + url, + data=json.dumps({"id": form_definition.id, "name": form_definition.name}), + content_type="application/json", + ) + assert resp.status_code in (302, 403) + def test_clone_form(self, client, superuser, form_with_fields): client.login(username="admin", password="testpass123") url = _fb_url(f"builder/api/clone/{form_with_fields.id}/") @@ -1455,6 +1477,13 @@ def test_clone_form(self, client, superuser, form_with_fields): assert cloned.id != form_with_fields.id assert cloned.fields.count() == form_with_fields.fields.count() + def test_clone_requires_staff(self, client, user, form_with_fields): + client.force_login(user) + url = _fb_url(f"builder/api/clone/{form_with_fields.id}/") + resp = client.post(url) + assert resp.status_code in (302, 403) + assert FormDefinition.objects.filter(id=form_with_fields.id).count() == 1 + def test_preview_form(self, client, superuser, form_with_fields): client.login(username="admin", password="testpass123") url = _fb_url("builder/api/preview/") @@ -1465,6 +1494,16 @@ def test_preview_form(self, client, superuser, form_with_fields): ) assert resp.status_code == 200 + def test_preview_requires_staff(self, client, user, form_with_fields): + client.force_login(user) + url = _fb_url("builder/api/preview/") + resp = client.post( + url, + data=json.dumps({"form_id": form_with_fields.id}), + content_type="application/json", + ) + assert resp.status_code in (302, 403) + # ── Document Template API endpoints ───────────────────────────────────────── @@ -1502,6 +1541,19 @@ def test_save_new_template(self, client, superuser, form_definition): assert data["success"] is True assert data["id"] is not None + def test_save_requires_staff(self, client, user, form_definition): + from django_forms_workflows.models import DocumentTemplate + + client.force_login(user) + url = _fb_url(f"builder/api/doc-templates/{form_definition.id}/save/") + resp = client.post( + url, + data=json.dumps({"name": "Guarded", "html_content": "

guarded

"}), + content_type="application/json", + ) + assert resp.status_code in (302, 403) + assert not DocumentTemplate.objects.filter(name="Guarded").exists() + def test_save_template_requires_name(self, client, superuser, form_definition): client.force_login(superuser) url = _fb_url(f"builder/api/doc-templates/{form_definition.id}/save/") @@ -1555,6 +1607,22 @@ def test_delete_template(self, client, superuser, form_definition): assert resp.status_code == 200 assert not DocumentTemplate.objects.filter(id=tpl.id).exists() + def test_delete_requires_staff(self, client, user, form_definition): + from django_forms_workflows.models import DocumentTemplate + + tpl = DocumentTemplate.objects.create( + form_definition=form_definition, + name="Guarded", + html_content="

guarded

", + ) + client.force_login(user) + url = _fb_url( + f"builder/api/doc-templates/{form_definition.id}/delete/{tpl.id}/" + ) + resp = client.post(url) + assert resp.status_code in (302, 403) + assert DocumentTemplate.objects.filter(id=tpl.id).exists() + def test_set_default_clears_previous_default( self, client, superuser, form_definition ): @@ -1589,3 +1657,153 @@ def test_non_staff_cannot_access(self, client, user, form_definition): resp = client.get(url) # staff_member_required redirects non-staff assert resp.status_code == 302 + + +# ── Prebuilt form template API endpoints ──────────────────────────────────── + + +class TestFormBuilderTemplateAPI: + """Tests for the prebuilt form template list/load API endpoints.""" + + def test_list_requires_staff(self, client, user): + client.force_login(user) + url = _fb_url("builder/api/templates/") + resp = client.get(url) + assert resp.status_code in (302, 403) + + def test_list_returns_active_templates(self, client, superuser): + from django_forms_workflows.models import FormTemplate + + FormTemplate.objects.create( + name="Contact Form", + slug="contact-form", + description="A simple contact form", + template_data={"fields": []}, + ) + client.force_login(superuser) + url = _fb_url("builder/api/templates/") + resp = client.get(url) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert [t["slug"] for t in data["templates"]] == ["contact-form"] + + def test_load_template_requires_staff(self, client, user): + from django_forms_workflows.models import FormTemplate + + template = FormTemplate.objects.create( + name="Contact Form", + slug="contact-form-guarded", + description="A simple contact form", + template_data={"fields": []}, + ) + client.force_login(user) + url = _fb_url(f"builder/api/templates/{template.id}/") + resp = client.get(url) + assert resp.status_code in (302, 403) + template.refresh_from_db() + assert template.usage_count == 0 + + def test_load_template_returns_data_and_increments_usage(self, client, superuser): + from django_forms_workflows.models import FormTemplate + + template = FormTemplate.objects.create( + name="Contact Form", + slug="contact-form-load", + description="A simple contact form", + template_data={"fields": [{"field_name": "email"}]}, + ) + client.force_login(superuser) + url = _fb_url(f"builder/api/templates/{template.id}/") + resp = client.get(url) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["template_data"] == {"fields": [{"field_name": "email"}]} + template.refresh_from_db() + assert template.usage_count == 1 + + +# ── Shared option list API endpoints ──────────────────────────────────────── + + +class TestSharedOptionListAPI: + """Tests for the centrally managed shared option list CRUD API endpoints.""" + + def test_list_requires_staff(self, client, user): + client.force_login(user) + url = _fb_url("builder/api/shared-lists/") + resp = client.get(url) + assert resp.status_code in (302, 403) + + def test_list_returns_active_lists(self, client, superuser): + from django_forms_workflows.models import SharedOptionList + + ol = SharedOptionList.objects.create( + name="Departments", slug="departments", items=["HR", "IT"] + ) + client.force_login(superuser) + url = _fb_url("builder/api/shared-lists/") + resp = client.get(url) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert len(data["lists"]) == 1 + assert data["lists"][0]["id"] == ol.id + assert data["lists"][0]["name"] == "Departments" + assert data["lists"][0]["slug"] == "departments" + assert data["lists"][0]["item_count"] == 2 + + def test_save_requires_staff(self, client, user): + from django_forms_workflows.models import SharedOptionList + + client.force_login(user) + url = _fb_url("builder/api/shared-lists/save/") + resp = client.post( + url, + data=json.dumps({"name": "Guarded", "items": ["a"]}), + content_type="application/json", + ) + assert resp.status_code in (302, 403) + assert not SharedOptionList.objects.filter(name="Guarded").exists() + + def test_save_creates_list(self, client, superuser): + from django_forms_workflows.models import SharedOptionList + + client.force_login(superuser) + url = _fb_url("builder/api/shared-lists/save/") + resp = client.post( + url, + data=json.dumps({"name": "Locations", "items": ["HQ", "Annex"]}), + content_type="application/json", + ) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + ol = SharedOptionList.objects.get(id=data["id"]) + assert ol.slug == "locations" + assert ol.items == ["HQ", "Annex"] + + def test_delete_requires_staff(self, client, user): + from django_forms_workflows.models import SharedOptionList + + ol = SharedOptionList.objects.create( + name="Guarded", slug="guarded", items=["a"] + ) + client.force_login(user) + url = _fb_url(f"builder/api/shared-lists/delete/{ol.id}/") + resp = client.post(url) + assert resp.status_code in (302, 403) + assert SharedOptionList.objects.filter(id=ol.id).exists() + + def test_delete_removes_list(self, client, superuser): + from django_forms_workflows.models import SharedOptionList + + ol = SharedOptionList.objects.create( + name="Temp Locations", slug="temp-locations", items=["a"] + ) + client.force_login(superuser) + url = _fb_url(f"builder/api/shared-lists/delete/{ol.id}/") + resp = client.post(url) + assert resp.status_code == 200 + assert not SharedOptionList.objects.filter(id=ol.id).exists()