diff --git a/app/app.py b/app/app.py index b552bf9..8b74021 100644 --- a/app/app.py +++ b/app/app.py @@ -342,8 +342,8 @@ def create_app(): # Database Initialization # ========================================================================= with app.app_context(): - import app.models as models # noqa: F401 — registers all models with SQLAlchemy - from app.models import User + import app.models.models as models # noqa: F401 — registers all models with SQLAlchemy + from app.models.models import User db.create_all() # Seed database if empty diff --git a/app/discord_bot.py b/app/discord_bot.py index aa51e63..964960a 100644 --- a/app/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 app.models import User as DBUser + from app.models.models import User as DBUser db_user = DBUser.query.get(user_id) if not db_user: logger.warning(f"DB user {user_id} not found for schedule notification") @@ -234,7 +234,7 @@ class TeamTryoutsBot(commands.Bot): async def handle_one_on_one_approve(self, coach, message_id, request_id, original_message): """Handle coach approving a One on One request.""" try: - from app.models import OneOnOneRequest, db + from app.models.models import OneOnOneRequest, db from sqlalchemy.orm import joinedload request = OneOnOneRequest.query.options( @@ -278,7 +278,7 @@ class TeamTryoutsBot(commands.Bot): async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message): """Handle coach rejecting a One on One request.""" try: - from app.models import OneOnOneRequest, db + from app.models.models import OneOnOneRequest, db from sqlalchemy.orm import joinedload request = OneOnOneRequest.query.options( @@ -336,7 +336,7 @@ class TeamTryoutsBot(commands.Bot): async def handle_attendance_confirm(self, player, message_id, reference_id, original_message): """Handle player confirming attendance for a match/tryout.""" try: - from app.models import MatchParticipant, TryoutRegistration, Match, Tryout, db + from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db request_info = self.pending_requests[message_id] event_type = request_info.get('event_type') @@ -361,7 +361,7 @@ class TeamTryoutsBot(commands.Bot): async def handle_attendance_decline(self, player, message_id, reference_id, original_message): """Handle player declining attendance for a match/tryout.""" try: - from app.models import MatchParticipant, TryoutRegistration, Match, Tryout, db + from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db request_info = self.pending_requests[message_id] event_type = request_info.get('event_type') @@ -471,7 +471,7 @@ class TeamTryoutsBot(commands.Bot): async def send_daily_reminders(self): """Send daily reminders at 18:00 EDT for events in 24-48 hours.""" try: - from app.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db + from app.models.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db from sqlalchemy.orm import joinedload now = datetime.now(self.timezone) diff --git a/app/models.py b/app/models.py deleted file mode 100644 index 5d2c5b8..0000000 --- a/app/models.py +++ /dev/null @@ -1,854 +0,0 @@ -"""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/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e82c7e9 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,95 @@ +"""All models — split into individual files for maintainability. + +Import this module to register all models with SQLAlchemy and expose every +class, constant, and helper for use throughout the application. + +Usage:: + + from app.models import User, Admin, Evaluation, ESPORT_GAMES, ... + +Backward-compatible — no consumer changes needed. +""" + +# ========================================================================= +# Layer 0: constants (no app deps) +# ========================================================================= +from app.models._constants import ( + USER_TYPES, + ESPORT_GAMES, + GAME_POSITIONS, + GAME_PLATFORMS, + PLATFORM_CODES, + TRN_URLS, +) + +# ========================================================================= +# Layer 1: loaders & associations +# ========================================================================= +from app.models._loaders import load_user # noqa: F401 — registers Flask-Login callback + +# ========================================================================= +# Layer 2: abstract base classes +# ========================================================================= +from app.models.availability.base import BaseAvailability +from app.models.match_model.base import BaseMatch +from app.models.participant.base import BaseParticipant + +# ========================================================================= +# Layer 3: user hierarchy (polymorphic) +# ========================================================================= +from app.models.user_model.user import User +from app.models.user_model.admin import Admin +from app.models.user_model.manager import Manager +from app.models.user_model.coach import Coach +from app.models.user_model.player import Player +from app.models.user_model.scout import Scout + +# ========================================================================= +# Layer 4: org_team + junction +# ========================================================================= +from app.models.org_team.org_team import OrgTeam +from app.models.org_team.team_player import TeamPlayer + +# ========================================================================= +# Layer 5: concrete availability models +# ========================================================================= +from app.models.availability.player_disponibility import PlayerDisponibility +from app.models.availability.coach_availability import CoachAvailability + +# ========================================================================= +# Layer 6: tryout + registration +# ========================================================================= +from app.models.tryout.tryout import Tryout +from app.models.tryout.tryout_registration import TryoutRegistration + +# ========================================================================= +# Layer 7: evaluation +# ========================================================================= +from app.models.evaluation import Evaluation + +# ========================================================================= +# Layer 8: tryout-specific teams +# ========================================================================= +from app.models.team.team import Team +from app.models.team.team_member import TeamMember + +# ========================================================================= +# Layer 9: matches (tryout-scoped + regular-season) +# ========================================================================= +from app.models.match_model.match import Match +from app.models.match_model.team_match import TeamMatch + +# ========================================================================= +# Layer 10: participants +# ========================================================================= +from app.models.participant.match_participant import MatchParticipant +from app.models.participant.team_match_participant import TeamMatchParticipant + +# ========================================================================= +# Layer 11: remaining standalone models +# ========================================================================= +from app.models.user_gamertag import UserGamertag +from app.models.contract import Contract +from app.models.team_note import TeamNote +from app.models.personal_note import PersonalNote +from app.models.one_on_one_request import OneOnOneRequest \ No newline at end of file diff --git a/app/models/_associations.py b/app/models/_associations.py new file mode 100644 index 0000000..c0d30be --- /dev/null +++ b/app/models/_associations.py @@ -0,0 +1,18 @@ +"""Many-to-many association tables for OrgTeam ↔ User relationships.""" + +from app.extensions import db + + +org_team_coaches = db.Table('org_team_coaches', + db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), + primary_key=True), + db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), + primary_key=True), +) + +org_team_managers = db.Table('org_team_managers', + db.Column('org_team_id', db.Integer, db.ForeignKey('org_teams.id', ondelete='CASCADE'), + primary_key=True), + db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), + primary_key=True), +) \ No newline at end of file diff --git a/app/models/_constants.py b/app/models/_constants.py new file mode 100644 index 0000000..ca26638 --- /dev/null +++ b/app/models/_constants.py @@ -0,0 +1,64 @@ +"""Global constants shared by all model files. + +Contains game lists, position mappings, platform codes, and TRN URL templates. +""" + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +USER_TYPES = ['admin', 'manager', 'coach', 'player', 'scout'] + +ESPORT_GAMES = [ + 'Valorant', + 'League of Legends', + 'Counter-Strike 2', + 'Apex Legends', + 'Overwatch 2', + 'Rainbow Six Siege', + 'Rocket League', + 'Super Smash Bros.', +] + +GAME_POSITIONS = { + 'League of Legends': ['Top Lane', 'Jungle', 'Mid Lane', 'ADC', 'Support'], + 'Valorant': ['Controller', 'Initiator', 'Duelist', 'Sentinel', 'Flex'], + 'Counter-Strike 2': ['AWPer', 'Entry Fragger', 'Lurker', 'In-Game Leader', 'Support'], + 'Rainbow Six Siege': ['Entry', 'Support', 'Breacher', 'Anchor', 'Flex'], + 'Overwatch 2': ['Tank', 'Damage', 'Support'], + 'Apex Legends': [], + 'Rocket League': [], + 'Super Smash Bros.': [], +} + +GAME_PLATFORMS = { + 'Valorant': [], + 'League of Legends': [], + 'Counter-Strike 2': [], + 'Apex Legends': ['PC', 'PlayStation', 'Xbox', 'Nintendo Switch'], + 'Overwatch 2': [], + 'Rainbow Six Siege': ['Ubisoft', 'PlayStation', 'Xbox'], + 'Rocket League': ['Epic', 'PlayStation', 'Xbox', 'Nintendo Switch'], + 'Super Smash Bros.': ['Nintendo Switch'], +} + +PLATFORM_CODES = { + 'Ubisoft': 'ubi', + 'PlayStation': 'psn', + 'Xbox': 'xbl', + 'Nintendo Switch': 'switch', + 'PC': 'pc', + 'Steam': 'steam', + 'Epic': 'epic', +} + +TRN_URLS = { + 'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}', + 'League of Legends': 'https://tracker.gg/lol/profile/{username}', + 'Counter-Strike 2': 'https://tracker.gg/cs2/profile/steam/{username}', + 'Apex Legends': 'https://tracker.gg/apex/profile/{platform}/{username}', + 'Overwatch 2': 'https://tracker.gg/overwatch/profile/battlenet/{username}', + 'Rainbow Six Siege': 'https://r6.tracker.network/r6siege/profile/{platform_code}/{username}', + 'Rocket League': 'https://rocketleague.tracker.network/rocket-league/profile/{platform_code}/{username}', + 'Super Smash Bros.': 'https://tracker.gg/smash/profile/{username}', +} \ No newline at end of file diff --git a/app/models/_loaders.py b/app/models/_loaders.py new file mode 100644 index 0000000..0d71338 --- /dev/null +++ b/app/models/_loaders.py @@ -0,0 +1,14 @@ +"""Flask-Login user loader — registered with login_manager in models.py.""" + +from app.extensions import login_manager + + +@login_manager.user_loader +def load_user(user_id): + """Load a user by ID for Flask-Login session management. + + Returns the correct polymorphic subclass (Admin, Coach, Player, etc.) + automatically because SQLAlchemy resolves the identity column. + """ + from app.models.user_model.user import User + return User.query.get(int(user_id)) \ No newline at end of file diff --git a/app/models/availability/__init__.py b/app/models/availability/__init__.py new file mode 100644 index 0000000..c4c2f7a --- /dev/null +++ b/app/models/availability/__init__.py @@ -0,0 +1,7 @@ +"""Availability models — BaseAvailability and its concrete subclasses.""" + +from app.models.availability.base import BaseAvailability +from app.models.availability.player_disponibility import PlayerDisponibility +from app.models.availability.coach_availability import CoachAvailability + +__all__ = ['BaseAvailability', 'PlayerDisponibility', 'CoachAvailability'] \ No newline at end of file diff --git a/app/models/availability/base.py b/app/models/availability/base.py new file mode 100644 index 0000000..fb1dd06 --- /dev/null +++ b/app/models/availability/base.py @@ -0,0 +1,14 @@ +"""Abstract base class for availability models (PlayerDisponibility + CoachAvailability).""" +from app.extensions import db +from datetime import datetime + + +class BaseAvailability(db.Model): + """Shared schema for player disponibilities and coach availabilities.""" + __abstract__ = True + + day_of_week = db.Column(db.Integer, nullable=False) + start_time = db.Column(db.Time, nullable=False) + end_time = db.Column(db.Time, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) \ No newline at end of file diff --git a/app/models/availability/coach_availability.py b/app/models/availability/coach_availability.py new file mode 100644 index 0000000..deafc35 --- /dev/null +++ b/app/models/availability/coach_availability.py @@ -0,0 +1,12 @@ +"""Coach availability in 30-minute time blocks for One on One sessions.""" +from app.extensions import db +from app.models.availability.base import BaseAvailability + + +class CoachAvailability(BaseAvailability): + """Coach availability in 30-minute blocks for One on One sessions.""" + __tablename__ = 'coach_availabilities' + id = db.Column(db.Integer, primary_key=True) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + coach = db.relationship('User', backref='coach_availabilities') \ No newline at end of file diff --git a/app/models/availability/player_disponibility.py b/app/models/availability/player_disponibility.py new file mode 100644 index 0000000..7d51dad --- /dev/null +++ b/app/models/availability/player_disponibility.py @@ -0,0 +1,12 @@ +"""Player availability in 30-minute time blocks.""" +from app.extensions import db +from app.models.availability.base import BaseAvailability + + +class PlayerDisponibility(BaseAvailability): + """Player availability in 30-minute blocks.""" + __tablename__ = 'player_disponibilities' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + player = db.relationship('User', backref='disponibilities') \ No newline at end of file diff --git a/app/models/contract.py b/app/models/contract.py new file mode 100644 index 0000000..8ce40cf --- /dev/null +++ b/app/models/contract.py @@ -0,0 +1,51 @@ +"""Contract documents for players to sign.""" +from app.extensions import db +from datetime import datetime + + +class Contract(db.Model): + """Contract documents for players to sign.""" + __tablename__ = 'contracts' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) + uploaded_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + original_filename = db.Column(db.String(255), nullable=False) + stored_filename = db.Column(db.String(255), nullable=False) + file_path = db.Column(db.String(500), nullable=False) + + signed_filename = db.Column(db.String(255), nullable=True) + signed_file_path = db.Column(db.String(500), nullable=True) + + status = db.Column(db.String(20), default='pending') + notes = db.Column(db.Text, nullable=True) + uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + signed_at = db.Column(db.DateTime, nullable=True) + + player = db.relationship('User', foreign_keys=[player_id], backref='contracts') + team = db.relationship('OrgTeam', foreign_keys=[team_id]) + uploader = db.relationship('User', foreign_keys=[uploaded_by_id]) + + def can_view(self, user): + if user.id == self.player_id: + return True + from app.models.user_model.admin import Admin + from app.models.user_model.manager import Manager + from app.models.user_model.coach import Coach + from app.models.user_model.user import User + from app.models.org_team.org_team import OrgTeam + if isinstance(user, Admin): + return True + if isinstance(user, Manager): + player = User.query.get(self.player_id) + if player and player.get_org_teams(): + return True + if isinstance(user, Coach): + org_team = OrgTeam.query.filter_by(coach_id=user.id).first() + if org_team and (not self.team_id or self.team_id == org_team.id): + return True + return False + + def can_upload_signed(self, user): + return user.id == self.player_id \ No newline at end of file diff --git a/app/models/evaluation.py b/app/models/evaluation.py new file mode 100644 index 0000000..8f5ec5b --- /dev/null +++ b/app/models/evaluation.py @@ -0,0 +1,30 @@ +"""Player evaluation record.""" +from app.extensions import db +from datetime import datetime + + +class Evaluation(db.Model): + """Player evaluation record.""" + __tablename__ = 'evaluations' + id = db.Column(db.Integer, primary_key=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + evaluator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + mecanics_score = db.Column(db.Integer, nullable=True) + cohesion_score = db.Column(db.Integer, nullable=True) + communication_score = db.Column(db.Integer, nullable=True) + gamesense_score = db.Column(db.Integer, nullable=True) + versatility_score = db.Column(db.Integer, nullable=True) + discipline_score = db.Column(db.Integer, nullable=True) + analysis_score = db.Column(db.Integer, nullable=True) + sport_ethics_score = db.Column(db.Integer, nullable=True) + mental_score = db.Column(db.Integer, nullable=True) + overall_score = db.Column(db.Float, nullable=True) + comments = db.Column(db.Text, nullable=True) + position_recommendation = db.Column(db.String(50), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + __table_args__ = ( + db.UniqueConstraint('tryout_id', 'player_id', 'evaluator_id', name='unique_evaluation'), + ) \ No newline at end of file diff --git a/app/models/match_model/__init__.py b/app/models/match_model/__init__.py new file mode 100644 index 0000000..1608108 --- /dev/null +++ b/app/models/match_model/__init__.py @@ -0,0 +1,6 @@ +"""Match models — BaseMatch and its concrete subclasses.""" +from app.models.match_model.base import BaseMatch +from app.models.match_model.match import Match +from app.models.match_model.team_match import TeamMatch + +__all__ = ['BaseMatch', 'Match', 'TeamMatch'] \ No newline at end of file diff --git a/app/models/match_model/base.py b/app/models/match_model/base.py new file mode 100644 index 0000000..48ad8df --- /dev/null +++ b/app/models/match_model/base.py @@ -0,0 +1,18 @@ +"""Abstract base class for match models (Match + TeamMatch).""" +from app.extensions import db +from datetime import datetime + + +class BaseMatch(db.Model): + """Shared schema for tryout-scoped matches and regular-season team matches.""" + __abstract__ = True + + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + date = db.Column(db.Date, nullable=False) + start_time = db.Column(db.Time, nullable=True) + end_time = db.Column(db.Time, nullable=True) + location = db.Column(db.String(200), nullable=True) + status = db.Column(db.String(20), default='scheduled') + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/app/models/match_model/match.py b/app/models/match_model/match.py new file mode 100644 index 0000000..caa22e1 --- /dev/null +++ b/app/models/match_model/match.py @@ -0,0 +1,22 @@ +"""Match / scrimmage within a tryout.""" +from app.extensions import db +from app.models.match_model.base import BaseMatch + + +class Match(BaseMatch): + """Match / scrimmage within a tryout.""" + __tablename__ = 'matches' + id = db.Column(db.Integer, primary_key=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) + match_type = db.Column(db.String(20), nullable=False) + team1_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) + team2_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) + + creator = db.relationship('User', backref='created_matches') + tryout = db.relationship('Tryout', backref='matches') + team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1') + team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2') + participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic') + + def get_participating_players(self): + return [p.player_id for p in self.participants.all()] \ No newline at end of file diff --git a/app/models/match_model/team_match.py b/app/models/match_model/team_match.py new file mode 100644 index 0000000..cc36947 --- /dev/null +++ b/app/models/match_model/team_match.py @@ -0,0 +1,22 @@ +"""Regular-season match for an organisation team (not tied to a tryout).""" +from app.extensions import db +from app.models.match_model.base import BaseMatch + + +class TeamMatch(BaseMatch): + """Regular-season match for an organisation team (not tied to a tryout).""" + __tablename__ = 'team_matches' + id = db.Column(db.Integer, primary_key=True) + org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) + opponent = db.Column(db.String(200), nullable=True) + + org_team = db.relationship('OrgTeam', backref='team_matches') + creator = db.relationship('User', backref='created_team_matches') + participants = db.relationship( + 'TeamMatchParticipant', backref='team_match', lazy='dynamic', + cascade='all, delete-orphan') + + def get_confirmed_count(self): + all_p = self.participants.all() + confirmed = sum(1 for p in all_p if p.is_confirmed) + return confirmed, len(all_p) \ No newline at end of file diff --git a/app/models/one_on_one_request.py b/app/models/one_on_one_request.py new file mode 100644 index 0000000..1d264ae --- /dev/null +++ b/app/models/one_on_one_request.py @@ -0,0 +1,25 @@ +"""Request from player to coach for a One on One session.""" +from app.extensions import db +from datetime import datetime + + +class OneOnOneRequest(db.Model): + """Request from player to coach for a One on One session.""" + __tablename__ = 'one_on_one_requests' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) + date = db.Column(db.Date, nullable=False) + start_time = db.Column(db.Time, nullable=False) + end_time = db.Column(db.Time, nullable=False) + points = db.Column(db.Text, nullable=True) + status = db.Column(db.String(20), default='pending') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + responded_at = db.Column(db.DateTime, nullable=True) + discord_message_id = db.Column(db.BigInteger, nullable=True) + coach_rejection_message = db.Column(db.Text, nullable=True) + + player = db.relationship('User', foreign_keys=[player_id], backref='one_on_one_requests') + coach = db.relationship('User', foreign_keys=[coach_id]) + team = db.relationship('OrgTeam', foreign_keys=[org_team_id]) \ No newline at end of file diff --git a/app/models/org_team/__init__.py b/app/models/org_team/__init__.py new file mode 100644 index 0000000..a5f2da9 --- /dev/null +++ b/app/models/org_team/__init__.py @@ -0,0 +1,5 @@ +"""Organisation team models.""" +from app.models.org_team.org_team import OrgTeam +from app.models.org_team.team_player import TeamPlayer + +__all__ = ['OrgTeam', 'TeamPlayer'] \ No newline at end of file diff --git a/app/models/org_team/org_team.py b/app/models/org_team/org_team.py new file mode 100644 index 0000000..8cb7a15 --- /dev/null +++ b/app/models/org_team/org_team.py @@ -0,0 +1,53 @@ +"""Persistent organisation team (e.g. Varsity, JV).""" +from app.extensions import db +from app.models._associations import org_team_coaches, org_team_managers +from datetime import datetime + + +class OrgTeam(db.Model): + """Persistent organisation team (e.g. Varsity, JV).""" + __tablename__ = 'org_teams' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + + creator = db.relationship('User', foreign_keys=[created_by]) + coaches = db.relationship( + 'User', secondary=org_team_coaches, lazy='dynamic', + backref=db.backref('coached_org_teams', lazy='dynamic')) + managers = db.relationship( + 'User', secondary=org_team_managers, lazy='dynamic', + backref=db.backref('managed_org_teams', lazy='dynamic')) + + coach = db.relationship( + 'User', foreign_keys=[coach_id], + backref=db.backref('coached_org_team_legacy', uselist=False), + viewonly=True) + manager = db.relationship( + 'User', foreign_keys=[manager_id], + backref=db.backref('managed_org_team_legacy', uselist=False), + viewonly=True) + + def get_coaches(self): + coach_list = self.coaches.all() + if not coach_list and self.coach: + return [self.coach] + return coach_list + + def get_managers(self): + manager_list = self.managers.all() + if not manager_list and self.manager: + return [self.manager] + return manager_list + + @property + def players(self): + return [tp.player for tp in self.team_players] + + def get_players_with_status(self): + return [{'player': tp.player, 'status': tp.status, + 'position': tp.position} for tp in self.team_players] \ No newline at end of file diff --git a/app/models/org_team/team_player.py b/app/models/org_team/team_player.py new file mode 100644 index 0000000..0b4a720 --- /dev/null +++ b/app/models/org_team/team_player.py @@ -0,0 +1,21 @@ +"""Many-to-many junction: player to org-team.""" +from app.extensions import db +from datetime import datetime + + +class TeamPlayer(db.Model): + """Many-to-many: player to org-team.""" + __tablename__ = 'team_players' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) + status = db.Column(db.String(20), nullable=False, default='starter') + position = db.Column(db.String(50), nullable=True) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + + player = db.relationship('User', foreign_keys=[player_id], backref='team_placements') + org_team = db.relationship('OrgTeam', foreign_keys=[org_team_id], backref='team_players') + + __table_args__ = ( + db.UniqueConstraint('player_id', 'org_team_id', name='unique_player_org_team'), + ) \ No newline at end of file diff --git a/app/models/participant/__init__.py b/app/models/participant/__init__.py new file mode 100644 index 0000000..a282cb7 --- /dev/null +++ b/app/models/participant/__init__.py @@ -0,0 +1,6 @@ +"""Participant models — BaseParticipant and its concrete subclasses.""" +from app.models.participant.base import BaseParticipant +from app.models.participant.match_participant import MatchParticipant +from app.models.participant.team_match_participant import TeamMatchParticipant + +__all__ = ['BaseParticipant', 'MatchParticipant', 'TeamMatchParticipant'] \ No newline at end of file diff --git a/app/models/participant/base.py b/app/models/participant/base.py new file mode 100644 index 0000000..ff1dddc --- /dev/null +++ b/app/models/participant/base.py @@ -0,0 +1,11 @@ +"""Abstract base class for match participant models.""" +from app.extensions import db +from datetime import datetime + + +class BaseParticipant(db.Model): + """Shared schema for match participants.""" + __abstract__ = True + + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + added_at = db.Column(db.DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/app/models/participant/match_participant.py b/app/models/participant/match_participant.py new file mode 100644 index 0000000..6209e78 --- /dev/null +++ b/app/models/participant/match_participant.py @@ -0,0 +1,15 @@ +"""Participant in a tryout-scoped match.""" +from app.extensions import db +from app.models.participant.base import BaseParticipant + + +class MatchParticipant(BaseParticipant): + """Participant in a tryout-scoped match.""" + __tablename__ = 'match_participants' + id = db.Column(db.Integer, primary_key=True) + match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=False) + team_side = db.Column(db.Integer, nullable=True) + position = db.Column(db.String(50), nullable=True) + attendance_confirmed = db.Column(db.Boolean, default=False) + + player = db.relationship('User') \ No newline at end of file diff --git a/app/models/participant/team_match_participant.py b/app/models/participant/team_match_participant.py new file mode 100644 index 0000000..a57c61c --- /dev/null +++ b/app/models/participant/team_match_participant.py @@ -0,0 +1,13 @@ +"""Participant in a regular-season team match.""" +from app.extensions import db +from app.models.participant.base import BaseParticipant + + +class TeamMatchParticipant(BaseParticipant): + """Participant in a regular-season team match.""" + __tablename__ = 'team_match_participants' + id = db.Column(db.Integer, primary_key=True) + team_match_id = db.Column(db.Integer, db.ForeignKey('team_matches.id'), nullable=False) + is_confirmed = db.Column(db.Boolean, default=False) + + player = db.relationship('User') \ No newline at end of file diff --git a/app/models/personal_note.py b/app/models/personal_note.py new file mode 100644 index 0000000..c12318c --- /dev/null +++ b/app/models/personal_note.py @@ -0,0 +1,24 @@ +"""Personal notes from coach to individual player.""" +from app.extensions import db +from datetime import datetime + + +class PersonalNote(db.Model): + """Personal notes from coach to individual player.""" + __tablename__ = 'personal_notes' + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + content = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + match_id = db.Column(db.Integer, db.ForeignKey('matches.id'), nullable=True) + team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=True) + + player = db.relationship('User', foreign_keys=[player_id], backref='personal_notes') + coach = db.relationship('User', foreign_keys=[coach_id]) + match = db.relationship('Match', foreign_keys=[match_id]) + team = db.relationship('Team', foreign_keys=[team_id]) + tryout = db.relationship('Tryout', foreign_keys=[tryout_id]) \ No newline at end of file diff --git a/app/models/team/__init__.py b/app/models/team/__init__.py new file mode 100644 index 0000000..1808aa2 --- /dev/null +++ b/app/models/team/__init__.py @@ -0,0 +1,5 @@ +"""Tryout-specific temporary team models.""" +from app.models.team.team import Team +from app.models.team.team_member import TeamMember + +__all__ = ['Team', 'TeamMember'] \ No newline at end of file diff --git a/app/models/team/team.py b/app/models/team/team.py new file mode 100644 index 0000000..c692eba --- /dev/null +++ b/app/models/team/team.py @@ -0,0 +1,16 @@ +"""Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" +from app.extensions import db +from datetime import datetime + + +class Team(db.Model): + """Tryout-specific team (e.g. Alpha, Bravo within a single tryout).""" + __tablename__ = 'teams' + id = db.Column(db.Integer, primary_key=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) + name = db.Column(db.String(100), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + creator = db.relationship('User', backref='created_teams') + members = db.relationship('TeamMember', backref='team', lazy='dynamic') \ No newline at end of file diff --git a/app/models/team/team_member.py b/app/models/team/team_member.py new file mode 100644 index 0000000..e84b231 --- /dev/null +++ b/app/models/team/team_member.py @@ -0,0 +1,15 @@ +"""Link between a player and a tryout-specific team.""" +from app.extensions import db +from datetime import datetime + + +class TeamMember(db.Model): + """Link between a player and a tryout-specific team.""" + __tablename__ = 'team_members' + id = db.Column(db.Integer, primary_key=True) + team_id = db.Column(db.Integer, db.ForeignKey('teams.id'), nullable=False) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + position = db.Column(db.String(50), nullable=True) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + + player = db.relationship('User', overlaps="player_ref,team_assignments") \ No newline at end of file diff --git a/app/models/team_note.py b/app/models/team_note.py new file mode 100644 index 0000000..2de61f5 --- /dev/null +++ b/app/models/team_note.py @@ -0,0 +1,17 @@ +"""Team improvement notes from coach.""" +from app.extensions import db +from datetime import datetime + + +class TeamNote(db.Model): + """Team improvement notes from coach.""" + __tablename__ = 'team_notes' + id = db.Column(db.Integer, primary_key=True) + org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + content = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + team = db.relationship('OrgTeam', backref='team_notes') + coach = db.relationship('User', foreign_keys=[coach_id]) \ No newline at end of file diff --git a/app/models/tryout/__init__.py b/app/models/tryout/__init__.py new file mode 100644 index 0000000..5765899 --- /dev/null +++ b/app/models/tryout/__init__.py @@ -0,0 +1,5 @@ +"""Tryout models.""" +from app.models.tryout.tryout import Tryout +from app.models.tryout.tryout_registration import TryoutRegistration + +__all__ = ['Tryout', 'TryoutRegistration'] \ No newline at end of file diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py new file mode 100644 index 0000000..752ae81 --- /dev/null +++ b/app/models/tryout/tryout.py @@ -0,0 +1,29 @@ +"""Tryout event for player evaluations and team formation.""" +from app.extensions import db +from datetime import datetime + + +class Tryout(db.Model): + """Tryout event for player evaluations and team formation.""" + __tablename__ = 'tryouts' + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + game = db.Column(db.String(50), nullable=False) + date = db.Column(db.Date, nullable=False) + location = db.Column(db.String(200), nullable=True) + status = db.Column(db.String(20), default='upcoming') + max_players = db.Column(db.Integer, nullable=True) + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + target_org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=True) + manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') + manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') + coach = db.relationship('User', foreign_keys=[coach_id], backref='coached_tryouts') + registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic') + evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic') + teams = db.relationship('Team', backref='tryout', lazy='dynamic') + target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]) \ No newline at end of file diff --git a/app/models/tryout/tryout_registration.py b/app/models/tryout/tryout_registration.py new file mode 100644 index 0000000..8016ad7 --- /dev/null +++ b/app/models/tryout/tryout_registration.py @@ -0,0 +1,14 @@ +"""Registration linking a player to a tryout.""" +from app.extensions import db +from datetime import datetime + + +class TryoutRegistration(db.Model): + """Registration linking a player to a tryout.""" + __tablename__ = 'tryout_registrations' + id = db.Column(db.Integer, primary_key=True) + tryout_id = db.Column(db.Integer, db.ForeignKey('tryouts.id'), nullable=False) + player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + registered_at = db.Column(db.DateTime, default=datetime.utcnow) + status = db.Column(db.String(20), default='registered') + notes = db.Column(db.Text, nullable=True) \ No newline at end of file diff --git a/app/models/user_gamertag.py b/app/models/user_gamertag.py new file mode 100644 index 0000000..aa85eec --- /dev/null +++ b/app/models/user_gamertag.py @@ -0,0 +1,40 @@ +"""Store gamertag per game for each user.""" +from app.extensions import db +from app.models._constants import TRN_URLS, PLATFORM_CODES +from urllib.parse import quote + + +class UserGamertag(db.Model): + """Store gamertag per game for each user.""" + __tablename__ = 'user_gamertags' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + game = db.Column(db.String(50), nullable=False) + gamertag = db.Column(db.String(120), nullable=False) + platform = db.Column(db.String(30), nullable=True) + + user = db.relationship('User', backref='gamertags') + + __table_args__ = ( + db.UniqueConstraint('user_id', 'game', name='unique_user_game'), + ) + + def get_trn_url(self): + if self.game not in TRN_URLS: + return None + url = TRN_URLS[self.game] + encoded_gamertag = quote(self.gamertag, safe='') + if '{platform_code}' in url and '{username}' in url: + platform_code = PLATFORM_CODES.get( + self.platform, + self.platform.lower().replace(' ', '-') if self.platform else '', + ) + return url.format(platform_code=platform_code, username=encoded_gamertag) + elif '{platform}' in url and '{username}' in url: + return url.format( + platform=self.platform.lower().replace(' ', '-'), + username=encoded_gamertag, + ) + elif '{username}' in url: + return url.format(username=encoded_gamertag) + return url \ No newline at end of file diff --git a/app/models/user_model/__init__.py b/app/models/user_model/__init__.py new file mode 100644 index 0000000..05166d5 --- /dev/null +++ b/app/models/user_model/__init__.py @@ -0,0 +1,9 @@ +"""User hierarchy — single-table polymorphic inheritance (User → Admin, Manager, Coach, Player, Scout).""" +from app.models.user_model.user import User +from app.models.user_model.admin import Admin +from app.models.user_model.manager import Manager +from app.models.user_model.coach import Coach +from app.models.user_model.player import Player +from app.models.user_model.scout import Scout + +__all__ = ['User', 'Admin', 'Manager', 'Coach', 'Player', 'Scout'] diff --git a/app/models/user_model/admin.py b/app/models/user_model/admin.py new file mode 100644 index 0000000..3d0d1e2 --- /dev/null +++ b/app/models/user_model/admin.py @@ -0,0 +1,32 @@ +"""Admin / President — full access to everything.""" +from app.models.user_model.user import User + + +class Admin(User): + """President / super-admin — full access to everything.""" + __mapper_args__ = {'polymorphic_identity': 'admin'} + + def can_evaluate(self): + return True + + def can_manage_users(self): + return True + + def can_manage_teams(self): + return True + + def can_manage_tryouts(self): + return True + + def can_schedule_matches(self): + return True + + def can_manage_this_tryout(self, tryout): + return True + + def can_manage_this_org_team(self, org_team): + return True + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + return Tryout.query.order_by(Tryout.date).all() diff --git a/app/models/user_model/coach.py b/app/models/user_model/coach.py new file mode 100644 index 0000000..ccd9aa0 --- /dev/null +++ b/app/models/user_model/coach.py @@ -0,0 +1,45 @@ +"""Coach — evaluates, schedules matches, manages their own org team.""" +from app.models.user_model.user import User + + +class Coach(User): + """Coach — evaluates, schedules matches, manages their own org team.""" + __mapper_args__ = {'polymorphic_identity': 'coach'} + + def can_evaluate(self): + return True + + def can_schedule_matches(self): + return True + + def can_manage_tryouts(self): + return True + + def can_manage_this_tryout(self, tryout): + from app.models.org_team.org_team import OrgTeam + if tryout.target_org_team_id: + is_coach_of_target = OrgTeam.query.filter( + OrgTeam.id == tryout.target_org_team_id, + OrgTeam.coaches.any(id=self.id), + ).first() is not None + if is_coach_of_target: + return True + if tryout.coach_id == self.id: + return True + return False + + def can_manage_this_org_team(self, org_team): + if org_team.coaches.filter_by(id=self.id).first(): + return True + if org_team.coach_id == self.id: + return True + return False + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + team_ids = [t.id for t in self.coached_org_teams.all()] + if not team_ids: + return Tryout.query.filter(Tryout.id == -1).all() # empty + return Tryout.query.filter( + Tryout.target_org_team_id.in_(team_ids) + ).order_by(Tryout.date).all() diff --git a/app/models/user_model/manager.py b/app/models/user_model/manager.py new file mode 100644 index 0000000..05944d3 --- /dev/null +++ b/app/models/user_model/manager.py @@ -0,0 +1,29 @@ +"""Manager — manages own tryouts, all org teams, all contracts.""" +from app.models.user_model.user import User + + +class Manager(User): + """Manager — manages own tryouts, all org teams, all contracts.""" + __mapper_args__ = {'polymorphic_identity': 'manager'} + + def can_evaluate(self): + return True + + def can_manage_teams(self): + return True + + def can_manage_tryouts(self): + return True + + def can_schedule_matches(self): + return True + + def can_manage_this_tryout(self, tryout): + return tryout.created_by == self.id or tryout.manager_id == self.id + + def can_manage_this_org_team(self, org_team): + return True + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + return Tryout.query.filter_by(created_by=self.id).order_by(Tryout.date).all() diff --git a/app/models/user_model/player.py b/app/models/user_model/player.py new file mode 100644 index 0000000..f53a2ee --- /dev/null +++ b/app/models/user_model/player.py @@ -0,0 +1,30 @@ +"""Player — registers for tryouts, manages their own profile.""" +from app.models.user_model.user import User + + +class Player(User): + """Player — registers for tryouts, manages their own profile.""" + __mapper_args__ = {'polymorphic_identity': 'player'} + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + from app.models.match_model.match import Match + from app.models.participant.match_participant import MatchParticipant + + # tryouts they registered for + player_tryout_ids = [r.tryout_id for r in self.tryout_registrations.all()] + tryouts = Tryout.query.filter( + Tryout.id.in_(player_tryout_ids) + ).order_by(Tryout.date).all() if player_tryout_ids else [] + + # plus tryouts where they participate in a match + player_matches = Match.query.join(MatchParticipant).filter( + MatchParticipant.player_id == self.id, + ).all() + extra_ids = set(m.tryout_id for m in player_matches) + extra = Tryout.query.filter( + Tryout.id.in_(extra_ids), + ).order_by(Tryout.date).all() if extra_ids else [] + + all_ids = {t.id for t in tryouts} + return tryouts + [t for t in extra if t.id not in all_ids] diff --git a/app/models/user_model/scout.py b/app/models/user_model/scout.py new file mode 100644 index 0000000..5757160 --- /dev/null +++ b/app/models/user_model/scout.py @@ -0,0 +1,14 @@ +"""Scout — view-only access to tryouts and evaluations.""" +from app.models.user_model.user import User + + +class Scout(User): + """Scout — view-only access to tryouts and evaluations.""" + __mapper_args__ = {'polymorphic_identity': 'scout'} + + def can_evaluate(self): + return True + + def get_visible_tryouts(self): + from app.models.tryout.tryout import Tryout + return Tryout.query.order_by(Tryout.date).all() diff --git a/app/models/user_model/user.py b/app/models/user_model/user.py new file mode 100644 index 0000000..4a50676 --- /dev/null +++ b/app/models/user_model/user.py @@ -0,0 +1,93 @@ +"""Base User model — shared fields and polymorphic configuration.""" +from app.extensions import db +from flask_login import UserMixin +from datetime import datetime + + +class User(UserMixin, db.Model): + """Base user model — shared fields for every role. + + Do not instantiate this class directly; use Admin, Manager, Coach, Player, + or Scout so that `polymorphic_identity` is set correctly. + """ + __tablename__ = 'users' + + # --- columns ----------------------------------------------------------- + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password_hash = db.Column(db.String(128), nullable=False) + role = db.Column(db.String(20), nullable=False, default='player') # polymorphic discriminator + full_name = db.Column(db.String(100), nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + phone = db.Column(db.String(20), nullable=True) + is_active_account = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + failed_login_attempts = db.Column(db.Integer, default=0) + locked_until = db.Column(db.DateTime, nullable=True) + + # E-Sports fields + games = db.Column(db.Text, nullable=True) # comma-separated (only meaningful for Player) + discord_username = db.Column(db.String(128), nullable=True) + discord_user_id = db.Column(db.String(64), nullable=True) + league_os_profile = db.Column(db.String(256), nullable=True) + + # --- polymorphic configuration ----------------------------------------- + __mapper_args__ = { + 'polymorphic_identity': 'user', + 'polymorphic_on': role, + } + + # --- relationships (defined once on the base) -------------------------- + evaluations_given = db.relationship( + 'Evaluation', foreign_keys='Evaluation.evaluator_id', + backref='evaluator', lazy='dynamic') + evaluations_received = db.relationship( + 'Evaluation', foreign_keys='Evaluation.player_id', + backref='player', lazy='dynamic') + tryout_registrations = db.relationship( + 'TryoutRegistration', backref='player', lazy='dynamic') + team_assignments = db.relationship( + 'TeamMember', foreign_keys='TeamMember.player_id', + backref='player_ref', lazy='dynamic') + + # --- shared helper methods --------------------------------------------- + def get_games_list(self): + """Return the user's games as a list.""" + if self.games: + return [g.strip() for g in self.games.split(',') if g.strip()] + return [] + + def get_gamertags(self): + """Return gamertags as a dict keyed by game.""" + return {gt.game: {'gamertag': gt.gamertag, 'platform': gt.platform} + for gt in self.gamertags} + + def get_org_teams(self): + """Return all OrgTeams this player belongs to.""" + return [tp.org_team for tp in self.team_placements] + + # --- stubs (overridden in subclasses) ---------------------------------- + def can_evaluate(self): + return False + + def can_manage_users(self): + return False + + def can_manage_teams(self): + return False + + def can_manage_tryouts(self): + return False + + def can_schedule_matches(self): + return False + + def can_manage_this_tryout(self, tryout): + return False + + def can_manage_this_org_team(self, org_team): + return False + + def get_visible_tryouts(self): + return [] diff --git a/app/routes/auth.py b/app/routes/auth.py index 274f677..32edd65 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -10,7 +10,7 @@ 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 app.extensions import db, hash_password, check_password, limiter -from app.models import User, Player, ESPORT_GAMES +from app.models.models import User, Player, ESPORT_GAMES from app.validators import RegisterSchema, LoginSchema from marshmallow import ValidationError from urllib.parse import urlparse diff --git a/app/routes/evaluations.py b/app/routes/evaluations.py index 6efa290..9f0dd4b 100644 --- a/app/routes/evaluations.py +++ b/app/routes/evaluations.py @@ -6,7 +6,7 @@ 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 app.extensions import db -from app.models import ( +from app.models.models import ( Admin, Coach, Manager, Player, User, Tryout, Evaluation, TryoutRegistration, OrgTeam, GAME_POSITIONS, diff --git a/app/routes/main.py b/app/routes/main.py index 1927546..7f37e18 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -6,7 +6,7 @@ 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 app.extensions import db -from app.models import ( +from app.models.models import ( Admin, Manager, Coach, Player, Scout, User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember, Match, MatchParticipant, OrgTeam, diff --git a/app/routes/matches.py b/app/routes/matches.py index 2f8f81e..497f9ee 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -6,7 +6,7 @@ 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 app.extensions import db -from app.models import ( +from app.models.models import ( Admin, Manager, Coach, Player, Scout, User, Tryout, Match, MatchParticipant, Team, TeamMember, OrgTeam, TryoutRegistration, PlayerDisponibility, diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index 3d2115f..853e3d0 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -6,7 +6,7 @@ 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 app.extensions import db -from app.models import ( +from app.models.models import ( Admin, Manager, Coach, Player, OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer, ) diff --git a/app/routes/teams.py b/app/routes/teams.py index 6e1b410..fb3042e 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -6,7 +6,7 @@ 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 app.extensions import db -from app.models import ( +from app.models.models import ( Admin, Manager, Coach, Player, OrgTeam, User, Team, TeamMember, PersonalNote, TeamNote, Tryout, TeamPlayer, @@ -60,7 +60,7 @@ def my_teams(): flash('This page is for players.', 'info') return redirect(url_for('teams.list_teams')) - from app.models import TeamMatch, TeamMatchParticipant + from app.models.models import TeamMatch, TeamMatchParticipant player_teams = current_user.get_org_teams() now = datetime.utcnow() diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index f313104..c95a74b 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -7,7 +7,7 @@ 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 app.extensions import db -from app.models import ( +from app.models.models import ( Admin, Manager, Coach, Player, Scout, User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, OrgTeam, Match, MatchParticipant, diff --git a/app/routes/users.py b/app/routes/users.py index b22bfdd..3b4b2b3 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -8,7 +8,7 @@ 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 app.extensions import db, hash_password, csrf -from app.models import ( +from app.models.models import ( Admin, Manager, Coach, Player, Scout, User, USER_TYPES, ESPORT_GAMES, PlayerDisponibility, UserGamertag, GAME_PLATFORMS, diff --git a/app/supporting_scrits/seed.py b/app/supporting_scrits/seed.py index f7f6d78..01927c7 100644 --- a/app/supporting_scrits/seed.py +++ b/app/supporting_scrits/seed.py @@ -5,7 +5,7 @@ Admin, Manager, Coach, Player, Scout. """ from app.extensions import db, hash_password -from app.models import ( +from app.models.models import ( Admin, Manager, Coach, Player, Scout, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, OrgTeam, TeamPlayer, diff --git a/app/validators.py b/app/validators.py index 4215442..537a4bc 100644 --- a/app/validators.py +++ b/app/validators.py @@ -12,7 +12,7 @@ Usage: import re from marshmallow import Schema, fields, validate, ValidationError, pre_load, validates_schema, EXCLUDE -from app.models import USER_TYPES +from app.models.models import USER_TYPES # =============================================================================