Le commit precedent finissait teams.py en notant que le defaut venait d'une
correction appliquee a un seul endroit. Balayer les autres modules
immediatement, plutot que d'attendre qu'une passe d'audit les retrouve, a
sorti les deux derniers.
tryouts.register_player lisait int(request.form.get('player_id')) -- 500 sur
une valeur non numerique -- et verifiait le role sans regarder
is_active_account. Un compte desactive pouvait donc etre inscrit a une
selection.
users/contracts._selectable_players ne filtrait pas non plus les comptes
desactives dans sa branche non-coach : la liste de depot de contrat proposait
encore des gens partis du club. Un contrat est un document nominatif signe.
PlayerSelectionSchema porte desormais le champ, et TeamPlayerSchema en herite
en ajoutant son statut. Un schema partage est ce qui empeche le prochain
appelant d'etre oublie -- c'est precisement parce que chaque route avait le
sien, ecrit a la main, que la correction a du etre faite trois fois.
Verifie par mutation.
Co-Authored-By: Claude Opus 5 <[email protected]>
923 lines
30 KiB
Python
923 lines
30 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,
|
|
)
|
|
|
|
|
|
#: Where a player stands on a team roster.
|
|
#:
|
|
#: `TeamPlayer.status` is a NOT NULL String(20) that `add_player` filled from
|
|
#: `request.form.get('status', 'starter')` with no check at all, so a forged
|
|
#: submission stored any string it liked. It then survived until someone
|
|
#: pressed the toggle, which reads `'substitute' if status == 'starter' else
|
|
#: 'starter'` — so an unknown value silently became `starter`, i.e. promoted
|
|
#: whoever held it (SEC-16).
|
|
TEAM_PLAYER_STATUSES = ('starter', 'substitute')
|
|
|
|
|
|
class TeamStaffSchema(StripMixin):
|
|
"""One staff id, posted by the add/remove coach and manager forms.
|
|
|
|
Both ids are optional here even though each route needs exactly one:
|
|
`remove_coach` treats an absent id as "remove every coach", and the add
|
|
routes already carry their own translated "Please select a coach."
|
|
message. Making the field required would replace that message with a
|
|
generic one for no gain.
|
|
|
|
What this schema is for is the conversion. Wave K closed `create_team`
|
|
and `edit_team` and left five sibling routes reading `int(...)` straight
|
|
off the form — a non-numeric id was a 500 in each. Fixing a pattern in
|
|
one place and not its neighbours is the mistake this project keeps
|
|
making; this is the same mistake, made by the fix for it.
|
|
"""
|
|
|
|
coach_id = fields.Integer(
|
|
allow_none=True,
|
|
load_default=None,
|
|
validate=validate.Range(min=1),
|
|
error_messages={'invalid': _l('Invalid coach selection.')},
|
|
)
|
|
manager_id = fields.Integer(
|
|
allow_none=True,
|
|
load_default=None,
|
|
validate=validate.Range(min=1),
|
|
error_messages={'invalid': _l('Invalid manager selection.')},
|
|
)
|
|
|
|
|
|
class PlayerSelectionSchema(StripMixin):
|
|
"""One player id, picked from a `<select>` the browser rendered.
|
|
|
|
Shared by every route that takes a player from a roster form — putting
|
|
someone on a team, registering them for a tryout. They all read
|
|
`int(request.form.get('player_id'))` directly, so they were all a 500 on
|
|
a non-numeric value, and they were all fixed one at a time (SEC-16).
|
|
Having one schema is what stops the next one being missed.
|
|
"""
|
|
|
|
player_id = fields.Integer(
|
|
allow_none=True,
|
|
load_default=None,
|
|
validate=validate.Range(min=1),
|
|
error_messages={'invalid': _l('Invalid player selection.')},
|
|
)
|
|
|
|
|
|
class TeamPlayerSchema(PlayerSelectionSchema):
|
|
"""A player being put on a team roster, and where they stand on it."""
|
|
|
|
status = fields.String(
|
|
load_default='starter',
|
|
validate=validate.OneOf(TEAM_PLAYER_STATUSES, error=_l('Unknown roster status.')),
|
|
)
|
|
|
|
|
|
class OneOnOneRequestSchema(StripMixin):
|
|
"""A player asking their coach for a session (MNT-12).
|
|
|
|
This schema existed before and **was never called**: not imported by any
|
|
route, not exercised by any test. `one_on_one.py` parsed the same three
|
|
fields by hand, three lines apart, and parsed the date a second time to
|
|
get its weekday. That is the pattern the audit named as SEC-AUTHZ-001 —
|
|
a validation policy that is written down and not applied — surviving in
|
|
a corner nobody had looked at since.
|
|
|
|
The fields were `String` + `Regexp`, which checked the shape and left the
|
|
caller to convert. `Date` and `Time` do both, so the route receives the
|
|
objects it is going to store and the "is this a date" question is asked
|
|
once, in one place.
|
|
"""
|
|
|
|
date = fields.Date(
|
|
required=True,
|
|
error_messages={
|
|
'invalid': _l('Date must be in YYYY-MM-DD format.'),
|
|
'required': _l('A date is required.'),
|
|
},
|
|
)
|
|
start_time = fields.Time(
|
|
required=True,
|
|
error_messages={
|
|
'invalid': _l('Start time must be in HH:MM format.'),
|
|
'required': _l('A start time is required.'),
|
|
},
|
|
)
|
|
end_time = fields.Time(
|
|
required=True,
|
|
error_messages={
|
|
'invalid': _l('End time must be in HH:MM format.'),
|
|
'required': _l('An end time is required.'),
|
|
},
|
|
)
|
|
points = fields.String(
|
|
validate=validate.Length(max=2000, error=_l('Points must be 2000 characters or less.')),
|
|
allow_none=True,
|
|
load_default=None,
|
|
)
|
|
|
|
@validates_schema
|
|
def validate_window(self, data, **kwargs):
|
|
"""A session cannot end before it starts.
|
|
|
|
Nothing checked this. The form's own `<select>`s made it awkward to
|
|
do by accident, which is not the same as impossible — and the request
|
|
went to the coach's Discord either way.
|
|
"""
|
|
start, end = data.get('start_time'), data.get('end_time')
|
|
if start and end and end <= start:
|
|
raise ValidationError(_l('End time must be after start time.'), field_name='end_time')
|
|
|
|
|
|
class TimeSlotSchema(Schema):
|
|
"""One half-hour slot on a weekday: an availability or a coach booking.
|
|
|
|
Replaces `DisponibilityAddSchema`, which was also defined and never
|
|
called. Used for both the form-encoded single add and the JSON bulk
|
|
posts, which is why it is a plain Schema — `StripMixin` reads a blank
|
|
string as an absent field, and these payloads arrive as JSON where a
|
|
missing key is already absent.
|
|
|
|
`day_of_week` was checked inline as `day is None or day < 0 or day > 6`,
|
|
which raises TypeError rather than rejecting when the JSON holds a
|
|
string. Here it is an Integer field with a Range.
|
|
"""
|
|
|
|
class Meta:
|
|
"""Marshmallow Meta options.
|
|
|
|
EXCLUDE, like StripMixin, because the single-slot endpoint is posted
|
|
as a form and therefore carries `csrf_token`. Without this the schema
|
|
rejected every valid submission — found by the test, not by reading.
|
|
"""
|
|
|
|
unknown = EXCLUDE
|
|
|
|
day_of_week = fields.Integer(
|
|
required=True,
|
|
validate=validate.Range(min=0, max=6, error=_l('Day must be 0 (Monday) to 6 (Sunday).')),
|
|
error_messages={
|
|
'invalid': _l('Day must be 0 (Monday) to 6 (Sunday).'),
|
|
'required': _l('A day is required.'),
|
|
},
|
|
)
|
|
start_time = fields.Time(
|
|
required=True,
|
|
error_messages={
|
|
'invalid': _l('Start time must be in HH:MM format.'),
|
|
'required': _l('A start time is required.'),
|
|
},
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# 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'
|
|
)
|
|
|
|
|
|
class OrgTeamSchema(StripMixin):
|
|
"""An organisation team, created or edited (SEC-16).
|
|
|
|
`teams.py` was the one route module wave G's validation pass did not
|
|
reach, and it still read every field through `request.form.get` and
|
|
converted with a bare `int()`. Two consequences, both reachable from a
|
|
hand-made POST by anyone allowed to manage teams:
|
|
|
|
- `int('abc')` raises, so a non-numeric `coach_id` was a 500;
|
|
- `int('-1')` was accepted, and the id was then looked up without ever
|
|
checking what role the account had.
|
|
|
|
The second is the same defect wave G found in `tryouts.py`, where a
|
|
forged submission could name a *player* as coach. Here it survived in
|
|
two of the three branches of the same file: `edit_team`'s `sync_staff`
|
|
path checks `isinstance(user, Coach)`, its other path does not, and
|
|
`create_team` does not either. Role checking belongs with the lookup,
|
|
not with the schema, so it lives in `_staff_member` in the route — but
|
|
the ids have to survive the trip as integers first.
|
|
"""
|
|
|
|
name = fields.String(
|
|
required=True,
|
|
validate=validate.Length(min=1, max=100, error=_l('Team name is required.')),
|
|
error_messages={'required': _l('Team name is required.')},
|
|
)
|
|
coach_id = fields.Integer(
|
|
allow_none=True,
|
|
load_default=None,
|
|
validate=validate.Range(min=1),
|
|
error_messages={'invalid': _l('Invalid coach selection.')},
|
|
)
|
|
manager_id = fields.Integer(
|
|
allow_none=True,
|
|
load_default=None,
|
|
validate=validate.Range(min=1),
|
|
error_messages={'invalid': _l('Invalid manager selection.')},
|
|
)
|
|
coach_ids = fields.List(fields.Integer(validate=validate.Range(min=1)), load_default=list)
|
|
manager_ids = fields.List(fields.Integer(validate=validate.Range(min=1)), load_default=list)
|
|
sync_staff = fields.String(allow_none=True, load_default=None)
|
|
|
|
|
|
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,
|
|
)
|