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>
96 lines
3.7 KiB
Python
96 lines
3.7 KiB
Python
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}"
|