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

View File

@@ -455,7 +455,7 @@ class AdminRegistrationSmokeTests(TestCase):
registered = set(django_admin.site._registry)
# Concrete, non-auto-created models in these apps should all be registered.
project_apps = {"authentication", "club", "members", "teams", "events"}
project_apps = {"authentication", "club", "members", "teams", "events", "formbuilder"}
for model in apps.get_models():
if model._meta.app_label not in project_apps or model._meta.auto_created:
continue

View File

@@ -49,6 +49,7 @@ INSTALLED_APPS = [
"members.apps.MembersConfig",
"teams.apps.TeamsConfig",
"events.apps.EventsConfig",
"formbuilder.apps.FormbuilderConfig",
]
MIDDLEWARE = [

0
formbuilder/__init__.py Normal file
View File

50
formbuilder/admin.py Normal file
View File

@@ -0,0 +1,50 @@
from django.contrib import admin
from .models import Answer, Field, Form, Submission
class FieldInline(admin.TabularInline):
model = Field
extra = 0
fields = ["order", "key", "label", "field_type", "required", "is_active"]
ordering = ["order"]
@admin.register(Form)
class FormAdmin(admin.ModelAdmin):
list_display = ["title", "slug", "club", "is_active", "login_required", "opens_at", "closes_at"]
list_filter = ["club", "is_active", "login_required"]
search_fields = ["title", "slug"]
prepopulated_fields = {"slug": ["title"]}
inlines = [FieldInline]
@admin.register(Field)
class FieldAdmin(admin.ModelAdmin):
list_display = ["label", "key", "form", "field_type", "order", "required", "is_active"]
list_filter = ["field_type", "required", "is_active"]
search_fields = ["label", "key", "form__title"]
raw_id_fields = ["form"]
class AnswerInline(admin.TabularInline):
model = Answer
extra = 0
raw_id_fields = ["field"]
@admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin):
list_display = ["form", "member", "submitted_at"]
list_filter = ["form"]
search_fields = ["form__title", "member__first_name", "member__last_name"]
raw_id_fields = ["form", "member"]
readonly_fields = ["submitted_at"]
inlines = [AnswerInline]
@admin.register(Answer)
class AnswerAdmin(admin.ModelAdmin):
list_display = ["submission", "field", "value"]
search_fields = ["field__label", "field__key"]
raw_id_fields = ["submission", "field"]

5
formbuilder/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class FormbuilderConfig(AppConfig):
name = "formbuilder"

View File

@@ -0,0 +1,90 @@
# Generated by Django 6.0.6 on 2026-07-12 19:02
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('club', '0008_alter_clubmembership_unique_together_and_more'),
('members', '0002_alter_familymembership_unique_together_and_more'),
]
operations = [
migrations.CreateModel(
name='Form',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=255, verbose_name='title')),
('slug', models.SlugField(unique=True, verbose_name='slug')),
('description', models.TextField(blank=True, verbose_name='description')),
('is_active', models.BooleanField(default=True, verbose_name='is active?')),
('login_required', models.BooleanField(default=False, verbose_name='login required?')),
('opens_at', models.DateTimeField(blank=True, null=True, verbose_name='opens at')),
('closes_at', models.DateTimeField(blank=True, null=True, verbose_name='closes at')),
('max_submissions_per_user', models.PositiveIntegerField(blank=True, null=True, verbose_name='max submissions per user')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='club.club')),
],
options={
'verbose_name': 'form',
'verbose_name_plural': 'forms',
'ordering': ['title'],
},
),
migrations.CreateModel(
name='Field',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('key', models.SlugField(unique=True, verbose_name='key')),
('label', models.CharField(max_length=255, verbose_name='label')),
('field_type', models.CharField(choices=[('text', 'text'), ('textarea', 'textarea'), ('number', 'number'), ('email', 'email'), ('date', 'date'), ('choice', 'choice'), ('multichoice', 'multichoice'), ('checkbox', 'checkbox'), ('file', 'file')], default='text', max_length=255, verbose_name='field type')),
('required', models.BooleanField(default=True, verbose_name='required?')),
('help_text', models.TextField(blank=True, verbose_name='help text')),
('order', models.PositiveIntegerField(default=0, verbose_name='order')),
('is_active', models.BooleanField(default=True, verbose_name='is active?')),
('options', models.JSONField(blank=True, null=True, verbose_name='options')),
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='formbuilder.form', verbose_name='form')),
],
options={
'verbose_name': 'field',
'verbose_name_plural': 'fields',
'ordering': ['form', 'order'],
},
),
migrations.CreateModel(
name='Submission',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('submitted_at', models.DateTimeField(auto_now_add=True, verbose_name='submitted at')),
('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='formbuilder.form', verbose_name='form')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='members.member', verbose_name='member')),
],
options={
'verbose_name': 'submission',
'verbose_name_plural': 'submissions',
'ordering': ['-submitted_at'],
},
),
migrations.CreateModel(
name='Answer',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('value', models.JSONField(blank=True, null=True, verbose_name='value')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='formbuilder.field', verbose_name='field')),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='formbuilder.submission', verbose_name='submission')),
],
options={
'verbose_name': 'answer',
'verbose_name_plural': 'answers',
'ordering': ['submission', 'field'],
},
),
migrations.AddConstraint(
model_name='form',
constraint=models.UniqueConstraint(fields=('club', 'slug'), name='unique_form_slug_per_club'),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.6 on 2026-07-12 19:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('formbuilder', '0001_initial'),
('members', '0002_alter_familymembership_unique_together_and_more'),
]
operations = [
migrations.AlterField(
model_name='answer',
name='field',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='answers', to='formbuilder.field', verbose_name='field'),
),
migrations.AlterField(
model_name='submission',
name='member',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submissions', to='members.member', verbose_name='member'),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0.6 on 2026-07-12 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('formbuilder', '0002_alter_answer_field_alter_submission_member'),
]
operations = [
migrations.AlterField(
model_name='field',
name='key',
field=models.SlugField(verbose_name='key'),
),
migrations.AlterField(
model_name='form',
name='slug',
field=models.SlugField(verbose_name='slug'),
),
migrations.AddConstraint(
model_name='answer',
constraint=models.UniqueConstraint(fields=('submission', 'field'), name='unique_answer_per_field_per_submission'),
),
migrations.AddConstraint(
model_name='field',
constraint=models.UniqueConstraint(fields=('form', 'key'), name='unique_field_key_per_form'),
),
]

View File

95
formbuilder/models.py Normal file
View File

@@ -0,0 +1,95 @@
from django.db import models
from django.db.models import UniqueConstraint
from django.utils.translation import gettext_lazy as _
from clubmanager.base import ClubScopedModel, UUIDModel
from members.models import Member
class Form(ClubScopedModel):
title = models.CharField(_("title"), max_length=255)
slug = models.SlugField(_("slug"))
description = models.TextField(_("description"), blank=True)
is_active = models.BooleanField(_("is active?"), default=True)
login_required = models.BooleanField(_("login required?"), default=False)
opens_at = models.DateTimeField(_("opens at"), blank=True, null=True)
closes_at = models.DateTimeField(_("closes at"), blank=True, null=True)
max_submissions_per_user = models.PositiveIntegerField(_("max submissions per user"), blank=True, null=True)
class Meta:
verbose_name = _("form")
verbose_name_plural = _("forms")
ordering = ["title"]
constraints = [
UniqueConstraint(fields=["club", "slug"], name="unique_form_slug_per_club"),
]
def __str__(self):
return self.title
class Field(UUIDModel):
class FieldType(models.TextChoices):
TEXT = "text", _("text")
TEXTAREA = "textarea", _("textarea")
NUMBER = "number", _("number")
EMAIL = "email", _("email")
DATE = "date", _("date")
CHOICE = "choice", _("choice")
MULTICHOICE = "multichoice", _("multichoice")
CHECKBOX = "checkbox", _("checkbox")
FILE = "file", _("file")
form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name="fields", verbose_name=_("form"))
key = models.SlugField(_("key"))
label = models.CharField(_("label"), max_length=255)
field_type = models.CharField(_("field type"), max_length=255, choices=FieldType.choices, default=FieldType.TEXT)
required = models.BooleanField(_("required?"), default=True)
help_text = models.TextField(_("help text"), blank=True)
order = models.PositiveIntegerField(_("order"), default=0)
is_active = models.BooleanField(_("is active?"), default=True)
options = models.JSONField(_("options"), blank=True, null=True)
class Meta:
verbose_name = _("field")
verbose_name_plural = _("fields")
ordering = ["form", "order"]
constraints = [
UniqueConstraint(fields=["form", "key"], name="unique_field_key_per_form"),
]
def __str__(self):
return self.label
class Submission(UUIDModel):
form = models.ForeignKey(Form, on_delete=models.CASCADE, related_name="submissions", verbose_name=_("form"))
member = models.ForeignKey(Member, on_delete=models.SET_NULL, related_name="submissions", verbose_name=_("member"), blank=True, null=True)
submitted_at = models.DateTimeField(_("submitted at"), auto_now_add=True)
class Meta:
verbose_name = _("submission")
verbose_name_plural = _("submissions")
ordering = ["-submitted_at"]
def __str__(self):
return f"{self.form} - {self.member}"
class Answer(UUIDModel):
submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name="answers", verbose_name=_("submission"))
field = models.ForeignKey(Field, on_delete=models.PROTECT, related_name="answers", verbose_name=_("field"))
value = models.JSONField(_("value"), blank=True, null=True)
class Meta:
verbose_name = _("answer")
verbose_name_plural = _("answers")
ordering = ["submission", "field"]
constraints = [
UniqueConstraint(fields=["submission", "field"], name="unique_answer_per_field_per_submission"),
]
def __str__(self):
return f"{self.submission} - {self.field}"

View File

@@ -0,0 +1,16 @@
from .form_factory import build_form, build_form_class
from .options import allowed_values, field_choices
from .reporting import FormReport, ReportRow, form_report
from .submission import FormSubmissionError, submit_form
__all__ = [
"FormReport",
"FormSubmissionError",
"ReportRow",
"allowed_values",
"build_form",
"build_form_class",
"field_choices",
"form_report",
"submit_form",
]

View File

@@ -0,0 +1,35 @@
"""Build a live ``django.forms.Form`` class from a Form instance's fields."""
from django import forms
from formbuilder.models import Field
from .options import field_choices
FIELD_BUILDERS = {
Field.FieldType.TEXT: lambda field, kw: forms.CharField(**kw),
Field.FieldType.TEXTAREA: lambda field, kw: forms.CharField(widget=forms.Textarea, **kw),
Field.FieldType.NUMBER: lambda field, kw: forms.DecimalField(**kw),
Field.FieldType.EMAIL: lambda field, kw: forms.EmailField(**kw),
Field.FieldType.DATE: lambda field, kw: forms.DateField(**kw),
Field.FieldType.CHOICE: lambda field, kw: forms.ChoiceField(choices=field_choices(field), **kw),
Field.FieldType.MULTICHOICE: lambda field, kw: forms.MultipleChoiceField(choices=field_choices(field), **kw),
Field.FieldType.CHECKBOX: lambda field, kw: forms.BooleanField(**kw),
Field.FieldType.FILE: lambda field, kw: forms.FileField(**kw),
}
def _build_field(field):
kwargs = {"required": field.required, "label": field.label, "help_text": field.help_text}
return FIELD_BUILDERS[field.field_type](field, kwargs)
def build_form_class(form):
"""Return a ``forms.Form`` subclass with a field per active Field, keyed by ``key``."""
fields = {field.key: _build_field(field) for field in form.fields.filter(is_active=True).order_by("order")}
return type("DynamicForm", (forms.Form,), fields)
def build_form(form, data=None, files=None):
"""Instantiate the dynamic form, bound to ``data``/``files`` when provided."""
return build_form_class(form)(data=data, files=files)

View File

@@ -0,0 +1,23 @@
"""Helpers for interpreting a Field's ``options`` (choice definitions).
``options`` is a JSON list, either of plain strings (``["a", "b"]``) or of
``{"value": ..., "label": ...}`` dicts.
"""
def field_choices(field):
"""Return ``[(value, label), ...]`` for a choice-type field."""
choices = []
for option in field.options or []:
if isinstance(option, dict):
value = option.get("value")
label = option.get("label", value)
else:
value = label = option
choices.append((value, label))
return choices
def allowed_values(field):
"""Return the set of accepted values for a choice-type field."""
return {value for value, _ in field_choices(field)}

View File

@@ -0,0 +1,46 @@
"""Build a tabular overview of all answers for a Form."""
from dataclasses import dataclass
from formbuilder.models import Field
CHOICE_TYPES = {Field.FieldType.CHOICE, Field.FieldType.MULTICHOICE, Field.FieldType.CHECKBOX}
@dataclass
class ReportRow:
submission: object
values: dict # field id -> answer value
@dataclass
class FormReport:
form: object
columns: list # Field instances, in display order
rows: list # ReportRow, newest submission first
summaries: dict # field id -> {stringified value: count} for choice-like fields
count: int
def form_report(form):
"""Return a FormReport: one column per field, one row per submission."""
columns = list(form.fields.order_by("order"))
summaries = {column.id: {} for column in columns if column.field_type in CHOICE_TYPES}
rows = []
submissions = form.submissions.select_related("member").prefetch_related("answers")
for submission in submissions:
values = {answer.field_id: answer.value for answer in submission.answers.all()}
rows.append(ReportRow(submission=submission, values=values))
for field_id, bucket in summaries.items():
if field_id in values:
_tally(bucket, values[field_id])
return FormReport(form=form, columns=columns, rows=rows, summaries=summaries, count=len(rows))
def _tally(bucket, value):
items = value if isinstance(value, list) else [value]
for item in items:
key = str(item)
bucket[key] = bucket.get(key, 0) + 1

View File

@@ -0,0 +1,84 @@
"""Validate and persist a submission against a Form's fields and rules."""
from django.db import transaction
from django.utils import timezone
from formbuilder.models import Answer, Field, Submission
from .options import allowed_values
class FormSubmissionError(Exception):
"""Raised when a submission is rejected. ``errors`` maps field key -> message."""
def __init__(self, message, *, errors=None):
super().__init__(message)
self.errors = errors or {}
def _is_empty(value):
return value is None or value == "" or value == []
@transaction.atomic
def submit_form(form, member, data, *, when=None):
"""Create a Submission (with Answers) for ``data`` or raise FormSubmissionError."""
when = when or timezone.now()
_check_open(form, member, when)
cleaned = _clean_answers(form, data)
submission = Submission.objects.create(form=form, member=member)
Answer.objects.bulk_create([Answer(submission=submission, field=field, value=value) for field, value in cleaned])
return submission
def _check_open(form, member, when):
if not form.is_active:
raise FormSubmissionError("This form is not accepting submissions.")
if form.login_required and member is None:
raise FormSubmissionError("You must be signed in to submit this form.")
if form.opens_at is not None and when < form.opens_at:
raise FormSubmissionError("This form is not open yet.")
if form.closes_at is not None and when > form.closes_at:
raise FormSubmissionError("This form has closed.")
if form.max_submissions_per_user is not None and member is not None:
used = form.submissions.filter(member=member).count()
if used >= form.max_submissions_per_user:
raise FormSubmissionError("You have reached the maximum number of submissions for this form.")
def _clean_answers(form, data):
errors = {}
cleaned = []
for field in form.fields.filter(is_active=True):
raw = data.get(field.key)
if _is_empty(raw):
if field.required:
errors[field.key] = "This field is required."
continue
message = _validate_choice(field, raw)
if message is not None:
errors[field.key] = message
continue
cleaned.append((field, raw))
if errors:
raise FormSubmissionError("The submission has errors.", errors=errors)
return cleaned
def _validate_choice(field, raw):
if field.field_type == Field.FieldType.CHOICE:
allowed = allowed_values(field)
if allowed and raw not in allowed:
return "Select a valid choice."
elif field.field_type == Field.FieldType.MULTICHOICE:
allowed = allowed_values(field)
values = raw if isinstance(raw, list) else [raw]
if allowed and not set(values) <= allowed:
return "Select valid choices."
return None

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, [])

1
formbuilder/views.py Normal file
View File

@@ -0,0 +1 @@
# Create your views here.

View File