remodulation du projet et des classes
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
"""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
|
||||
from app.models import USER_TYPES
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Custom Validators
|
||||
# =============================================================================
|
||||
|
||||
PASSWORD_POLICY = re.compile(
|
||||
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$'
|
||||
)
|
||||
"""Password policy: minimum 8 chars, 1 uppercase, 1 lowercase, 1 digit."""
|
||||
|
||||
|
||||
def validate_password(value):
|
||||
"""Validate password meets strength requirements.
|
||||
|
||||
Requires: minimum 8 characters, at least one uppercase letter,
|
||||
one lowercase letter, and one digit.
|
||||
|
||||
Args:
|
||||
value: The password string to validate.
|
||||
|
||||
Raises:
|
||||
ValidationError: If password does not meet requirements.
|
||||
"""
|
||||
if not PASSWORD_POLICY.match(value):
|
||||
raise ValidationError(
|
||||
'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(
|
||||
USER_TYPES,
|
||||
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(
|
||||
USER_TYPES,
|
||||
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.'
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user