feat(formbuilder): fix tenancy bugs, add admin + services

Fix multi-tenancy/integrity bugs in the models: Form.slug and Field.key
were globally unique (unique=True), so two clubs couldn't reuse a form
slug and two forms couldn't reuse a field key — make slug unique per club
(constraint already present) and key unique per form. Add a
(submission, field) uniqueness constraint on Answer.

Register all four models in the admin (Field inline on Form, Answer inline
on Submission) and add formbuilder to the admin registration smoke test.

Add a service layer:
- submit_form(form, member, data): enforces is_active / login_required /
  open window / max_submissions, validates required + choice fields, and
  writes a Submission with Answers atomically (FormSubmissionError carries
  per-field errors).
- build_form(form): a live django.forms.Form built from a Form's active
  fields, mapping each FieldType to the matching form field.
- form_report(form): a tabular overview of every submission's answers plus
  per-value tallies for choice-type fields.

Full suite at 100% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:13:16 +02:00
parent 2653dd6d08
commit 5dd3715c1f
18 changed files with 789 additions and 1 deletions

286
formbuilder/tests.py Normal file
View File

@@ -0,0 +1,286 @@
from datetime import timedelta
from django import forms
from django.db import IntegrityError
from django.db.models import ProtectedError
from django.test import TestCase
from django.utils import timezone
from club.models import Club
from members.models import Member
from .models import Answer, Field, Form, Submission
from .services import (
FormSubmissionError,
build_form,
build_form_class,
field_choices,
form_report,
submit_form,
)
class FormbuilderTestBase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.form = Form.objects.create(club=self.club, title="Sign-up", slug="sign-up")
self.name = Field.objects.create(form=self.form, key="name", label="Name", field_type=Field.FieldType.TEXT, required=True, order=1)
self.size = Field.objects.create(form=self.form, key="size", label="Shirt size", field_type=Field.FieldType.CHOICE, required=False, order=2, options=["S", "M", "L"])
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
class ModelTests(FormbuilderTestBase):
def test_str_methods(self):
submission = Submission.objects.create(form=self.form, member=self.member)
answer = Answer.objects.create(submission=submission, field=self.name, value="Jane")
self.assertEqual(str(self.form), "Sign-up")
self.assertEqual(str(self.name), "Name")
self.assertEqual(str(submission), "Sign-up - Jane Doe")
self.assertEqual(str(answer), "Sign-up - Jane Doe - Name")
def test_slug_is_unique_per_club_not_globally(self):
other_club = Club.objects.create(name="Rival FC", slug="rival-fc")
Form.objects.create(club=other_club, title="Sign-up", slug="sign-up")
with self.assertRaises(IntegrityError):
Form.objects.create(club=self.club, title="Another", slug="sign-up")
def test_key_is_unique_per_form_not_globally(self):
other_form = Form.objects.create(club=self.club, title="Other", slug="other")
Field.objects.create(form=other_form, key="name", label="Name", order=1)
with self.assertRaises(IntegrityError):
Field.objects.create(form=self.form, key="name", label="Duplicate", order=9)
def test_answer_is_unique_per_field_per_submission(self):
submission = Submission.objects.create(form=self.form, member=self.member)
Answer.objects.create(submission=submission, field=self.name, value="Jane")
with self.assertRaises(IntegrityError):
Answer.objects.create(submission=submission, field=self.name, value="Again")
def test_deleting_form_cascades_to_fields_and_submissions(self):
Submission.objects.create(form=self.form, member=self.member)
self.form.delete()
self.assertFalse(Field.objects.exists())
self.assertFalse(Submission.objects.exists())
def test_field_referenced_by_answer_is_protected(self):
submission = Submission.objects.create(form=self.form, member=self.member)
Answer.objects.create(submission=submission, field=self.name, value="Jane")
with self.assertRaises(ProtectedError):
self.name.delete()
class FieldChoicesTests(FormbuilderTestBase):
def test_string_options(self):
self.assertEqual(field_choices(self.size), [("S", "S"), ("M", "M"), ("L", "L")])
def test_dict_options(self):
field = Field.objects.create(form=self.form, key="tier", label="Tier", field_type=Field.FieldType.CHOICE, order=3, options=[{"value": "a", "label": "Gold"}, {"value": "b", "label": "Silver"}])
self.assertEqual(field_choices(field), [("a", "Gold"), ("b", "Silver")])
def test_no_options(self):
self.assertEqual(field_choices(self.name), [])
class SubmitFormTests(FormbuilderTestBase):
def test_successful_submission_creates_submission_and_answers(self):
submission = submit_form(self.form, self.member, {"name": "Jane", "size": "M"})
self.assertEqual(submission.form, self.form)
self.assertEqual(submission.member, self.member)
self.assertEqual(submission.answers.count(), 2)
self.assertEqual(submission.answers.get(field=self.name).value, "Jane")
def test_optional_field_can_be_omitted(self):
submission = submit_form(self.form, self.member, {"name": "Jane"})
self.assertEqual(submission.answers.count(), 1)
def test_missing_required_field_is_rejected(self):
with self.assertRaises(FormSubmissionError) as ctx:
submit_form(self.form, self.member, {"size": "M"})
self.assertIn("name", ctx.exception.errors)
self.assertFalse(Submission.objects.exists())
def test_inactive_form_is_rejected(self):
self.form.is_active = False
self.form.save()
with self.assertRaises(FormSubmissionError):
submit_form(self.form, self.member, {"name": "Jane"})
def test_login_required_without_member_is_rejected(self):
self.form.login_required = True
self.form.save()
with self.assertRaises(FormSubmissionError):
submit_form(self.form, None, {"name": "Jane"})
def test_anonymous_submission_allowed_when_login_not_required(self):
submission = submit_form(self.form, None, {"name": "Jane"})
self.assertIsNone(submission.member)
def test_not_open_yet_is_rejected(self):
self.form.opens_at = timezone.now() + timedelta(days=1)
self.form.save()
with self.assertRaises(FormSubmissionError):
submit_form(self.form, self.member, {"name": "Jane"})
def test_closed_form_is_rejected(self):
self.form.closes_at = timezone.now() - timedelta(days=1)
self.form.save()
with self.assertRaises(FormSubmissionError):
submit_form(self.form, self.member, {"name": "Jane"})
def test_open_window_respects_when_argument(self):
self.form.opens_at = timezone.now() + timedelta(days=1)
self.form.save()
submission = submit_form(self.form, self.member, {"name": "Jane"}, when=timezone.now() + timedelta(days=2))
self.assertTrue(Submission.objects.filter(pk=submission.pk).exists())
def test_max_submissions_per_user_is_enforced(self):
self.form.max_submissions_per_user = 1
self.form.save()
submit_form(self.form, self.member, {"name": "Jane"})
with self.assertRaises(FormSubmissionError):
submit_form(self.form, self.member, {"name": "Jane"})
def test_invalid_choice_is_rejected(self):
with self.assertRaises(FormSubmissionError) as ctx:
submit_form(self.form, self.member, {"name": "Jane", "size": "XL"})
self.assertIn("size", ctx.exception.errors)
def test_multichoice_validation(self):
field = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue", "wed"])
submit_form(self.form, self.member, {"name": "Jane", "days": ["mon", "wed"]})
with self.assertRaises(FormSubmissionError):
submit_form(self.form, self.member, {"name": "Joe", "days": ["mon", "sun"]})
self.assertEqual(Answer.objects.filter(field=field).count(), 1)
def test_inactive_field_is_ignored(self):
Field.objects.create(form=self.form, key="secret", label="Secret", field_type=Field.FieldType.TEXT, required=True, is_active=False, order=4)
submission = submit_form(self.form, self.member, {"name": "Jane"})
self.assertFalse(submission.answers.filter(field__key="secret").exists())
def test_unknown_keys_are_ignored(self):
submission = submit_form(self.form, self.member, {"name": "Jane", "bogus": "x"})
self.assertEqual(submission.answers.count(), 1)
class BuildFormTests(FormbuilderTestBase):
def make_all_field_types_form(self):
form = Form.objects.create(club=self.club, title="All", slug="all")
specs = [
("f_text", Field.FieldType.TEXT, forms.CharField),
("f_area", Field.FieldType.TEXTAREA, forms.CharField),
("f_num", Field.FieldType.NUMBER, forms.DecimalField),
("f_email", Field.FieldType.EMAIL, forms.EmailField),
("f_date", Field.FieldType.DATE, forms.DateField),
("f_choice", Field.FieldType.CHOICE, forms.ChoiceField),
("f_multi", Field.FieldType.MULTICHOICE, forms.MultipleChoiceField),
("f_check", Field.FieldType.CHECKBOX, forms.BooleanField),
("f_file", Field.FieldType.FILE, forms.FileField),
]
for i, (key, field_type, _) in enumerate(specs):
Field.objects.create(form=form, key=key, label=key, field_type=field_type, required=False, order=i, options=["a", "b"])
return form, specs
def test_field_classes_map_from_field_type(self):
form, specs = self.make_all_field_types_form()
instance = build_form_class(form)()
for key, _, expected in specs:
with self.subTest(key=key):
self.assertIsInstance(instance.fields[key], expected)
def test_required_and_labels_propagate(self):
instance = build_form_class(self.form)()
self.assertTrue(instance.fields["name"].required)
self.assertFalse(instance.fields["size"].required)
self.assertEqual(instance.fields["name"].label, "Name")
def test_choice_field_gets_choices(self):
instance = build_form(self.form)
self.assertEqual(list(instance.fields["size"].choices), [("S", "S"), ("M", "M"), ("L", "L")])
def test_bound_form_validates(self):
bound = build_form(self.form, data={"name": "Jane", "size": "M"})
self.assertTrue(bound.is_valid())
self.assertEqual(bound.cleaned_data["name"], "Jane")
def test_bound_form_reports_required_error(self):
bound = build_form(self.form, data={"size": "M"})
self.assertFalse(bound.is_valid())
self.assertIn("name", bound.errors)
def test_inactive_fields_are_excluded(self):
Field.objects.create(form=self.form, key="hidden", label="Hidden", is_active=False, order=9)
instance = build_form(self.form)
self.assertNotIn("hidden", instance.fields)
class FormReportTests(FormbuilderTestBase):
def test_columns_are_fields_in_order(self):
report = form_report(self.form)
self.assertEqual(report.columns, [self.name, self.size])
def test_rows_carry_answer_values(self):
submit_form(self.form, self.member, {"name": "Jane", "size": "M"})
report = form_report(self.form)
self.assertEqual(report.count, 1)
row = report.rows[0]
self.assertEqual(row.values[self.name.id], "Jane")
self.assertEqual(row.values[self.size.id], "M")
def test_choice_field_is_summarised(self):
submit_form(self.form, self.member, {"name": "Jane", "size": "M"})
submit_form(self.form, Member.objects.create(first_name="Joe", last_name="Roe"), {"name": "Joe", "size": "M"})
submit_form(self.form, Member.objects.create(first_name="Kim", last_name="Ash"), {"name": "Kim", "size": "L"})
report = form_report(self.form)
self.assertEqual(report.summaries[self.size.id], {"M": 2, "L": 1})
self.assertNotIn(self.name.id, report.summaries)
def test_multichoice_values_are_tallied_per_option(self):
days = Field.objects.create(form=self.form, key="days", label="Days", field_type=Field.FieldType.MULTICHOICE, required=False, order=3, options=["mon", "tue"])
submit_form(self.form, self.member, {"name": "Jane", "days": ["mon", "tue"]})
report = form_report(self.form)
self.assertEqual(report.summaries[days.id], {"mon": 1, "tue": 1})
def test_empty_form_reports_no_rows(self):
report = form_report(self.form)
self.assertEqual(report.count, 0)
self.assertEqual(report.rows, [])