ARCH-005, premiere moitie. matches.py et team_matches.py lisaient une quarantaine de champs sur request.form a la main et les croyaient tous. Ce que ca produisait n etait pas bruyant : - edit_match attrapait une heure invalide et faisait start_time = None, puis annoncait que le match etait mis a jour. Le match perdait son heure et le calendrier l affichait a minuit ; - match_type etait accepte tel quel. Une valeur inconnue creait un match auquel aucun joueur n etait rattache, sans un mot ; - une fin avant le debut etait enregistree telle quelle ; - title est NOT NULL dans le modele et n etait pas verifie dans la route, donc un titre vide etait un 500 ; - 'a,b' dans la selection de joueurs arrivait sur int() sans garde. app/forms.py rassemble les deux fonctions de frontiere, qui vivaient dans users/_shared.py parce que c est la qu elles avaient d abord servi. Elles y restent re-exportees, donc aucun des trente appels n a bouge. Le mixin des schemas lit desormais un champ vide comme un champ absent. C est ce qui rendait ces formulaires invalidables : un formulaire HTML envoie tout ce qu il affiche, donc une date optionnelle non remplie arrive comme '' et non comme rien. Seuls les champs declares optionnels sont concernes ; un champ requis laisse vide doit toujours echouer. Deux duplications absorbees au passage, toutes deux nommees par l audit : la boucle de creation des participants, ecrite deux fois et deja divergee — la copie de edit_match gardait ses identifiants en chaines et appelait int() une ligne plus loin — et le contexte de re-affichage du formulaire, dont les versions courtes faisaient mourir un refus dans tojson sur un Undefined : un message de validation devenait un 500. Limite connue et consignee : le formulaire revient rempli avec les valeurs enregistrees, pas avec la saisie refusee. Reafficher la soumission demande de toucher aux gabarits, c est un autre changement. 19 tests neufs sur ces routes, qui n en avaient aucun. 447 au total.
64 lines
2.6 KiB
Python
64 lines
2.6 KiB
Python
"""The boundary between an HTTP form and a validated payload (ARCH-005).
|
|
|
|
Every POST in this application arrives as a `werkzeug.MultiDict` of strings.
|
|
Turning that into typed, checked values was done inline, differently, in each
|
|
route: `int(x) if x else None` here, `datetime.strptime` inside a bare `try`
|
|
there, and in several places not at all. The failures that produced were not
|
|
loud ones — a bad time silently became `None` and the page said the match had
|
|
been updated.
|
|
|
|
Two functions here, one schema module next to them (`app.validators`):
|
|
|
|
payload = form_payload(list_fields=('player_ids',))
|
|
try:
|
|
data = MatchSchema().load(payload)
|
|
except ValidationError as err:
|
|
flash_validation_errors(err)
|
|
return _rerender()
|
|
|
|
Both were originally inside `app/routes/users/_shared.py`, which is where
|
|
they were first needed. They are re-exported from there so that nothing had
|
|
to be renamed when the match and tryout routes started using them too.
|
|
"""
|
|
|
|
from flask import flash, request
|
|
from flask_babel import gettext as _
|
|
|
|
|
|
def flash_validation_errors(err):
|
|
"""Surface marshmallow errors, one flash per problem.
|
|
|
|
The uniform reporting half of ARCH-005: before this, a bad date flashed
|
|
'Invalid date format.' from one route, redirected from another, and was
|
|
silently dropped by a third.
|
|
"""
|
|
for field, messages in err.messages.items():
|
|
for msg in messages:
|
|
flash(_('%(field)s: %(msg)s', field=field, msg=msg), 'danger')
|
|
|
|
|
|
def form_payload(*, checkboxes=(), list_fields=('games',), optional_blank=('password',)):
|
|
"""Turn the multi-valued request form into a plain dict for marshmallow.
|
|
|
|
request.form.to_dict() keeps only the first value of a repeated key, so
|
|
list fields have to be re-read with getlist(). Unchecked HTML checkboxes
|
|
are simply absent from the submission, which is not the same as a schema
|
|
default, so they are injected explicitly. Blank optional fields are
|
|
dropped rather than sent as '' — an empty password means "leave the
|
|
current one alone", not "set the password to the empty string".
|
|
|
|
Args:
|
|
checkboxes: Names to report as True/False on presence.
|
|
list_fields: Names to read with getlist(), always producing a list.
|
|
optional_blank: Names to drop entirely when submitted empty.
|
|
"""
|
|
payload = request.form.to_dict()
|
|
for name in list_fields:
|
|
payload[name] = request.form.getlist(name)
|
|
for name in checkboxes:
|
|
payload[name] = name in request.form
|
|
for name in optional_blank:
|
|
if not payload.get(name):
|
|
payload.pop(name, None)
|
|
return payload
|