Files
team-tryouts/app/forms.py
T

94 lines
3.7 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
def form_gamertags(selected_games):
"""Validate the dynamic gamertag fields for the selected games.
These fields cannot be declared statically on the account schemas: their
names contain the game label. They are still untrusted form data, so
every caller uses this shared boundary before adding or changing rows.
"""
from marshmallow import ValidationError
from app.models import GAME_PLATFORMS
from app.validators import GamertagSchema
validated = {}
for game in selected_games:
raw_gamertag = request.form.get(f'gamertag_{game}', '')
raw_platform = (
request.form.get(f'platform_{game}', '') if GAME_PLATFORMS.get(game) else None
)
if not raw_gamertag.strip():
continue
try:
validated[game] = GamertagSchema().load(
{'game': game, 'gamertag': raw_gamertag, 'platform': raw_platform}
)
except ValidationError as err:
messages = [message for values in err.messages.values() for message in values]
raise ValidationError({f'gamertag_{game}': messages}) from err
return validated