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:
@@ -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
|
||||
|
||||
@@ -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
0
formbuilder/__init__.py
Normal file
50
formbuilder/admin.py
Normal file
50
formbuilder/admin.py
Normal 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
5
formbuilder/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FormbuilderConfig(AppConfig):
|
||||
name = "formbuilder"
|
||||
90
formbuilder/migrations/0001_initial.py
Normal file
90
formbuilder/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
31
formbuilder/migrations/0003_fix_tenant_uniqueness.py
Normal file
31
formbuilder/migrations/0003_fix_tenant_uniqueness.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
formbuilder/migrations/__init__.py
Normal file
0
formbuilder/migrations/__init__.py
Normal file
95
formbuilder/models.py
Normal file
95
formbuilder/models.py
Normal 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}"
|
||||
16
formbuilder/services/__init__.py
Normal file
16
formbuilder/services/__init__.py
Normal 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",
|
||||
]
|
||||
35
formbuilder/services/form_factory.py
Normal file
35
formbuilder/services/form_factory.py
Normal 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)
|
||||
23
formbuilder/services/options.py
Normal file
23
formbuilder/services/options.py
Normal 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)}
|
||||
46
formbuilder/services/reporting.py
Normal file
46
formbuilder/services/reporting.py
Normal 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
|
||||
84
formbuilder/services/submission.py
Normal file
84
formbuilder/services/submission.py
Normal 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
286
formbuilder/tests.py
Normal 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
1
formbuilder/views.py
Normal file
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
0
shop/migrations/__init__.py
Normal file
0
shop/migrations/__init__.py
Normal file
Reference in New Issue
Block a user