472 lines
13 KiB
Python
472 lines
13 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 marshmallow import Schema, fields, validate, ValidationError, pre_load, validates_schema, EXCLUDE
|
|
|
|
|
|
# =============================================================================
|
|
# 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(
|
|
'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(
|
|
'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('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('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('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 strip_strings(self, data, **kwargs):
|
|
"""Strip whitespace from all string values in the input data.
|
|
|
|
Args:
|
|
data: The input dictionary.
|
|
|
|
Returns:
|
|
dict: Data with stripped strings.
|
|
"""
|
|
if isinstance(data, dict):
|
|
return {k: v.strip() if isinstance(v, str) else v for k, v in data.items()}
|
|
return data
|
|
|
|
|
|
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='Username is required.'),
|
|
)
|
|
password = fields.String(
|
|
required=True,
|
|
validate=validate.Length(min=1, error='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='Username must be 3-80 characters.'),
|
|
validate_username,
|
|
],
|
|
)
|
|
email = fields.Email(
|
|
required=True,
|
|
validate=validate.Length(max=120, error='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='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,
|
|
)
|
|
trn_username = fields.String(
|
|
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('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='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='Full name is required.'),
|
|
)
|
|
role = fields.String(
|
|
required=True,
|
|
validate=validate.OneOf(
|
|
['admin', 'manager', 'coach', 'player', 'scout'],
|
|
error='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='Full name is required.'),
|
|
)
|
|
email = fields.Email(
|
|
required=True,
|
|
validate=validate.Length(max=120),
|
|
)
|
|
role = fields.String(
|
|
required=True,
|
|
validate=validate.OneOf(
|
|
['admin', 'manager', 'coach', 'player', 'scout'],
|
|
error='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='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='Player must be selected.'),
|
|
)
|
|
notes = fields.String(
|
|
validate=validate.Length(max=2000, error='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='Date must be in YYYY-MM-DD format.'
|
|
),
|
|
)
|
|
start_time = fields.String(
|
|
required=True,
|
|
validate=validate.Regexp(
|
|
r'^\d{2}:\d{2}$',
|
|
error='Start time must be in HH:MM format.'
|
|
),
|
|
)
|
|
end_time = fields.String(
|
|
required=True,
|
|
validate=validate.Regexp(
|
|
r'^\d{2}:\d{2}$',
|
|
error='End time must be in HH:MM format.'
|
|
),
|
|
)
|
|
points = fields.String(
|
|
validate=validate.Length(max=2000, error='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='Day must be 0 (Monday) to 6 (Sunday).'
|
|
),
|
|
)
|
|
start_time = fields.String(
|
|
required=True,
|
|
validate=validate.Regexp(
|
|
r'^\d{2}:\d{2}$',
|
|
error='Start time must be in HH:MM format.'
|
|
),
|
|
) |