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:
GGThed
2026-08-11 13:09:00 -04:00
parent d8541678a6
commit 0308eb9eef
6 changed files with 769 additions and 358 deletions
+188 -6
View File
@@ -137,18 +137,55 @@ class StripMixin(Schema):
unknown = EXCLUDE # Ignore csrf_token and other unknown fields
@pre_load
def strip_strings(self, data, **kwargs):
"""Strip whitespace from all string values in the input data.
def normalise_form_values(self, data, **kwargs):
"""Strip whitespace, and read an empty input as an absent one.
Both halves in one hook rather than two: marshmallow gives no
ordering guarantee between several pre_load hooks on the same schema,
and these two have to happen in this order.
The second half is what let the match and tryout forms be validated
at all (ARCH-005). An HTML form submits every field it renders, so an
untouched optional date arrives as '' rather than not arriving —
and '' is not a date, so a schema written the obvious way rejected
every form with a blank optional field. Dropping the key instead lets
`load_default` do its job.
Only fields the schema declares as optional are dropped. A blank
*required* field still has to fail, and say so.
Args:
data: The input dictionary.
Returns:
dict: Data with stripped strings.
dict: Data with stripped strings and blank optionals removed.
"""
if isinstance(data, dict):
return {k: v.strip() if isinstance(v, str) else v for k, v in data.items()}
return data
if not isinstance(data, dict):
return data
stripped = {k: v.strip() if isinstance(v, str) else v for k, v in data.items()}
return {
key: value
for key, value in stripped.items()
if value != '' or self._is_required(key) or self._accepts_blank(key)
}
def _is_required(self, key):
field = self.fields.get(key)
return field is not None and field.required
def _accepts_blank(self, key):
"""True when '' is a value this field means to receive.
A plain String is one: `description=''` on an edit form means "clear
the description", not "leave it alone". An Email is not — there is no
such thing as an empty address, so a blank one is an absent one.
Typed fields (Integer, Date, Time, List) are not Strings and so are
never kept blank, which is the whole point of the hook.
"""
field = self.fields.get(key)
return isinstance(field, fields.String) and not isinstance(field, fields.Email)
class LoginSchema(StripMixin):
@@ -470,3 +507,148 @@ class DisponibilityAddSchema(StripMixin):
required=True,
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
)
# =============================================================================
# Scheduling — matches and tryouts (ARCH-005)
# =============================================================================
#: The three shapes a tryout match can take. Read by the route to decide how
#: participants are drawn; a value outside this set produced a match with no
#: participants at all and no complaint.
MATCH_TYPES = ('team_vs_team', 'player_vs_player', 'player_scrim')
#: Match lifecycle. Was checked with an inline `if status in [...]` that
#: silently kept the old value on anything else.
MATCH_STATUSES = ('scheduled', 'completed', 'cancelled')
class CommaSeparatedIds(fields.Field):
"""A hidden input holding '3,7,12' — ids picked in the page.
The match form posts its player selections this way. Every route parsed
it by hand, and each did it slightly differently: one dropped empty
segments, another did not, a third called int() on whatever came out and
would have raised a 500 on 'a,b'.
"""
default_error_messages = {'invalid': _l('Player selection is malformed.')}
def _deserialize(self, value, attr, data, **kwargs):
if value is None or value == '':
return []
if isinstance(value, (list, tuple)):
parts = value
else:
parts = str(value).split(',')
try:
return [int(part) for part in (str(p).strip() for p in parts) if part]
except (TypeError, ValueError) as exc:
raise self.make_error('invalid') from exc
class ScheduledEventSchema(StripMixin):
"""What every scheduled thing has: a title, a day, and a window on it.
Shared by matches and team matches, which is also what BaseMatch says at
the model level. Times are real time objects here rather than strings —
the point of validating at the boundary is that a route never handles a
'18:00' again.
"""
title = fields.String(
required=True,
validate=validate.Length(min=1, max=200, error=_l('A title is required.')),
)
description = fields.String(
validate=validate.Length(max=5000),
allow_none=True,
load_default=None,
)
date = fields.Date(
required=True,
error_messages={'invalid': _l('Invalid date format.')},
)
start_time = fields.Time(
required=True,
error_messages={
'invalid': _l('Invalid time format.'),
'required': _l('Start time is required. Please select a time slot.'),
},
)
end_time = fields.Time(
allow_none=True,
load_default=None,
error_messages={'invalid': _l('Invalid time format.')},
)
location = fields.String(
validate=validate.Length(max=200),
allow_none=True,
load_default=None,
)
status = fields.String(
validate=validate.OneOf(MATCH_STATUSES, error=_l('Unknown match status.')),
load_default='scheduled',
)
@validates_schema
def validate_window(self, data, **kwargs):
"""An event cannot end before it starts.
Nothing checked this. A match from 20:00 to 18:00 was accepted, shown
on the calendar as a negative-length block, and announced by Discord
as exactly that.
"""
start = data.get('start_time')
end = data.get('end_time')
if start and end and end <= start:
raise ValidationError(
_l('The end time must come after the start time.'), field_name='end_time'
)
class MatchSchema(ScheduledEventSchema):
"""A match inside a tryout.
match_type is required on creation and immutable afterwards — the edit
form posts it as a hidden field and the route reads it off the record,
not off the form.
"""
match_type = fields.String(
required=True,
validate=validate.OneOf(MATCH_TYPES, error=_l('Unknown match type.')),
)
team1_id = fields.Integer(allow_none=True, load_default=None)
team2_id = fields.Integer(allow_none=True, load_default=None)
team1_player_ids = CommaSeparatedIds(load_default=list)
team2_player_ids = CommaSeparatedIds(load_default=list)
player_ids = fields.List(fields.Integer(), load_default=list)
@validates_schema
def validate_sides(self, data, **kwargs):
"""A team cannot play itself."""
if data.get('team1_id') and data.get('team1_id') == data.get('team2_id'):
raise ValidationError(_l('A team cannot play against itself.'), field_name='team2_id')
class MatchEditSchema(MatchSchema):
"""The same match, being edited.
match_type is not accepted here at all: it decides how participants are
drawn, and changing it on an existing match would leave the old ones
behind. The route takes it from the record.
"""
match_type = fields.String(load_default=None)
class TeamMatchSchema(ScheduledEventSchema):
"""A regular-season match for an organisation team."""
opponent = fields.String(
validate=validate.Length(max=200),
allow_none=True,
load_default=None,
)
is_practice = fields.Boolean(load_default=False)