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.
655 lines
20 KiB
Python
655 lines
20 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 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)
|