Files
team-tryouts/app/validators.py
T
GGThed c5e5cfa014 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.
2026-08-11 13:35:05 -04:00

764 lines
24 KiB
Python

"""Input validation schemas for the Team Tryouts application.
This module provides Marshmallow schemas for validating and sanitizing
all user inputs including forms, JSON requests, and file uploads.
All validation is centralized here for consistency and maintainability.
Usage:
from validators import LoginSchema
schema = LoginSchema()
errors = schema.validate(request.form)
"""
import re
from flask_babel import lazy_gettext as _l
from marshmallow import (
EXCLUDE,
Schema,
ValidationError,
fields,
pre_load,
validate,
validates_schema,
)
from app.models import ESPORT_GAMES, USER_TYPES
# =============================================================================
# Custom Validators
# =============================================================================
PASSWORD_POLICY = re.compile(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$')
"""Password policy: minimum 8 chars, 1 uppercase, 1 lowercase, 1 digit."""
def validate_password(value):
"""Validate password meets strength requirements.
Requires: minimum 8 characters, at least one uppercase letter,
one lowercase letter, and one digit.
Args:
value: The password string to validate.
Raises:
ValidationError: If password does not meet requirements.
"""
if not PASSWORD_POLICY.match(value):
raise ValidationError(
_l('Password must be at least 8 characters with uppercase, lowercase, and a number.')
)
def validate_username(value):
"""Validate username format.
Usernames must be 3-30 characters and contain only alphanumeric
characters, underscores, and hyphens.
Args:
value: The username string to validate.
Raises:
ValidationError: If username does not meet requirements.
"""
if not re.match(r'^[a-zA-Z0-9_-]{3,30}$', value):
raise ValidationError(
_l('Username must be 3-30 characters (letters, numbers, underscore, hyphen).')
)
def validate_discord_username(value):
"""Validate Discord username format if provided.
Accepts empty strings (optional field). Validates that the username
matches common Discord username patterns.
Args:
value: The Discord username to validate.
Raises:
ValidationError: If the format is invalid.
"""
if not value:
return
if not re.match(r'^[a-zA-Z0-9_.]{2,32}$', value):
raise ValidationError(_l('Invalid Discord username format.'))
def validate_discord_user_id(value):
"""Validate Discord user ID (snowflake) if provided.
Discord user IDs are 17-20 digit numbers.
Args:
value: The Discord user ID to validate.
Raises:
ValidationError: If the format is invalid.
"""
if not value:
return
if not re.match(r'^\d{17,20}$', value):
raise ValidationError(_l('Discord User ID must be a 17-20 digit number.'))
def validate_phone(value):
"""Validate optional phone number format.
Accepts empty strings. Validates common phone formats.
Args:
value: The phone number to validate.
Raises:
ValidationError: If the format is invalid.
"""
if not value:
return
cleaned = re.sub(r'[\s\-\(\)\.]', '', value)
if not re.match(r'^\+?\d{7,15}$', cleaned):
raise ValidationError(_l('Invalid phone number format.'))
# =============================================================================
# Validation Schemas
# =============================================================================
class StripMixin(Schema):
"""Mixin that automatically strips whitespace from all string fields
and ignores unknown fields (e.g., csrf_token from Flask-WTF)."""
class Meta:
"""Marshmallow Meta options."""
unknown = EXCLUDE # Ignore csrf_token and other unknown fields
@pre_load
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 and blank optionals removed.
"""
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):
"""Validate login form input.
Fields:
username: 3-30 chars, required.
password: Non-empty, required.
"""
username = fields.String(
required=True,
validate=validate.Length(min=1, max=80, error=_l('Username is required.')),
)
password = fields.String(
required=True,
validate=validate.Length(min=1, error=_l('Password is required.')),
)
class RegisterSchema(StripMixin):
"""Validate player registration form input.
Fields:
username: 3-30 chars alphanumeric, required.
email: Valid email, required.
password: Meets password policy, required.
confirm_password: Must match password, required.
full_name: 1-100 chars, required.
phone: Optional, valid phone format.
games: Optional list.
discord_username: Optional, valid format.
league_os_profile: Optional URL.
"""
username = fields.String(
required=True,
validate=[
validate.Length(min=3, max=80, error=_l('Username must be 3-80 characters.')),
validate_username,
],
)
email = fields.Email(
required=True,
validate=validate.Length(max=120, error=_l('Email must be 120 characters or less.')),
)
password = fields.String(
required=True,
validate=validate_password,
load_only=True,
)
confirm_password = fields.String(
required=True,
load_only=True,
)
full_name = fields.String(
required=True,
validate=validate.Length(min=1, max=100, error=_l('Full name is required.')),
)
phone = fields.String(
validate=validate_phone,
allow_none=True,
load_default=None,
)
games = fields.List(fields.String(), load_default=[])
discord_username = fields.String(
validate=validate_discord_username,
allow_none=True,
load_default=None,
)
discord_user_id = fields.String(
validate=validate_discord_user_id,
allow_none=True,
load_default=None,
)
league_os_profile = fields.String(
validate=validate.Length(max=256),
allow_none=True,
load_default=None,
)
@validates_schema
def validate_password_match(self, data, **kwargs):
"""Ensure confirm_password matches password.
Args:
data: The validated data dictionary.
Raises:
ValidationError: If passwords do not match.
"""
if data.get('password') != data.get('confirm_password'):
raise ValidationError(_l('Passwords do not match.'), field_name='confirm_password')
class CreateUserSchema(StripMixin):
"""Validate president-created user form input.
Fields:
username: 3-30 chars alphanumeric, required.
email: Valid email, required.
password: Meets password policy, required.
full_name: 1-100 chars, required.
role: Must be valid role, required.
phone: Optional, valid phone format.
"""
username = fields.String(
required=True,
validate=[
validate.Length(min=3, max=80, error=_l('Username must be 3-80 characters.')),
validate_username,
],
)
email = fields.Email(
required=True,
validate=validate.Length(max=120),
)
password = fields.String(
required=True,
validate=validate_password,
load_only=True,
)
full_name = fields.String(
required=True,
validate=validate.Length(min=1, max=100, error=_l('Full name is required.')),
)
role = fields.String(
required=True,
validate=validate.OneOf(USER_TYPES, error=_l('Invalid role selected.')),
)
phone = fields.String(
validate=validate_phone,
allow_none=True,
load_default=None,
)
class EditUserSchema(StripMixin):
"""Validate president-edited user form input.
Fields:
full_name: 1-100 chars, required.
email: Valid email, required.
role: Must be valid role, required.
is_active_account: Boolean.
phone: Optional.
password: Optional (only if changing).
discord_username: Optional.
discord_user_id: Optional.
league_os_profile: Optional.
games: Optional list.
"""
full_name = fields.String(
required=True,
validate=validate.Length(min=1, max=100, error=_l('Full name is required.')),
)
email = fields.Email(
required=True,
validate=validate.Length(max=120),
)
role = fields.String(
required=True,
validate=validate.OneOf(USER_TYPES, error=_l('Invalid role selected.')),
)
is_active_account = fields.Boolean(load_default=True)
phone = fields.String(
validate=validate_phone,
allow_none=True,
load_default=None,
)
password = fields.String(
validate=validate_password,
load_only=True,
allow_none=True,
load_default='',
)
discord_username = fields.String(
validate=validate_discord_username,
allow_none=True,
load_default=None,
)
discord_user_id = fields.String(
validate=validate_discord_user_id,
allow_none=True,
load_default=None,
)
league_os_profile = fields.String(
validate=validate.Length(max=256),
allow_none=True,
load_default=None,
)
games = fields.List(fields.String(), load_default=[])
class EditProfileSchema(StripMixin):
"""Validate self-edit profile form input.
Fields:
username: 3-30 chars, required.
full_name: 1-100 chars, required.
email: Valid email, required.
phone: Optional.
password: Optional (only if changing).
discord_username: Optional.
discord_user_id: Optional.
league_os_profile: Optional.
games: Optional list.
"""
username = fields.String(
required=True,
validate=[
validate.Length(min=3, max=80),
validate_username,
],
)
full_name = fields.String(
required=True,
validate=validate.Length(min=1, max=100, error=_l('Full name is required.')),
)
email = fields.Email(
required=True,
validate=validate.Length(max=120),
)
phone = fields.String(
validate=validate_phone,
allow_none=True,
load_default=None,
)
password = fields.String(
validate=validate_password,
load_only=True,
allow_none=True,
load_default='',
)
discord_username = fields.String(
validate=validate_discord_username,
allow_none=True,
load_default=None,
)
discord_user_id = fields.String(
validate=validate_discord_user_id,
allow_none=True,
load_default=None,
)
league_os_profile = fields.String(
validate=validate.Length(max=256),
allow_none=True,
load_default=None,
)
games = fields.List(fields.String(), load_default=[])
class UploadContractSchema(StripMixin):
"""Validate contract upload form input.
Fields:
player_id: Integer, required.
notes: Optional text.
"""
player_id = fields.Integer(
required=True,
validate=validate.Range(min=1, error=_l('Player must be selected.')),
)
notes = fields.String(
validate=validate.Length(max=2000, error=_l('Notes must be 2000 characters or less.')),
allow_none=True,
load_default=None,
)
class OneOnOneRequestSchema(StripMixin):
"""Validate One on One session request form input.
Fields:
date: Date string (YYYY-MM-DD), required.
start_time: Time string (HH:MM), required.
end_time: Time string (HH:MM), required.
points: Optional text.
"""
date = fields.String(
required=True,
validate=validate.Regexp(
r'^\d{4}-\d{2}-\d{2}$', error=_l('Date must be in YYYY-MM-DD format.')
),
)
start_time = fields.String(
required=True,
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('Start time must be in HH:MM format.')),
)
end_time = fields.String(
required=True,
validate=validate.Regexp(r'^\d{2}:\d{2}$', error=_l('End time must be in HH:MM format.')),
)
points = fields.String(
validate=validate.Length(max=2000, error=_l('Points must be 2000 characters or less.')),
allow_none=True,
load_default=None,
)
class DisponibilityAddSchema(StripMixin):
"""Validate disponibility block addition.
Fields:
day_of_week: Integer 0-6, required.
start_time: Time string (HH:MM), required.
"""
day_of_week = fields.Integer(
required=True,
validate=validate.Range(min=0, max=6, error=_l('Day must be 0 (Monday) to 6 (Sunday).')),
)
start_time = fields.String(
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)
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,
)