diff --git a/club/tests.py b/club/tests.py index 6c699e4..8186d95 100644 --- a/club/tests.py +++ b/club/tests.py @@ -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 diff --git a/clubmanager/settings.py b/clubmanager/settings.py index a547daa..63ec28e 100644 --- a/clubmanager/settings.py +++ b/clubmanager/settings.py @@ -49,6 +49,7 @@ INSTALLED_APPS = [ "members.apps.MembersConfig", "teams.apps.TeamsConfig", "events.apps.EventsConfig", + "formbuilder.apps.FormbuilderConfig", ] MIDDLEWARE = [ diff --git a/formbuilder/__init__.py b/formbuilder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/formbuilder/admin.py b/formbuilder/admin.py new file mode 100644 index 0000000..94c6188 --- /dev/null +++ b/formbuilder/admin.py @@ -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"] diff --git a/formbuilder/apps.py b/formbuilder/apps.py new file mode 100644 index 0000000..28e84de --- /dev/null +++ b/formbuilder/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FormbuilderConfig(AppConfig): + name = "formbuilder" diff --git a/formbuilder/migrations/0001_initial.py b/formbuilder/migrations/0001_initial.py new file mode 100644 index 0000000..ec03e17 --- /dev/null +++ b/formbuilder/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/formbuilder/migrations/0002_alter_answer_field_alter_submission_member.py b/formbuilder/migrations/0002_alter_answer_field_alter_submission_member.py new file mode 100644 index 0000000..017078c --- /dev/null +++ b/formbuilder/migrations/0002_alter_answer_field_alter_submission_member.py @@ -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'), + ), + ] diff --git a/formbuilder/migrations/0003_fix_tenant_uniqueness.py b/formbuilder/migrations/0003_fix_tenant_uniqueness.py new file mode 100644 index 0000000..c3749f0 --- /dev/null +++ b/formbuilder/migrations/0003_fix_tenant_uniqueness.py @@ -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'), + ), + ] diff --git a/formbuilder/migrations/__init__.py b/formbuilder/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/formbuilder/models.py b/formbuilder/models.py new file mode 100644 index 0000000..2ec8457 --- /dev/null +++ b/formbuilder/models.py @@ -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}" diff --git a/formbuilder/services/__init__.py b/formbuilder/services/__init__.py new file mode 100644 index 0000000..0a5c57e --- /dev/null +++ b/formbuilder/services/__init__.py @@ -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", +] diff --git a/formbuilder/services/form_factory.py b/formbuilder/services/form_factory.py new file mode 100644 index 0000000..13b388e --- /dev/null +++ b/formbuilder/services/form_factory.py @@ -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) diff --git a/formbuilder/services/options.py b/formbuilder/services/options.py new file mode 100644 index 0000000..fea825e --- /dev/null +++ b/formbuilder/services/options.py @@ -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)} diff --git a/formbuilder/services/reporting.py b/formbuilder/services/reporting.py new file mode 100644 index 0000000..15dc2d0 --- /dev/null +++ b/formbuilder/services/reporting.py @@ -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 diff --git a/formbuilder/services/submission.py b/formbuilder/services/submission.py new file mode 100644 index 0000000..eaac775 --- /dev/null +++ b/formbuilder/services/submission.py @@ -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 diff --git a/formbuilder/tests.py b/formbuilder/tests.py new file mode 100644 index 0000000..a959654 --- /dev/null +++ b/formbuilder/tests.py @@ -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, []) diff --git a/formbuilder/views.py b/formbuilder/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/formbuilder/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/shop/migrations/__init__.py b/shop/migrations/__init__.py new file mode 100644 index 0000000..e69de29