94 lines
3.7 KiB
Python
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
|