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:
+188
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user