Modularize confirmation modal for destructive POST actions and add notify helper for concise message handling across the UI.
This commit is contained in:
@@ -3,13 +3,13 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from formbuilder.models import Answer, Field, Submission
|
||||
from formbuilder.models import Answer, Submission
|
||||
|
||||
from .options import allowed_values
|
||||
from .form_factory import build_form
|
||||
|
||||
|
||||
class FormSubmissionError(Exception):
|
||||
"""Raised when a submission is rejected. ``errors`` maps field key -> message."""
|
||||
"""Raised when a submission is rejected. ``errors`` maps field key -> messages."""
|
||||
|
||||
def __init__(self, message, *, errors=None):
|
||||
super().__init__(message)
|
||||
@@ -21,12 +21,17 @@ def _is_empty(value):
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def submit_form(form, member, data, *, when=None):
|
||||
"""Create a Submission (with Answers) for ``data`` or raise FormSubmissionError."""
|
||||
def submit_form(form, member, data, *, files=None, when=None):
|
||||
"""Create a Submission (with Answers) for ``data``/``files`` or raise FormSubmissionError.
|
||||
|
||||
Validation goes through ``build_form`` — the same dynamic Django Form the UI would
|
||||
render — so a NUMBER field is actually checked as a decimal, an EMAIL as an email, a
|
||||
CHOICE against its real options, and so on, rather than a hand-rolled subset of that.
|
||||
"""
|
||||
when = when or timezone.now()
|
||||
|
||||
_check_open(form, member, when)
|
||||
cleaned = _clean_answers(form, data)
|
||||
cleaned = _clean_answers(form, data, files)
|
||||
|
||||
submission = Submission.objects.create(form=form, member=member)
|
||||
Answer.objects.bulk_create([Answer(submission=submission, field=field, value=value) for field, value in cleaned])
|
||||
@@ -48,37 +53,13 @@ def _check_open(form, member, when):
|
||||
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:
|
||||
def _clean_answers(form, data, files):
|
||||
bound_form = build_form(form, data=data, files=files or {})
|
||||
if not bound_form.is_valid():
|
||||
errors = {key: list(messages) for key, messages in bound_form.errors.items()}
|
||||
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
|
||||
# Blank optional answers are validated (they may legitimately be empty) but not
|
||||
# stored — an Answer row exists only where the submitter actually said something.
|
||||
fields_by_key = {field.key: field for field in form.fields.filter(is_active=True)}
|
||||
return [(fields_by_key[key], value) for key, value in bound_form.cleaned_data.items() if not _is_empty(value)]
|
||||
|
||||
@@ -211,6 +211,24 @@ class SubmitFormTests(FormbuilderTestBase):
|
||||
|
||||
self.assertIn("size", ctx.exception.errors)
|
||||
|
||||
def test_number_field_rejects_non_numeric_input(self):
|
||||
# Validation goes through the same dynamic Django Form the UI renders, so a
|
||||
# NUMBER field is checked as a decimal — not merely "present".
|
||||
Field.objects.create(form=self.form, key="age", label="Age", field_type=Field.FieldType.NUMBER, required=True, order=3)
|
||||
|
||||
with self.assertRaises(FormSubmissionError) as ctx:
|
||||
submit_form(self.form, self.member, {"name": "Jane", "age": "not-a-number"})
|
||||
|
||||
self.assertIn("age", ctx.exception.errors)
|
||||
|
||||
def test_email_field_rejects_an_invalid_address(self):
|
||||
Field.objects.create(form=self.form, key="contact", label="Contact", field_type=Field.FieldType.EMAIL, required=True, order=3)
|
||||
|
||||
with self.assertRaises(FormSubmissionError) as ctx:
|
||||
submit_form(self.form, self.member, {"name": "Jane", "contact": "not-an-email"})
|
||||
|
||||
self.assertIn("contact", 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"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user