diff --git a/.gitignore b/.gitignore index 6332952..3098b58 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ htmlcov/ *.log .certs/ -*.pem \ No newline at end of file +*.pem + +docs/ +*.html \ No newline at end of file diff --git a/CHANGES_TODO b/CHANGES_TODO deleted file mode 100644 index 5cdfd59..0000000 --- a/CHANGES_TODO +++ /dev/null @@ -1 +0,0 @@ -1. Ajouter l'option d'enlever des joueurs dans les tryouts. \ No newline at end of file diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 15bb7d8..0000000 Binary files a/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc deleted file mode 100644 index f62c93d..0000000 Binary files a/__pycache__/app.cpython-313.pyc and /dev/null differ diff --git a/__pycache__/extensions.cpython-312.pyc b/__pycache__/extensions.cpython-312.pyc deleted file mode 100644 index 52b4558..0000000 Binary files a/__pycache__/extensions.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/extensions.cpython-313.pyc b/__pycache__/extensions.cpython-313.pyc deleted file mode 100644 index 4124b08..0000000 Binary files a/__pycache__/extensions.cpython-313.pyc and /dev/null differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc deleted file mode 100644 index cea274c..0000000 Binary files a/__pycache__/models.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 42c0d3c..0000000 Binary files a/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/__pycache__/seed.cpython-312.pyc b/__pycache__/seed.cpython-312.pyc deleted file mode 100644 index f1b1a9f..0000000 Binary files a/__pycache__/seed.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/seed.cpython-313.pyc b/__pycache__/seed.cpython-313.pyc deleted file mode 100644 index cbc4531..0000000 Binary files a/__pycache__/seed.cpython-313.pyc and /dev/null differ diff --git a/app.py b/app/app.py similarity index 92% rename from app.py rename to app/app.py index 7571d95..24ace0e 100644 --- a/app.py +++ b/app/app.py @@ -7,7 +7,7 @@ the Flask application instance with comprehensive security hardening. import os from flask import Flask, request, redirect, jsonify, render_template, url_for from flask_cors import CORS -from extensions import db, login_manager, csrf, hash_password, check_password, limiter +from app.extensions import db, login_manager, csrf, hash_password, check_password, limiter from sqlalchemy import text from werkzeug.exceptions import HTTPException import markupsafe @@ -96,17 +96,17 @@ def create_app(): limiter.init_app(app) # Configure structured logging - from logging_config import configure_logging + from app.logging_config import configure_logging configure_logging(app) - from routes.auth import auth_bp - from routes.tryouts import tryouts_bp - from routes.evaluations import evaluations_bp - from routes.users import users_bp - from routes.main import main_bp - from routes.teams import teams_bp - from routes.matches import matches_bp - from routes.team_matches import team_matches_bp + from app.routes.auth import auth_bp + from app.routes.tryouts import tryouts_bp + from app.routes.evaluations import evaluations_bp + from app.routes.users import users_bp + from app.routes.main import main_bp + from app.routes.teams import teams_bp + from app.routes.matches import matches_bp + from app.routes.team_matches import team_matches_bp app.register_blueprint(auth_bp) app.register_blueprint(tryouts_bp) @@ -342,26 +342,18 @@ def create_app(): # Database Initialization # ========================================================================= with app.app_context(): - import models - from models import User - try: - # Check if the database schema is up to date by testing a query - db.session.execute(text('SELECT games, team_side FROM match_participants LIMIT 1')) - db.create_all() - except Exception: - # If there's a schema mismatch, drop and recreate all tables - db.session.rollback() - db.drop_all() - db.create_all() + import app.models as models # noqa: F401 — registers all models with SQLAlchemy + from app.models import User + db.create_all() # Seed database if empty if User.query.count() == 0: - from seed import seed_database + from app.supporting_scrits.seed import seed_database seed_database() # Start the Discord bot for notifications try: - from discord_bot import start_bot + from app.discord_bot import start_bot start_bot() except Exception as e: app.logger.warning('Could not start Discord bot: %s', e) diff --git a/discord_bot.py b/app/discord_bot.py similarity index 98% rename from discord_bot.py rename to app/discord_bot.py index 6099659..964960a 100644 --- a/discord_bot.py +++ b/app/discord_bot.py @@ -188,7 +188,7 @@ class TeamTryoutsBot(commands.Bot): """ try: # Look up the DB user to get their Discord user ID - from models import User as DBUser + from app.models.models import User as DBUser db_user = DBUser.query.get(user_id) if not db_user: logger.warning(f"DB user {user_id} not found for schedule notification") @@ -234,7 +234,7 @@ class TeamTryoutsBot(commands.Bot): async def handle_one_on_one_approve(self, coach, message_id, request_id, original_message): """Handle coach approving a One on One request.""" try: - from models import OneOnOneRequest, db + from app.models.models import OneOnOneRequest, db from sqlalchemy.orm import joinedload request = OneOnOneRequest.query.options( @@ -278,7 +278,7 @@ class TeamTryoutsBot(commands.Bot): async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message): """Handle coach rejecting a One on One request.""" try: - from models import OneOnOneRequest, db + from app.models.models import OneOnOneRequest, db from sqlalchemy.orm import joinedload request = OneOnOneRequest.query.options( @@ -336,7 +336,7 @@ class TeamTryoutsBot(commands.Bot): async def handle_attendance_confirm(self, player, message_id, reference_id, original_message): """Handle player confirming attendance for a match/tryout.""" try: - from models import MatchParticipant, TryoutRegistration, Match, Tryout, db + from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db request_info = self.pending_requests[message_id] event_type = request_info.get('event_type') @@ -361,7 +361,7 @@ class TeamTryoutsBot(commands.Bot): async def handle_attendance_decline(self, player, message_id, reference_id, original_message): """Handle player declining attendance for a match/tryout.""" try: - from models import MatchParticipant, TryoutRegistration, Match, Tryout, db + from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db request_info = self.pending_requests[message_id] event_type = request_info.get('event_type') @@ -471,7 +471,7 @@ class TeamTryoutsBot(commands.Bot): async def send_daily_reminders(self): """Send daily reminders at 18:00 EDT for events in 24-48 hours.""" try: - from models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db + from app.models.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db from sqlalchemy.orm import joinedload now = datetime.now(self.timezone) diff --git a/extensions.py b/app/extensions.py similarity index 100% rename from extensions.py rename to app/extensions.py diff --git a/logging_config.py b/app/logging_config.py similarity index 100% rename from logging_config.py rename to app/logging_config.py diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e82c7e9 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,95 @@ +"""All models — split into individual files for maintainability. + +Import this module to register all models with SQLAlchemy and expose every +class, constant, and helper for use throughout the application. + +Usage:: + + from app.models import User, Admin, Evaluation, ESPORT_GAMES, ... + +Backward-compatible — no consumer changes needed. +""" + +# ========================================================================= +# Layer 0: constants (no app deps) +# ========================================================================= +from app.models._constants import ( + USER_TYPES, + ESPORT_GAMES, + GAME_POSITIONS, + GAME_PLATFORMS, + PLATFORM_CODES, + TRN_URLS, +) + +# ========================================================================= +# Layer 1: loaders & associations +# ========================================================================= +from app.models._loaders import load_user # noqa: F401 — registers Flask-Login callback + +# ========================================================================= +# Layer 2: abstract base classes +# ========================================================================= +from app.models.availability.base import BaseAvailability +from app.models.match_model.base import BaseMatch +from app.models.participant.base import BaseParticipant + +# ========================================================================= +# Layer 3: user hierarchy (polymorphic) +# ========================================================================= +from app.models.user_model.user import User +from app.models.user_model.admin import Admin +from app.models.user_model.manager import Manager +from app.models.user_model.coach import Coach +from app.models.user_model.player import Player +from app.models.user_model.scout import Scout + +# ========================================================================= +# Layer 4: org_team + junction +# ========================================================================= +from app.models.org_team.org_team import OrgTeam +from app.models.org_team.team_player import TeamPlayer + +# ========================================================================= +# Layer 5: concrete availability models +# ========================================================================= +from app.models.availability.player_disponibility import PlayerDisponibility +from app.models.availability.coach_availability import CoachAvailability + +# ========================================================================= +# Layer 6: tryout + registration +# ========================================================================= +from app.models.tryout.tryout import Tryout +from app.models.tryout.tryout_registration import TryoutRegistration + +# ========================================================================= +# Layer 7: evaluation +# ========================================================================= +from app.models.evaluation import Evaluation + +# ========================================================================= +# Layer 8: tryout-specific teams +# ========================================================================= +from app.models.team.team import Team +from app.models.team.team_member import TeamMember + +# ========================================================================= +# Layer 9: matches (tryout-scoped + regular-season) +# ========================================================================= +from app.models.match_model.match import Match +from app.models.match_model.team_match import TeamMatch + +# ========================================================================= +# Layer 10: participants +# ========================================================================= +from app.models.participant.match_participant import MatchParticipant +from app.models.participant.team_match_participant import TeamMatchParticipant + +# ========================================================================= +# Layer 11: remaining standalone models +# ========================================================================= +from app.models.user_gamertag import UserGamertag +from app.models.contract import Contract +from app.models.team_note import TeamNote +from app.models.personal_note import PersonalNote +from app.models.one_on_one_request import OneOnOneRequest \ No newline at end of file diff --git a/app/models/_associations.py b/app/models/_associations.py new file mode 100644 index 0000000..c0d30be --- /dev/null +++ b/app/models/_associations.py @@ -0,0 +1,18 @@ +"""Many-to-many association tables for OrgTeam ↔ User relationships.""" + +from app.extensions import db + + +org_team_coaches = db.Table('org_team_coaches', + db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), + primary_key=True), + db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), + primary_key=True), +) + +org_team_managers = db.Table('org_team_managers', + db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), + primary_key=True), + db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), + primary_key=True), +) \ No newline at end of file diff --git a/app/models/_constants.py b/app/models/_constants.py new file mode 100644 index 0000000..ca26638 --- /dev/null +++ b/app/models/_constants.py @@ -0,0 +1,64 @@ +"""Global constants shared by all model files. + +Contains game lists, position mappings, platform codes, and TRN URL templates. +""" + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout'] + +ESPORT_GAMES = [ + 'Valorant', + 'League of Legends', + 'Counter-Strike 2', + 'Apex Legends', + 'Overwatch 2', + 'Rainbow Six Siege', + 'Rocket League', + 'Super Smash Bros.', +] + +GAME_POSITIONS = { + 'League of Legends': ['Top Lane', 'Jungle', 'Mid Lane', 'ADC', 'Support'], + 'Valorant': ['Controller', 'Initiator', 'Duelist', 'Sentinel', 'Flex'], + 'Counter-Strike 2': ['AWPer', 'Entry Fragger', 'Lurker', 'In-Game Leader', 'Support'], + 'Rainbow Six Siege': ['Entry', 'Support', 'Breacher', 'Anchor', 'Flex'], + 'Overwatch 2': ['Tank', 'Damage', 'Support'], + 'Apex Legends': [], + 'Rocket League': [], + 'Super Smash Bros.': [], +} + +GAME_PLATFORMS = { + 'Valorant': [], + 'League of Legends': [], + 'Counter-Strike 2': [], + 'Apex Legends': ['PC', 'PlayStation', 'Xbox', 'Nintendo Switch'], + 'Overwatch 2': [], + 'Rainbow Six Siege': ['Ubisoft', 'PlayStation', 'Xbox'], + 'Rocket League': ['Epic', 'PlayStation', 'Xbox', 'Nintendo Switch'], + 'Super Smash Bros.': ['Nintendo Switch'], +} + +PLATFORM_CODES = { + 'Ubisoft': 'ubi', + 'PlayStation': 'psn', + 'Xbox': 'xbl', + 'Nintendo Switch': 'switch', + 'PC': 'pc', + 'Steam': 'steam', + 'Epic': 'epic', +} + +TRN_URLS = { + 'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}', + 'League of Legends': 'https://tracker.gg/lol/profile/{username}', + 'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}', + 'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}', + 'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}', + 'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}', + 'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}', + 'Super Smash Bros.': 'https://tracker.gg/smash/profile/{username}', +} \ No newline at end of file diff --git a/app/models/_loaders.py b/app/models/_loaders.py new file mode 100644 index 0000000..0d71338 --- /dev/null +++ b/app/models/_loaders.py @@ -0,0 +1,14 @@ +"""Flask-Login user loader — registered with login_manager in models.py.""" + +from app.extensions import login_manager + + +@login_manager.user_loader +def load_user(user_id): + """Load a user by ID for Flask-Login session management. + + Returns the correct polymorphic subclass (Admin, Coach, Player, etc.) + automatically because SQLAlchemy resolves the identity column. + """ + from app.models.user_model.user import User + return User.query.get(int(user_id)) \ No newline at end of file diff --git a/app/models/availability/__init__.py b/app/models/availability/__init__.py new file mode 100644 index 0000000..c4c2f7a --- /dev/null +++ b/app/models/availability/__init__.py @@ -0,0 +1,7 @@ +"""Availability models — BaseAvailability and its concrete subclasses.""" + +from app.models.availability.base import BaseAvailability +from app.models.availability.player_disponibility import PlayerDisponibility +from app.models.availability.coach_availability import CoachAvailability + +__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability'] \ No newline at end of file diff --git a/app/models/availability/base.py b/app/models/availability/base.py new file mode 100644 index 0000000..fb1dd06 --- /dev/null +++ b/app/models/availability/base.py @@ -0,0 +1,14 @@ +"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" +from app.extensions import db +from datetime import datetime + + +class BaseAvailability(db.Model): + """Shared schema for player disponibilities and coach availabilities.""" + __abstract__ = True + + day_of_week = db.Column(db.Integer, nullable=False) + start_time = db.Column(db.Time, nullable=False) + end_time = db.Column(db.Time, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) \ No newline at end of file diff --git a/app/models/availability/coach_availability.py b/app/models/availability/coach_availability.py new file mode 100644 index 0000000..deafc35 --- /dev/null +++ b/app/models/availability/coach_availability.py @@ -0,0 +1,12 @@ +"""Coach availability in 30-minute time blocks for One on One sessions.""" +from app.extensions import db +from app.models.availability.base import BaseAvailability + + +class CoachAvailability(BaseAvailability): + """Coach availability in 30-minute blocks for One on One sessions.""" + __tablename__ = 'coach_availabilities' + id = db.Column(db.Integer, primary_key=True) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + coach = db.relationship('User', backref='coach_availabilities') \ No newline at end of file diff --git a/app/models/availability/player_disponibility.py b/app/models/availability/player_disponibility.py new file mode 100644 index 0000000..7d51dad --- /dev/null +++ b/app/models/availability/player_disponibility.py @@ -0,0 +1,12 @@ +"""Player availability in 30-minute time blocks.""" +from app.extensions import db +from app.models.availability.base import BaseAvailability + + +class PlayerDisponibility(BaseAvailability): + """Player availability in 30-minute blocks.""" + __tablename__ = 'player_disponibilities' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + player = db.relationship('User', backref='disponibilities') \ No newline at end of file diff --git a/app/models/contract.py b/app/models/contract.py new file mode 100644 index 0000000..8ce40cf --- /dev/null +++ b/app/models/contract.py @@ -0,0 +1,51 @@ +"""Contract documents for players to sign.""" +from app.extensions import db +from datetime import datetime + + +class Contract(db.Model): + """Contract documents for players to sign.""" + __tablename__ = 'contracts' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) + uploaded_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + original_filename = db.Column(db.String(255), nullable=False) + stored_filename = db.Column(db.String(255), nullable=False) + file_path = db.Column(db.String(500), nullable=False) + + signed_filename = db.Column(db.String(255), nullable=True) + signed_file_path = db.Column(db.String(500), nullable=True) + + status = db.Column(db.String(20), default='pending') + notes = db.Column(db.Text, nullable=True) + uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + signed_at = db.Column(db.DateTime, nullable=True) + + player = db.relationship('User', foreign_keys=[player_id], backref='contracts') + team = db.relationship('OrgTeam', foreign_keys=[team_id]) + uploader = db.relationship('User', foreign_keys=[uploaded_by_id]) + + def can_view(self, user): + if user.id == self.player_id: + return True + from app.models.user_model.admin import Admin + from app.models.user_model.manager import Manager + from app.models.user_model.coach import Coach + from app.models.user_model.user import User + from app.models.org_team.org_team import OrgTeam + if isinstance(user, Admin): + return True + if isinstance(user, Manager): + player = User.query.get(self.player_id) + if player and player.get_org_teams(): + return True + if isinstance(user, Coach): + org_team = OrgTeam.query.filter_by(coach_id=user.id).first() + if org_team and (not self.team_id or self.team_id == org_team.id): + return True + return False + + def can_upload_signed(self, user): + return user.id == self.player_id \ No newline at end of file diff --git a/app/models/evaluation.py b/app/models/evaluation.py new file mode 100644 index 0000000..8f5ec5b --- /dev/null +++ b/app/models/evaluation.py @@ -0,0 +1,30 @@ +"""Player evaluation record.""" +from app.extensions import db +from datetime import datetime + + +class Evaluation(db.Model): + """Player evaluation record.""" + __tablename__ = 'evaluations' + id = db.Column(db.Integer, primary_key=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + evaluator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + mecanics_score = db.Column(db.Integer, nullable=True) + cohesion_score = db.Column(db.Integer, nullable=True) + communication_score = db.Column(db.Integer, nullable=True) + gamesense_score = db.Column(db.Integer, nullable=True) + versatility_score = db.Column(db.Integer, nullable=True) + discipline_score = db.Column(db.Integer, nullable=True) + analysis_score = db.Column(db.Integer, nullable=True) + sport_ethics_score = db.Column(db.Integer, nullable=True) + mental_score = db.Column(db.Integer, nullable=True) + overall_score = db.Column(db.Float, nullable=True) + comments = db.Column(db.Text, nullable=True) + position_recommendation = db.Column(db.String(50), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + __table_args__ = ( + db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), + ) \ No newline at end of file diff --git a/app/models/match_model/__init__.py b/app/models/match_model/__init__.py new file mode 100644 index 0000000..1608108 --- /dev/null +++ b/app/models/match_model/__init__.py @@ -0,0 +1,6 @@ +"""Match models — BaseMatch and its concrete subclasses.""" +from app.models.match_model.base import BaseMatch +from app.models.match_model.match import Match +from app.models.match_model.team_match import TeamMatch + +__all__ = ['BaseMatch', 'Match', 'TeamMatch'] \ No newline at end of file diff --git a/app/models/match_model/base.py b/app/models/match_model/base.py new file mode 100644 index 0000000..48ad8df --- /dev/null +++ b/app/models/match_model/base.py @@ -0,0 +1,18 @@ +"""Abstract base class for match models (Match + TeamMatch).""" +from app.extensions import db +from datetime import datetime + + +class BaseMatch(db.Model): + """Shared schema for tryout-scoped matches and regular-season team matches.""" + __abstract__ = True + + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + date = db.Column(db.Date, nullable=False) + start_time = db.Column(db.Time, nullable=True) + end_time = db.Column(db.Time, nullable=True) + location = db.Column(db.String(200), nullable=True) + status = db.Column(db.String(20), default='scheduled') + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/app/models/match_model/match.py b/app/models/match_model/match.py new file mode 100644 index 0000000..caa22e1 --- /dev/null +++ b/app/models/match_model/match.py @@ -0,0 +1,22 @@ +"""Match / scrimmage within a tryout.""" +from app.extensions import db +from app.models.match_model.base import BaseMatch + + +class Match(BaseMatch): + """Match / scrimmage within a tryout.""" + __tablename__ = 'matches' + id = db.Column(db.Integer, primary_key=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) + match_type = db.Column(db.String(20), nullable=False) + team1_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) + team2_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) + + creator = db.relationship('User', backref='created_matches') + tryout = db.relationship('Tryout', backref='matches') + team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1') + team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2') + participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic') + + def get_participating_players(self): + return [p.player_id for p in self.participants.all()] \ No newline at end of file diff --git a/app/models/match_model/team_match.py b/app/models/match_model/team_match.py new file mode 100644 index 0000000..cc36947 --- /dev/null +++ b/app/models/match_model/team_match.py @@ -0,0 +1,22 @@ +"""Regular-season match for an organisation team (not tied to a tryout).""" +from app.extensions import db +from app.models.match_model.base import BaseMatch + + +class TeamMatch(BaseMatch): + """Regular-season match for an organisation team (not tied to a tryout).""" + __tablename__ = 'team_matches' + id = db.Column(db.Integer, primary_key=True) + org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) + opponent = db.Column(db.String(200), nullable=True) + + org_team = db.relationship('OrgTeam', backref='team_matches') + creator = db.relationship('User', backref='created_team_matches') + participants = db.relationship( + 'TeamMatchParticipant', backref='team_match', lazy='dynamic', + cascade='all, delete-orphan') + + def get_confirmed_count(self): + all_p = self.participants.all() + confirmed = sum(1 for p in all_p if p.is_confirmed) + return confirmed, len(all_p) \ No newline at end of file diff --git a/app/models/one_on_one_request.py b/app/models/one_on_one_request.py new file mode 100644 index 0000000..1d264ae --- /dev/null +++ b/app/models/one_on_one_request.py @@ -0,0 +1,25 @@ +"""Request from player to coach for a One on One session.""" +from app.extensions import db +from datetime import datetime + + +class OneOnOneRequest(db.Model): + """Request from player to coach for a One on One session.""" + __tablename__ = 'one_on_one_requests' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) + date = db.Column(db.Date, nullable=False) + start_time = db.Column(db.Time, nullable=False) + end_time = db.Column(db.Time, nullable=False) + points = db.Column(db.Text, nullable=True) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + responded_at = db.Column(db.DateTime, nullable=True) + discord_message_id = db.Column(db.BigInteger, nullable=True) + coach_rejection_message = db.Column(db.Text, nullable=True) + + player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests') + coach = db.relationship('User', foreign_keys=[coach_id]) + team = db.relationship('OrgTeam', foreign_keys=[org_team_id]) \ No newline at end of file diff --git a/app/models/org_team/__init__.py b/app/models/org_team/__init__.py new file mode 100644 index 0000000..a5f2da9 --- /dev/null +++ b/app/models/org_team/__init__.py @@ -0,0 +1,5 @@ +"""Organisation team models.""" +from app.models.org_team.org_team import OrgTeam +from app.models.org_team.team_player import TeamPlayer + +__all__ = ['OrgTeam', 'TeamPlayer'] \ No newline at end of file diff --git a/app/models/org_team/org_team.py b/app/models/org_team/org_team.py new file mode 100644 index 0000000..8cb7a15 --- /dev/null +++ b/app/models/org_team/org_team.py @@ -0,0 +1,53 @@ +"""Persistent organisation team (e.g. Varsity, JV).""" +from app.extensions import db +from app.models._associations import org_team_coaches, org_team_managers +from datetime import datetime + + +class OrgTeam(db.Model): + """Persistent organisation team (e.g. Varsity, JV).""" + __tablename__ = 'org_teams' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + + creator = db.relationship('User', foreign_keys=[created_by]) + coaches = db.relationship( + 'User', secondary=org_team_coaches, lazy='dynamic', + backref=db.backref('coached_org_teams', lazy='dynamic')) + managers = db.relationship( + 'User', secondary=org_team_managers, lazy='dynamic', + backref=db.backref('managed_org_teams', lazy='dynamic')) + + coach = db.relationship( + 'User', foreign_keys=[coach_id], + backref=db.backref('coached_org_team_legacy', uselist=False), + viewonly=True) + manager = db.relationship( + 'User', foreign_keys=[manager_id], + backref=db.backref('managed_org_team_legacy', uselist=False), + viewonly=True) + + def get_coaches(self): + coach_list = self.coaches.all() + if not coach_list and self.coach: + return [self.coach] + return coach_list + + def get_managers(self): + manager_list = self.managers.all() + if not manager_list and self.manager: + return [self.manager] + return manager_list + + @property + def players(self): + return [tp.player for tp in self.team_players] + + def get_players_with_status(self): + return [{'player': tp.player, 'status': tp.status, + 'position': tp.position} for tp in self.team_players] \ No newline at end of file diff --git a/app/models/org_team/team_player.py b/app/models/org_team/team_player.py new file mode 100644 index 0000000..0b4a720 --- /dev/null +++ b/app/models/org_team/team_player.py @@ -0,0 +1,21 @@ +"""Many-to-many junction: player to org-team.""" +from app.extensions import db +from datetime import datetime + + +class TeamPlayer(db.Model): + """Many-to-many: player to org-team.""" + __tablename__ = 'team_players' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) + status = db.Column(db.String(20), nullable=False, default='starter') + position = db.Column(db.String(50), nullable=True) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + + player = db.relationship('User', foreign_keys=[player_id], backref='team_placements') + org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players') + + __table_args__ = ( + db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'), + ) \ No newline at end of file diff --git a/app/models/participant/__init__.py b/app/models/participant/__init__.py new file mode 100644 index 0000000..a282cb7 --- /dev/null +++ b/app/models/participant/__init__.py @@ -0,0 +1,6 @@ +"""Participant models — BaseParticipant and its concrete subclasses.""" +from app.models.participant.base import BaseParticipant +from app.models.participant.match_participant import MatchParticipant +from app.models.participant.team_match_participant import TeamMatchParticipant + +__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant'] \ No newline at end of file diff --git a/app/models/participant/base.py b/app/models/participant/base.py new file mode 100644 index 0000000..ff1dddc --- /dev/null +++ b/app/models/participant/base.py @@ -0,0 +1,11 @@ +"""Abstract base class for match participant models.""" +from app.extensions import db +from datetime import datetime + + +class BaseParticipant(db.Model): + """Shared schema for match participants.""" + __abstract__ = True + + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + added_at = db.Column(db.DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/app/models/participant/match_participant.py b/app/models/participant/match_participant.py new file mode 100644 index 0000000..6209e78 --- /dev/null +++ b/app/models/participant/match_participant.py @@ -0,0 +1,15 @@ +"""Participant in a tryout-scoped match.""" +from app.extensions import db +from app.models.participant.base import BaseParticipant + + +class MatchParticipant(BaseParticipant): + """Participant in a tryout-scoped match.""" + __tablename__ = 'match_participants' + id = db.Column(db.Integer, primary_key=True) + match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False) + team_side = db.Column(db.Integer, nullable=True) + position = db.Column(db.String(50), nullable=True) + attendance_confirmed = db.Column(db.Boolean, default=False) + + player = db.relationship('User') \ No newline at end of file diff --git a/app/models/participant/team_match_participant.py b/app/models/participant/team_match_participant.py new file mode 100644 index 0000000..a57c61c --- /dev/null +++ b/app/models/participant/team_match_participant.py @@ -0,0 +1,13 @@ +"""Participant in a regular-season team match.""" +from app.extensions import db +from app.models.participant.base import BaseParticipant + + +class TeamMatchParticipant(BaseParticipant): + """Participant in a regular-season team match.""" + __tablename__ = 'team_match_participants' + id = db.Column(db.Integer, primary_key=True) + team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False) + is_confirmed = db.Column(db.Boolean, default=False) + + player = db.relationship('User') \ No newline at end of file diff --git a/app/models/personal_note.py b/app/models/personal_note.py new file mode 100644 index 0000000..c12318c --- /dev/null +++ b/app/models/personal_note.py @@ -0,0 +1,24 @@ +"""Personal notes from coach to individual player.""" +from app.extensions import db +from datetime import datetime + + +class PersonalNote(db.Model): + """Personal notes from coach to individual player.""" + __tablename__ = 'personal_notes' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + content = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True) + team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=True) + + player = db.relationship('User', foreign_keys=[player_id], backref='personal_notes') + coach = db.relationship('User', foreign_keys=[coach_id]) + match = db.relationship('Match', foreign_keys=[match_id]) + team = db.relationship('Team', foreign_keys=[team_id]) + tryout = db.relationship('Tryout', foreign_keys=[tryout_id]) \ No newline at end of file diff --git a/app/models/team/__init__.py b/app/models/team/__init__.py new file mode 100644 index 0000000..1808aa2 --- /dev/null +++ b/app/models/team/__init__.py @@ -0,0 +1,5 @@ +"""Tryout-specific temporary team models.""" +from app.models.team.team import Team +from app.models.team.team_member import TeamMember + +__all__ = ['Team', 'TeamMember'] \ No newline at end of file diff --git a/app/models/team/team.py b/app/models/team/team.py new file mode 100644 index 0000000..c692eba --- /dev/null +++ b/app/models/team/team.py @@ -0,0 +1,16 @@ +"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" +from app.extensions import db +from datetime import datetime + + +class Team(db.Model): + """Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" + __tablename__ = 'teams' + id = db.Column(db.Integer, primary_key=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) + name = db.Column(db.String(100), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + creator = db.relationship('User', backref='created_teams') + members = db.relationship('TeamMember', backref='team', lazy='dynamic') \ No newline at end of file diff --git a/app/models/team/team_member.py b/app/models/team/team_member.py new file mode 100644 index 0000000..e84b231 --- /dev/null +++ b/app/models/team/team_member.py @@ -0,0 +1,15 @@ +"""Link between a player and a tryout-specific team.""" +from app.extensions import db +from datetime import datetime + + +class TeamMember(db.Model): + """Link between a player and a tryout-specific team.""" + __tablename__ = 'team_members' + id = db.Column(db.Integer, primary_key=True) + team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + position = db.Column(db.String(50), nullable=True) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + + player = db.relationship('User', overlaps="player_ref,team_assignments") \ No newline at end of file diff --git a/app/models/team_note.py b/app/models/team_note.py new file mode 100644 index 0000000..2de61f5 --- /dev/null +++ b/app/models/team_note.py @@ -0,0 +1,17 @@ +"""Team improvement notes from coach.""" +from app.extensions import db +from datetime import datetime + + +class TeamNote(db.Model): + """Team improvement notes from coach.""" + __tablename__ = 'team_notes' + id = db.Column(db.Integer, primary_key=True) + org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + content = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + team = db.relationship('OrgTeam', backref='team_notes') + coach = db.relationship('User', foreign_keys=[coach_id]) \ No newline at end of file diff --git a/app/models/tryout/__init__.py b/app/models/tryout/__init__.py new file mode 100644 index 0000000..5765899 --- /dev/null +++ b/app/models/tryout/__init__.py @@ -0,0 +1,5 @@ +"""Tryout models.""" +from app.models.tryout.tryout import Tryout +from app.models.tryout.tryout_registration import TryoutRegistration + +__all__ = ['Tryout', 'TryoutRegistration'] \ No newline at end of file diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py new file mode 100644 index 0000000..752ae81 --- /dev/null +++ b/app/models/tryout/tryout.py @@ -0,0 +1,29 @@ +"""Tryout event for player evaluations and team formation.""" +from app.extensions import db +from datetime import datetime + + +class Tryout(db.Model): + """Tryout event for player evaluations and team formation.""" + __tablename__ = 'tryouts' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + game = db.Column(db.String(50), nullable=False) + date = db.Column(db.Date, nullable=False) + location = db.Column(db.String(200), nullable=True) + status = db.Column(db.String(20), default='upcoming') + max_players = db.Column(db.Integer, nullable=True) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) + manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') + manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') + coach = db.relationship('User', foreign_keys=[coach_id], backref='coached_tryouts') + registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic') + evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic') + teams = db.relationship('Team', backref='tryout', lazy='dynamic') + target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]) \ No newline at end of file diff --git a/app/models/tryout/tryout_registration.py b/app/models/tryout/tryout_registration.py new file mode 100644 index 0000000..8016ad7 --- /dev/null +++ b/app/models/tryout/tryout_registration.py @@ -0,0 +1,14 @@ +"""Registration linking a player to a tryout.""" +from app.extensions import db +from datetime import datetime + + +class TryoutRegistration(db.Model): + """Registration linking a player to a tryout.""" + __tablename__ = 'tryout_registrations' + id = db.Column(db.Integer, primary_key=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + registered_at = db.Column(db.DateTime, default=datetime.utcnow) + status = db.Column(db.String(20), default='registered') + notes = db.Column(db.Text, nullable=True) \ No newline at end of file diff --git a/app/models/user_gamertag.py b/app/models/user_gamertag.py new file mode 100644 index 0000000..aa85eec --- /dev/null +++ b/app/models/user_gamertag.py @@ -0,0 +1,40 @@ +"""Store gamertag per game for each user.""" +from app.extensions import db +from app.models._constants import TRN_URLS, PLATFORM_CODES +from urllib.parse import quote + + +class UserGamertag(db.Model): + """Store gamertag per game for each user.""" + __tablename__ = 'user_gamertags' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + game = db.Column(db.String(50), nullable=False) + gamertag = db.Column(db.String(120), nullable=False) + platform = db.Column(db.String(30), nullable=True) + + user = db.relationship('User', backref='gamertags') + + __table_args__ = ( + db.UniqueConstraint('user_id', 'game', name='unique_user_game'), + ) + + def get_trn_url(self): + if self.game not in TRN_URLS: + return None + url = TRN_URLS[self.game] + encoded_gamertag = quote(self.gamertag, safe='') + if '{platform_code}' in url and '{username}' in url: + platform_code = PLATFORM_CODES.get( + self.platform, + self.platform.lower().replace(' ', '-') if self.platform else '', + ) + return url.format(platform_code=platform_code, username=encoded_gamertag) + elif '{platform}' in url and '{username}' in url: + return url.format( + platform=self.platform.lower().replace(' ', '-'), + username=encoded_gamertag, + ) + elif '{username}' in url: + return url.format(username=encoded_gamertag) + return url \ No newline at end of file diff --git a/app/models/user_model/__init__.py b/app/models/user_model/__init__.py new file mode 100644 index 0000000..05166d5 --- /dev/null +++ b/app/models/user_model/__init__.py @@ -0,0 +1,9 @@ +"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout).""" +from app.models.user_model.user import User +from app.models.user_model.admin import Admin +from app.models.user_model.manager import Manager +from app.models.user_model.coach import Coach +from app.models.user_model.player import Player +from app.models.user_model.scout import Scout + +__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout'] diff --git a/app/models/user_model/admin.py b/app/models/user_model/admin.py new file mode 100644 index 0000000..3d0d1e2 --- /dev/null +++ b/app/models/user_model/admin.py @@ -0,0 +1,32 @@ +"""Admin / President — full access to everything.""" +from app.models.user_model.user import User + + +class Admin(User): + """President / super-admin — full access to everything.""" + __mapper_args__ = {'polymorphic_identity': 'admin'} + + def can_evaluate(self): + return True + + def can_manage_users(self): + return True + + def can_manage_teams(self): + return True + + def can_manage_tryouts(self): + return True + + def can_schedule_matches(self): + return True + + def can_manage_this_tryout(self, tryout): + return True + + def can_manage_this_org_team(self, org_team): + return True + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + return Tryout.query.order_by(Tryout.date).all() diff --git a/app/models/user_model/coach.py b/app/models/user_model/coach.py new file mode 100644 index 0000000..ccd9aa0 --- /dev/null +++ b/app/models/user_model/coach.py @@ -0,0 +1,45 @@ +"""Coach — evaluates, schedules matches, manages their own org team.""" +from app.models.user_model.user import User + + +class Coach(User): + """Coach — evaluates, schedules matches, manages their own org team.""" + __mapper_args__ = {'polymorphic_identity': 'coach'} + + def can_evaluate(self): + return True + + def can_schedule_matches(self): + return True + + def can_manage_tryouts(self): + return True + + def can_manage_this_tryout(self, tryout): + from app.models.org_team.org_team import OrgTeam + if tryout.target_org_team_id: + is_coach_of_target = OrgTeam.query.filter( + OrgTeam.id == tryout.target_org_team_id, + OrgTeam.coaches.any(id=self.id), + ).first() is not None + if is_coach_of_target: + return True + if tryout.coach_id == self.id: + return True + return False + + def can_manage_this_org_team(self, org_team): + if org_team.coaches.filter_by(id=self.id).first(): + return True + if org_team.coach_id == self.id: + return True + return False + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + team_ids = [t.id for t in self.coached_org_teams.all()] + if not team_ids: + return Tryout.query.filter(Tryout.id == -1).all() # empty + return Tryout.query.filter( + Tryout.target_org_team_id.in_(team_ids) + ).order_by(Tryout.date).all() diff --git a/app/models/user_model/manager.py b/app/models/user_model/manager.py new file mode 100644 index 0000000..05944d3 --- /dev/null +++ b/app/models/user_model/manager.py @@ -0,0 +1,29 @@ +"""Manager — manages own tryouts, all org teams, all contracts.""" +from app.models.user_model.user import User + + +class Manager(User): + """Manager — manages own tryouts, all org teams, all contracts.""" + __mapper_args__ = {'polymorphic_identity': 'manager'} + + def can_evaluate(self): + return True + + def can_manage_teams(self): + return True + + def can_manage_tryouts(self): + return True + + def can_schedule_matches(self): + return True + + def can_manage_this_tryout(self, tryout): + return tryout.created_by == self.id or tryout.manager_id == self.id + + def can_manage_this_org_team(self, org_team): + return True + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + return Tryout.query.filter_by(created_by=self.id).order_by(Tryout.date).all() diff --git a/app/models/user_model/player.py b/app/models/user_model/player.py new file mode 100644 index 0000000..f53a2ee --- /dev/null +++ b/app/models/user_model/player.py @@ -0,0 +1,30 @@ +"""Player — registers for tryouts, manages their own profile.""" +from app.models.user_model.user import User + + +class Player(User): + """Player — registers for tryouts, manages their own profile.""" + __mapper_args__ = {'polymorphic_identity': 'player'} + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + from app.models.match_model.match import Match + from app.models.participant.match_participant import MatchParticipant + + # tryouts they registered for + player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()] + tryouts = Tryout.query.filter( + Tryout.id.in_(player_tryout_ids) + ).order_by(Tryout.date).all() if player_tryout_ids else [] + + # plus tryouts where they participate in a match + player_matches = Match.query.join(MatchParticipant).filter( + MatchParticipant.player_id == self.id, + ).all() + extra_ids = set(m.tryout_id for m in player_matches) + extra = Tryout.query.filter( + Tryout.id.in_(extra_ids), + ).order_by(Tryout.date).all() if extra_ids else [] + + all_ids = {t.id for t in tryouts} + return tryouts + [t for t in extra if t.id not in all_ids] diff --git a/app/models/user_model/scout.py b/app/models/user_model/scout.py new file mode 100644 index 0000000..5757160 --- /dev/null +++ b/app/models/user_model/scout.py @@ -0,0 +1,14 @@ +"""Scout — view-only access to tryouts and evaluations.""" +from app.models.user_model.user import User + + +class Scout(User): + """Scout — view-only access to tryouts and evaluations.""" + __mapper_args__ = {'polymorphic_identity': 'scout'} + + def can_evaluate(self): + return True + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + return Tryout.query.order_by(Tryout.date).all() diff --git a/app/models/user_model/user.py b/app/models/user_model/user.py new file mode 100644 index 0000000..4a50676 --- /dev/null +++ b/app/models/user_model/user.py @@ -0,0 +1,93 @@ +"""Base User model — shared fields and polymorphic configuration.""" +from app.extensions import db +from flask_login import UserMixin +from datetime import datetime + + +class User(UserMixin, db.Model): + """Base user model — shared fields for every role. + + Do not instantiate this class directly; use Admin, Manager, Coach, Player, + or Scout so that `polymorphic_identity` is set correctly. + """ + __tablename__ = 'users' + + # --- columns ----------------------------------------------------------- + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password_hash = db.Column(db.String(128), nullable=False) + role = db.Column(db.String(20), nullable=False, default='player') # polymorphic discriminator + full_name = db.Column(db.String(100), nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + phone = db.Column(db.String(20), nullable=True) + is_active_account = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + failed_login_attempts = db.Column(db.Integer, default=0) + locked_until = db.Column(db.DateTime, nullable=True) + + # E-Sports fields + games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player) + discord_username = db.Column(db.String(128), nullable=True) + discord_user_id = db.Column(db.String(64), nullable=True) + league_os_profile = db.Column(db.String(256), nullable=True) + + # --- polymorphic configuration ----------------------------------------- + __mapper_args__ = { + 'polymorphic_identity': 'user', + 'polymorphic_on': role, + } + + # --- relationships (defined once on the base) -------------------------- + evaluations_given = db.relationship( + 'Evaluation', foreign_keys='Evaluation.evaluator_id', + backref='evaluator', lazy='dynamic') + evaluations_received = db.relationship( + 'Evaluation', foreign_keys='Evaluation.player_id', + backref='player', lazy='dynamic') + tryout_registrations = db.relationship( + 'TryoutRegistration', backref='player', lazy='dynamic') + team_assignments = db.relationship( + 'TeamMember', foreign_keys='TeamMember.player_id', + backref='player_ref', lazy='dynamic') + + # --- shared helper methods --------------------------------------------- + def get_games_list(self): + """Return the user's games as a list.""" + if self.games: + return [g.strip() for g in self.games.split(',') if g.strip()] + return [] + + def get_gamertags(self): + """Return gamertags as a dict keyed by game.""" + return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} + for gt in self.gamertags} + + def get_org_teams(self): + """Return all OrgTeams this player belongs to.""" + return [tp.org_team for tp in self.team_placements] + + # --- stubs (overridden in subclasses) ---------------------------------- + def can_evaluate(self): + return False + + def can_manage_users(self): + return False + + def can_manage_teams(self): + return False + + def can_manage_tryouts(self): + return False + + def can_schedule_matches(self): + return False + + def can_manage_this_tryout(self, tryout): + return False + + def can_manage_this_org_team(self, org_team): + return False + + def get_visible_tryouts(self): + return [] diff --git a/nginx.conf b/app/nginx.conf similarity index 100% rename from nginx.conf rename to app/nginx.conf diff --git a/routes/__init__.py b/app/routes/__init__.py similarity index 100% rename from routes/__init__.py rename to app/routes/__init__.py diff --git a/routes/auth.py b/app/routes/auth.py similarity index 97% rename from routes/auth.py rename to app/routes/auth.py index 1378a89..274f677 100644 --- a/routes/auth.py +++ b/app/routes/auth.py @@ -9,9 +9,9 @@ import uuid from datetime import datetime, timedelta from flask import Blueprint, render_template, redirect, url_for, flash, request, session from flask_login import login_user, logout_user, login_required, current_user -from extensions import db, hash_password, check_password, limiter -from models import User, ESPORT_GAMES -from validators import RegisterSchema, LoginSchema +from app.extensions import db, hash_password, check_password, limiter +from app.models import User, Player, ESPORT_GAMES +from app.validators import RegisterSchema, LoginSchema from marshmallow import ValidationError from urllib.parse import urlparse @@ -246,7 +246,7 @@ def register(): ) hashed_password = hash_password(password) - user = User( + user = Player( username=username, password_hash=hashed_password, role='player', @@ -255,7 +255,7 @@ def register(): phone=phone, games=','.join(selected_games) if selected_games else None, discord_username=discord_username, - league_os_profile=league_os_profile + league_os_profile=league_os_profile, ) db.session.add(user) db.session.commit() diff --git a/routes/evaluations.py b/app/routes/evaluations.py similarity index 67% rename from routes/evaluations.py rename to app/routes/evaluations.py index ceb619b..6efa290 100644 --- a/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -1,12 +1,16 @@ """Evaluation routes for assessing player performance during tryouts. -This module handles player evaluation creation, management, and viewing. +Uses polymorphic isinstance checks instead of role-string comparisons. """ from flask import Blueprint, render_template, redirect, url_for, flash, request from flask_login import login_required, current_user -from extensions import db -from models import User, Tryout, Evaluation, TryoutRegistration, GAME_POSITIONS, OrgTeam +from app.extensions import db +from app.models import ( + Admin, Coach, Manager, Player, + User, Tryout, Evaluation, TryoutRegistration, + OrgTeam, GAME_POSITIONS, +) from sqlalchemy import func from sqlalchemy.orm import aliased @@ -14,14 +18,7 @@ evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations') def validate_score(score_value): - """Validate that a score is between 1 and 10. - - Args: - score_value: The score value to validate (can be None, string, or int). - - Returns: - int or None: The validated score or None if invalid/empty. - """ + """Validate that a score is between 1 and 10.""" if score_value is None: return None try: @@ -36,32 +33,18 @@ def validate_score(score_value): @evaluations_bp.route('') @login_required def list_evaluations(): - """List all evaluations accessible to the current user. - - President: All evaluations with player score summaries. - Evaluators (coach/manager): Their given evaluations. - - Supports sorting by any column header via 'sort' and 'order' query parameters. - - Returns: - Response: Rendered evaluations list template. - """ + """List all evaluations accessible to the current user.""" user = current_user - # Players are not allowed to view evaluations - if user.role == 'player': + if isinstance(user, Player): flash('You do not have permission to view evaluations.', 'danger') return redirect(url_for('main.dashboard')) - # Get sort parameters sort_column = request.args.get('sort', 'created_at') sort_order = request.args.get('order', 'desc') - - # Validate sort_order if sort_order not in ('asc', 'desc'): sort_order = 'desc' - # Map sort columns to SQLAlchemy expressions using aliased User models for relationship sorting player_alias = aliased(User, name='eval_player') evaluator_alias = aliased(User, name='eval_evaluator') @@ -89,7 +72,7 @@ def list_evaluations(): else: sort_expr = sort_expr.desc() - if user.role == 'president': + if isinstance(user, Admin): evaluations = Evaluation.query \ .outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \ .outerjoin(player_alias, Evaluation.player_id == player_alias.id) \ @@ -98,14 +81,16 @@ def list_evaluations(): avg_scores = db.session.query( Evaluation.player_id, func.count(Evaluation.id).label('eval_count'), - func.avg(Evaluation.overall_score).label('avg_score') + func.avg(Evaluation.overall_score).label('avg_score'), ).group_by(Evaluation.player_id).all() player_scores = {} for row in avg_scores: p = User.query.get(row.player_id) if p: - player_scores[p.id] = {'player': p, 'count': row.eval_count, 'avg': round(row.avg_score, 1) if row.avg_score else 0} - + player_scores[p.id] = { + 'player': p, 'count': row.eval_count, + 'avg': round(row.avg_score, 1) if row.avg_score else 0, + } elif user.can_evaluate(): evaluations = Evaluation.query \ .outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \ @@ -123,53 +108,38 @@ def list_evaluations(): .order_by(sort_expr).all() player_scores = {} - return render_template('pages/evaluations.html', evaluations=evaluations, player_scores=player_scores, sort_column=sort_column, sort_order=sort_order) + return render_template('pages/evaluations.html', + evaluations=evaluations, player_scores=player_scores, + sort_column=sort_column, sort_order=sort_order) @evaluations_bp.route('//', methods=['GET', 'POST']) @login_required def evaluate_player(tryout_id, player_id): - """Evaluate a specific player in a tryout. - - GET: Render the evaluation form with any existing evaluation. - POST: Create or update the evaluation for the player. - - Args: - tryout_id: The ID of the tryout. - player_id: The ID of the player to evaluate. - - Returns: - Response: Evaluation form or redirect to tryout view. - """ + """Evaluate a specific player in a tryout.""" if not current_user.can_evaluate(): flash('You do not have permission to evaluate players.', 'danger') return redirect(url_for('main.dashboard')) tryout = Tryout.query.get_or_404(tryout_id) - - # Check if user has permission to evaluate players in this tryout if not current_user.can_manage_this_tryout(tryout): flash('You do not have permission to evaluate players in this tryout.', 'danger') return redirect(url_for('tryouts.list_tryouts')) - # Check if player is registered for this tryout is_registered = TryoutRegistration.query.filter_by( - tryout_id=tryout_id, player_id=player_id + tryout_id=tryout_id, player_id=player_id, ).first() is not None if not is_registered: flash('Player is not registered for this tryout.', 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) player = User.query.get_or_404(player_id) - - if player.role != 'player': + if not isinstance(player, Player): flash('Can only evaluate players.', 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) existing_eval = Evaluation.query.filter_by( - tryout_id=tryout_id, - player_id=player_id, - evaluator_id=current_user.id + tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id, ).first() if request.method == 'POST': @@ -185,7 +155,9 @@ def evaluate_player(tryout_id, player_id): comments = request.form.get('comments') position = request.form.get('position_recommendation') - scores = [s for s in [mecanics, cohesion, communication, gamesense, versatility, discipline, analysis, sport_ethics, mental] if s is not None] + scores = [s for s in [mecanics, cohesion, communication, gamesense, + versatility, discipline, analysis, sport_ethics, mental] + if s is not None] overall = sum(scores) / len(scores) if scores else None if existing_eval: @@ -204,21 +176,14 @@ def evaluate_player(tryout_id, player_id): flash('Evaluation updated!', 'success') else: evaluation = Evaluation( - tryout_id=tryout_id, - player_id=player_id, + tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id, - mecanics_score=mecanics, - cohesion_score=cohesion, - communication_score=communication, - gamesense_score=gamesense, - versatility_score=versatility, - discipline_score=discipline, - analysis_score=analysis, - sport_ethics_score=sport_ethics, - mental_score=mental, - overall_score=overall, - comments=comments, - position_recommendation=position + mecanics_score=mecanics, cohesion_score=cohesion, + communication_score=communication, gamesense_score=gamesense, + versatility_score=versatility, discipline_score=discipline, + analysis_score=analysis, sport_ethics_score=sport_ethics, + mental_score=mental, overall_score=overall, + comments=comments, position_recommendation=position, ) db.session.add(evaluation) flash('Evaluation submitted successfully!', 'success') @@ -227,53 +192,42 @@ def evaluate_player(tryout_id, player_id): return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) evaluators = None - if current_user.role == 'president': - all_evaluations = Evaluation.query.filter_by(tryout_id=tryout_id, player_id=player_id).all() - evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations] + if isinstance(current_user, Admin): + all_evaluations = Evaluation.query.filter_by( + tryout_id=tryout_id, player_id=player_id, + ).all() + evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e} + for e in all_evaluations] return render_template('pages/evaluate_player.html', - tryout=tryout, - player=player, - existing_eval=existing_eval, - evaluators=evaluators, - game_positions=GAME_POSITIONS) + tryout=tryout, player=player, + existing_eval=existing_eval, + evaluators=evaluators, + game_positions=GAME_POSITIONS) @evaluations_bp.route('//players') @login_required def players_to_evaluate(tryout_id): - """List players that need evaluation in a specific tryout. - - Shows all registered players and marks which ones have already been evaluated - by the current user. - - Args: - tryout_id: The ID of the tryout. - - Returns: - Response: Rendered players-to-evaluate template. - """ + """List players that need evaluation in a specific tryout.""" if not current_user.can_evaluate(): flash('Permission denied.', 'danger') return redirect(url_for('main.dashboard')) tryout = Tryout.query.get_or_404(tryout_id) - - # Check if user has permission to evaluate players in this tryout if not current_user.can_manage_this_tryout(tryout): flash('You do not have permission to evaluate players in this tryout.', 'danger') return redirect(url_for('tryouts.list_tryouts')) - + registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() players = [] for reg in registrations: p = User.query.get(reg.player_id) - if p and p.role == 'player': + if p and isinstance(p, Player): existing = Evaluation.query.filter_by( - tryout_id=tryout_id, - player_id=p.id, - evaluator_id=current_user.id + tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id, ).first() - players.append({'player': p, 'evaluated': existing is not None, 'registration': reg}) + players.append({'player': p, 'evaluated': existing is not None, + 'registration': reg}) - return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players) + return render_template('pages/players_to_evaluate.html', tryout=tryout, players=players) \ No newline at end of file diff --git a/routes/main.py b/app/routes/main.py similarity index 62% rename from routes/main.py rename to app/routes/main.py index c160724..1927546 100644 --- a/routes/main.py +++ b/app/routes/main.py @@ -1,27 +1,25 @@ """Main dashboard routes for the Team Tryouts application. -This module provides the main dashboard view with role-specific statistics. +Uses polymorphic isinstance checks instead of role-string comparisons. """ from flask import Blueprint, render_template, redirect, url_for, flash from flask_login import login_required, current_user -from extensions import db -from models import User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember, Match, MatchParticipant, OrgTeam +from app.extensions import db +from app.models import ( + Admin, Manager, Coach, Player, Scout, + User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember, + Match, MatchParticipant, OrgTeam, +) from sqlalchemy import func -from datetime import datetime, date +from datetime import date main_bp = Blueprint('main', __name__) @main_bp.route('/') def index(): - """Redirect root URL to login page. - - This is the entry point for the application when no specific route is provided. - - Returns: - Response: Redirect to login page. - """ + """Redirect root URL to login page.""" return redirect(url_for('auth.login')) @@ -29,21 +27,13 @@ def index(): @login_required def dashboard(): """Render the main dashboard with role-specific statistics. - - Displays different statistics based on the user's role: - - President: Overview of all users, tryouts, and evaluations - - Manager: Their created tryouts and evaluations - - Coach: Their evaluations and pending evaluations - - Player: Their registrations, evaluations, and upcoming matches - - Scout: Top-rated players across all tryouts - - Returns: - Response: Rendered dashboard template with user stats. + + Each User subclass provides its own stats view. """ user = current_user stats = {} - if user.role == 'president': + if isinstance(user, Admin): stats['total_users'] = User.query.count() stats['total_players'] = User.query.filter_by(role='player').count() stats['total_tryouts'] = Tryout.query.count() @@ -54,101 +44,92 @@ def dashboard(): stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all() today = date.today() stats['upcoming_matches'] = Match.query.filter( - Match.status == 'scheduled', - Match.date >= today + Match.status == 'scheduled', Match.date >= today, ).order_by(Match.date, Match.start_time).limit(5).all() - elif user.role == 'manager': + elif isinstance(user, Manager): stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count() - stats['active_tryouts'] = Tryout.query.filter_by(created_by=user.id, status='in_progress').count() + stats['active_tryouts'] = Tryout.query.filter_by( + created_by=user.id, status='in_progress').count() stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count() - stats['my_tryouts'] = Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all() + stats['my_tryouts'] = Tryout.query.filter_by( + created_by=user.id).order_by(Tryout.date.desc()).limit(5).all() today = date.today() manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()] stats['upcoming_matches'] = Match.query.filter( Match.tryout_id.in_(manager_tryout_ids), - Match.status == 'scheduled', - Match.date >= today + Match.status == 'scheduled', Match.date >= today, ).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else [] - elif user.role == 'coach': + elif isinstance(user, Coach): stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count() - stats['pending_evaluations'] = 0 - registrations = TryoutRegistration.query.filter(TryoutRegistration.status.in_(['registered', 'attended'])).all() + registrations = TryoutRegistration.query.filter( + TryoutRegistration.status.in_(['registered', 'attended'])).all() registered_player_ids = [r.player_id for r in registrations] evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()] stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids)) - stats['my_recent_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all() + stats['my_recent_evaluations'] = Evaluation.query.filter_by( + evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all() today = date.today() org_team = OrgTeam.query.filter_by(coach_id=user.id).first() - coach_tryout_ids = [t.id for t in Tryout.query.filter_by(target_org_team_id=org_team.id).all()] if org_team else [] + coach_tryout_ids = [t.id for t in Tryout.query.filter_by( + target_org_team_id=org_team.id).all()] if org_team else [] stats['upcoming_matches'] = Match.query.filter( Match.tryout_id.in_(coach_tryout_ids), - Match.status == 'scheduled', - Match.date >= today + Match.status == 'scheduled', Match.date >= today, ).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else [] - elif user.role == 'player': + elif isinstance(user, Player): stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count() - stats['my_registrations'] = TryoutRegistration.query.filter_by(player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all() - - # Get upcoming matches for the player + stats['my_registrations'] = TryoutRegistration.query.filter_by( + player_id=user.id).order_by(TryoutRegistration.registered_at.desc()).limit(5).all() + today = date.today() next_matches = [] - - # Get all tryouts the player is registered for (not just the 5 most recent) all_registrations = TryoutRegistration.query.filter_by(player_id=user.id).all() registered_tryout_ids = [r.tryout_id for r in all_registrations] - - # Get all matches where player is a participant (any match type) player_participant_matches = MatchParticipant.query.filter_by(player_id=user.id).all() player_match_ids = [p.match_id for p in player_participant_matches] - - # Get all team memberships for this player player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all() player_team_ids = [tm.team_id for tm in player_team_memberships] - - # Find all scheduled matches in registered tryouts + upcoming_matches = Match.query.filter( Match.tryout_id.in_(registered_tryout_ids), - Match.status == 'scheduled', - Match.date >= today + Match.status == 'scheduled', Match.date >= today, ).order_by(Match.date, Match.start_time).all() - + for match in upcoming_matches: is_participant = False team = None - if match.match_type == 'team_vs_team': - # Check if player is on either team if match.team1_id in player_team_ids: is_participant = True - team = next((tm for tm in player_team_memberships if tm.team_id == match.team1_id), None) + team = next((tm for tm in player_team_memberships + if tm.team_id == match.team1_id), None) elif match.team2_id in player_team_ids: is_participant = True - team = next((tm for tm in player_team_memberships if tm.team_id == match.team2_id), None) + team = next((tm for tm in player_team_memberships + if tm.team_id == match.team2_id), None) else: - # For player_vs_player and player_scrim, check MatchParticipant if match.id in player_match_ids: is_participant = True - + if is_participant: - tryout = match.tryout next_matches.append({ - 'tryout': tryout, - 'match': match, - 'team': team.team if team else None + 'tryout': match.tryout, 'match': match, + 'team': team.team if team else None, }) - + stats['next_matches'] = next_matches - elif user.role == 'scout': + elif isinstance(user, Scout): stats['total_players'] = User.query.filter_by(role='player').count() stats['total_evaluations'] = Evaluation.query.count() stats['avg_scores'] = db.session.query( Evaluation.player_id, - func.avg(Evaluation.overall_score).label('avg_score') - ).group_by(Evaluation.player_id).order_by(func.avg(Evaluation.overall_score).desc()).limit(5).all() + func.avg(Evaluation.overall_score).label('avg_score'), + ).group_by(Evaluation.player_id).order_by( + func.avg(Evaluation.overall_score).desc()).limit(5).all() stats['top_players'] = [] for row in stats['avg_scores']: p = User.query.get(row.player_id) diff --git a/routes/matches.py b/app/routes/matches.py similarity index 55% rename from routes/matches.py rename to app/routes/matches.py index 9a5947e..2f8f81e 100644 --- a/routes/matches.py +++ b/app/routes/matches.py @@ -1,73 +1,65 @@ """Match scheduling routes for managing scrimmages and matches within tryouts. -This module handles calendar views, match creation, and player availability. +Uses polymorphic isinstance checks instead of role-string comparisons. """ from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask_login import login_required, current_user -from extensions import db -from models import User, Tryout, Match, MatchParticipant, Team, TeamMember, OrgTeam, TryoutRegistration, PlayerDisponibility +from app.extensions import db +from app.models import ( + Admin, Manager, Coach, Player, Scout, + User, Tryout, Match, MatchParticipant, Team, TeamMember, + OrgTeam, TryoutRegistration, PlayerDisponibility, +) from datetime import datetime, time, timedelta -from discord_bot import send_schedule_notification +from app.discord_bot import send_schedule_notification matches_bp = Blueprint('matches', __name__, url_prefix='/matches') def can_schedule_match(): - """Check if user can schedule matches (coaches and above). - - Returns: - bool: True if user is president, manager, coach, or scout. + """Check if user can schedule matches (Admin, Manager, Coach, Scout).""" + return isinstance(current_user, (Admin, Manager, Coach, Scout)) + + +def get_visible_tryouts_for_user(): + """Get tryouts that the current user can see based on their role. + + Delegates to the polymorphic User subclass. """ - return current_user.role in ['president', 'manager', 'coach', 'scout'] + return current_user.get_visible_tryouts() @matches_bp.route('/calendar') @login_required def calendar(): - """Render the calendar view showing all tryouts and matches. - - Returns: - Response: Rendered calendar template. - """ + """Render the calendar view.""" return render_template('pages/calendar.html') @matches_bp.route('/api/events') @login_required def api_events(): - """API endpoint returning calendar events for FullCalendar. - - Returns tryout events and match events with participant information. - - Returns: - Response: JSON array of calendar events. - """ + """API endpoint returning calendar events for FullCalendar.""" events = [] - - # Get tryouts based on user permissions (this already filters by user's role) tryouts = get_visible_tryouts_for_user() - + for tryout in tryouts: events.append({ 'id': f'tryout_{tryout.id}', 'title': tryout.title, 'date': tryout.date.strftime('%Y-%m-%d'), - 'type': 'tryout', - 'color': '#3b82f6', # Blue for tryouts + 'type': 'tryout', 'color': '#3b82f6', 'extendedProps': { 'location': tryout.location or 'TBD', 'status': tryout.status, 'description': tryout.description or '', - 'tryout_id': tryout.id - } + 'tryout_id': tryout.id, + }, }) - - # Add matches for this tryout + for match in tryout.matches: match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b' - - # Build match description with participants match_desc = match.description or '' participants_str = '' if match.match_type == 'team_vs_team': @@ -79,108 +71,76 @@ def api_events(): participants_str = f"{' vs '.join(teams)}" match_desc = participants_str + (f"
{match.description}" if match.description else '') else: - # Player scrim - show all participants player_names = [] for p in match.participants.all(): - player_name = p.player.username if p.player else 'Unknown Player' - player_names.append(player_name) + player_names.append(p.player.username if p.player else 'Unknown Player') participants_str = ', '.join(player_names) if player_names else 'No players' match_desc = participants_str + (f"
{match.description}" if match.description else '') - - # Include time for calendar display + start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None - - # Find current user's participant record for presence toggle + user_participant = MatchParticipant.query.filter_by( - match_id=match.id, - player_id=current_user.id + match_id=match.id, player_id=current_user.id, ).first() - + events.append({ 'id': f'match_{match.id}', 'title': match.title, 'date': match.date.strftime('%Y-%m-%d'), - 'type': 'match', - 'color': match_color, + 'type': 'match', 'color': match_color, 'extendedProps': { 'location': match.location or tryout.location or 'TBD', - 'status': match.status, - 'description': match_desc, + 'status': match.status, 'description': match_desc, 'match_type': match.match_type, - 'tryout_id': tryout.id, - 'match_id': match.id, - 'start_time': start_time_str, - 'end_time': end_time_str, + 'tryout_id': tryout.id, 'match_id': match.id, + 'start_time': start_time_str, 'end_time': end_time_str, 'participants': participants_str, 'user_participant_id': user_participant.id if user_participant else None, - 'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False - } + 'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False, + }, }) - + return jsonify(events) @matches_bp.route('/api/events/') @login_required def api_events_for_tryout(tryout_id): - """API endpoint returning calendar events for a specific tryout. - - Args: - tryout_id: The ID of the tryout to get events for. - - Returns: - Response: JSON array of calendar events for the tryout. - """ + """API endpoint returning calendar events for a specific tryout.""" tryout = Tryout.query.get_or_404(tryout_id) - - # Check if user can view this tryout can_view = current_user.can_manage_this_tryout(tryout) - - # For players, check if they're registered or participating in a match + is_registered = False player_in_match = False - if current_user.role == 'player': + if isinstance(current_user, Player): is_registered = TryoutRegistration.query.filter_by( - tryout_id=tryout_id, player_id=current_user.id + tryout_id=tryout_id, player_id=current_user.id, ).first() is not None - - # Check if player is participating in any matches for this tryout player_matches = Match.query.join(MatchParticipant).filter( MatchParticipant.player_id == current_user.id, - Match.tryout_id == tryout_id + Match.tryout_id == tryout_id, ).all() player_in_match = len(player_matches) > 0 - - # Non-participating players cannot see the calendar + if not can_view and not is_registered and not player_in_match: return jsonify([]) - - events = [] - - # Add tryout date as an event (read-only, for context) - events.append({ + + events = [{ 'id': f'tryout_{tryout.id}', 'title': f'Tryout: {tryout.title}', 'date': tryout.date.strftime('%Y-%m-%d'), - 'type': 'tryout', - 'color': '#3b82f6', # Blue for tryouts + 'type': 'tryout', 'color': '#3b82f6', 'extendedProps': { 'location': tryout.location or 'TBD', 'status': tryout.status, 'description': tryout.description or '', - 'tryout_id': tryout.id - } - }) - + 'tryout_id': tryout.id, + }, + }] + for match in tryout.matches: - # Determine color based on match type - if match.match_type == 'team_vs_team' or match.match_type == 'player_vs_player': - match_color = '#10b981' # Green for team matches - else: - match_color = '#f59e0b' # Orange for scrims - - # Build participant string with proper grouping + match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b' participants_str = '' if match.match_type == 'team_vs_team': teams = [] @@ -190,124 +150,51 @@ def api_events_for_tryout(tryout_id): teams.append(match.team2.name) participants_str = f"{' vs '.join(teams)}" elif match.match_type == 'player_vs_player': - # Get players grouped by team side - team1_players = [] - for p in match.participants.filter_by(team_side=1).all(): - if p.player: - team1_players.append(p.player.username) - team2_players = [] - for p in match.participants.filter_by(team_side=2).all(): - if p.player: - team2_players.append(p.player.username) + team1_players = [p.player.username for p in match.participants.filter_by(team_side=1).all() if p.player] + team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player] if team1_players and team2_players: participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}" else: participants_str = 'TBD vs TBD' else: - player_names = [] - for p in match.participants.all(): - player_name = p.player.username if p.player else 'Unknown Player' - player_names.append(player_name) + player_names = [p.player.username for p in match.participants.all() if p.player] participants_str = ', '.join(player_names) if player_names else 'No players' - - # Include time for calendar display + start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None - + events.append({ 'id': f'match_{match.id}', 'title': match.title, 'date': match.date.strftime('%Y-%m-%d'), - 'type': 'match', - 'color': match_color, + 'type': 'match', 'color': match_color, 'extendedProps': { 'location': match.location or tryout.location or 'TBD', - 'status': match.status, - 'match_type': match.match_type, - 'tryout_id': tryout.id, - 'match_id': match.id, + 'status': match.status, 'match_type': match.match_type, + 'tryout_id': tryout.id, 'match_id': match.id, 'participants': participants_str, - 'start_time': start_time_str, - 'end_time': end_time_str - } + 'start_time': start_time_str, 'end_time': end_time_str, + }, }) - + return jsonify(events) -def get_visible_tryouts_for_user(): - """Get tryouts that the current user can see based on their role. - - Permission hierarchy: - - President: All tryouts - - Manager: Only their created tryouts - - Coach: Tryouts targeting their org team - - Player: Tryouts they're registered for or matches they're in - - Scout: All tryouts - - Returns: - list: Query result of Tryout objects. - """ - if current_user.role == 'president': - return Tryout.query.order_by(Tryout.date).all() - elif current_user.role == 'manager': - return Tryout.query.filter_by(created_by=current_user.id).order_by(Tryout.date).all() - elif current_user.role == 'coach': - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - if org_team: - return Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date).all() - return [] - elif current_user.role == 'player': - # Get tryouts player is registered for - player_tryout_ids = [r.tryout_id for r in current_user.tryout_registrations.all()] - tryouts = Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all() if player_tryout_ids else [] - - # Also include matches where player is participating - player_matches = Match.query.join(MatchParticipant).filter( - MatchParticipant.player_id == current_user.id - ).all() - - player_match_tryout_ids = list(set(m.tryout_id for m in player_matches)) - additional_tryouts = Tryout.query.filter( - Tryout.id.in_(player_match_tryout_ids) - ).order_by(Tryout.date).all() if player_match_tryout_ids else [] - - # Combine and deduplicate - all_tryouts = tryouts + [t for t in additional_tryouts if t.id not in player_tryout_ids] - return all_tryouts - else: # scout - return Tryout.query.order_by(Tryout.date).all() - - @matches_bp.route('/create/', methods=['GET', 'POST']) @login_required def create_match(tryout_id): - """Create a new match/scrimmage within a tryout. - - GET: Render the match creation form. - POST: Create a match with submitted details. - - Args: - tryout_id: The ID of the tryout to create the match for. - - Returns: - Response: Create form or redirect to tryout view. - """ + """Create a new match / scrimmage within a tryout.""" tryout = Tryout.query.get_or_404(tryout_id) - - # Check if user can manage this tryout (president, manager, or coach) if not current_user.can_manage_this_tryout(tryout): flash('You do not have permission to schedule matches for this tryout.', 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - + teams = Team.query.filter_by(tryout_id=tryout_id).all() registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() all_players = [User.query.get(r.player_id) for r in registrations if User.query.get(r.player_id)] all_players = sorted([p for p in all_players if p], key=lambda x: x.username) - - # Allow pre-filling the date from query param (e.g., from calendar click) prefill_date = request.args.get('date', '') - + if request.method == 'POST': title = request.form.get('title') description = request.form.get('description') @@ -316,59 +203,49 @@ def create_match(tryout_id): end_time_str = request.form.get('end_time') location = request.form.get('location') match_type = request.form.get('match_type') - - # Start time is now mandatory + if not start_time_str: flash('Start time is required. Please select a time slot.', 'danger') - return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players, prefill_date=prefill_date) - + return render_template('pages/match_form.html', tryout=tryout, teams=teams, + all_players=all_players, prefill_date=prefill_date) + try: date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date except (ValueError, TypeError): flash('Invalid date format.', 'danger') - return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players, prefill_date=prefill_date) - + return render_template('pages/match_form.html', tryout=tryout, teams=teams, + all_players=all_players, prefill_date=prefill_date) + start_time = None end_time = None try: start_time = datetime.strptime(start_time_str, '%H:%M').time() - # Auto-calculate end time if not provided (start + 30 minutes) if end_time_str: end_time = datetime.strptime(end_time_str, '%H:%M').time() else: - # Auto-calculate end time as start + 30 minutes start_dt = datetime.combine(date_obj, start_time) end_dt = start_dt + timedelta(minutes=30) end_time = end_dt.time() except ValueError: flash('Invalid time format.', 'danger') return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players) - + match = Match( - tryout_id=tryout_id, - title=title, - description=description, - date=date_obj, - start_time=start_time, - end_time=end_time, - location=location, - match_type=match_type, - created_by=current_user.id + tryout_id=tryout_id, title=title, description=description, + date=date_obj, start_time=start_time, end_time=end_time, + location=location, match_type=match_type, created_by=current_user.id, ) db.session.add(match) - db.session.flush() # Get match.id before commit - - # Collect player IDs for Discord notifications + db.session.flush() + notified_player_ids = [] - - # Handle team vs team matches + notified_participant_ids = [] + if match_type == 'team_vs_team': team1_id = request.form.get('team1_id') team2_id = request.form.get('team2_id') match.team1_id = int(team1_id) if team1_id else None match.team2_id = int(team2_id) if team2_id else None - # Create MatchParticipant records for all team members AND get notified player IDs - notified_participant_ids = [] if match.team1_id: for m in TeamMember.query.filter_by(team_id=match.team1_id).all(): participant = MatchParticipant(match_id=match.id, player_id=m.player_id, team_side=1) @@ -383,14 +260,11 @@ def create_match(tryout_id): db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids.append(m.player_id) - - # Handle player vs player matches elif match_type == 'player_vs_player': team1_player_ids = request.form.get('team1_player_ids', '') team2_player_ids = request.form.get('team2_player_ids', '') team1_ids = [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else [] team2_ids = [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else [] - notified_participant_ids = [] for pid in team1_ids: participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1) db.session.add(participant) @@ -402,85 +276,54 @@ def create_match(tryout_id): db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids = team1_ids + team2_ids - - # Handle player scrim matches elif match_type == 'player_scrim': player_ids = request.form.getlist('player_ids') - notified_participant_ids = [] for pid in player_ids: participant = MatchParticipant(match_id=match.id, player_id=int(pid)) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids = [int(p) for p in player_ids] - + db.session.commit() - - # Send Discord notifications to players (one per participant for proper attendance tracking) + + # Discord notifications event_date_str = date_obj.strftime('%Y-%m-%d') event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD' - - # Send Discord notifications with proper participant reference IDs - if match_type == 'team_vs_team': - for i, player_id in enumerate(notified_player_ids): - reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id - send_schedule_notification( - user_id=player_id, - event_type='match', - event_title=match.title, - event_date=event_date_str, - event_time=event_time_str, - reference_id=reference_id - ) - else: - for i, player_id in enumerate(notified_player_ids): - reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id - send_schedule_notification( - user_id=player_id, - event_type='match', - event_title=match.title, - event_date=event_date_str, - event_time=event_time_str, - reference_id=reference_id - ) - + for i, player_id in enumerate(notified_player_ids): + reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id + send_schedule_notification( + user_id=player_id, event_type='match', event_title=match.title, + event_date=event_date_str, event_time=event_time_str, + reference_id=reference_id, + ) + flash('Match scheduled successfully!', 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - - return render_template('pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players) + + return render_template('pages/match_form.html', tryout=tryout, teams=teams, + all_players=all_players, prefill_date=prefill_date) @matches_bp.route('//edit', methods=['GET', 'POST']) @login_required def edit_match(match_id): - """Edit an existing match. - - GET: Render the match edit form. - POST: Update match with submitted changes. - - Args: - match_id: The ID of the match to edit. - - Returns: - Response: Edit form or redirect to tryout view. - """ + """Edit an existing match.""" match = Match.query.get_or_404(match_id) tryout = match.tryout - + if not current_user.can_manage_this_tryout(tryout): flash('You do not have permission to edit this match.', 'danger') return redirect(url_for('matches.calendar')) - + teams = Team.query.filter_by(tryout_id=tryout.id).all() - # Only show players registered for this tryout registrations = TryoutRegistration.query.filter_by(tryout_id=tryout.id).all() all_players = [User.query.get(r.player_id) for r in registrations if r.player_id] all_players = sorted([p for p in all_players if p], key=lambda x: x.username) current_player_ids = [p.player_id for p in match.participants.all()] - # Get players grouped by team side for player_vs_player matches team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()] team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()] - + if request.method == 'POST': match.title = request.form.get('title') match.description = request.form.get('description') @@ -489,47 +332,45 @@ def edit_match(match_id): end_time_str = request.form.get('end_time') location = request.form.get('location') status = request.form.get('status') - + try: match.date = datetime.strptime(date_str, '%Y-%m-%d').date() except (ValueError, TypeError): flash('Invalid date format.', 'danger') - return render_template('pages/match_form.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids) - - # Start time is now mandatory + return render_template('pages/match_form.html', match=match, tryout=tryout, + teams=teams, all_players=all_players, + current_player_ids=current_player_ids) + if not start_time_str: - flash('Start time is required. Please select a time slot.', 'danger') - return render_template('pages/match_form.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids) - + flash('Start time is required.', 'danger') + return render_template('pages/match_form.html', match=match, tryout=tryout, + teams=teams, all_players=all_players, + current_player_ids=current_player_ids) + try: match.start_time = datetime.strptime(start_time_str, '%H:%M').time() - # Auto-calculate end time if not provided (start + 30 minutes) if end_time_str: match.end_time = datetime.strptime(end_time_str, '%H:%M').time() else: - # Auto-calculate end time as start + 30 minutes start_dt = datetime.combine(match.date, match.start_time) end_dt = start_dt + timedelta(minutes=30) match.end_time = end_dt.time() except ValueError: match.start_time = None - + match.location = location if status in ['scheduled', 'completed', 'cancelled']: match.status = status - - # Collect player IDs for Discord notifications + notified_player_ids = [] - - # Handle team vs team matches notified_participant_ids = [] + if match.match_type == 'team_vs_team': team1_id = request.form.get('team1_id') team2_id = request.form.get('team2_id') new_team1_id = int(team1_id) if team1_id else None new_team2_id = int(team2_id) if team2_id else None - - # If teams changed, recreate MatchParticipant records + if new_team1_id != match.team1_id or new_team2_id != match.team2_id: MatchParticipant.query.filter_by(match_id=match.id).delete() match.team1_id = new_team1_id @@ -549,127 +390,87 @@ def edit_match(match_id): notified_participant_ids.append(participant.id) notified_player_ids.append(m.player_id) else: - # Teams didn't change, still get notified player IDs if match.team1_id: notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()]) if match.team2_id: notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()]) - - # Handle player vs player matches - update participants elif match.match_type == 'player_vs_player': MatchParticipant.query.filter_by(match_id=match.id).delete() - team1_player_ids = request.form.getlist('team1_player_ids') - team2_player_ids = request.form.getlist('team2_player_ids') - notified_participant_ids = [] - for pid in team1_player_ids: + team1_str = request.form.get('team1_player_ids', '') + team2_str = request.form.get('team2_player_ids', '') + t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else [] + t2_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else [] + for pid in t1_ids: participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) - for pid in team2_player_ids: + for pid in t2_ids: participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) - notified_player_ids = [int(p) for p in team1_player_ids] + [int(p) for p in team2_player_ids] - - # Handle player scrim matches - update participants + notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids] elif match.match_type == 'player_scrim': MatchParticipant.query.filter_by(match_id=match.id).delete() player_ids = request.form.getlist('player_ids') - notified_participant_ids = [] for pid in player_ids: participant = MatchParticipant(match_id=match.id, player_id=int(pid)) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids = [int(p) for p in player_ids] - + db.session.commit() - - # Send Discord notifications to players - if match.match_type in ['player_vs_player', 'player_scrim']: - end_time_val = match.end_time if match.end_time else match.start_time if match.start_time else None - if match.start_time and end_time_val: - event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}" - else: - event_time_str = 'TBD' - event_date_str = match.date.strftime('%Y-%m-%d') - for i, player_id in enumerate(notified_player_ids): - reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id - send_schedule_notification( - user_id=player_id, - event_type='match', - event_title=match.title, - event_date=event_date_str, - event_time=event_time_str, - reference_id=reference_id - ) - elif match.match_type == 'team_vs_team': - event_date_str = match.date.strftime('%Y-%m-%d') - end_time_val = match.end_time if match.end_time else match.start_time if match.start_time else None - event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}" if match.start_time and end_time_val else 'TBD' - if notified_participant_ids: - for i, player_id in enumerate(notified_player_ids): - reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id - send_schedule_notification( - user_id=player_id, - event_type='match', - event_title=match.title, - event_date=event_date_str, - event_time=event_time_str, - reference_id=reference_id - ) - else: - for player_id in notified_player_ids: - send_schedule_notification( - user_id=player_id, - event_type='match', - event_title=match.title, - event_date=event_date_str, - event_time=event_time_str, - reference_id=match.id - ) - + + # Discord notifications + end_time_val = match.end_time or (match.start_time if match.start_time else None) + if match.start_time and end_time_val: + event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}" + else: + event_time_str = 'TBD' + event_date_str = match.date.strftime('%Y-%m-%d') + for i, player_id in enumerate(notified_player_ids): + reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id + send_schedule_notification( + user_id=player_id, event_type='match', event_title=match.title, + event_date=event_date_str, event_time=event_time_str, + reference_id=reference_id, + ) + flash('Match updated successfully!', 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) - - # Build participant attendance map for the template + participants_map = {} for p in match.participants.all(): participants_map[p.player_id] = { 'participant_id': p.id, 'attendance_confirmed': p.attendance_confirmed, - 'team_side': p.team_side + 'team_side': p.team_side, } - - return render_template('pages/match_form.html', match=match, tryout=tryout, teams=teams, - all_players=all_players, current_player_ids=current_player_ids, - team1_player_ids=team1_player_ids, team2_player_ids=team2_player_ids, + + return render_template('pages/match_form.html', match=match, tryout=tryout, + teams=teams, all_players=all_players, + current_player_ids=current_player_ids, + team1_player_ids=team1_player_ids, + team2_player_ids=team2_player_ids, participants_map=participants_map) @matches_bp.route('/api/manageable-tryouts') @login_required def api_manageable_tryouts(): - """API endpoint returning tryouts the current user can manage. - - Used by the calendar's "Create Event" modal to populate the tryout dropdown. - - Returns: - Response: JSON array of {id, title, date}. - """ + """API endpoint returning tryouts the current user can manage.""" if not can_schedule_match(): return jsonify([]) - + tryouts = get_visible_tryouts_for_user() manageable = [] for t in tryouts: if current_user.can_manage_this_tryout(t): manageable.append({ - 'id': t.id, - 'title': t.title, - 'date': t.date.strftime('%Y-%m-%d') + 'id': t.id, 'title': t.title, + 'date': t.date.strftime('%Y-%m-%d'), }) return jsonify(manageable) @@ -677,21 +478,12 @@ def api_manageable_tryouts(): @matches_bp.route('//delete', methods=['POST']) @login_required def delete_match(match_id): - """Delete a match. - - Args: - match_id: The ID of the match to delete. - - Returns: - Response: Redirect to tryout view with status message. - """ + """Delete a match.""" match = Match.query.get_or_404(match_id) tryout = match.tryout - if not current_user.can_manage_this_tryout(tryout): flash('You do not have permission to delete this match.', 'danger') return redirect(url_for('matches.calendar')) - db.session.delete(match) db.session.commit() flash('Match deleted successfully.', 'success') @@ -699,72 +491,38 @@ def delete_match(match_id): def get_players_available_at_time(date_str, time_str): - """Get list of player IDs available at a specific date and time. - - Checks player disponibility records to find who is available - during the specified time block. - - Args: - date_str: Date in YYYY-MM-DD format. - time_str: Time in HH:MM format. - - Returns: - list: List of available player IDs. - """ + """Get list of player IDs available at a specific date and time.""" try: date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() time_obj = datetime.strptime(time_str, '%H:%M').time() except (ValueError, TypeError): return [] - - # Calculate day of week (Python: 0=Monday, 6=Sunday) - # JavaScript: 0=Sunday, 6=Saturday, so we convert - date_parts = date_str.split('-') - date_for_day = datetime(int(date_parts[0]), int(date_parts[1]), int(date_parts[2])) - js_day = date_for_day.weekday() - - # Convert Python weekday (Mon=0) to our format (Mon=0) - day_of_week = js_day - - # Get all active players + + date_for_day = datetime.strptime(date_str, '%Y-%m-%d') + day_of_week = date_for_day.weekday() + players = User.query.filter_by(role='player', is_active_account=True).all() - available_players = [] for player in players: - # Check if player has disponibility at this time disponibilities = PlayerDisponibility.query.filter_by( - player_id=player.id, - day_of_week=day_of_week + player_id=player.id, day_of_week=day_of_week, ).all() - for disp in disponibilities: - # Check if time falls within disponibility block disp_start = disp.start_time.hour * 60 + disp.start_time.minute disp_end = disp.end_time.hour * 60 + disp.end_time.minute match_time = time_obj.hour * 60 + time_obj.minute - if disp_start <= match_time < disp_end: available_players.append(player.id) break - return available_players @matches_bp.route('/api/available_players//