Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`,
Expand Down
14 changes: 0 additions & 14 deletions django_forms_workflows/form_builder_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,7 +30,6 @@
logger = logging.getLogger(__name__)


@staff_member_required
@require_GET
def form_builder_templates(request):
"""
Expand Down Expand Up @@ -64,7 +62,6 @@ def form_builder_templates(request):
)


@staff_member_required
@require_GET
def form_builder_load_template(request, template_id):
"""
Expand All @@ -85,7 +82,6 @@ def form_builder_load_template(request, template_id):
)


@staff_member_required
@require_POST
def form_builder_clone(request, form_id):
"""
Expand Down Expand Up @@ -228,7 +224,6 @@ def form_builder_clone(request, form_id):
)


@staff_member_required
@require_GET
def form_builder_view(request, form_id=None):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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."""
Expand All @@ -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."""
Expand Down Expand Up @@ -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."""
Expand All @@ -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)."""
Expand All @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
218 changes: 218 additions & 0 deletions tests/test_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +1408 to +1412

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}/")
Expand All @@ -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/")
Expand Down Expand Up @@ -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}/")
Expand All @@ -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/")
Expand All @@ -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 ─────────────────────────────────────────

Expand Down Expand Up @@ -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": "<p>guarded</p>"}),
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/")
Expand Down Expand Up @@ -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="<p>guarded</p>",
)
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
):
Expand Down Expand Up @@ -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()
Loading