Serving a formset¶
A many spec takes a list of rows under its many_argument, and the kernel
validates the call per row: the shape check and the Validator run on each
row, every failing row is refused at once, and each refusal is addressed by the
row's index. SpecFormSetView serves one as a page: a formset of the spec's
form, one form a row, posted as the list. The single-form page
refuses a many spec, since one form posts one row; this is the page that
serves it. The request's hooks, the
htmx fragment, a post that does not
commit and the denials are that page's
own code; this page describes what differs, which is the form, the reading,
the refusal, the scope check and the success message.
The examples on this page add several books in one call. The spec's
Validator is a FormValidator
of a row's form, and the spec is many:
class BookRowForm(forms.Form):
title = forms.CharField(max_length=100)
price = forms.DecimalField(max_digits=6, decimal_places=2)
author = forms.ModelChoiceField(queryset=Author.objects.all())
class IsSignedIn(PermissionCheck):
message = "Sign in to add books."
def has_permission(self, principal: Any, spec: Any) -> bool:
return principal.is_authenticated
def add_books(*, data: list[dict[str, Any]]) -> list[Book]:
# ``data`` is a list: each row's cleaned data, in the order posted.
return [Book.objects.create(**row) for row in data]
# A many spec: its rows travel as one list under ``items``, each validated by
# the form, and the service runs once for them all. At most five a call.
add_books_spec = ServiceSpec(
service=add_books,
permissions=[IsSignedIn()],
validator=FormValidator(BookRowForm),
many=True,
many_max_length=5,
)
urlpatterns = [
path(
"books/batch/",
SpecFormSetView.as_view(
spec=add_books_spec,
template_name="spec_formset.html",
fragment_template_name="spec_formset_form.html",
success_url=reverse_lazy("books"),
# Three blank forms; a form left blank sends no row.
extra=3,
# "Save" runs the operation, and "Add a row" offers one more form.
commit_button="save",
add_button="add",
success_message="%(count)d books were added.",
),
),
path("books/", books, name="books"),
]
The template renders the formset inside a POST form with its token. Render
{{ formset.management_form }} as well as the forms, or every post is
refused, and {{ formset.non_form_errors }}, where a refusal of the list as a
whole is placed:
<form method="post" hx-post="" hx-swap="outerHTML">
{% csrf_token %}{{ formset.management_form }}{{ formset.non_form_errors }}
{% for form in formset %}<fieldset>{{ form }}</fieldset>{% endfor %}
<button name="add">Add a row</button>
<button name="save">Save</button>
</form>
A template is optional. A view naming none renders
django_spec_html/spec_form_set.html, which this package ships, with its
formset in django_spec_html/spec_form_set_fragment.html, the file an htmx
request is answered with. It is found, refused at as_view() when no engine
finds it, and overridden as the form page's is:
list django_spec_html in INSTALLED_APPS with APP_DIRS on. It renders,
escaped and with no stylesheet, script or htmx attribute, the management
form, the non-form errors - a question's message among them - and each form
as a fieldset of its own with its errors, then the submit button, posting
commit_button's name where one is set, and the add_button, which skips the
browser's checks, since its post commits nothing. It has no target pane: a
many spec has no target.
The formset¶
GET renders template_name with an unbound formset under formset, and
view and spec beside it, once the principal is admitted: an anonymous
visitor above gets Django's own PermissionDenied.
-
Admission asks the scope check of nothing. There is no target to look up, so after the spec's class-level check the page asks the scope check of
None, as dispatch asks it of everymanycall, and a principal the post would refuse is never offered the page. A route value the spec'sreadsrefuse is a 400, as on every page. A post asks it once: dispatch asks it, and where the page's own reading refuses the post first - its management form, a row's coercion - the page asks it before answering, so a principal it refuses is denied rather than shown the formset, as the single page denies one before reading its form. A route value thereadsrefuse leaves it unasked, as on the single page: there are no arguments to ask it with. -
extrablank forms, 1 by default, after the spec'smany_min_lengthforms, which a person must fill in. The page above offers three. - Never more forms than the spec takes. The formset's
min_numandmax_numare the spec'smany_min_lengthandmany_max_length, never attributes of the view, so no second declaration can drift from the first. The formset validates neither: the list's length is the kernel's to refuse, in its words. - Every form is built as the Validator builds a row's, with what its
form_kwargsanswers, through the kernel'sform_keywords, as the single page builds its form: asked once per request, with the principal, the request's seeds and its call context. Amanyspec has no target to hand it, so the answer is the same for every row. - No target pane. A
manyspec declares no target selector - the kernel refuses one when the spec is built - so the context carries notarget,target_countortarget_display, anddisplay_targetis refused atas_view().
The post¶
Each form is read as the single page reads its form: through its own
widgets, a blank field absent, a disabled field not read, a date in the
form's input format sent in the form the shape check reads, and a choice's key
coerced against the row's own declaration. The rows travel in the formset's
order as one list under many_argument, and the service receives it once.
- A form left as it came sends no row. An extra form a person did not
touch is not sent, so the page above, posted with its middle form blank,
adds two books, not three. The first
many_min_lengthforms are sent however they are left, and the kernel refuses a blank row there as it refuses any row missing a required field. - The management form is never an argument. Its keys -
TOTAL_FORMS,INITIAL_FORMSand the rest, under the formset'sform-prefix - are the formset's own, as the CSRF token is the page's. A post missing them, or carrying ones Django refuses, is answered at 400 with Django's own message as a non-form error, and nothing is dispatched: a formset without its management form reads no forms, and would send an empty list. - The route's kwargs are the call's, read and coerced once beside the
list, never inside a row, and checked against the spec's top level: a
manyspec'sreads.
A refused post¶
A refused post re-renders the formset at the status the single page answers it with, from the one map, and the refusal is split back onto the forms:
- A refusal of row i lands on the form that sent it, through
add_argument_errors, so a form left blank and dropped shifts nothing: on the page above, a bad price in the third form, posted beside a blank second one, is the list's row 1 and is placed on the third form. - A refusal of the list as a whole lands on the formset's
non_form_errors(): its length - six books where the spec takes five - and a message the Validator gives about the rows together. - A refusal of a route value is a non-form error too, prefixed with its
name,
"shelf: Enter a whole number.", in the same post as the rows'. - Every row's own checks answer in the same post. Beside an
InvalidArguments, each sent form is validated as the Validator validates a row's, so a row the shape check refused - a required field left blank - shows its form's further checks, aclean()among them, beside it. On apartialspec the forms carry the refusal alone, as a partial page's form does. - Any other refusal - a conflict, a service's own rule - is its message as a non-form error, and no form is validated again.
A question is answered by its message alone. An
AdditionalInputRequired is checked by the kernel's check_requested_input
first, as on every page, so a schema no route could hear answered - a key
declared only inside a row among them - raises ImproperlyConfigured and is
never rendered. Then the formset is re-rendered at 422 with the message as a
non-form error and requested_input, its message and schema, in the
context, with no fields. A many call's top level is the list and the
spec's reads, which no form declares, and the page builds no widget from a
schema, so a confirmation asked once for the whole call is not built on this
page.
Adding a row¶
add_button names a second submit button whose post is the page again with
one more form, bound to everything posted, and nothing dispatched. It needs a
commit_button, since without one every post commits, the add button's
included. The page above names both: "Save" runs the operation, and "Add a row"
offers a fourth blank form with the first three as they were filled in. At the
spec's many_max_length it offers none, since the list takes no more. Every
other post that does not commit is the page again with the forms it posted,
validated by nothing, as on the single page.
A success¶
A success redirects to success_url, or answers HX-Redirect or the result
fragment to htmx, as on the single page, and a view naming
redirect_field_name returns to the URL it reads,
as the single page does. success_message is formatted
with the rows' count, % with {"count": n}, so "%(count)d books were
added." above says how many: the single page formats with the cleaned data,
and a many call's is a list, which % with a name cannot read. Override
get_success_message(result) to read the rows, under result.data, or what
the operation returned, under result.value.
What as_view() refuses¶
When the URLconf is imported: a spec whose Validator is not a FormValidator;
a spec that is not many; a view displaying its target; an extra that is
not a non-negative int; an add_button that is not a button's name, is set
without a commit_button, or is the commit_button; a redirect_field_name
that is either button; and either button or the redirect_field_name named
under the formset's form- prefix, which every post carries. Then every check
the single page makes of the request's hooks. The view is sync only in this
release.