SEC-16, de l'audit anterieur. La vague G a pose un schema a la frontiere de
matches, team_matches, tryouts et evaluations, et le HANDOFF en a tire une
regle : tout champ de formulaire passe par un schema de app/validators.py,
pas par request.form.get. teams.py ne l'avait jamais appliquee.
Deux defauts, pas un.
int(request.form.get('coach_id')) leve sur une valeur non numerique : une
soumission fabriquee etait un 500.
Et l'identifiant obtenu etait ensuite resolu sans verifier le role du compte,
dans deux des trois endroits qui le faisaient. La branche sync_staff
d'edit_team testait isinstance(user, Coach) ; son autre branche non, et
create_team non plus. Le meme fichier en desaccord avec lui-meme, sur
exactement le defaut que la vague G avait corrige dans tryouts.py -- une
soumission fabriquee pouvait nommer un joueur parmi les coachs d'une equipe.
L'identifiant vient d'un <select> rendu par le navigateur : c'est une valeur
que le client choisit.
_staff_member est la reponse unique, et OrgTeamSchema garantit que les
identifiants arrivent en entiers. Les deux tests qui epinglent le role
tombent si la verification saute : verifie par mutation.
Co-Authored-By: Claude Opus 5 <[email protected]>
807 lines
26 KiB
Python
807 lines
26 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'
|
|
)
|
|
|
|
|
|
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,
|
|
)
|