refactor(validation): un schema aux frontieres tryout et evaluation

ARCH-005, seconde moitie. Meme forme que pour les matchs : des champs lus
a la main sur request.form, deux verifies et le reste cru sur parole.

Cote tryout :
- game pilote la liste des postes et les champs de gamertag montres au
  joueur qui s inscrit. Il etait accepte tel quel : une faute de frappe
  produisait une selection pour laquelle personne ne pouvait etre evalue ;
- max_players etait int(x) if x else None — un 500 sur « twelve », et un
  -3 accepte sans broncher ;
- coach_ids etait charge par User.id.in_(...) sans filtre de role. Une
  soumission fabriquee a la main pouvait donc nommer un joueur coach d une
  selection, ce qui est une attribution de droits : gerer la selection et
  evaluer ses joueurs. Ce n est pas un formulaire que l interface propose,
  et ca marchait.

Cote evaluation, validate_score transformait tout ce qui sortait de 1..10
— 11, 0, « bien » — en None. Le critere disparaissait de la moyenne et la
page annoncait l evaluation enregistree. Rien ne distinguait « non
evalue » de « evalue, refuse, et oublie ».

Le calcul de la moyenne remonte sur le modele, en Evaluation.overall_from
et apply_scores. Il vivait dans la route, additionnant neuf variables
locales, et ne pouvait pas etre exerce sans requete HTTP, session
authentifiee et base — c est TEST-002, et c est pourquoi le calcul des
scores n avait aucun test. Il en a maintenant six, sans rien monter.

Une precision qui compte : aucun critere rempli donne None, pas 0. La
grille commence a 1, donc un zero serait une note qu aucun joueur ne peut
recevoir, et qui le classerait sous tout le monde dans la liste.

Les neuf criteres sont ecrits en toutes lettres dans le schema plutot que
generes depuis le modele — un schema se lit — et un test verifie que les
deux listes coincident. C est la garde qui empeche la derive, pas
l astuce.

Douze chaines traduites, dont trois que pybabel avait devinees en fuzzy :
une entree fuzzy est ignoree a l execution, le piege est consigne dans
docs/translations.md.

30 tests neufs. 477 au total.
This commit is contained in:
GGThed
2026-08-11 13:35:05 -04:00
parent 0308eb9eef
commit c5e5cfa014
10 changed files with 866 additions and 454 deletions
+110 -1
View File
@@ -23,7 +23,7 @@ from marshmallow import (
validates_schema,
)
from app.models import USER_TYPES
from app.models import ESPORT_GAMES, USER_TYPES
# =============================================================================
# Custom Validators
@@ -652,3 +652,112 @@ class TeamMatchSchema(ScheduledEventSchema):
load_default=None,
)
is_practice = fields.Boolean(load_default=False)
class TryoutSchema(StripMixin):
"""A tryout event, created or edited.
`game` is checked against ESPORT_GAMES: it drives the position list and
the gamertag fields shown to registering players, so an unknown value
produced a tryout nobody could be evaluated for. It was accepted as any
string.
`max_players` was `int(x) if x else None`, which raised on 'twelve' and
happily stored -3.
"""
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,
)
game = fields.String(
required=True,
validate=validate.OneOf(ESPORT_GAMES, error=_l('Unknown game.')),
)
date = fields.Date(
required=True,
error_messages={
'invalid': _l('Invalid start date format.'),
'required': _l('A start date is required.'),
},
)
end_date = fields.Date(
allow_none=True,
load_default=None,
error_messages={'invalid': _l('Invalid end date format.')},
)
location = fields.String(
validate=validate.Length(max=200),
allow_none=True,
load_default=None,
)
max_players = fields.Integer(
validate=validate.Range(min=1, error=_l('A tryout must allow at least one player.')),
allow_none=True,
load_default=None,
error_messages={'invalid': _l('The player limit must be a whole number.')},
)
target_org_team_id = fields.Integer(allow_none=True, load_default=None)
manager_id = fields.Integer(allow_none=True, load_default=None)
coach_ids = fields.List(fields.Integer(), load_default=list)
@validates_schema
def validate_span(self, data, **kwargs):
"""A tryout cannot end before it starts."""
end = data.get('end_date')
if end and data.get('date') and end < data['date']:
raise ValidationError(
_l('End date cannot be before start date.'), field_name='end_date'
)
def score_field():
"""One evaluation criterion: 1 to 10, or not scored at all."""
return fields.Integer(
allow_none=True,
load_default=None,
validate=validate.Range(min=1, max=10, error=_l('Scores run from 1 to 10.')),
error_messages={'invalid': _l('A score must be a whole number from 1 to 10.')},
)
class EvaluationSchema(StripMixin):
"""A coach's assessment of one player in one tryout (ARCH-005).
Each criterion is scored 1 to 10, or left blank. `validate_score` used to
turn anything else — 11, 0, 'good' — into None: the criterion silently
vanished from the average and the page reported the evaluation as
submitted. A coach could score a player 11 out of 10 and have it counted
as no score at all.
The nine are spelled out rather than generated from Evaluation.CRITERIA,
because a schema is worth reading. test_evaluations.py asserts that the
two lists match, so adding a tenth criterion to the model and forgetting
this file fails the suite rather than silently dropping the field.
"""
mecanics_score = score_field()
cohesion_score = score_field()
communication_score = score_field()
gamesense_score = score_field()
versatility_score = score_field()
discipline_score = score_field()
analysis_score = score_field()
sport_ethics_score = score_field()
mental_score = score_field()
comments = fields.String(
validate=validate.Length(max=5000),
allow_none=True,
load_default=None,
)
position_recommendation = fields.String(
validate=validate.Length(max=50),
allow_none=True,
load_default=None,
)