1052 lines
34 KiB
Python
1052 lines
34 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, GAME_PLATFORMS, 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,
|
|
)
|
|
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 GamertagSchema(StripMixin):
|
|
"""One dynamic per-game identity submitted beside an account form."""
|
|
|
|
game = fields.String(
|
|
required=True,
|
|
validate=validate.OneOf(ESPORT_GAMES, error=_l('Unknown game.')),
|
|
)
|
|
gamertag = fields.String(
|
|
required=True,
|
|
validate=validate.Length(
|
|
min=1,
|
|
max=120,
|
|
error=_l('Gamertag must be between 1 and 120 characters.'),
|
|
),
|
|
)
|
|
platform = fields.String(
|
|
allow_none=True,
|
|
load_default=None,
|
|
validate=validate.Length(max=30, error=_l('Platform must be 30 characters or less.')),
|
|
)
|
|
|
|
@validates_schema
|
|
def validate_platform_for_game(self, data, **kwargs):
|
|
"""A forged platform must belong to the selected game's list."""
|
|
platform = data.get('platform')
|
|
allowed = GAME_PLATFORMS.get(data.get('game'), ())
|
|
if platform and platform not in allowed:
|
|
raise ValidationError(_l('Unknown platform for this game.'), field_name='platform')
|
|
|
|
|
|
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.
|
|
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,
|
|
)
|
|
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.')),
|
|
)
|
|
|
|
|
|
TRYOUT_STATUSES = ('upcoming', 'in_progress', 'completed')
|
|
TRYOUT_REGISTRATION_STATUSES = ('registered', 'attended', 'no_show')
|
|
|
|
|
|
class TryoutStatusSchema(StripMixin):
|
|
"""A state transition requested from the tryout detail page."""
|
|
|
|
status = fields.String(
|
|
required=True,
|
|
validate=validate.OneOf(TRYOUT_STATUSES, error=_l('Unknown tryout status.')),
|
|
)
|
|
|
|
|
|
class TryoutRegistrationStatusSchema(StripMixin):
|
|
"""Attendance state for one tryout registration."""
|
|
|
|
status = fields.String(
|
|
required=True,
|
|
validate=validate.OneOf(
|
|
TRYOUT_REGISTRATION_STATUSES,
|
|
error=_l('Unknown registration status.'),
|
|
),
|
|
)
|
|
|
|
|
|
class TryoutTeamSchema(StripMixin):
|
|
"""A tryout-local team created from its compact inline form."""
|
|
|
|
team_name = fields.String(
|
|
required=True,
|
|
validate=validate.Length(
|
|
min=1,
|
|
max=100,
|
|
error=_l('Team name must be between 1 and 100 characters.'),
|
|
),
|
|
)
|
|
|
|
|
|
class TryoutTeamMemberSchema(StripMixin):
|
|
"""A registered player and their optional position on a tryout team."""
|
|
|
|
player_id = fields.Integer(
|
|
required=True,
|
|
validate=validate.Range(min=1),
|
|
error_messages={
|
|
'invalid': _l('Invalid player selection.'),
|
|
'required': _l('Player must be selected.'),
|
|
},
|
|
)
|
|
position = fields.String(
|
|
load_default='',
|
|
validate=validate.Length(
|
|
max=50,
|
|
error=_l('Position must be 50 characters or less.'),
|
|
),
|
|
)
|
|
|
|
|
|
class NoteContentSchema(StripMixin):
|
|
"""Bounded text stored as a team or personal coaching note."""
|
|
|
|
content = fields.String(
|
|
required=True,
|
|
validate=validate.Length(
|
|
min=1,
|
|
max=5000,
|
|
error=_l('Note content must be between 1 and 5000 characters.'),
|
|
),
|
|
)
|
|
|
|
|
|
class PersonalNoteSchema(NoteContentSchema):
|
|
"""A personal note with at most one optional, typed context."""
|
|
|
|
player_id = fields.Integer(
|
|
required=True,
|
|
validate=validate.Range(min=1),
|
|
error_messages={
|
|
'invalid': _l('Invalid player selection.'),
|
|
'required': _l('Player must be selected.'),
|
|
},
|
|
)
|
|
match_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
|
|
tryout_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
|
|
team_id = fields.Integer(allow_none=True, load_default=None, validate=validate.Range(min=1))
|
|
|
|
@validates_schema
|
|
def validate_one_context(self, data, **kwargs):
|
|
"""A note cannot claim several unrelated contexts at once."""
|
|
contexts = [data.get(name) for name in ('match_id', 'tryout_id', 'team_id')]
|
|
if sum(value is not None for value in contexts) > 1:
|
|
raise ValidationError(
|
|
_l('Select at most one note context.'),
|
|
field_name='context',
|
|
)
|
|
|
|
|
|
class OneOnOneRejectionSchema(StripMixin):
|
|
"""Optional explanation sent to a player when a request is rejected."""
|
|
|
|
rejection_reason = fields.String(
|
|
load_default='',
|
|
validate=validate.Length(
|
|
max=2000,
|
|
error=_l('Rejection reason must be 2000 characters or less.'),
|
|
),
|
|
)
|
|
|
|
|
|
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)
|
|
manager_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,
|
|
)
|