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>
85 lines
2.9 KiB
Python
85 lines
2.9 KiB
Python
"""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
|