From 3feae80767187aef6c90a919381e706c5373f7ce Mon Sep 17 00:00:00 2001 From: cedrick2711 Date: Tue, 28 Jul 2026 23:33:31 -0400 Subject: [PATCH] remodulation du projet et des classes --- CHANGES_TODO | 1 - __pycache__/app.cpython-312.pyc | Bin 7180 -> 0 bytes __pycache__/app.cpython-313.pyc | Bin 7171 -> 0 bytes __pycache__/extensions.cpython-312.pyc | Bin 1593 -> 0 bytes __pycache__/extensions.cpython-313.pyc | Bin 1513 -> 0 bytes __pycache__/models.cpython-312.pyc | Bin 49265 -> 0 bytes __pycache__/models.cpython-313.pyc | Bin 46380 -> 0 bytes __pycache__/seed.cpython-312.pyc | Bin 28005 -> 0 bytes __pycache__/seed.cpython-313.pyc | Bin 28080 -> 0 bytes app/__init__.py | 1 + app.py => app/app.py | 38 +- discord_bot.py => app/discord_bot.py | 12 +- extensions.py => app/extensions.py | 0 logging_config.py => app/logging_config.py | 0 app/models.py | 854 +++++++++ nginx.conf => app/nginx.conf | 0 {routes => app/routes}/__init__.py | 0 {routes => app/routes}/auth.py | 10 +- {routes => app/routes}/evaluations.py | 146 +- {routes => app/routes}/main.py | 111 +- {routes => app/routes}/matches.py | 574 ++---- {routes => app/routes}/team_matches.py | 255 +-- {routes => app/routes}/teams.py | 191 +- {routes => app/routes}/tryouts.py | 319 +--- app/routes/users.py | 770 ++++++++ {static => app/static}/css/style.css | 0 {static => app/static}/js/main.js | 0 app/supporting_scrits/__init__.py | 1 + backup.py => app/supporting_scrits/backup.py | 0 .../supporting_scrits/run_https.py | 2 +- .../supporting_scrits/security_scan.py | 2 +- app/supporting_scrits/seed.py | 460 +++++ {templates => app/templates}/errors/400.html | 0 {templates => app/templates}/errors/403.html | 0 {templates => app/templates}/errors/404.html | 0 {templates => app/templates}/errors/429.html | 0 {templates => app/templates}/errors/500.html | 0 .../templates}/layouts/base.html | 0 .../templates}/layouts/macros.html | 0 .../templates}/pages/add_note.html | 0 .../templates}/pages/calendar.html | 0 .../templates}/pages/coach_availability.html | 0 .../templates}/pages/contracts.html | 0 .../templates}/pages/create_user.html | 0 .../templates}/pages/dashboard.html | 0 .../templates}/pages/edit_profile.html | 0 .../templates}/pages/edit_user.html | 0 .../templates}/pages/evaluate_player.html | 0 .../templates}/pages/evaluations.html | 0 {templates => app/templates}/pages/login.html | 0 .../templates}/pages/match_form.html | 0 .../templates}/pages/my_teams.html | 0 {templates => app/templates}/pages/notes.html | 0 .../templates}/pages/one_on_one.html | 0 .../templates}/pages/personal_notes.html | 0 .../pages/player_personal_notes.html | 0 .../templates}/pages/players_to_evaluate.html | 0 .../templates}/pages/profile.html | 0 .../templates}/pages/register.html | 0 .../templates}/pages/team_match_form.html | 0 .../templates}/pages/team_matches.html | 0 .../templates}/pages/team_notes.html | 0 {templates => app/templates}/pages/teams.html | 0 .../templates}/pages/tryout_form.html | 0 .../templates}/pages/tryouts.html | 0 .../templates}/pages/upload_contract.html | 0 {templates => app/templates}/pages/users.html | 0 .../templates}/pages/view_tryout.html | 0 .../templates}/pages/view_user.html | 0 validators.py => app/validators.py | 5 +- wsgi.py => app/wsgi.py | 2 +- instance/team_tryouts.db | Bin 204800 -> 200704 bytes migrate_usernames.py | 58 - models.py | 1011 ---------- requirement.txt | Bin 1672 -> 1682 bytes routes/__pycache__/__init__.cpython-312.pyc | Bin 152 -> 0 bytes routes/__pycache__/__init__.cpython-313.pyc | Bin 153 -> 0 bytes routes/__pycache__/auth.cpython-312.pyc | Bin 7225 -> 0 bytes routes/__pycache__/auth.cpython-313.pyc | Bin 7207 -> 0 bytes .../__pycache__/evaluations.cpython-312.pyc | Bin 14016 -> 0 bytes .../__pycache__/evaluations.cpython-313.pyc | Bin 13867 -> 0 bytes routes/__pycache__/main.cpython-312.pyc | Bin 12695 -> 0 bytes routes/__pycache__/main.cpython-313.pyc | Bin 12652 -> 0 bytes routes/__pycache__/matches.cpython-312.pyc | Bin 38528 -> 0 bytes routes/__pycache__/matches.cpython-313.pyc | Bin 34654 -> 0 bytes routes/__pycache__/teams.cpython-312.pyc | Bin 19505 -> 0 bytes routes/__pycache__/teams.cpython-313.pyc | Bin 19306 -> 0 bytes routes/__pycache__/tryouts.cpython-312.pyc | Bin 29279 -> 0 bytes routes/__pycache__/tryouts.cpython-313.pyc | Bin 28550 -> 0 bytes routes/__pycache__/users.cpython-312.pyc | Bin 79524 -> 0 bytes routes/__pycache__/users.cpython-313.pyc | Bin 78317 -> 0 bytes routes/users.py | 1638 ----------------- run.py | 26 + seed.py | 523 ------ 94 files changed, 2644 insertions(+), 4366 deletions(-) delete mode 100644 CHANGES_TODO delete mode 100644 __pycache__/app.cpython-312.pyc delete mode 100644 __pycache__/app.cpython-313.pyc delete mode 100644 __pycache__/extensions.cpython-312.pyc delete mode 100644 __pycache__/extensions.cpython-313.pyc delete mode 100644 __pycache__/models.cpython-312.pyc delete mode 100644 __pycache__/models.cpython-313.pyc delete mode 100644 __pycache__/seed.cpython-312.pyc delete mode 100644 __pycache__/seed.cpython-313.pyc create mode 100644 app/__init__.py rename app.py => app/app.py (92%) rename discord_bot.py => app/discord_bot.py (98%) rename extensions.py => app/extensions.py (100%) rename logging_config.py => app/logging_config.py (100%) create mode 100644 app/models.py rename nginx.conf => app/nginx.conf (100%) rename {routes => app/routes}/__init__.py (100%) rename {routes => app/routes}/auth.py (97%) rename {routes => app/routes}/evaluations.py (67%) rename {routes => app/routes}/main.py (62%) rename {routes => app/routes}/matches.py (56%) rename {routes => app/routes}/team_matches.py (65%) rename {routes => app/routes}/teams.py (80%) rename {routes => app/routes}/tryouts.py (63%) create mode 100644 app/routes/users.py rename {static => app/static}/css/style.css (100%) rename {static => app/static}/js/main.js (100%) create mode 100644 app/supporting_scrits/__init__.py rename backup.py => app/supporting_scrits/backup.py (100%) rename run_https.py => app/supporting_scrits/run_https.py (99%) rename security_scan.py => app/supporting_scrits/security_scan.py (99%) create mode 100644 app/supporting_scrits/seed.py rename {templates => app/templates}/errors/400.html (100%) rename {templates => app/templates}/errors/403.html (100%) rename {templates => app/templates}/errors/404.html (100%) rename {templates => app/templates}/errors/429.html (100%) rename {templates => app/templates}/errors/500.html (100%) rename {templates => app/templates}/layouts/base.html (100%) rename {templates => app/templates}/layouts/macros.html (100%) rename {templates => app/templates}/pages/add_note.html (100%) rename {templates => app/templates}/pages/calendar.html (100%) rename {templates => app/templates}/pages/coach_availability.html (100%) rename {templates => app/templates}/pages/contracts.html (100%) rename {templates => app/templates}/pages/create_user.html (100%) rename {templates => app/templates}/pages/dashboard.html (100%) rename {templates => app/templates}/pages/edit_profile.html (100%) rename {templates => app/templates}/pages/edit_user.html (100%) rename {templates => app/templates}/pages/evaluate_player.html (100%) rename {templates => app/templates}/pages/evaluations.html (100%) rename {templates => app/templates}/pages/login.html (100%) rename {templates => app/templates}/pages/match_form.html (100%) rename {templates => app/templates}/pages/my_teams.html (100%) rename {templates => app/templates}/pages/notes.html (100%) rename {templates => app/templates}/pages/one_on_one.html (100%) rename {templates => app/templates}/pages/personal_notes.html (100%) rename {templates => app/templates}/pages/player_personal_notes.html (100%) rename {templates => app/templates}/pages/players_to_evaluate.html (100%) rename {templates => app/templates}/pages/profile.html (100%) rename {templates => app/templates}/pages/register.html (100%) rename {templates => app/templates}/pages/team_match_form.html (100%) rename {templates => app/templates}/pages/team_matches.html (100%) rename {templates => app/templates}/pages/team_notes.html (100%) rename {templates => app/templates}/pages/teams.html (100%) rename {templates => app/templates}/pages/tryout_form.html (100%) rename {templates => app/templates}/pages/tryouts.html (100%) rename {templates => app/templates}/pages/upload_contract.html (100%) rename {templates => app/templates}/pages/users.html (100%) rename {templates => app/templates}/pages/view_tryout.html (100%) rename {templates => app/templates}/pages/view_user.html (100%) rename validators.py => app/validators.py (99%) rename wsgi.py => app/wsgi.py (97%) delete mode 100644 migrate_usernames.py delete mode 100644 models.py delete mode 100644 routes/__pycache__/__init__.cpython-312.pyc delete mode 100644 routes/__pycache__/__init__.cpython-313.pyc delete mode 100644 routes/__pycache__/auth.cpython-312.pyc delete mode 100644 routes/__pycache__/auth.cpython-313.pyc delete mode 100644 routes/__pycache__/evaluations.cpython-312.pyc delete mode 100644 routes/__pycache__/evaluations.cpython-313.pyc delete mode 100644 routes/__pycache__/main.cpython-312.pyc delete mode 100644 routes/__pycache__/main.cpython-313.pyc delete mode 100644 routes/__pycache__/matches.cpython-312.pyc delete mode 100644 routes/__pycache__/matches.cpython-313.pyc delete mode 100644 routes/__pycache__/teams.cpython-312.pyc delete mode 100644 routes/__pycache__/teams.cpython-313.pyc delete mode 100644 routes/__pycache__/tryouts.cpython-312.pyc delete mode 100644 routes/__pycache__/tryouts.cpython-313.pyc delete mode 100644 routes/__pycache__/users.cpython-312.pyc delete mode 100644 routes/__pycache__/users.cpython-313.pyc delete mode 100644 routes/users.py create mode 100644 run.py delete mode 100644 seed.py 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 15bb7d83e54ab791f8235bbf695cac39c17bda15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7180 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

+Y5BP8S6#)xg?!+`&c($-;FX=Fl|o=; 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 diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc deleted file mode 100644 index cea274c4e682a972d9236325f9865f3d8ac2993d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49265 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 b_W`%jXu5xL!o9|Q(9L`Q?_G4yzKs6|Ozwby diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 42c0d3c9218575c1679cf7f7a733b183446cc5a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46380 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 diff --git a/__pycache__/seed.cpython-313.pyc b/__pycache__/seed.cpython-313.pyc deleted file mode 100644 index cbc45310e6f238b1a3e93cd80df52d63e166ea7d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28080 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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..bd737e1 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# app package \ No newline at end of file diff --git a/app.py b/app/app.py similarity index 92% rename from app.py rename to app/app.py index ad7a101..b552bf9 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..aa51e63 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 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 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 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 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 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 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.py b/app/models.py new file mode 100644 index 0000000..5d2c5b8 --- /dev/null +++ b/app/models.py @@ -0,0 +1,854 @@ +"""Database models for the Team Tryouts application. + +This module defines all SQLAlchemy models using: + - Single-table polymorphic inheritance for User → Admin, Manager, Coach, Player, Scout + - Abstract base classes for repeating hierarchies: BaseMatch, BaseParticipant, BaseAvailability + +All logic stays roughly the same; role-check chains are replaced with proper +polymorphic dispatch. +""" + +from app.extensions import db, login_manager +from flask_login import UserMixin +from datetime import datetime +from urllib.parse import quote + + +# --------------------------------------------------------------------------- +# 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}', +} + + +# --------------------------------------------------------------------------- +# Flask-Login user loader +# --------------------------------------------------------------------------- + +@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. + """ + return User.query.get(int(user_id)) + + +# =========================================================================== +# USER HIERARCHY (single-table polymorphic inheritance) +# =========================================================================== +# +# User (base, __tablename__ = 'users', polymorphic_on = 'role') +# ├── Admin ─ polymorphic_identity = 'admin' +# ├── Manager ─ polymorphic_identity = 'manager' +# ├── Coach ─ polymorphic_identity = 'coach' +# ├── Player ─ polymorphic_identity = 'player' +# └── Scout ─ polymorphic_identity = 'scout' +# +# Single-table inheritance keeps the DB simple while giving full isinstance() +# support and per-subclass methods. Every existing foreign-key pointing at +# users.id continues to work without migration. +# =========================================================================== + +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 [] + + +# --------------------------------------------------------------------------- +# Concrete user subclasses +# --------------------------------------------------------------------------- + +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): + return Tryout.query.order_by(Tryout.date).all() + + +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): + return Tryout.query.filter_by(created_by=self.id).order_by(Tryout.date).all() + + +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): + 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): + 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() + + +class Player(User): + """Player — registers for tryouts, manages their own profile.""" + + __mapper_args__ = {'polymorphic_identity': 'player'} + + def can_evaluate(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): + # 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] + + +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): + return Tryout.query.order_by(Tryout.date).all() + + +# =========================================================================== +# GAMERTAGS +# =========================================================================== + +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 + + +# =========================================================================== +# ABSTRACT BASE: AVAILABILITY (PlayerDisponibility + CoachAvailability) +# =========================================================================== + +class BaseAvailability(db.Model): + """Shared schema for player disponibilities and coach availabilities.""" + + __abstract__ = True + + day_of_week = db.Column(db.Integer, nullable=False) # 0=Monday … 6=Sunday + 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) + + +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') + + +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') + + +# =========================================================================== +# ASSOCIATION TABLES +# =========================================================================== + +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), +) + + +# =========================================================================== +# ORGANISATION TEAMS +# =========================================================================== + +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) + + # legacy single-assignment columns (kept for back-compat during migration) + 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) + + # --- helpers ----------------------------------------------------------- + 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] + + +class TeamPlayer(db.Model): + """Many-to-many: player ↔ 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'), + ) + + +# =========================================================================== +# TRYOUTS +# =========================================================================== + +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]) + + +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) + + +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'), + ) + + +# =========================================================================== +# TRYOUT-SPECIFIC TEAMS +# =========================================================================== + +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') + + +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") + + +# =========================================================================== +# ABSTRACT BASE: MATCH (Match + TeamMatch) +# =========================================================================== + +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') # scheduled | completed | cancelled + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +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) # team_vs_team | player_scrim | player_vs_player + 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()] + + +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) + + +# =========================================================================== +# ABSTRACT BASE: PARTICIPANT (MatchParticipant + TeamMatchParticipant) +# =========================================================================== + +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) + + +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) # 1 or 2 (player_vs_player) + position = db.Column(db.String(50), nullable=True) + attendance_confirmed = db.Column(db.Boolean, default=False) + + player = db.relationship('User') + + +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') + + +# =========================================================================== +# CONTRACTS +# =========================================================================== + +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 + 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 + + +# =========================================================================== +# NOTES +# =========================================================================== + +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]) + + +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]) + + +# =========================================================================== +# ONE-ON-ONE REQUESTS +# =========================================================================== + +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/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 72a3fd3..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 == 'admin': + 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 == '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] + 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 db2c91a..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 == 'admin': + 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 56% rename from routes/matches.py rename to app/routes/matches.py index 8c279c5..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 ['admin', '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 == 'admin': - 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,129 +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_str = request.form.get('team1_player_ids', '') team2_str = request.form.get('team2_player_ids', '') - team1_player_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else [] - team2_player_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else [] - notified_participant_ids = [] - for pid in team1_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) @@ -679,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') @@ -701,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//

)iN{vuAo>>d|1z__K&4M4) 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�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≪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 diff --git a/routes/__pycache__/users.cpython-313.pyc b/routes/__pycache__/users.cpython-313.pyc deleted file mode 100644 index 92499804006ade74b615d2a6306e6c9d1e08614c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78317 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 diff --git a/routes/users.py b/routes/users.py deleted file mode 100644 index cce8732..0000000 --- a/routes/users.py +++ /dev/null @@ -1,1638 +0,0 @@ -"""User management routes for profiles, disponibilities, and contracts. - -This module handles user CRUD operations, profile editing, player availability, -and contract management with secure file upload handling. -""" - -import os -import uuid -from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file -from flask_login import login_required, current_user -from extensions import db, hash_password, csrf -from models import User, ROLES, ESPORT_GAMES, PlayerDisponibility, UserGamertag, GAME_PLATFORMS, Contract, OrgTeam, CoachAvailability, TeamNote, PersonalNote, OneOnOneRequest, Evaluation, Match, Team, TeamMember, MatchParticipant, Tryout, TryoutRegistration, TeamPlayer -from werkzeug.utils import secure_filename -from datetime import datetime, timedelta, date as date_type -from marshmallow import ValidationError -from validators import CreateUserSchema, EditUserSchema, EditProfileSchema, UploadContractSchema, OneOnOneRequestSchema -import requests - -# Allowed file extensions for uploads -ALLOWED_CONTRACT_EXTENSIONS = {'pdf'} -ALLOWED_SIGNED_EXTENSIONS = {'pdf'} - - -users_bp = Blueprint('users', __name__, url_prefix='/users') - - -def update_user_gamertags(user, selected_games): - """Update gamertags for a user based on form input. - -++++++++++++++++++++++++++++++ - Handles creating, updating, and deleting gamertag records for the specified games. - Used by both edit_user and edit_profile routes to avoid code duplication. - - Args: - user: The User object to update gamertags for. - selected_games: List of game names that were selected in the form. - """ - # Get all existing gamertags for this user - existing_gamertags = {gt.game: gt for gt in user.gamertags} - - for game in selected_games: - gamertag = request.form.get(f'gamertag_{game}', '').strip() - platform = request.form.get(f'platform_{game}', '').strip() if GAME_PLATFORMS.get(game) else None - - existing = existing_gamertags.get(game) - if gamertag: - if existing: - existing.gamertag = gamertag - existing.platform = platform - else: - gt = UserGamertag(user_id=user.id, game=game, gamertag=gamertag, platform=platform) - db.session.add(gt) - elif existing: - db.session.delete(existing) - - # Remove gamertags for games that are no longer selected - for game in existing_gamertags: - if game not in selected_games: - db.session.delete(existing_gamertags[game]) - - -@users_bp.route('') -@login_required -def list_users(): - """List all users for management (president only). - - Displays all users ordered by role and name. Only accessible to presidents. - - Returns: - Response: Rendered users list template or redirect to dashboard. - """ - if current_user.role != 'admin': - flash('Only the president can manage users.', 'danger') - return redirect(url_for('main.dashboard')) - - users = User.query.order_by(User.role, User.username).all() - return render_template('pages/users.html', users=users, roles=ROLES) - - -@users_bp.route('//edit', methods=['GET', 'POST']) -@login_required -def edit_user(user_id): - """Edit an existing user (president only). - - GET: Render the user edit form. - POST: Update user details including gamertags and password. - - Args: - user_id: The ID of the user to edit. - - Returns: - Response: Edit form or redirect to users list. - """ - if current_user.role != 'admin': - flash('Only the president can edit users.', 'danger') - return redirect(url_for('main.dashboard')) - - user = User.query.get_or_404(user_id) - - if request.method == 'POST': - full_name = request.form.get('full_name') - email = request.form.get('email') - phone = request.form.get('phone') - role = request.form.get('role') - is_active = request.form.get('is_active_account') == 'on' - - if role not in ROLES: - flash('Invalid role selected.', 'danger') - return render_template('pages/edit_user.html', user=user, roles=ROLES, esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS) - - selected_games = request.form.getlist('games') - discord_username = request.form.get('discord_username', '').strip() - discord_user_id = request.form.get('discord_user_id', '').strip() - league_os_profile = request.form.get('league_os_profile', '').strip() - - user.full_name = full_name - user.email = email - user.phone = phone - user.role = role - user.is_active_account = is_active - user.games = ','.join(selected_games) if selected_games else None - user.discord_username = discord_username or None - user.discord_user_id = discord_user_id or None - user.league_os_profile = league_os_profile or None - - # Update gamertags using shared function - update_user_gamertags(user, selected_games) - - password = request.form.get('password') - if password: - user.password_hash = hash_password(password) - - db.session.commit() - flash(f'User {user.username} updated successfully!', 'success') - return redirect(url_for('users.list_users')) - - user_gamertags = {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in user.gamertags} - return render_template('pages/edit_user.html', user=user, roles=ROLES, esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, user_gamertags=user_gamertags) - - -@users_bp.route('//delete', methods=['POST']) -@login_required -def delete_user(user_id): - """Delete a user (president only). - - Args: - user_id: The ID of the user to delete. - - Returns: - Response: Redirect to users list with status message. - """ - if current_user.role != 'admin': - flash('Only the president can delete users.', 'danger') - return redirect(url_for('main.dashboard')) - - if current_user.id == user_id: - flash('You cannot delete your own account.', 'danger') - return redirect(url_for('users.list_users')) - - user = User.query.get_or_404(user_id) - - # Clean up related records before deleting the user - # 1. Delete evaluations involving this user - Evaluation.query.filter( - db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id) - ).delete(synchronize_session=False) - - # 2. Delete availability/disponibility records - PlayerDisponibility.query.filter_by(player_id=user_id).delete() - CoachAvailability.query.filter_by(coach_id=user_id).delete() - - # 3. Delete personal notes involving this user - PersonalNote.query.filter( - db.or_(PersonalNote.player_id == user_id, PersonalNote.coach_id == user_id) - ).delete(synchronize_session=False) - - # 4. Delete team notes authored by this user - TeamNote.query.filter_by(coach_id=user_id).delete() - - # 5. Delete one-on-one requests involving this user - OneOnOneRequest.query.filter( - db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id) - ).delete(synchronize_session=False) - - # 6. Delete gamertags - UserGamertag.query.filter_by(user_id=user_id).delete() - - # 7. Delete contracts for this player (and any they uploaded as manager/coach) - Contract.query.filter_by(player_id=user_id).delete() - - # 8. Delete tryout registrations - TryoutRegistration.query.filter_by(player_id=user_id).delete() - - # 9. Delete team placements - TeamPlayer.query.filter_by(player_id=user_id).delete() - - # 10. Delete team memberships in tryout teams - TeamMember.query.filter_by(player_id=user_id).delete() - - # 11. Delete match participants - MatchParticipant.query.filter_by(player_id=user_id).delete() - - # 12. Nullify org team references so the user can be deleted - OrgTeam.query.filter_by(coach_id=user_id).update({'coach_id': None}) - OrgTeam.query.filter_by(manager_id=user_id).update({'manager_id': None}) - - # 13. Handle records this user created (tryouts, matches, teams, org teams, contracts) - # Reassign created_by to the current president (deleting user) to avoid FK violations - Tryout.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) - Match.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) - Team.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) - OrgTeam.query.filter_by(created_by=user_id).update({'created_by': current_user.id}) - Contract.query.filter_by(uploaded_by_id=user_id).update({'uploaded_by_id': current_user.id}) - - db.session.delete(user) - db.session.commit() - flash(f'User {user.username} has been removed.', 'success') - return redirect(url_for('users.list_users')) - - -@users_bp.route('/create', methods=['GET', 'POST']) -@login_required -def create_user(): - """Create a new user (president only). - - GET: Render the user creation form. - POST: Create a new user with the provided details. - - Returns: - Response: Create form or redirect to users list. - """ - if current_user.role != 'admin': - flash('Only the president can create users.', 'danger') - return redirect(url_for('main.dashboard')) - - if request.method == 'POST': - username = request.form.get('username') - email = request.form.get('email') - password = request.form.get('password') - full_name = request.form.get('full_name') - phone = request.form.get('phone') - role = request.form.get('role') - - if role not in ROLES: - flash('Invalid role selected.', 'danger') - return render_template('pages/create_user.html', roles=ROLES) - - if User.query.filter_by(username=username).first(): - flash('Username already exists.', 'danger') - return render_template('pages/create_user.html', roles=ROLES) - - if User.query.filter_by(email=email).first(): - flash('Email already registered.', 'danger') - return render_template('pages/create_user.html', roles=ROLES) - - hashed_password = hash_password(password) - user = User( - username=username, - password_hash=hashed_password, - role=role, - full_name=full_name, - email=email, - phone=phone - ) - db.session.add(user) - db.session.commit() - flash(f'User {full_name} created as {role}!', 'success') - return redirect(url_for('users.list_users')) - - return render_template('pages/create_user.html', roles=ROLES) - - -@users_bp.route('//view') -@login_required -def view_user(user_id): - """View a public profile for any user. - - Shows username, games, Discord username, and gamertags. - Does NOT expose full_name, phone, or email. - - Args: - user_id: The ID of the user to view. - - Returns: - Response: Rendered public profile template. - """ - user = User.query.get_or_404(user_id) - return render_template('pages/view_user.html', profile_user=user) - - -@users_bp.route('/profile') -@login_required -def profile(): - """View the current user's profile. - - Players see their contracts along with their profile information. - - Returns: - Response: Rendered profile template. - """ - # Get contracts ordered by uploaded_at desc for the current user - contracts = None - if current_user.role == 'player': - contracts = Contract.query.filter_by(player_id=current_user.id).order_by(Contract.uploaded_at.desc()).all() - return render_template('pages/profile.html', user=current_user, contracts=contracts) - - -@users_bp.route('/profile/edit', methods=['GET', 'POST']) -@login_required -def edit_profile(): - """Edit the current user's profile. - - GET: Render the profile edit form. - POST: Update profile details including gamertags and password. - - Returns: - Response: Edit form or redirect to profile. - """ - if request.method == 'POST': - username = request.form.get('username') - full_name = request.form.get('full_name') - email = request.form.get('email') - phone = request.form.get('phone') - - selected_games = request.form.getlist('games') - discord_username = request.form.get('discord_username', '').strip() - discord_user_id = request.form.get('discord_user_id', '').strip() - league_os_profile = request.form.get('league_os_profile', '').strip() - - if username != current_user.username and User.query.filter_by(username=username).first(): - flash('Username already taken.', 'danger') - return render_template('pages/edit_profile.html', user=current_user, esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, user_gamertags={gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in current_user.gamertags}) - - if email != current_user.email and User.query.filter_by(email=email).first(): - flash('Email already in use.', 'danger') - return render_template('pages/edit_profile.html', user=current_user, esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, user_gamertags={gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in current_user.gamertags}) - - current_user.username = username - current_user.full_name = full_name - current_user.email = email - current_user.phone = phone - current_user.games = ','.join(selected_games) if selected_games else None - current_user.discord_username = discord_username or None - current_user.discord_user_id = discord_user_id or None - current_user.league_os_profile = league_os_profile or None - - # Update gamertags using shared function - update_user_gamertags(current_user, selected_games) - - password = request.form.get('password') - if password: - current_user.password_hash = hash_password(password) - - db.session.commit() - flash('Profile updated successfully!', 'success') - return redirect(url_for('users.profile')) - - user_gamertags = {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} for gt in current_user.gamertags} - return render_template('pages/edit_profile.html', user=current_user, esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS, user_gamertags=user_gamertags) - - -# Day names for disponibility display -DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] - - -def add_30_minutes(t): - """Add 30 minutes to a time object. - - Args: - t (datetime.time): The time object to add 30 minutes to. - - Returns: - datetime.time: New time 30 minutes later. - """ - return (datetime.combine(datetime.today(), t) + timedelta(minutes=30)).time() - - -@users_bp.route('/disponibilities') -@login_required -def get_disponibilities(): - """API endpoint to get all player disponibilities for scheduling. - - Only accessible to managers, coaches, and scouts. Used for match scheduling. - - Returns: - Response: JSON with disponibility data for all players. - """ - # Only managers and above can view disponibilities for scheduling - if not current_user.can_manage_teams() and not current_user.can_schedule_matches(): - return jsonify({'error': 'Unauthorized'}), 403 - - players = User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all() - result = {} - - for player in players: - disponibilities = list(player.disponibilities) - result[player.id] = { - 'username': player.username, - 'disponibilities': [ - { - 'id': d.id, - 'day_of_week': d.day_of_week, - 'day_name': DAY_NAMES[d.day_of_week], - 'start_time': d.start_time.strftime('%H:%M'), - 'end_time': d.end_time.strftime('%H:%M') - } - for d in disponibilities - ] - } - - return jsonify(result) - - -@users_bp.route('/disponibilities/my') -@login_required -def get_my_disponibilities(): - """API endpoint for players to get their own disponibilities. - - Returns: - Response: JSON with disponibility data grouped by day. - """ - disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all() - - # Group by day for easier display - result = {} - for d in disponibilities: - day = d.day_of_week - if day not in result: - result[day] = [] - result[day].append({ - 'id': d.id, - 'day_of_week': d.day_of_week, - 'day_name': DAY_NAMES[d.day_of_week], - 'start_time': d.start_time.strftime('%H:%M'), - 'end_time': d.end_time.strftime('%H:%M') - }) - - return jsonify(result) - - -@users_bp.route('/disponibilities/add', methods=['POST']) -@login_required -def add_disponibility(): - """Add a disponibility block for the current player. - - Returns: - Response: JSON with the created disponibility data. - """ - day_of_week = request.form.get('day_of_week', type=int) - start_time_str = request.form.get('start_time') - - if day_of_week is None or day_of_week < 0 or day_of_week > 6: - return jsonify({'error': 'Invalid day of week'}), 400 - - try: - start_time = datetime.strptime(start_time_str, '%H:%M').time() - except (ValueError, TypeError): - return jsonify({'error': 'Invalid time format'}), 400 - - end_time = add_30_minutes(start_time) - - disponibility = PlayerDisponibility( - player_id=current_user.id, - day_of_week=day_of_week, - start_time=start_time, - end_time=end_time - ) - - db.session.add(disponibility) - db.session.commit() - - return jsonify({ - 'id': disponibility.id, - 'day_of_week': disponibility.day_of_week, - 'day_name': DAY_NAMES[disponibility.day_of_week], - 'start_time': disponibility.start_time.strftime('%H:%M'), - 'end_time': disponibility.end_time.strftime('%H:%M') - }) - - -@users_bp.route('/disponibilities/add_bulk', methods=['POST']) -@login_required -def add_disponibilities_bulk(): - """Add multiple disponibility blocks at once (for grid selection). - - Used for the disponibility grid UI where players can select multiple - time slots at once. - - Returns: - Response: JSON with success status and created slots. - """ - data = request.get_json() - slots = data.get('slots', []) # List of {day_of_week, start_time} - - created = [] - for slot in slots: - day_of_week = slot.get('day_of_week') - start_time_str = slot.get('start_time') - - if day_of_week is None or day_of_week < 0 or day_of_week > 6: - continue - - try: - start_time = datetime.strptime(start_time_str, '%H:%M').time() - except (ValueError, TypeError): - continue - - end_time = add_30_minutes(start_time) - - # Check if this slot already exists for this player - existing = PlayerDisponibility.query.filter_by( - player_id=current_user.id, - day_of_week=day_of_week, - start_time=start_time - ).first() - - if not existing: - disponibility = PlayerDisponibility( - player_id=current_user.id, - day_of_week=day_of_week, - start_time=start_time, - end_time=end_time - ) - db.session.add(disponibility) - db.session.flush() - created.append({ - 'id': disponibility.id, - 'day_of_week': disponibility.day_of_week, - 'day_name': DAY_NAMES[disponibility.day_of_week], - 'start_time': disponibility.start_time.strftime('%H:%M'), - 'end_time': disponibility.start_time.strftime('%H:%M') - }) - - db.session.commit() - return jsonify({'success': True, 'created': created}) - - -@users_bp.route('/disponibilities/clear', methods=['POST']) -@login_required -def clear_disponibilities(): - """Clear all disponibilities for the current player (for resetting). - - Returns: - Response: JSON with success status. - """ - PlayerDisponibility.query.filter_by(player_id=current_user.id).delete() - db.session.commit() - return jsonify({'success': True}) - - -@users_bp.route('/disponibilities//delete', methods=['POST']) -@login_required -def delete_disponibility(disponibility_id): - """Delete a disponibility block. - - Args: - disponibility_id: The ID of the disponibility to delete. - - Returns: - Response: JSON with success status or error. - """ - disponibility = PlayerDisponibility.query.get_or_404(disponibility_id) - - # Only the owner can delete their disponibility - if disponibility.player_id != current_user.id: - return jsonify({'error': 'Unauthorized'}), 403 - - db.session.delete(disponibility) - db.session.commit() - - return jsonify({'success': True}) - - -# Contract Management Functions - - -def can_manage_player_contract(user, player_id): - """Check if a user can upload contracts for a specific player. - - Presidents and managers can manage all contracts. Coaches can only - manage contracts for players on their team. - - Args: - user: The User requesting to manage contracts. - player_id: The ID of the player whose contract is being managed. - - Returns: - bool: True if user has permission to manage the contract. - """ - # President can manage all contracts - if user.role == 'admin': - return True - - # Manager can upload contracts for any player - if user.role == 'manager': - return True - - # Coach can upload contracts only for players on their team - if user.role == 'coach': - org_team = OrgTeam.query.filter_by(coach_id=user.id).first() - if org_team: - tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=org_team.id).first() - if tp: - return True - return False - - -@users_bp.route('/contracts') -@login_required -def list_contracts(): - """View contracts for the current user (player) or players they manage. - - Players see their own contracts. Coaches/managers see contracts for - players on their teams. Presidents see all contracts. - - Returns: - Response: Rendered contracts list template. - """ - contracts = None - - if current_user.role == 'player': - # Players see their own contracts - contracts = Contract.query.filter_by(player_id=current_user.id).order_by(Contract.uploaded_at.desc()).all() - elif current_user.role in ['admin', 'manager', 'coach']: - # Superiors see contracts for players on their teams - players = [] - if current_user.role == 'coach': - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - if org_team: - player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()] - players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else [] - else: # president or manager - players = User.query.filter_by(role='player').all() - - if players: - player_ids = [p.id for p in players] - contracts = Contract.query.filter(Contract.player_id.in_(player_ids)).order_by(Contract.uploaded_at.desc()).all() - - return render_template('pages/contracts.html', contracts=contracts, players=players if current_user.role in ['admin', 'manager', 'coach'] else None) - - -@users_bp.route('/contracts/upload', methods=['GET', 'POST']) -@login_required -def upload_contract(): - """Upload a contract for a player. - - GET: Render the contract upload form. - POST: Save the uploaded contract file and create database record. - - Returns: - Response: Upload form or redirect to contracts list. - """ - if current_user.role not in ['admin', 'manager', 'coach']: - flash('Only presidents, managers, and coaches can upload contracts.', 'danger') - return redirect(url_for('users.list_contracts')) - - # Get players this user can manage - if current_user.role == 'coach': - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - if org_team: - player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()] - players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else [] - else: - players = [] - else: - players = User.query.filter_by(role='player').all() - - if request.method == 'POST': - # Validate form input - contract_schema = UploadContractSchema() - try: - validated = contract_schema.load(request.form) - except ValidationError as err: - for field, messages in err.messages.items(): - for msg in messages: - flash(f'{field}: {msg}', 'danger') - return render_template('pages/upload_contract.html', players=players) - - player_id = validated['player_id'] - notes = validated.get('notes') - - if not can_manage_player_contract(current_user, player_id): - flash('You do not have permission to upload a contract for this player.', 'danger') - return redirect(url_for('users.upload_contract')) - - if 'contract_file' not in request.files: - flash('No file selected.', 'danger') - return redirect(url_for('users.upload_contract')) - - file = request.files['contract_file'] - if file.filename == '': - flash('No file selected.', 'danger') - return redirect(url_for('users.upload_contract')) - - # Validate file extension (PDF only) - if not file.filename.lower().endswith('.pdf'): - flash('Only PDF files are allowed for contracts.', 'danger') - return redirect(url_for('users.upload_contract')) - - # Create upload directory - upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés') - os.makedirs(upload_dir, exist_ok=True) - - # Get player info for folder structure - player = User.query.get_or_404(player_id) - player_teams = player.get_org_teams() - team = player_teams[0] if player_teams else None - - # Create team folder if team exists - if team: - team_folder = os.path.join(upload_dir, secure_filename(team.name)) - os.makedirs(team_folder, exist_ok=True) - final_dir = team_folder - else: - final_dir = upload_dir - - # Generate UUID-based filename for security (prevents filename guessing) - original_filename = secure_filename(file.filename) - file_uuid = str(uuid.uuid4()) - stored_filename = f"{file_uuid}.pdf" - - # Save file - file_path = os.path.join(final_dir, stored_filename) - file.save(file_path) - - # Create database record - contract = Contract( - player_id=player_id, - team_id=team.id if team else None, - uploaded_by_id=current_user.id, - original_filename=original_filename, - stored_filename=stored_filename, - file_path=file_path, - notes=notes if notes else None - ) - db.session.add(contract) - db.session.commit() - - flash(f'Contract uploaded successfully for {player.username}!', 'success') - return redirect(url_for('users.list_contracts')) - - return render_template('pages/upload_contract.html', players=players) - - -@users_bp.route('/contracts//upload_signed', methods=['POST']) -@login_required -def upload_signed_contract(contract_id): - """Upload a signed contract (player only). - - Args: - contract_id: The ID of the contract to upload signed version for. - - Returns: - Response: Redirect to contracts list with status message. - """ - contract = Contract.query.get_or_404(contract_id) - - if not contract.can_upload_signed(current_user): - flash('Only the player can upload their signed contract.', 'danger') - return redirect(url_for('users.list_contracts')) - - if 'signed_file' not in request.files: - flash('No file selected.', 'danger') - return redirect(url_for('users.list_contracts')) - - file = request.files['signed_file'] - if file.filename == '': - flash('No file selected.', 'danger') - return redirect(url_for('users.list_contracts')) - - # Generate unique filename for signed contract - original_filename = secure_filename(file.filename) - signed_filename = f"signed_{contract.stored_filename}" - - # Save file - file.save(contract.file_path.replace(contract.stored_filename, signed_filename)) - - # Update contract record - contract.signed_filename = signed_filename - contract.signed_file_path = contract.file_path.replace(contract.stored_filename, signed_filename) - contract.status = 'signed' - contract.signed_at = datetime.utcnow() - db.session.commit() - - flash('Signed contract uploaded successfully!', 'success') - return redirect(url_for('users.list_contracts')) - - -@users_bp.route('/contracts//download') -@login_required -def download_contract(contract_id): - """Download a contract file. - - Args: - contract_id: The ID of the contract to download. - - Returns: - Response: File download response. - """ - contract = Contract.query.get_or_404(contract_id) - - if not contract.can_view(current_user): - flash('You do not have permission to download this contract.', 'danger') - return redirect(url_for('users.list_contracts')) - - return send_file(contract.file_path, as_attachment=True, download_name=contract.original_filename) - - -@users_bp.route('/contracts//download_signed') -@login_required -def download_signed_contract(contract_id): - """Download a signed contract file. - - Args: - contract_id: The ID of the contract to download the signed version for. - - Returns: - Response: File download response. - """ - contract = Contract.query.get_or_404(contract_id) - - if not contract.can_view(current_user): - flash('You do not have permission to download this contract.', 'danger') - return redirect(url_for('users.list_contracts')) - - if not contract.signed_file_path: - flash('No signed contract available.', 'danger') - return redirect(url_for('users.list_contracts')) - - return send_file(contract.signed_file_path, as_attachment=True, download_name=contract.signed_filename) - - -# One on One Functions - - -DISCORD_WEBHOOK_URL = os.environ.get('DISCORD_WEBHOOK_URL', '') - - -def send_discord_notification(player_name, points, date_str, start_time_str, end_time_str, team_name, coach_name, coach_discord, coach_discord_id, request_id=None): - """Send a Discord notification for a One on One request. - - Sends a DM to the coach via Discord bot if their Discord User ID is configured, - otherwise falls back to webhook notification. - - Args: - player_name: Name of the player making the request. - points: Discussion points from the player. - date_str: Date of the requested session. - start_time_str: Start time of the requested session. - end_time_str: End time of the requested session. - team_name: Name of the player's team. - coach_name: Name of the coach. - coach_discord: Coach's Discord username. - coach_discord_id: Coach's Discord User ID (for DMs). - """ - import logging - logger = logging.getLogger(__name__) - - # Try to send DM via Discord bot first (requires coach's Discord User ID) - if coach_discord_id: - try: - from discord_bot import send_one_on_one_dm - send_one_on_one_dm( - coach_name=coach_name, - coach_discord_id=coach_discord_id, - player_name=player_name, - team_name=team_name, - date_str=date_str, - start_time=start_time_str, - end_time=end_time_str, - points=points, - request_id=request_id - ) - except Exception as e: - logger.warning(f"Failed to send Discord DM: {e}") - - # Also send to webhook as backup/fallback - if DISCORD_WEBHOOK_URL: - try: - from discord_bot import send_one_on_one_dm - - # Try to send DM to webhook URL if it's a user ID - if DISCORD_WEBHOOK_URL.isdigit() and not coach_discord_id: - send_one_on_one_dm( - coach_name=coach_name, - coach_discord_id=DISCORD_WEBHOOK_URL, - player_name=player_name, - team_name=team_name, - date_str=date_str, - start_time=start_time_str, - end_time=end_time_str, - points=points - ) - elif not DISCORD_WEBHOOK_URL.isdigit(): - # Legacy webhook URL - send traditional webhook embed - embed = { - "embeds": [{ - "title": "One on One Request", - "color": 3447003, - "fields": [ - {"name": "Player", "value": player_name, "inline": True}, - {"name": "Team", "value": team_name or "Unknown Team", "inline": True}, - {"name": "Date", "value": date_str, "inline": True}, - {"name": "Time", "value": f"{start_time_str} - {end_time_str}", "inline": True}, - {"name": "Discussion Points", "value": points or "No specific points provided", "inline": False} - ], - "footer": { - "text": f"Coach: {coach_name}" + (f" (Discord: {coach_discord})" if coach_discord else "") - } - }] - } - requests.post(DISCORD_WEBHOOK_URL, json=embed, timeout=5) - except Exception as e: - logger.warning(f"Failed to send Discord notification: {e}") - -@users_bp.route('/one-on-one', methods=['GET', 'POST']) -@login_required -def one_on_one(): - """One on One request page for players. - - Players can view team notes, personal notes, and request One on One sessions. - - Returns: - Response: Rendered One on One page. - """ - if current_user.role != 'player': - flash('Only players can request One on One sessions.', 'danger') - return redirect(url_for('main.dashboard')) - - # Get player's teams and coach (use the first team the player is on) - org_teams = current_user.get_org_teams() - org_team = org_teams[0] if org_teams else None - coach = User.query.get(org_team.coach_id) if org_team and org_team.coach_id else None - - if not coach: - flash('You do not have a coach assigned to your team.', 'info') - - # Get team notes for this player's team - team_notes = [] - if org_team: - team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() - - # Get personal notes for this player - personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all() - - # Get coach's availability for the next 7 days - coach_availability = [] - if coach: - coach_availability = CoachAvailability.query.filter_by(coach_id=coach.id).all() - - if request.method == 'POST': - # Handle One on One request submission - date_str = request.form.get('date') - start_time_str = request.form.get('start_time') - end_time_str = request.form.get('end_time') - points = request.form.get('points', '').strip() - - if not coach: - flash('Cannot request One on One - no coach assigned.', 'danger') - return redirect(url_for('users.one_on_one')) - - # Validate date and time - try: - date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() - start_time = datetime.strptime(start_time_str, '%H:%M').time() - end_time = datetime.strptime(end_time_str, '%H:%M').time() - except (ValueError, TypeError): - flash('Invalid date or time format.', 'danger') - return redirect(url_for('users.one_on_one')) - - # Check if the requested slot is within coach's availability - date_parts = date_str.split('-') - check_date = datetime(int(date_parts[0]), int(date_parts[1]), int(date_parts[2])) - # Python weekday: Monday=0, Sunday=6 - day_of_week = check_date.weekday() - - is_available = any( - av.day_of_week == day_of_week and - av.start_time <= start_time and - av.end_time >= end_time - for av in coach_availability - ) - - if not is_available: - flash('The requested time is not within the coach\'s availability.', 'danger') - return redirect(url_for('users.one_on_one')) - - # Create the request - request_obj = OneOnOneRequest( - player_id=current_user.id, - coach_id=coach.id, - org_team_id=org_team.id if org_team else None, - date=date_obj, - start_time=start_time, - end_time=end_time, - points=points if points else None - ) - db.session.add(request_obj) - db.session.commit() - - # Send Discord notification - send_discord_notification( - player_name=current_user.username, - points=points, - date_str=date_str, - start_time_str=start_time_str, - end_time_str=end_time_str, - team_name=org_team.name if org_team else None, - coach_name=coach.username, - coach_discord=coach.discord_username, - coach_discord_id=coach.discord_user_id, - request_id=request_obj.id - ) - - flash('One on One request sent to your coach!', 'success') - return redirect(url_for('users.one_on_one')) - - # Calculate dates for the next week (starting from today) - dates = [] - for i in range(7): - d = date_type.today() + timedelta(days=i) - dates.append({ - 'value': d.strftime('%Y-%m-%d'), - 'display': d.strftime('%A, %b %d'), - 'day_of_week': d.weekday() - }) - - # Serialize coach availability for JavaScript - coach_availability_serialized = [ - { - 'id': av.id, - 'day_of_week': av.day_of_week, - 'start_time': av.start_time.strftime('%H:%M'), - 'end_time': av.end_time.strftime('%H:%M') - } - for av in coach_availability - ] - - return render_template('pages/one_on_one.html', - org_team=org_team, - coach=coach, - team_notes=team_notes, - personal_notes=personal_notes, - coach_availability=coach_availability_serialized, - dates=dates) - - -@users_bp.route('/coach-availability', methods=['GET', 'POST']) -@login_required -@csrf.exempt -def manage_coach_availability(): - """Manage coach availability (for coaches). - - GET: Render the availability management page. - POST: Add availability slots via bulk. - - Returns: - Response: Rendered management page or redirect. - """ - if current_user.role != 'coach': - flash('Only coaches can manage availability.', 'danger') - return redirect(url_for('main.dashboard')) - - if request.method == 'POST': - data = request.get_json() - slots = data.get('slots', []) - - # Clear all existing availability slots first, then re-insert from client state. - # This fixes the bug where un-toggling a slot on the client did not delete it - # from the database (the old code only added, never removed). - CoachAvailability.query.filter_by(coach_id=current_user.id).delete() - - created = [] - for slot in slots: - day_of_week = slot.get('day_of_week') - start_time_str = slot.get('start_time') - - if day_of_week is None or day_of_week < 0 or day_of_week > 6: - continue - - try: - start_time = datetime.strptime(start_time_str, '%H:%M').time() - except (ValueError, TypeError): - continue - - end_time = add_30_minutes(start_time) - - availability = CoachAvailability( - coach_id=current_user.id, - day_of_week=day_of_week, - start_time=start_time, - end_time=end_time - ) - db.session.add(availability) - db.session.flush() - created.append({ - 'id': availability.id, - 'day_of_week': availability.day_of_week, - 'day_name': DAY_NAMES[availability.day_of_week], - 'start_time': availability.start_time.strftime('%H:%M'), - 'end_time': availability.end_time.strftime('%H:%M') - }) - - db.session.commit() - return jsonify({'success': True, 'created': created}) - - # Get existing availability - existing_availability = CoachAvailability.query.filter_by(coach_id=current_user.id).all() - - return render_template('pages/coach_availability.html', - existing_availability=existing_availability) - - -@users_bp.route('/coach-availability/clear', methods=['POST']) -@login_required -@csrf.exempt -def clear_coach_availability(): - """Clear all coach availability slots. - - Returns: - Response: JSON with success status. - """ - if current_user.role != 'coach': - return jsonify({'error': 'Unauthorized'}), 403 - - CoachAvailability.query.filter_by(coach_id=current_user.id).delete() - db.session.commit() - return jsonify({'success': True}) - - -@users_bp.route('/coach-availability//delete', methods=['POST']) -@login_required -@csrf.exempt -def delete_coach_availability(availability_id): - """Delete a coach availability slot. - - Args: - availability_id: The ID of the availability slot to delete. - - Returns: - Response: JSON with success status or error. - """ - if current_user.role != 'coach': - return jsonify({'error': 'Unauthorized'}), 403 - - availability = CoachAvailability.query.get_or_404(availability_id) - - if availability.coach_id != current_user.id: - return jsonify({'error': 'Unauthorized'}), 403 - - db.session.delete(availability) - db.session.commit() - return jsonify({'success': True}) - - -@users_bp.route('/api/coach-availability/') -@login_required -def api_get_coach_availability(coach_id): - """API endpoint to get coach availability. - - Args: - coach_id: The ID of the coach. - - Returns: - Response: JSON with availability data. - """ - if current_user.role != 'player': - return jsonify({'error': 'Unauthorized'}), 403 - - # Players can only view their own coach's availability - player_teams = current_user.get_org_teams() - org_team = player_teams[0] if player_teams else None - if org_team and org_team.coach_id != coach_id: - return jsonify({'error': 'Unauthorized'}), 403 - - availability = CoachAvailability.query.filter_by(coach_id=coach_id).all() - - result = {} - for av in availability: - if av.day_of_week not in result: - result[av.day_of_week] = [] - result[av.day_of_week].append({ - 'id': av.id, - 'start_time': av.start_time.strftime('%H:%M'), - 'end_time': av.end_time.strftime('%H:%M') - }) - - return jsonify(result) - - -@users_bp.route('/team-notes', methods=['GET', 'POST']) -@login_required -def manage_team_notes(): - """Manage team improvement notes (for coaches). - - GET: Render the team notes management page. - POST: Create or update team notes. - - Returns: - Response: Rendered management page or redirect. - """ - if current_user.role != 'coach': - flash('Only coaches can manage team notes.', 'danger') - return redirect(url_for('main.dashboard')) - - # Get the coach's team - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - if not org_team: - flash('You are not assigned to coach any team.', 'danger') - return redirect(url_for('main.dashboard')) - - # Get existing team notes - team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() - - if request.method == 'POST': - content = request.form.get('content', '').strip() - - if content: - # Create new team note entry (keeps history) - note = TeamNote( - org_team_id=org_team.id, - coach_id=current_user.id, - content=content - ) - db.session.add(note) - db.session.commit() - flash('Team notes added successfully!', 'success') - return redirect(url_for('users.manage_team_notes')) - - return render_template('pages/team_notes.html', - org_team=org_team, - team_notes=team_notes) - - -@users_bp.route('/personal-notes', methods=['GET', 'POST']) -@login_required -def manage_personal_notes(): - """Manage personal notes for players (for coaches). - - GET: Render the personal notes management page. - POST: Create a personal note for a player. - - Returns: - Response: Rendered management page or redirect. - """ - if current_user.role != 'coach': - flash('Only coaches can manage personal notes.', 'danger') - return redirect(url_for('main.dashboard')) - - # Get players on the coach's team - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - players = [] - if org_team: - player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()] - players = User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() if player_ids else [] - - if request.method == 'POST': - player_id = request.form.get('player_id', type=int) - content = request.form.get('content', '').strip() - - if not player_id or not content: - flash('Please select a player and enter note content.', 'danger') - return redirect(url_for('users.manage_personal_notes')) - - # Verify player is on coach's team - if org_team and player_id not in [p.id for p in players]: - flash('You can only add notes for players on your team.', 'danger') - return redirect(url_for('users.manage_personal_notes')) - - note = PersonalNote( - player_id=player_id, - coach_id=current_user.id, - content=content - ) - db.session.add(note) - db.session.commit() - flash('Personal note added successfully!', 'success') - return redirect(url_for('users.manage_personal_notes')) - - # Get all personal notes for players on this team - personal_notes = [] - if org_team: - personal_notes = PersonalNote.query.filter( - PersonalNote.player_id.in_([p.id for p in players]) - ).order_by(PersonalNote.created_at.desc()).all() - - return render_template('pages/personal_notes.html', - players=players, - personal_notes=personal_notes, - org_team=org_team) - - -@users_bp.route('/notes', methods=['GET']) -@login_required -def notes_dashboard(): - """Unified notes dashboard for coaches. - - GET: Render the combined notes management page with both team and personal notes forms. - - Returns: - Response: Rendered notes dashboard template. - """ - if current_user.role != 'coach': - flash('Only coaches can manage notes.', 'danger') - return redirect(url_for('main.dashboard')) - - # Get the coach's team - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - players = [] - if org_team: - player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()] - players = User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() if player_ids else [] - - # Get existing team notes - team_notes = [] - latest_team_note = None - if org_team: - team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() - latest_team_note = team_notes[0] if team_notes else None - - # Get all personal notes for players on this team - personal_notes = [] - if org_team: - personal_notes = PersonalNote.query.filter( - PersonalNote.player_id.in_([p.id for p in players]) - ).order_by(PersonalNote.created_at.desc()).all() - - # Get available matches and tryouts for context - matches = [] - tryouts = [] - teams = [] - if current_user.role == 'coach' and org_team: - tryouts = Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date.desc()).all() - matches = Match.query.join(Tryout).filter(Tryout.target_org_team_id == org_team.id).order_by(Match.date.desc()).all() - teams = Team.query.order_by(Team.name).all() - - return render_template('pages/notes.html', - org_team=org_team, - players=players, - team_notes=team_notes, - latest_team_note=latest_team_note, - personal_notes=personal_notes, - matches=matches, - tryouts=tryouts, - teams=teams) - - -# Note source types for filtering -NOTE_SOURCE_MATCH = 'match' -NOTE_SOURCE_TRYOUT = 'tryout' -NOTE_SOURCE_TEAM = 'team' - - -@users_bp.route('/my-notes') -@login_required -def my_notes(): - """View all notes for the current player. - - Shows both personal notes and team notes that the player has received. - Personal notes are grouped by source (match, tryout, team). - - Returns: - Response: Rendered my notes template. - """ - if current_user.role != 'player': - flash('Only players can view their notes.', 'danger') - return redirect(url_for('main.dashboard')) - - # Get player's teams and coach (use the first team the player is on) - player_teams = current_user.get_org_teams() - org_team = player_teams[0] if player_teams else None - - # Get all personal notes for this player with eager loading for relationships - personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all() - - # Get team notes for this player's team - team_notes = [] - if org_team: - team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all() - - return render_template('pages/player_personal_notes.html', - org_team=org_team, - personal_notes=personal_notes, - team_notes=team_notes) - - -@users_bp.route('/notes/add', methods=['GET', 'POST']) -@login_required -def add_personal_note(): - """Add a personal note with optional context (for coaches and managers). - - GET: Render the note creation form. - POST: Create a personal note, optionally linked to match/tryout/team. - - Returns: - Response: Create form or redirect to notes view. - """ - if current_user.role not in ['coach', 'manager', 'admin']: - flash('Only coaches and managers can add notes.', 'danger') - return redirect(url_for('main.dashboard')) - - # Get players this user can manage - players = [] - org_team = None - if current_user.role == 'coach': - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - if org_team: - player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()] - players = User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() if player_ids else [] - elif current_user.role in ['admin', 'manager']: - players = User.query.filter_by(role='player').order_by(User.username).all() - - # Get available matches and tryouts for context - matches = [] - tryouts = [] - teams = [] - if current_user.role == 'coach' and org_team: - tryouts = Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date.desc()).all() - matches = Match.query.join(Tryout).filter(Tryout.target_org_team_id == org_team.id).order_by(Match.date.desc()).all() - elif current_user.role in ['admin', 'manager']: - tryouts = Tryout.query.order_by(Tryout.date.desc()).all() - matches = Match.query.order_by(Match.date.desc()).all() - teams = Team.query.order_by(Team.name).all() - - if request.method == 'POST': - player_id = request.form.get('player_id', type=int) - content = request.form.get('content', '').strip() - match_id = request.form.get('match_id', type=int) - tryout_id = request.form.get('tryout_id', type=int) - team_id = request.form.get('team_id', type=int) - - if not player_id or not content: - flash('Please select a player and enter note content.', 'danger') - return redirect(url_for('users.add_personal_note')) - - # Verify player is on coach's team (if coach) - if current_user.role == 'coach' and org_team and player_id not in [p.id for p in players]: - flash('You can only add notes for players on your team.', 'danger') - return redirect(url_for('users.add_personal_note')) - - # Validate context - ensure coach can access the match/tryout/team - if match_id: - match = Match.query.get(match_id) - if match: - match_tryout = Tryout.query.get(match.tryout_id) - # For coaches, verify the match is in their team's tryout - if current_user.role == 'coach' and org_team: - if match_tryout and match_tryout.target_org_team_id and match_tryout.target_org_team_id != org_team.id: - flash('You can only add notes for matches in your team\'s tryouts.', 'danger') - return redirect(url_for('users.add_personal_note')) - if tryout_id: - tryout = Tryout.query.get(tryout_id) - # For coaches, verify the tryout targets their team - if current_user.role == 'coach' and org_team: - if tryout and tryout.target_org_team_id and tryout.target_org_team_id != org_team.id: - flash('You can only add notes for your team\'s tryouts.', 'danger') - return redirect(url_for('users.add_personal_note')) - if team_id: - team = Team.query.get(team_id) - if team: - team_tryout = Tryout.query.get(team.tryout_id) - # For coaches, verify the team is in their tryout - if current_user.role == 'coach' and org_team: - if team_tryout and team_tryout.target_org_team_id and team_tryout.target_org_team_id != org_team.id: - flash('You can only add notes for your team\'s tryouts.', 'danger') - return redirect(url_for('users.add_personal_note')) - - note = PersonalNote( - player_id=player_id, - coach_id=current_user.id, - content=content, - match_id=match_id if match_id else None, - team_id=team_id if team_id else None, - tryout_id=tryout_id if tryout_id else None - ) - db.session.add(note) - db.session.commit() - flash('Personal note added successfully!', 'success') - return redirect(url_for('users.notes_dashboard')) - - return render_template('pages/add_note.html', - players=players, - matches=matches, - tryouts=tryouts, - teams=teams, - org_team=org_team, - context_type='general') - - -@users_bp.route('/match//add-note', methods=['GET', 'POST']) -@login_required -def add_note_from_match(match_id): - """Add a personal note from a match view context (for coaches). - - GET: Render the note creation form pre-filled with match info. - POST: Create a personal note linked to the match. - - Args: - match_id: The ID of the match to create note for. - - Returns: - Response: Create form or redirect. - """ - if current_user.role not in ['coach', 'manager', 'admin']: - flash('Only coaches can add notes from matches.', 'danger') - return redirect(url_for('main.dashboard')) - - match = Match.query.get_or_404(match_id) - tryout = Tryout.query.get(match.tryout_id) - - # Check permissions - if current_user.role == 'coach': - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - if not org_team or (tryout.target_org_team_id and tryout.target_org_team_id != org_team.id): - flash('You do not have permission for this match.', 'danger') - return redirect(url_for('matches.calendar')) - - # Get all participants for this match - participant_ids = [] - if match.match_type == 'team_vs_team': - if match.team1_id: - team_members = TeamMember.query.filter_by(team_id=match.team1_id).all() - participant_ids.extend([m.player_id for m in team_members]) - if match.team2_id: - team_members = TeamMember.query.filter_by(team_id=match.team2_id).all() - participant_ids.extend([m.player_id for m in team_members]) - else: - participants = MatchParticipant.query.filter_by(match_id=match_id).all() - participant_ids = [p.player_id for p in participants] - - players = User.query.filter(User.id.in_(participant_ids)).order_by(User.username).all() if participant_ids else [] - - # Get team notes for context - team_notes = [] - if tryout and tryout.target_org_team_id: - team_notes = TeamNote.query.filter_by(org_team_id=tryout.target_org_team_id).order_by(TeamNote.created_at.desc()).all() - - if request.method == 'POST': - player_id = request.form.get('player_id', type=int) - content = request.form.get('content', '').strip() - - if not player_id or not content: - flash('Please select a player and enter note content.', 'danger') - elif player_id not in participant_ids: - flash('Selected player is not in this match.', 'danger') - else: - note = PersonalNote( - player_id=player_id, - coach_id=current_user.id, - content=content, - match_id=match_id - ) - db.session.add(note) - db.session.commit() - flash('Personal note added successfully!', 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) - - return render_template('pages/add_note.html', - match=match, - tryout=tryout, - players=players, - team_notes=team_notes, - context_type='match') - - -@users_bp.route('/tryout//add-note', methods=['GET', 'POST']) -@login_required -def add_note_from_tryout(tryout_id): - """Add a personal note from a tryout view context (for coaches). - - GET: Render the note creation form pre-filled with tryout info. - POST: Create a personal note linked to the tryout. - - Args: - tryout_id: The ID of the tryout to create note for. - - Returns: - Response: Create form or redirect. - """ - if current_user.role not in ['coach', 'manager', 'admin']: - flash('Only coaches can add notes from tryouts.', 'danger') - return redirect(url_for('main.dashboard')) - - tryout = Tryout.query.get_or_404(tryout_id) - - # Check permissions - org_team = None - if current_user.role == 'coach': - org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() - if not org_team or (tryout.target_org_team_id and tryout.target_org_team_id != org_team.id): - flash('You do not have permission for this tryout.', 'danger') - return redirect(url_for('tryouts.list_tryouts')) - - # Get all registered players in this tryout - registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() - player_ids = [r.player_id for r in registrations] - - # If coach, filter to only their team players; otherwise include all - if org_team: - team_player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()] - players = User.query.filter(User.id.in_(player_ids), User.id.in_(team_player_ids)).order_by(User.username).all() - else: - players = User.query.filter(User.id.in_(player_ids)).order_by(User.username).all() - - # Get team notes for context - team_notes = [] - if tryout.target_org_team_id: - team_notes = TeamNote.query.filter_by(org_team_id=tryout.target_org_team_id).order_by(TeamNote.created_at.desc()).all() - - if request.method == 'POST': - player_id = request.form.get('player_id', type=int) - content = request.form.get('content', '').strip() - - if not player_id or not content: - flash('Please select a player and enter note content.', 'danger') - elif player_id not in [p.id for p in players]: - flash('Selected player is not registered for this tryout.', 'danger') - else: - note = PersonalNote( - player_id=player_id, - coach_id=current_user.id, - content=content, - tryout_id=tryout_id - ) - db.session.add(note) - db.session.commit() - flash('Personal note added successfully!', 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) - - # Allow pre-selecting a player via query parameter - preselected_player_id = request.args.get('player_id', type=int) - - return render_template('pages/add_note.html', - tryout=tryout, - players=players, - team_notes=team_notes, - preselected_player_id=preselected_player_id, - context_type='tryout') diff --git a/run.py b/run.py new file mode 100644 index 0000000..9e07aeb --- /dev/null +++ b/run.py @@ -0,0 +1,26 @@ +"""Entry point for the Team Tryouts application. + +Usage (from the project root): + python run.py + +Or for production with Waitress: + python app/wsgi.py +""" + +import os +import sys + +# Ensure the project root is on sys.path so 'app' is importable as a package +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.app import create_app + +if __name__ == '__main__': + app = create_app() + debug_mode = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' + if debug_mode: + app.logger.warning( + 'Running in DEBUG mode with Flask built-in server. ' + 'This is NOT suitable for production. Use wsgi.py instead.' + ) + app.run(debug=debug_mode, host='127.0.0.2', port=5000) \ No newline at end of file diff --git a/seed.py b/seed.py deleted file mode 100644 index 2d4ada2..0000000 --- a/seed.py +++ /dev/null @@ -1,523 +0,0 @@ -"""Database seeding script for Team Tryouts application. - -This module provides functions to seed the database with sample data including -users, tryouts, teams, evaluations, and player disponibilities. -""" - -from sqlalchemy import text -from extensions import db, hash_password -from models import User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, OrgTeam, PlayerDisponibility, UserGamertag, CoachAvailability, TeamNote, PersonalNote, Match, MatchParticipant, TeamPlayer -from datetime import datetime, timedelta, time -import random - - -def seed_database(): - """Seed the database with sample data for development and testing. - - Creates sample users with different roles (president, manager, coach, player, scout), - organization teams, tryouts, registrations, evaluations, and player disponibilities. - All existing data is cleared before seeding. - - The function creates: - - 7 admin users (president, 2 managers, 3 coaches, 1 scout) - - 10 player users with E-Sports profiles - - 4 organization teams - - 4 tryouts - - Registrations and evaluations - - Player disponibilities for match scheduling - """ - # Clear existing data - db.session.execute(text('DELETE FROM player_disponibilities')) - db.session.execute(text('DELETE FROM match_participants')) - db.session.execute(text('DELETE FROM matches')) - db.session.execute(text('DELETE FROM team_players')) - db.session.execute(text('DELETE FROM team_members')) - db.session.execute(text('DELETE FROM teams')) - db.session.execute(text('DELETE FROM evaluations')) - db.session.execute(text('DELETE FROM tryout_registrations')) - db.session.execute(text('DELETE FROM tryouts')) - db.session.execute(text('DELETE FROM org_teams')) - db.session.execute(text('DELETE FROM user_gamertags')) - db.session.execute(text('DELETE FROM users')) - db.session.commit() - - # Create users with different roles - users_data = [ - {'username': 'admin', 'password': 'password', 'role': 'admin', 'full_name': 'Sarah Johnson', 'email': 'sarah@teampro.com', 'phone': '555-0101'}, - {'username': 'manager1', 'password': 'password', 'role': 'manager', 'full_name': 'Mike Williams', 'email': 'mike@teampro.com', 'phone': '555-0102'}, - {'username': 'manager2', 'password': 'password', 'role': 'manager', 'full_name': 'Emily Davis', 'email': 'emily@teampro.com', 'phone': '555-0103'}, - {'username': 'coach1', 'password': 'password', 'role': 'coach', 'full_name': 'Coach Thompson', 'email': 'coach.t@teampro.com', 'phone': '555-0104', 'discord_user_id': '484107446298738689'}, - {'username': 'coach2', 'password': 'password', 'role': 'coach', 'full_name': 'Coach Martinez', 'email': 'coach.m@teampro.com', 'phone': '555-0105'}, - {'username': 'coach3', 'password': 'password', 'role': 'coach', 'full_name': 'Coach Anderson', 'email': 'coach.a@teampro.com', 'phone': '555-0106'}, - {'username': 'scout1', 'password': 'password', 'role': 'scout', 'full_name': 'Alex Rivera', 'email': 'alex@teampro.com', 'phone': '555-0107'}, - ] - - # Create players with E-Sports profile data (gamertags per game) - player_data = [ - {'username': 'jplayer1', 'full_name': 'nordjan', 'email': 'nordjan27@gmail.com', 'games': 'Valorant, Counter-Strike 2, Rainbow Six Siege, Rocket League, Overwatch 2', - 'gamertags': {'Valorant': 'nordjan#bad', 'Counter-Strike 2': 'nordjan', 'Rainbow Six Siege': 'n0rd-vpn', 'Rocket League': 'nordjiano'}, - 'discord': 'nordjan', 'discord_user_id': '484107446298738689', 'league_os': 'https://leagueos.gg/player/nordjan'}, - {'username': 'jplayer2', 'full_name': 'Emma Garcia', 'email': 'emma@email.com', 'games': 'League of Legends,Valorant', - 'gamertags': {'League of Legends': 'emmagarcia_lol', 'Valorant': 'emmagarcia_val'}, - 'discord': 'EmmaG#4452', 'discord_user_id': '', 'league_os': 'https://leagueos.gg/player/emmagarcia'}, - {'username': 'jplayer3', 'full_name': 'Liam Brown', 'email': 'liam@email.com', 'games': 'Apex Legends,Fortnite', - 'gamertags': {'Apex Legends': 'liambrown_apex', 'platform': 'PC', 'Fortnite': 'liambrown_fn', 'platform_fn': 'PC'}, - 'discord': 'LiamB#8103', 'discord_user_id': '', 'league_os': 'https://leagueos.gg/player/liambrown'}, - {'username': 'jplayer4', 'full_name': 'Sophia Lee', 'email': 'sophia@email.com', 'games': 'Overwatch 2,Valorant', - 'gamertags': {'Overwatch 2': 'sophialee_ow', 'platform_ow': 'PC', 'Valorant': 'sophialee_val'}, - 'discord': 'SophiaL#3327', 'discord_user_id': '', 'league_os': 'https://leagueos.gg/player/sophialee'}, - {'username': 'jplayer5', 'full_name': 'Noah Taylor', 'email': 'noah@email.com', 'games': 'Counter-Strike 2,Rainbow Six Siege', - 'gamertags': {'Counter-Strike 2': 'noahtaylor_cs', 'Rainbow Six Siege': 'noahtaylor_r6', 'platform_r6': 'PC'}, - 'discord': 'NoahT#6614', 'discord_user_id': '', 'league_os': 'https://leagueos.gg/player/noahtaylor'}, - {'username': 'jplayer6', 'full_name': 'Olivia Martin', 'email': 'olivia@email.com', 'games': 'Rocket League,Fortnite', - 'gamertags': {'Rocket League': 'oliviamartin_rl', 'platform_rl': 'PC', 'Fortnite': 'oliviamartin_fn', 'platform_fn': 'PC'}, - 'discord': 'OliviaM#2298', 'discord_user_id': '', 'league_os': 'https://leagueos.gg/player/oliviamartin'}, - {'username': 'jplayer7', 'full_name': 'Ethan Clark', 'email': 'ethan@email.com', 'games': 'Valorant,Apex Legends', - 'gamertags': {'Valorant': 'ethanclark_val', 'Apex Legends': 'ethanclark_apex', 'platform_apex': 'PC'}, - 'discord': 'EthanC#7743', 'discord_user_id': '', 'league_os': 'https://leagueos.gg/player/ethanclark'}, - {'username': 'jplayer8', 'full_name': 'Ava White', 'email': 'ava@email.com', 'games': 'League of Legends,Counter-Strike 2', - 'gamertags': {'League of Legends': 'avawhite_lol', 'Counter-Strike 2': 'avawhite_cs'}, - 'discord': 'AvaW#5561', 'discord_user_id': '', 'league_os': 'https://leagueos.gg/player/avawhite'}, - {'username': 'jplayer9', 'full_name': 'Mason Hall', 'email': 'mason@email.com', 'games': 'Call of Duty,Rocket League', - 'gamertags': {'Call of Duty': 'masonhall_cod', 'platform_cod': 'PC', 'Rocket League': 'masonhall_rl', 'platform_rl': 'PC'}, - 'discord': 'MasonH#1189', 'discord_user_id': '', 'league_os': 'https://leagueos.gg/player/masonhall'}, - {'username': 'jplayer10', 'full_name': 'Isabella Adams', 'email': 'isabella@email.com', 'games': 'Overwatch 2,Dota 2', - 'gamertags': {'Overwatch 2': 'isabellaadams_ow', 'platform_ow': 'PC', 'Dota 2': 'isabellaadams_dota'}, - 'discord': 'IsabellaA#4437', 'discord_user_id': '', 'league_os': 'https://leagueos.gg/player/isabellaadams'}, - ] - - for i, data in enumerate(player_data, start=10): - users_data.append({ - 'username': data['username'], 'password': 'password', 'role': 'player', - 'full_name': data['full_name'], 'email': data['email'], 'phone': f'555-01{i:02d}', - 'games': data['games'], 'gamertags': data.get('gamertags', {}), - 'discord_username': data['discord'], 'discord_user_id': data['discord_user_id'], 'league_os_profile': data['league_os'] - }) - - users = [] - for data in users_data: - hashed_pw = hash_password(data['password']) - user = User( - username=data['username'], - password_hash=hashed_pw, - role=data['role'], - full_name=data['full_name'], - email=data['email'], - phone=data.get('phone', ''), - games=data.get('games'), - discord_username=data.get('discord_username'), - discord_user_id=data.get('discord_user_id'), - league_os_profile=data.get('league_os_profile') - ) - db.session.add(user) - users.append(user) - db.session.commit() - - print(f"[OK] Created {len(users)} users") - - # Map users for easy access - user_map = {u.username: u for u in users} - president = user_map['admin'] - manager1 = user_map['manager1'] - manager2 = user_map['manager2'] - coaches = [user_map['coach1'], user_map['coach2'], user_map['coach3']] - scout = user_map['scout1'] - players = [user_map[f'jplayer{i}'] for i in range(1, 11)] - - # Create gamertags for players - gamertag_data = [ - {'user': users[7], 'game': 'Valorant', 'gamertag': 'nordjan#bad'}, - {'user': users[7], 'game': 'Counter-Strike 2', 'gamertag': 'nordjan'}, - {'user': users[7], 'game': 'Rainbow Six Siege', 'gamertag': 'nordjan', 'platform': 'Ubisoft'}, - {'user': users[7], 'game': 'Rocket League', 'gamertag': 'nordjiano', 'platform': 'Epic'}, - {'user': users[7], 'game': 'Overwatch 2', 'gamertag': 'nordjan', 'platform': 'PC'}, - {'user': users[8], 'game': 'League of Legends', 'gamertag': 'emmagarcia_lol'}, - {'user': users[8], 'game': 'Valorant', 'gamertag': 'emmagarcia_val'}, - {'user': users[9], 'game': 'Apex Legends', 'gamertag': 'liambrown_apex', 'platform': 'PC'}, - {'user': users[9], 'game': 'Fortnite', 'gamertag': 'liambrown_fn', 'platform': 'PC'}, - {'user': users[10], 'game': 'Overwatch 2', 'gamertag': 'sophialee_ow', 'platform': 'PC'}, - {'user': users[10], 'game': 'Valorant', 'gamertag': 'sophialee_val'}, - {'user': users[11], 'game': 'Counter-Strike 2', 'gamertag': 'noahtaylor_cs'}, - {'user': users[11], 'game': 'Rainbow Six Siege', 'gamertag': 'noahtaylor_r6', 'platform': 'Ubisoft'}, - {'user': users[12], 'game': 'Rocket League', 'gamertag': 'oliviamartin_rl', 'platform': 'Epic'}, - {'user': users[12], 'game': 'Fortnite', 'gamertag': 'oliviamartin_fn', 'platform': 'PC'}, - {'user': users[13], 'game': 'Valorant', 'gamertag': 'ethanclark_val'}, - {'user': users[13], 'game': 'Apex Legends', 'gamertag': 'ethanclark_apex', 'platform': 'PC'}, - {'user': users[14], 'game': 'League of Legends', 'gamertag': 'avawhite_lol'}, - {'user': users[14], 'game': 'Counter-Strike 2', 'gamertag': 'avawhite_cs'}, - {'user': users[15], 'game': 'Call of Duty', 'gamertag': 'masonhall_cod', 'platform': 'PC'}, - {'user': users[15], 'game': 'Rocket League', 'gamertag': 'masonhall_rl', 'platform': 'Epic'}, - {'user': users[16], 'game': 'Overwatch 2', 'gamertag': 'isabellaadams_ow', 'platform': 'PC'}, - {'user': users[16], 'game': 'Dota 2', 'gamertag': 'isabellaadams_dota'}, - ] - - for gt in gamertag_data: - gamertag = UserGamertag( - user_id=gt['user'].id, - game=gt['game'], - gamertag=gt['gamertag'], - platform=gt.get('platform') - ) - db.session.add(gamertag) - db.session.commit() - print(f"[OK] Created {len(gamertag_data)} user gamertags") - - # Create Organization Teams (OrgTeams) - org_teams_data = [ - {'name': 'Rocket League main', 'coach': coaches[0], 'creator': president}, - {'name': 'CS2', 'coach': coaches[1], 'creator': president}, - {'name': 'Valorant', 'coach': coaches[2], 'creator': president}, - {'name': 'Rocket League acad', 'coach': None, 'creator': manager1}, - ] - - org_teams = [] - for data in org_teams_data: - ot = OrgTeam( - name=data['name'], - coach_id=data['coach'].id if data['coach'] else None, - created_by=data['creator'].id - ) - db.session.add(ot) - org_teams.append(ot) - db.session.commit() - print(f"[OK] Created {len(org_teams)} organization teams") - - # Create tryouts - tryouts_data = [ - {'title': 'Rocket Leauge Tryouts', 'game': 'Rocket League', 'date': datetime.utcnow(), 'location': 'En ligne', 'description': 'Tryouts for the spring competitive season. All positions welcome.', 'status': 'in_progress', 'creator': manager1, 'target_team': org_teams[0]}, - {'title': 'CS2 Tryouts', 'game': 'Counter-Strike 2', 'date': datetime.utcnow() + timedelta(days=4), 'location': 'En ligne', 'description': 'Trials for the fall select team. High skill level required.', 'status': 'upcoming', 'creator': manager1, 'target_team': org_teams[3]}, - {'title': 'Valorant Tryouts', 'game': 'Valorant', 'date': datetime.utcnow() - timedelta(days=2), 'location': 'En ligne', 'description': 'Séléction pour l\'équipe de Valorant', 'status': 'in_progress', 'creator': manager2, 'target_team': org_teams[2]}, - ] - - tryouts = [] - for data in tryouts_data: - t = Tryout( - title=data['title'], - game=data['game'], - date=data['date'].date(), - location=data['location'], - description=data['description'], - status=data['status'], - max_players=15, - created_by=data['creator'].id, - target_org_team_id=data['target_team'].id if data['target_team'] else None - ) - db.session.add(t) - tryouts.append(t) - db.session.commit() - print(f"[OK] Created {len(tryouts)} tryouts") - - # Register players for tryouts - registrations_data = [ - (tryouts[0], players[:3]), - (tryouts[1], players[3:5]), - (tryouts[2], players) - ] - - regs = [] - for tryout, player_list in registrations_data: - for player in player_list: - reg = TryoutRegistration( - tryout_id=tryout.id, - player_id=player.id, - status=random.choice(['registered', 'attended', 'attended', 'no_show']) - ) - db.session.add(reg) - regs.append(reg) - db.session.commit() - print(f"[OK] Created {len(regs)} registrations") - - # Create evaluations (for in_progress and completed tryouts) - eval_data = [] - # Spring tryout (Rocket League) - some evaluations - rl_positions = ['None needed', 'None', 'N/A'] - for player in players[:8]: - for coach in coaches: - if random.random() > 0.3: - mecanics = random.randint(4, 10) - cohesion = random.randint(4, 10) - communication = random.randint(3, 10) - gamesense = random.randint(5, 10) - versatility = random.randint(5, 10) - discipline = random.randint(4, 10) - analysis = random.randint(4, 10) - sport_ethics = random.randint(5, 10) - mental = random.randint(5, 10) - overall = round((mecanics + cohesion + communication + gamesense + versatility + discipline + analysis + sport_ethics + mental) / 9, 1) - eval_entry = Evaluation( - tryout_id=tryouts[0].id, - player_id=player.id, - evaluator_id=coach.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=f"{'Great' if overall > 7 else 'Good'} performance. {'Shows promise.' if overall > 6 else 'Needs improvement in some areas.'}", - position_recommendation=random.choice(rl_positions) - ) - db.session.add(eval_entry) - eval_data.append(eval_entry) - - # Completed tryout (Valorant) - full evaluations - val_positions = ['Controller', 'Initiator', 'Duelist', 'Sentinel'] - for player in players[:6]: - for coach in coaches[:2]: - mecanics = random.randint(3, 10) - cohesion = random.randint(3, 10) - communication = random.randint(3, 10) - gamesense = random.randint(4, 10) - versatility = random.randint(4, 10) - discipline = random.randint(3, 10) - analysis = random.randint(3, 10) - sport_ethics = random.randint(4, 10) - mental = random.randint(4, 10) - overall = round((mecanics + cohesion + communication + gamesense + versatility + discipline + analysis + sport_ethics + mental) / 9, 1) - eval_entry = Evaluation( - tryout_id=tryouts[2].id, - player_id=player.id, - evaluator_id=coach.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=f"{'Excellent' if overall > 8 else 'Solid'} display of skills during the camp.", - position_recommendation=random.choice(val_positions) - ) - db.session.add(eval_entry) - eval_data.append(eval_entry) - - db.session.commit() - print(f"[OK] Created {len(eval_data)} evaluations") - - # Create teams for the completed tryout (tryout-specific teams) - team1 = Team(tryout_id=tryouts[2].id, name='Alpha Team', created_by=president.id) - team2 = Team(tryout_id=tryouts[2].id, name='Bravo Team', created_by=president.id) - db.session.add(team1) - db.session.add(team2) - db.session.commit() - - # Assign players to teams (Valorant tryout uses Valorant positions) - val_positions = ['Controller', 'Initiator', 'Duelist', 'Sentinel'] - team_members_data = [ - (team1.id, players[0].id, random.choice(val_positions)), - (team1.id, players[1].id, random.choice(val_positions)), - (team1.id, players[2].id, random.choice(val_positions)), - (team1.id, players[3].id, random.choice(val_positions)), - (team2.id, players[4].id, random.choice(val_positions)), - (team2.id, players[5].id, random.choice(val_positions)), - ] - for team_id, player_id, position in team_members_data: - tm = TeamMember(team_id=team_id, player_id=player_id, position=position) - db.session.add(tm) - db.session.commit() - print("[OK] Created 2 tryout-specific teams with player assignments") - - # Assign players to organization teams (using TeamPlayer for many-to-many) - org_team_assignments = [ - (org_teams[0], players[0], 'starter'), - (org_teams[0], players[1], 'starter'), - (org_teams[0], players[2], 'substitute'), - (org_teams[1], players[3], 'starter'), - (org_teams[1], players[4], 'starter'), - (org_teams[1], players[5], 'substitute'), - (org_teams[2], players[6], 'starter'), - (org_teams[2], players[7], 'substitute'), - ] - for org_team, player, status in org_team_assignments: - tp = TeamPlayer(player_id=player.id, org_team_id=org_team.id, status=status) - db.session.add(tp) - db.session.commit() - - # Create sample disponibilities for players - # Time slots from 5pm to 11pm (stored as 17:00-23:00) - time_slots = [(17, 0), (17, 30), (18, 0), (18, 30), (19, 0), (19, 30), (20, 0), (20, 30), (21, 0), (21, 30), (22, 0), (22, 30), (23, 0)] - disponibilities = [] - - for player in players: - # Each player gets random disponibilities - for day in range(7): # All days of the week - num_slots = random.randint(3, 8) - chosen_slots = random.sample(time_slots, min(num_slots, len(time_slots))) - for hour, minute in chosen_slots: - start_time = time(hour, minute) - # End time is start_time + 30 minutes - end_minute = minute + 30 - end_hour = hour - if end_minute >= 60: - end_minute -= 60 - end_hour += 1 - end_time = time(end_hour, end_minute) - - d = PlayerDisponibility( - player_id=player.id, - day_of_week=day, - start_time=start_time, - end_time=end_time - ) - db.session.add(d) - disponibilities.append(d) - - # Create sample coach availabilities - coach_availabilities = [] - coach_avail_time_slots = [(16, 0), (16, 30), (17, 0), (17, 30), (18, 0), (18, 30), (19, 0), (19, 30), (20, 0), (20, 30), (21, 0), (21, 30)] - - # Only coaches with teams get availabilities - for coach, org_team in zip(coaches[:3], org_teams[:3]): - if coach.id == coaches[2].id: # Skip coach3 as they don't have all days - days_available = [0, 1, 2, 3, 4] # Mon-Fri - else: - days_available = [0, 1, 2, 3, 4, 5] # Mon-Sat - - for day in days_available: - num_slots = random.randint(3, 5) - chosen_slots = random.sample(coach_avail_time_slots, min(num_slots, len(coach_avail_time_slots))) - for hour, minute in chosen_slots: - start_time = time(hour, minute) - end_minute = minute + 30 - end_hour = hour - if end_minute >= 60: - end_minute -= 60 - end_hour += 1 - end_time = time(end_hour, end_minute) - - ca = CoachAvailability( - coach_id=coach.id, - day_of_week=day, - start_time=start_time, - end_time=end_time - ) - db.session.add(ca) - coach_availabilities.append(ca) - - db.session.commit() - print(f"[OK] Created {len(coach_availabilities)} coach availabilities") - - # Create team notes for each org team - team_notes_data = [ - { - 'team': org_teams[0], - 'coach': coaches[0], - 'content': 'Team, focus on rotation and positioning during scrims. We need to improve our mechanical consistency and work on post-platoon transitions. Remember to communicate clearly and stay positive!' - }, - { - 'team': org_teams[1], - 'coach': coaches[1], - 'content': 'Great progress this week! Keep working on your smoke lineups and utility usage. Individual practice on aim trainers is paying off. Next week we focus on map control and trading.' - }, - { - 'team': org_teams[2], - 'coach': coaches[2], - 'content': 'Agent comp needs work. Make sure to stick to your roles and trust your teammates. Work on your crosshair placement and pre-aim common angles. Team chemistry is key!' - }, - ] - - for note_data in team_notes_data: - note = TeamNote( - org_team_id=note_data['team'].id, - coach_id=note_data['coach'].id, - content=note_data['content'] - ) - db.session.add(note) - - db.session.commit() - print(f"[OK] Created {len(team_notes_data)} team notes") - - # Create personal notes for players - personal_notes_data = [ - {'player': players[0], 'coach': coaches[0], 'content': 'Your mechanics are improving! Focus on staying calm during high-pressure situations. Keep practicing those flip resets.'}, - {'player': players[0], 'coach': coaches[0], 'content': 'Good positioning in last scrim. Work on your kickoffs - consistency will help the team.'}, - {'player': players[1], 'coach': coaches[0], 'content': 'Your aerial game is strong. Try to be more aggressive on the ball when you have space.'}, - {'player': players[3], 'coach': coaches[1], 'content': 'Need to work on your smoke grenade placement. Practice pre-aiming and strafe stopping.'}, - {'player': players[4], 'coach': coaches[1], 'content': 'Good clutch performance! Keep your utility management consistent throughout rounds.'}, - {'player': players[6], 'coach': coaches[2], 'content': 'Your aim trainer routine is paying off. Work on your agent abilities usage timing.'}, - {'player': players[7], 'coach': coaches[2], 'content': 'Focus on communication in matches. Call out enemy positions clearly and ask for help when needed.'}, - ] - - for note_data in personal_notes_data: - note = PersonalNote( - player_id=note_data['player'].id, - coach_id=note_data['coach'].id, - content=note_data['content'] - ) - db.session.add(note) - - db.session.commit() - print(f"[OK] Created {len(personal_notes_data)} personal notes") - - # Create sample matches for tryouts (for calendar testing) - matches_data = [ - {'tryout': tryouts[0], 'title': 'Alpha vs Bravo', 'date': tryouts[0].date, 'start_time': time(18, 0), 'end_time': time(18, 30), 'match_type': 'team_vs_team', 'team1_id': team1.id if 'team1' in dir() else None}, - {'tryout': tryouts[0], 'title': 'Bravo vs Alpha', 'date': tryouts[0].date, 'start_time': time(19, 0), 'end_time': time(19, 30), 'match_type': 'team_vs_team'}, - {'tryout': tryouts[1], 'title': 'Scrimmage', 'date': tryouts[1].date, 'start_time': time(17, 0), 'end_time': time(17, 30), 'match_type': 'player_scrim'}, - {'tryout': tryouts[2], 'title': 'Team Alpha Scrim', 'date': tryouts[2].date, 'start_time': time(18, 30), 'end_time': time(19, 0), 'match_type': 'player_vs_player'}, - ] - - # Re-fetch teams after commit - team1 = Team.query.filter_by(name='Alpha Team').first() - team2 = Team.query.filter_by(name='Bravo Team').first() - - matches = [] - for i, m_data in enumerate(matches_data): - m = Match( - tryout_id=m_data['tryout'].id, - title=m_data['title'], - date=m_data['date'], - start_time=m_data['start_time'], - end_time=m_data['end_time'], - match_type=m_data['match_type'], - created_by=president.id, - team1_id=m_data.get('team1_id') or (team1.id if i < 2 else None), - team2_id=team2.id if i < 2 else None - ) - db.session.add(m) - matches.append(m) - db.session.commit() - print(f"[OK] Created {len(matches)} matches") - - # Add match participants for scrim matches - player_scrim_match = matches[2] if len(matches) > 2 else None - if player_scrim_match: - for player in players[3:5]: - mp = MatchParticipant(match_id=player_scrim_match.id, player_id=player.id) - db.session.add(mp) - - pvp_match = matches[3] if len(matches) > 3 else None - if pvp_match and team1: - for player in players[:2]: - mp = MatchParticipant(match_id=pvp_match.id, player_id=player.id, team_side=1) - db.session.add(mp) - for player in players[2:4]: - mp = MatchParticipant(match_id=pvp_match.id, player_id=player.id, team_side=2) - db.session.add(mp) - - db.session.commit() - print("[OK] Created match participants") - - print("\n[SUCCESS] Database seeded successfully!") - print("\n=== Login Credentials ===") - print("President: username='admin', password='password'") - print("Manager: username='manager1', password='password'") - print("Coach: username='coach1', password='password' (assigned to Varsity)") - print("Coach: username='coach2', password='password' (assigned to Junior Varsity)") - print("Coach: username='coach3', password='password' (assigned to U14 Development)") - print("Player: username='jplayer1', password='password'") - print("Scout: username='scout1', password='password'") - - -if __name__ == '__main__': - from app import create_app - app = create_app() - with app.app_context(): - seed_database() \ No newline at end of file