refactor(validation): un schema a la frontiere des matchs
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.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user