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:
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
|
||||
Reference in New Issue
Block a user