The forms adapter¶
FormValidator
makes a Django form class a
Validator. The form
you already have - its fields, their validators, clean_<field>(), clean()
and, on a ModelForm, the model's own validation and uniqueness checks - is
what validates the arguments, and the adapter reads its fields into the
Parameters every transport describes itself from.
It needs nothing beyond Django, so like the dataclass adapters it is exported from the package root:
There is no presenter: a form describes what goes in, not what comes out.
To serve the form as a page - rendered on GET, dispatched on POST, and re-rendered with a refusal placed on it - see Forms on the HTTP page.
A form as a Validator¶
from typing import Any
from django import forms
from django.contrib.auth import get_user_model
from django_service_specs import (
FormValidator,
Parameter,
Parameters,
PermissionCheck,
SelectorKind,
SelectorSpec,
ServiceSpec,
update_from_input,
)
from tests.adapter_app.models import Author, Status
class BookForm(forms.Form):
title = forms.CharField(max_length=100, help_text="As printed on the cover.")
price = forms.DecimalField(max_digits=6, decimal_places=2)
status = forms.ChoiceField(choices=Status.choices)
published_on = forms.DateField(required=False)
author = forms.ModelChoiceField(queryset=Author.objects.all())
shelves = forms.MultipleChoiceField(
choices=[("fiction", "Fiction"), ("poetry", "Poetry")], required=False
)
def clean(self) -> dict[str, Any]:
cleaned = super().clean() or {}
if cleaned.get("status") == Status.PUBLISHED and not cleaned.get("published_on"):
raise forms.ValidationError("A published book needs its publication date.")
return cleaned
# The form is read here, once: a field with no JSON type fails on this line.
book_validator = FormValidator(BookForm)
What book_validator.parameters() declares, then, in the form's own order:
title, a required string with the help text as its help; price, a
required string in the decimal format; status, a required string with the
choices draft and published; published_on, an optional, nullable string
in the date format; author, a required integer, because authors are matched
on their primary key; and shelves, an optional array of strings whose every
element is one of fiction and poetry.
validate() binds the arguments to a new BookForm and returns its
cleaned_data: a Decimal for the price, a date, the Author row itself
for author. The rule clean() states is the form's to enforce, and its
message comes back about the whole call:
{"non_field_errors": ["A published book needs its publication date."]}.
The form is read when FormValidator is built, so a field it cannot describe
fails on that line, naming the form, the field and the field's class. Nothing
in the reading queries: a ModelChoiceField's rows are never listed. The one
thing read later is help_text, on each parameters() call, so a lazily
translated help text is in the caller's language rather than whichever was
active at import.
What each field declares¶
The first match wins, so a subclass is read as the most specific field it extends.
| Field | Declares |
|---|---|
BooleanField, NullBooleanField |
A boolean. |
DecimalField |
A string in the decimal format. It takes a JSON number too. |
FloatField |
A number. |
IntegerField |
An integer. |
DateTimeField, DateField |
A string in the date-time or date format. |
TimeField, DurationField |
A string. |
ChoiceField |
A string with the choices as strings, since a plain choice field cleans with str and compares against each choice's str. The empty choice is dropped and option groups are flattened. |
TypedChoiceField |
Choices, of the JSON type every value shares. Values of mixed types, or of a type JSON does not have, are refused. |
MultipleChoiceField, TypedMultipleChoiceField |
An array of the same, whose choices constrain each element. |
ModelChoiceField |
The JSON type of the model field rows are matched on - to_field_name, or the primary key - and no choices: they are rows, and listing them would query. It validates to the row. |
ModelMultipleChoiceField |
An array of the same. It validates to a queryset. |
any other CharField (EmailField, URLField, SlugField, UUIDField, RegexField, GenericIPAddressField) |
A string. |
A choice set given as a callable is computed per form and may query, so it is
not read: the field is declared as a string with no choices, which every
choice field accepts, and the form checks the choice when it validates.
Refused, with ImproperlyConfigured:
FileFieldandImageField: an upload does not cross a JSON transport.JSONField: it takes any JSON value, and a parameter declares one type.MultiValueField, such asSplitDateTimeField: it is cleaned from several widget values at once, which only an HTML form sends. Declare a field per value.ComboField: it has no JSON type of its own.- A
ModelChoiceFieldwith no queryset, since there is no model to read its key from, or one matching rows on a model field with no JSON type. - Any field class the table does not cover, a project's own included.
Presence, null and defaults¶
The Parameters say what the form does about presence and null, so on
those the shape check in front of it never refuses what the form would take.
requiredis the field's own. For aBooleanFieldthat is a statement about presence too: an absent checkbox cleans toFalse, which a required one refuses exactly as it refusesfalse. ANullBooleanFieldis never required, whatever it says: its validation is empty, and it takes an absent value asNone.nullableisnot required, and always true for aNullBooleanField. A form readsNoneas an empty value for every field, so an optional field takesnulland cleans it to its empty value -""for a string,Nonefor a number or a row,[]for a multiple choice - and a required one refuses it. The shape check and the form agree on it field for field.- There is no
default. A field'sinitialis what an unbound form displays; a bound form cleans an absent optional field to its empty value, never to its initial.
The types are JSON's, and do not cross. A form reads its data as an HTML
post's strings, so it also takes a value of another type through str - a
number for a CharField, 1 for the choice "1". The shape check refuses
one, as it does for any declaration: a caller sends the declared type. The
empty choice is the other case. An optional choice field reads "" as no
value, and a caller says that with null.
Declare the fields on the class¶
The adapter reads base_fields: the fields the class declares. A field the
form adds or changes in its own __init__ is not described, and under
UnknownArguments.REJECT,
the default, the closed argument set refuses its key before the form is ever
built. A field declared disabled is not a parameter either: a form ignores
what a caller sends for it and cleans its initial, which validate()
returns with the rest.
The form is built from the arguments alone - data=, and instance= for a
ModelForm - so a form whose __init__ requires anything more, such as the
signed-in user, cannot be used as it is.
A ModelForm behind an update¶
User = get_user_model()
class UsernameForm(forms.ModelForm):
class Meta:
model = User
fields = ["username"] # unique on the model
class IsThemselves(PermissionCheck):
def has_permission(self, principal: Any, spec: Any) -> bool:
return principal.is_authenticated
def has_object_permission(self, principal: Any, spec: Any, target: Any) -> bool:
return target.pk == principal.pk
def rename_user(*, instance: Any, data: dict[str, Any]) -> Any:
# Saves only the fields whose value changed, which is why the form never
# writes to ``instance`` itself: it validates against a copy.
return update_from_input(instance, data).instance
rename_user_spec = ServiceSpec(
service=rename_user,
permissions=[IsThemselves()],
validator=FormValidator(UsernameForm),
instance_selector_spec=SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=lambda *, pk: User.objects.filter(pk=pk),
reads=Parameters.of(Parameter("pk", "integer", required=True)),
),
)
Dispatch resolves the row before it validates, and hands it to the Validator
in the
ValidationContext.
When the target is a row of the form's model, the form is built with
instance= set to a copy of it, so its uniqueness check excludes the row
being updated: renaming ada to ada is not a clash, and renaming her to
bob is refused in the form's own words,
{"username": ["A user with that username already exists."]}.
It is a copy because a ModelForm writes the cleaned values onto its instance
as it validates. Handed the target itself, it would change the row the service
receives before the service runs, and
update_from_input,
which saves only the fields whose value changed, would find nothing to save.
Any other target - the queryset a collection selector resolves, or none for a
create - builds the form as a create.
Refusals¶
A refusal is one
InvalidArguments
carrying the form's own errors and messages, {field: [messages]}. The
messages about the whole form, which Django keeps under "__all__", are
moved to non_field_errors: the key every other refusal in the kernel uses,
so a transport reads a form's refusal as it reads any other. See
Arguments and refusals for the tree.