This commit is contained in:
cedrick2711
2026-07-29 14:48:52 -04:00
132 changed files with 4480 additions and 4423 deletions
+3
View File
@@ -19,3 +19,6 @@ htmlcov/
.certs/ .certs/
*.pem *.pem
docs/
*.html
-1
View File
@@ -1 +0,0 @@
1. Ajouter l'option d'enlever des joueurs dans les tryouts.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -22
View File
@@ -7,7 +7,7 @@ the Flask application instance with comprehensive security hardening.
import os import os
from flask import Flask, request, redirect, jsonify, render_template, url_for from flask import Flask, request, redirect, jsonify, render_template, url_for
from flask_cors import CORS 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 sqlalchemy import text
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
import markupsafe import markupsafe
@@ -96,17 +96,17 @@ def create_app():
limiter.init_app(app) limiter.init_app(app)
# Configure structured logging # Configure structured logging
from logging_config import configure_logging from app.logging_config import configure_logging
configure_logging(app) configure_logging(app)
from routes.auth import auth_bp from app.routes.auth import auth_bp
from routes.tryouts import tryouts_bp from app.routes.tryouts import tryouts_bp
from routes.evaluations import evaluations_bp from app.routes.evaluations import evaluations_bp
from routes.users import users_bp from app.routes.users import users_bp
from routes.main import main_bp from app.routes.main import main_bp
from routes.teams import teams_bp from app.routes.teams import teams_bp
from routes.matches import matches_bp from app.routes.matches import matches_bp
from routes.team_matches import team_matches_bp from app.routes.team_matches import team_matches_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(tryouts_bp) app.register_blueprint(tryouts_bp)
@@ -342,26 +342,18 @@ def create_app():
# Database Initialization # Database Initialization
# ========================================================================= # =========================================================================
with app.app_context(): with app.app_context():
import models import app.models as models # noqa: F401 — registers all models with SQLAlchemy
from models import User from app.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() db.create_all()
# Seed database if empty # Seed database if empty
if User.query.count() == 0: if User.query.count() == 0:
from seed import seed_database from app.supporting_scrits.seed import seed_database
seed_database() seed_database()
# Start the Discord bot for notifications # Start the Discord bot for notifications
try: try:
from discord_bot import start_bot from app.discord_bot import start_bot
start_bot() start_bot()
except Exception as e: except Exception as e:
app.logger.warning('Could not start Discord bot: %s', e) app.logger.warning('Could not start Discord bot: %s', e)
+6 -6
View File
@@ -188,7 +188,7 @@ class TeamTryoutsBot(commands.Bot):
""" """
try: try:
# Look up the DB user to get their Discord user ID # Look up the DB user to get their Discord user ID
from models import User as DBUser from app.models.models import User as DBUser
db_user = DBUser.query.get(user_id) db_user = DBUser.query.get(user_id)
if not db_user: if not db_user:
logger.warning(f"DB user {user_id} not found for schedule notification") 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): async def handle_one_on_one_approve(self, coach, message_id, request_id, original_message):
"""Handle coach approving a One on One request.""" """Handle coach approving a One on One request."""
try: try:
from models import OneOnOneRequest, db from app.models.models import OneOnOneRequest, db
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
request = OneOnOneRequest.query.options( 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): async def handle_one_on_one_reject(self, coach, message_id, request_id, original_message):
"""Handle coach rejecting a One on One request.""" """Handle coach rejecting a One on One request."""
try: try:
from models import OneOnOneRequest, db from app.models.models import OneOnOneRequest, db
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
request = OneOnOneRequest.query.options( 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): async def handle_attendance_confirm(self, player, message_id, reference_id, original_message):
"""Handle player confirming attendance for a match/tryout.""" """Handle player confirming attendance for a match/tryout."""
try: try:
from models import MatchParticipant, TryoutRegistration, Match, Tryout, db from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db
request_info = self.pending_requests[message_id] request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type') 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): async def handle_attendance_decline(self, player, message_id, reference_id, original_message):
"""Handle player declining attendance for a match/tryout.""" """Handle player declining attendance for a match/tryout."""
try: try:
from models import MatchParticipant, TryoutRegistration, Match, Tryout, db from app.models.models import MatchParticipant, TryoutRegistration, Match, Tryout, db
request_info = self.pending_requests[message_id] request_info = self.pending_requests[message_id]
event_type = request_info.get('event_type') event_type = request_info.get('event_type')
@@ -471,7 +471,7 @@ class TeamTryoutsBot(commands.Bot):
async def send_daily_reminders(self): async def send_daily_reminders(self):
"""Send daily reminders at 18:00 EDT for events in 24-48 hours.""" """Send daily reminders at 18:00 EDT for events in 24-48 hours."""
try: try:
from models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db from app.models.models import Match, Tryout, MatchParticipant, TryoutRegistration, OneOnOneRequest, db
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
now = datetime.now(self.timezone) now = datetime.now(self.timezone)
View File
+95
View File
@@ -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
+18
View File
@@ -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),
)
+64
View File
@@ -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}',
}
+14
View File
@@ -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))
+7
View File
@@ -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']
+14
View File
@@ -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)
@@ -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')
@@ -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')
+51
View File
@@ -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
+30
View File
@@ -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'),
)
+6
View File
@@ -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']
+18
View File
@@ -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)
+22
View File
@@ -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()]
+22
View File
@@ -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)
+25
View File
@@ -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])
+5
View File
@@ -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']
+53
View File
@@ -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]
+21
View File
@@ -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'),
)
+6
View File
@@ -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']
+11
View File
@@ -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)
@@ -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')
@@ -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')
+24
View File
@@ -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])
+5
View File
@@ -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']
+16
View File
@@ -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')
+15
View File
@@ -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")
+17
View File
@@ -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])
+5
View File
@@ -0,0 +1,5 @@
"""Tryout models."""
from app.models.tryout.tryout import Tryout
from app.models.tryout.tryout_registration import TryoutRegistration
__all__ = ['Tryout', 'TryoutRegistration']
+29
View File
@@ -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])
+14
View File
@@ -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)
+40
View File
@@ -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
+9
View File
@@ -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']
+32
View File
@@ -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()
+45
View File
@@ -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()
+29
View File
@@ -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()
+30
View File
@@ -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]
+14
View File
@@ -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()
+93
View File
@@ -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 []
View File
+5 -5
View File
@@ -9,9 +9,9 @@ import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, render_template, redirect, url_for, flash, request, session 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 flask_login import login_user, logout_user, login_required, current_user
from extensions import db, hash_password, check_password, limiter from app.extensions import db, hash_password, check_password, limiter
from models import User, ESPORT_GAMES from app.models import User, Player, ESPORT_GAMES
from validators import RegisterSchema, LoginSchema from app.validators import RegisterSchema, LoginSchema
from marshmallow import ValidationError from marshmallow import ValidationError
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -246,7 +246,7 @@ def register():
) )
hashed_password = hash_password(password) hashed_password = hash_password(password)
user = User( user = Player(
username=username, username=username,
password_hash=hashed_password, password_hash=hashed_password,
role='player', role='player',
@@ -255,7 +255,7 @@ def register():
phone=phone, phone=phone,
games=','.join(selected_games) if selected_games else None, games=','.join(selected_games) if selected_games else None,
discord_username=discord_username, discord_username=discord_username,
league_os_profile=league_os_profile league_os_profile=league_os_profile,
) )
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
@@ -1,12 +1,16 @@
"""Evaluation routes for assessing player performance during tryouts. """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 import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from extensions import db from app.extensions import db
from models import User, Tryout, Evaluation, TryoutRegistration, GAME_POSITIONS, OrgTeam from app.models import (
Admin, Coach, Manager, Player,
User, Tryout, Evaluation, TryoutRegistration,
OrgTeam, GAME_POSITIONS,
)
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
@@ -14,14 +18,7 @@ evaluations_bp = Blueprint('evaluations', __name__, url_prefix='/evaluations')
def validate_score(score_value): def validate_score(score_value):
"""Validate that a score is between 1 and 10. """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.
"""
if score_value is None: if score_value is None:
return None return None
try: try:
@@ -36,32 +33,18 @@ def validate_score(score_value):
@evaluations_bp.route('') @evaluations_bp.route('')
@login_required @login_required
def list_evaluations(): def list_evaluations():
"""List all evaluations accessible to the current user. """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.
"""
user = current_user user = current_user
# Players are not allowed to view evaluations if isinstance(user, Player):
if user.role == 'player':
flash('You do not have permission to view evaluations.', 'danger') flash('You do not have permission to view evaluations.', 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
# Get sort parameters
sort_column = request.args.get('sort', 'created_at') sort_column = request.args.get('sort', 'created_at')
sort_order = request.args.get('order', 'desc') sort_order = request.args.get('order', 'desc')
# Validate sort_order
if sort_order not in ('asc', 'desc'): if sort_order not in ('asc', 'desc'):
sort_order = 'desc' sort_order = 'desc'
# Map sort columns to SQLAlchemy expressions using aliased User models for relationship sorting
player_alias = aliased(User, name='eval_player') player_alias = aliased(User, name='eval_player')
evaluator_alias = aliased(User, name='eval_evaluator') evaluator_alias = aliased(User, name='eval_evaluator')
@@ -89,7 +72,7 @@ def list_evaluations():
else: else:
sort_expr = sort_expr.desc() sort_expr = sort_expr.desc()
if user.role == 'president': if isinstance(user, Admin):
evaluations = Evaluation.query \ evaluations = Evaluation.query \
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \ .outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
.outerjoin(player_alias, Evaluation.player_id == player_alias.id) \ .outerjoin(player_alias, Evaluation.player_id == player_alias.id) \
@@ -98,14 +81,16 @@ def list_evaluations():
avg_scores = db.session.query( avg_scores = db.session.query(
Evaluation.player_id, Evaluation.player_id,
func.count(Evaluation.id).label('eval_count'), 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() ).group_by(Evaluation.player_id).all()
player_scores = {} player_scores = {}
for row in avg_scores: for row in avg_scores:
p = User.query.get(row.player_id) p = User.query.get(row.player_id)
if p: 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(): elif user.can_evaluate():
evaluations = Evaluation.query \ evaluations = Evaluation.query \
.outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \ .outerjoin(Tryout, Evaluation.tryout_id == Tryout.id) \
@@ -123,53 +108,38 @@ def list_evaluations():
.order_by(sort_expr).all() .order_by(sort_expr).all()
player_scores = {} 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('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST']) @evaluations_bp.route('/<int:tryout_id>/<int:player_id>', methods=['GET', 'POST'])
@login_required @login_required
def evaluate_player(tryout_id, player_id): def evaluate_player(tryout_id, player_id):
"""Evaluate a specific player in a tryout. """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.
"""
if not current_user.can_evaluate(): if not current_user.can_evaluate():
flash('You do not have permission to evaluate players.', 'danger') flash('You do not have permission to evaluate players.', 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id) 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): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to evaluate players in this tryout.', 'danger') flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
# Check if player is registered for this tryout
is_registered = TryoutRegistration.query.filter_by( 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 ).first() is not None
if not is_registered: if not is_registered:
flash('Player is not registered for this tryout.', 'danger') flash('Player is not registered for this tryout.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(player_id) player = User.query.get_or_404(player_id)
if not isinstance(player, Player):
if player.role != 'player':
flash('Can only evaluate players.', 'danger') flash('Can only evaluate players.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing_eval = Evaluation.query.filter_by( existing_eval = Evaluation.query.filter_by(
tryout_id=tryout_id, tryout_id=tryout_id, player_id=player_id, evaluator_id=current_user.id,
player_id=player_id,
evaluator_id=current_user.id
).first() ).first()
if request.method == 'POST': if request.method == 'POST':
@@ -185,7 +155,9 @@ def evaluate_player(tryout_id, player_id):
comments = request.form.get('comments') comments = request.form.get('comments')
position = request.form.get('position_recommendation') 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 overall = sum(scores) / len(scores) if scores else None
if existing_eval: if existing_eval:
@@ -204,21 +176,14 @@ def evaluate_player(tryout_id, player_id):
flash('Evaluation updated!', 'success') flash('Evaluation updated!', 'success')
else: else:
evaluation = Evaluation( evaluation = Evaluation(
tryout_id=tryout_id, tryout_id=tryout_id, player_id=player_id,
player_id=player_id,
evaluator_id=current_user.id, evaluator_id=current_user.id,
mecanics_score=mecanics, mecanics_score=mecanics, cohesion_score=cohesion,
cohesion_score=cohesion, communication_score=communication, gamesense_score=gamesense,
communication_score=communication, versatility_score=versatility, discipline_score=discipline,
gamesense_score=gamesense, analysis_score=analysis, sport_ethics_score=sport_ethics,
versatility_score=versatility, mental_score=mental, overall_score=overall,
discipline_score=discipline, comments=comments, position_recommendation=position,
analysis_score=analysis,
sport_ethics_score=sport_ethics,
mental_score=mental,
overall_score=overall,
comments=comments,
position_recommendation=position
) )
db.session.add(evaluation) db.session.add(evaluation)
flash('Evaluation submitted successfully!', 'success') flash('Evaluation submitted successfully!', 'success')
@@ -227,13 +192,15 @@ def evaluate_player(tryout_id, player_id):
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
evaluators = None evaluators = None
if current_user.role == 'president': if isinstance(current_user, Admin):
all_evaluations = Evaluation.query.filter_by(tryout_id=tryout_id, player_id=player_id).all() all_evaluations = Evaluation.query.filter_by(
evaluators = [{'evaluator': User.query.get(e.evaluator_id), 'eval': e} for e in all_evaluations] 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', return render_template('pages/evaluate_player.html',
tryout=tryout, tryout=tryout, player=player,
player=player,
existing_eval=existing_eval, existing_eval=existing_eval,
evaluators=evaluators, evaluators=evaluators,
game_positions=GAME_POSITIONS) game_positions=GAME_POSITIONS)
@@ -242,24 +209,12 @@ def evaluate_player(tryout_id, player_id):
@evaluations_bp.route('/<int:tryout_id>/players') @evaluations_bp.route('/<int:tryout_id>/players')
@login_required @login_required
def players_to_evaluate(tryout_id): def players_to_evaluate(tryout_id):
"""List players that need evaluation in a specific tryout. """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.
"""
if not current_user.can_evaluate(): if not current_user.can_evaluate():
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
tryout = Tryout.query.get_or_404(tryout_id) 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): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to evaluate players in this tryout.', 'danger') flash('You do not have permission to evaluate players in this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -268,12 +223,11 @@ def players_to_evaluate(tryout_id):
players = [] players = []
for reg in registrations: for reg in registrations:
p = User.query.get(reg.player_id) p = User.query.get(reg.player_id)
if p and p.role == 'player': if p and isinstance(p, Player):
existing = Evaluation.query.filter_by( existing = Evaluation.query.filter_by(
tryout_id=tryout_id, tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
player_id=p.id,
evaluator_id=current_user.id
).first() ).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)
+40 -59
View File
@@ -1,27 +1,25 @@
"""Main dashboard routes for the Team Tryouts application. """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 import Blueprint, render_template, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from extensions import db from app.extensions import db
from models import User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember, Match, MatchParticipant, OrgTeam from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, Evaluation, TryoutRegistration, Team, TeamMember,
Match, MatchParticipant, OrgTeam,
)
from sqlalchemy import func from sqlalchemy import func
from datetime import datetime, date from datetime import date
main_bp = Blueprint('main', __name__) main_bp = Blueprint('main', __name__)
@main_bp.route('/') @main_bp.route('/')
def index(): def index():
"""Redirect root URL to login page. """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.
"""
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -30,20 +28,12 @@ def index():
def dashboard(): def dashboard():
"""Render the main dashboard with role-specific statistics. """Render the main dashboard with role-specific statistics.
Displays different statistics based on the user's role: Each User subclass provides its own stats view.
- 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.
""" """
user = current_user user = current_user
stats = {} stats = {}
if user.role == 'president': if isinstance(user, Admin):
stats['total_users'] = User.query.count() stats['total_users'] = User.query.count()
stats['total_players'] = User.query.filter_by(role='player').count() stats['total_players'] = User.query.filter_by(role='player').count()
stats['total_tryouts'] = Tryout.query.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() stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
today = date.today() today = date.today()
stats['upcoming_matches'] = Match.query.filter( stats['upcoming_matches'] = Match.query.filter(
Match.status == 'scheduled', Match.status == 'scheduled', Match.date >= today,
Match.date >= today
).order_by(Match.date, Match.start_time).limit(5).all() ).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['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['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() today = date.today()
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()] manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
stats['upcoming_matches'] = Match.query.filter( stats['upcoming_matches'] = Match.query.filter(
Match.tryout_id.in_(manager_tryout_ids), Match.tryout_id.in_(manager_tryout_ids),
Match.status == 'scheduled', Match.status == 'scheduled', Match.date >= today,
Match.date >= today
).order_by(Match.date, Match.start_time).limit(5).all() if manager_tryout_ids else [] ).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['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
stats['pending_evaluations'] = 0 registrations = TryoutRegistration.query.filter(
registrations = TryoutRegistration.query.filter(TryoutRegistration.status.in_(['registered', 'attended'])).all() TryoutRegistration.status.in_(['registered', 'attended'])).all()
registered_player_ids = [r.player_id for r in registrations] 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()] 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['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() today = date.today()
org_team = OrgTeam.query.filter_by(coach_id=user.id).first() 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( stats['upcoming_matches'] = Match.query.filter(
Match.tryout_id.in_(coach_tryout_ids), Match.tryout_id.in_(coach_tryout_ids),
Match.status == 'scheduled', Match.status == 'scheduled', Match.date >= today,
Match.date >= today
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else [] ).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_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() 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
today = date.today() today = date.today()
next_matches = [] 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() all_registrations = TryoutRegistration.query.filter_by(player_id=user.id).all()
registered_tryout_ids = [r.tryout_id for r in all_registrations] 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_participant_matches = MatchParticipant.query.filter_by(player_id=user.id).all()
player_match_ids = [p.match_id for p in player_participant_matches] 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_memberships = TeamMember.query.filter_by(player_id=user.id).all()
player_team_ids = [tm.team_id for tm in player_team_memberships] player_team_ids = [tm.team_id for tm in player_team_memberships]
# Find all scheduled matches in registered tryouts
upcoming_matches = Match.query.filter( upcoming_matches = Match.query.filter(
Match.tryout_id.in_(registered_tryout_ids), Match.tryout_id.in_(registered_tryout_ids),
Match.status == 'scheduled', Match.status == 'scheduled', Match.date >= today,
Match.date >= today
).order_by(Match.date, Match.start_time).all() ).order_by(Match.date, Match.start_time).all()
for match in upcoming_matches: for match in upcoming_matches:
is_participant = False is_participant = False
team = None team = None
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
# Check if player is on either team
if match.team1_id in player_team_ids: if match.team1_id in player_team_ids:
is_participant = True 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: elif match.team2_id in player_team_ids:
is_participant = True 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: else:
# For player_vs_player and player_scrim, check MatchParticipant
if match.id in player_match_ids: if match.id in player_match_ids:
is_participant = True is_participant = True
if is_participant: if is_participant:
tryout = match.tryout
next_matches.append({ next_matches.append({
'tryout': tryout, 'tryout': match.tryout, 'match': match,
'match': match, 'team': team.team if team else None,
'team': team.team if team else None
}) })
stats['next_matches'] = next_matches 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_players'] = User.query.filter_by(role='player').count()
stats['total_evaluations'] = Evaluation.query.count() stats['total_evaluations'] = Evaluation.query.count()
stats['avg_scores'] = db.session.query( stats['avg_scores'] = db.session.query(
Evaluation.player_id, Evaluation.player_id,
func.avg(Evaluation.overall_score).label('avg_score') func.avg(Evaluation.overall_score).label('avg_score'),
).group_by(Evaluation.player_id).order_by(func.avg(Evaluation.overall_score).desc()).limit(5).all() ).group_by(Evaluation.player_id).order_by(
func.avg(Evaluation.overall_score).desc()).limit(5).all()
stats['top_players'] = [] stats['top_players'] = []
for row in stats['avg_scores']: for row in stats['avg_scores']:
p = User.query.get(row.player_id) p = User.query.get(row.player_id)
+101 -355
View File
@@ -1,51 +1,47 @@
"""Match scheduling routes for managing scrimmages and matches within tryouts. """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 import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from extensions import db from app.extensions import db
from models import User, Tryout, Match, MatchParticipant, Team, TeamMember, OrgTeam, TryoutRegistration, PlayerDisponibility 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 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') matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
def can_schedule_match(): def can_schedule_match():
"""Check if user can schedule matches (coaches and above). """Check if user can schedule matches (Admin, Manager, Coach, Scout)."""
return isinstance(current_user, (Admin, Manager, Coach, Scout))
Returns:
bool: True if user is president, manager, coach, or scout. def get_visible_tryouts_for_user():
"""Get tryouts that the current user can see based on their role.
Delegates to the polymorphic User subclass.
""" """
return current_user.role in ['president', 'manager', 'coach', 'scout'] return current_user.get_visible_tryouts()
@matches_bp.route('/calendar') @matches_bp.route('/calendar')
@login_required @login_required
def calendar(): def calendar():
"""Render the calendar view showing all tryouts and matches. """Render the calendar view."""
Returns:
Response: Rendered calendar template.
"""
return render_template('pages/calendar.html') return render_template('pages/calendar.html')
@matches_bp.route('/api/events') @matches_bp.route('/api/events')
@login_required @login_required
def api_events(): def api_events():
"""API endpoint returning calendar events for FullCalendar. """API endpoint returning calendar events for FullCalendar."""
Returns tryout events and match events with participant information.
Returns:
Response: JSON array of calendar events.
"""
events = [] events = []
# Get tryouts based on user permissions (this already filters by user's role)
tryouts = get_visible_tryouts_for_user() tryouts = get_visible_tryouts_for_user()
for tryout in tryouts: for tryout in tryouts:
@@ -53,21 +49,17 @@ def api_events():
'id': f'tryout_{tryout.id}', 'id': f'tryout_{tryout.id}',
'title': tryout.title, 'title': tryout.title,
'date': tryout.date.strftime('%Y-%m-%d'), 'date': tryout.date.strftime('%Y-%m-%d'),
'type': 'tryout', 'type': 'tryout', 'color': '#3b82f6',
'color': '#3b82f6', # Blue for tryouts
'extendedProps': { 'extendedProps': {
'location': tryout.location or 'TBD', 'location': tryout.location or 'TBD',
'status': tryout.status, 'status': tryout.status,
'description': tryout.description or '', 'description': tryout.description or '',
'tryout_id': tryout.id 'tryout_id': tryout.id,
} },
}) })
# Add matches for this tryout
for match in tryout.matches: for match in tryout.matches:
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b' match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
# Build match description with participants
match_desc = match.description or '' match_desc = match.description or ''
participants_str = '' participants_str = ''
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
@@ -79,43 +71,34 @@ def api_events():
participants_str = f"{' vs '.join(teams)}" participants_str = f"{' vs '.join(teams)}"
match_desc = participants_str + (f"<br>{match.description}" if match.description else '') match_desc = participants_str + (f"<br>{match.description}" if match.description else '')
else: else:
# Player scrim - show all participants
player_names = [] player_names = []
for p in match.participants.all(): for p in match.participants.all():
player_name = p.player.username if p.player else 'Unknown Player' player_names.append(p.player.username if p.player else 'Unknown Player')
player_names.append(player_name)
participants_str = ', '.join(player_names) if player_names else 'No players' participants_str = ', '.join(player_names) if player_names else 'No players'
match_desc = participants_str + (f"<br>{match.description}" if match.description else '') match_desc = participants_str + (f"<br>{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 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 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( user_participant = MatchParticipant.query.filter_by(
match_id=match.id, match_id=match.id, player_id=current_user.id,
player_id=current_user.id
).first() ).first()
events.append({ events.append({
'id': f'match_{match.id}', 'id': f'match_{match.id}',
'title': match.title, 'title': match.title,
'date': match.date.strftime('%Y-%m-%d'), 'date': match.date.strftime('%Y-%m-%d'),
'type': 'match', 'type': 'match', 'color': match_color,
'color': match_color,
'extendedProps': { 'extendedProps': {
'location': match.location or tryout.location or 'TBD', 'location': match.location or tryout.location or 'TBD',
'status': match.status, 'status': match.status, 'description': match_desc,
'description': match_desc,
'match_type': match.match_type, 'match_type': match.match_type,
'tryout_id': tryout.id, 'tryout_id': tryout.id, 'match_id': match.id,
'match_id': match.id, 'start_time': start_time_str, 'end_time': end_time_str,
'start_time': start_time_str,
'end_time': end_time_str,
'participants': participants_str, 'participants': participants_str,
'user_participant_id': user_participant.id if user_participant else None, '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) return jsonify(events)
@@ -124,63 +107,40 @@ def api_events():
@matches_bp.route('/api/events/<int:tryout_id>') @matches_bp.route('/api/events/<int:tryout_id>')
@login_required @login_required
def api_events_for_tryout(tryout_id): def api_events_for_tryout(tryout_id):
"""API endpoint returning calendar events for a specific tryout. """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.
"""
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
# Check if user can view this tryout
can_view = current_user.can_manage_this_tryout(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 is_registered = False
player_in_match = False player_in_match = False
if current_user.role == 'player': if isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by( 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 ).first() is not None
# Check if player is participating in any matches for this tryout
player_matches = Match.query.join(MatchParticipant).filter( player_matches = Match.query.join(MatchParticipant).filter(
MatchParticipant.player_id == current_user.id, MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id Match.tryout_id == tryout_id,
).all() ).all()
player_in_match = len(player_matches) > 0 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: if not can_view and not is_registered and not player_in_match:
return jsonify([]) return jsonify([])
events = [] events = [{
# Add tryout date as an event (read-only, for context)
events.append({
'id': f'tryout_{tryout.id}', 'id': f'tryout_{tryout.id}',
'title': f'Tryout: {tryout.title}', 'title': f'Tryout: {tryout.title}',
'date': tryout.date.strftime('%Y-%m-%d'), 'date': tryout.date.strftime('%Y-%m-%d'),
'type': 'tryout', 'type': 'tryout', 'color': '#3b82f6',
'color': '#3b82f6', # Blue for tryouts
'extendedProps': { 'extendedProps': {
'location': tryout.location or 'TBD', 'location': tryout.location or 'TBD',
'status': tryout.status, 'status': tryout.status,
'description': tryout.description or '', 'description': tryout.description or '',
'tryout_id': tryout.id 'tryout_id': tryout.id,
} },
}) }]
for match in tryout.matches: for match in tryout.matches:
# Determine color based on match type match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
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
participants_str = '' participants_str = ''
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
teams = [] teams = []
@@ -190,27 +150,16 @@ def api_events_for_tryout(tryout_id):
teams.append(match.team2.name) teams.append(match.team2.name)
participants_str = f"{' vs '.join(teams)}" participants_str = f"{' vs '.join(teams)}"
elif match.match_type == 'player_vs_player': elif match.match_type == 'player_vs_player':
# Get players grouped by team side team1_players = [p.player.username for p in match.participants.filter_by(team_side=1).all() if p.player]
team1_players = [] team2_players = [p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player]
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)
if team1_players and team2_players: if team1_players and team2_players:
participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}" participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}"
else: else:
participants_str = 'TBD vs TBD' participants_str = 'TBD vs TBD'
else: else:
player_names = [] player_names = [p.player.username for p in match.participants.all() if p.player]
for p in match.participants.all():
player_name = p.player.username if p.player else 'Unknown Player'
player_names.append(player_name)
participants_str = ', '.join(player_names) if player_names else 'No players' 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 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 end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
@@ -218,84 +167,24 @@ def api_events_for_tryout(tryout_id):
'id': f'match_{match.id}', 'id': f'match_{match.id}',
'title': match.title, 'title': match.title,
'date': match.date.strftime('%Y-%m-%d'), 'date': match.date.strftime('%Y-%m-%d'),
'type': 'match', 'type': 'match', 'color': match_color,
'color': match_color,
'extendedProps': { 'extendedProps': {
'location': match.location or tryout.location or 'TBD', 'location': match.location or tryout.location or 'TBD',
'status': match.status, 'status': match.status, 'match_type': match.match_type,
'match_type': match.match_type, 'tryout_id': tryout.id, 'match_id': match.id,
'tryout_id': tryout.id,
'match_id': match.id,
'participants': participants_str, 'participants': participants_str,
'start_time': start_time_str, 'start_time': start_time_str, 'end_time': end_time_str,
'end_time': end_time_str },
}
}) })
return jsonify(events) return jsonify(events)
def get_visible_tryouts_for_user():
"""Get tryouts that the current user can see based on their role.
Permission hierarchy:
- President: All tryouts
- Manager: Only their created tryouts
- Coach: Tryouts targeting their org team
- Player: Tryouts they're registered for or matches they're in
- Scout: All tryouts
Returns:
list: Query result of Tryout objects.
"""
if current_user.role == 'president':
return Tryout.query.order_by(Tryout.date).all()
elif current_user.role == 'manager':
return Tryout.query.filter_by(created_by=current_user.id).order_by(Tryout.date).all()
elif current_user.role == 'coach':
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
if org_team:
return Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date).all()
return []
elif current_user.role == 'player':
# Get tryouts player is registered for
player_tryout_ids = [r.tryout_id for r in current_user.tryout_registrations.all()]
tryouts = Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all() if player_tryout_ids else []
# Also include matches where player is participating
player_matches = Match.query.join(MatchParticipant).filter(
MatchParticipant.player_id == current_user.id
).all()
player_match_tryout_ids = list(set(m.tryout_id for m in player_matches))
additional_tryouts = Tryout.query.filter(
Tryout.id.in_(player_match_tryout_ids)
).order_by(Tryout.date).all() if player_match_tryout_ids else []
# Combine and deduplicate
all_tryouts = tryouts + [t for t in additional_tryouts if t.id not in player_tryout_ids]
return all_tryouts
else: # scout
return Tryout.query.order_by(Tryout.date).all()
@matches_bp.route('/create/<int:tryout_id>', methods=['GET', 'POST']) @matches_bp.route('/create/<int:tryout_id>', methods=['GET', 'POST'])
@login_required @login_required
def create_match(tryout_id): def create_match(tryout_id):
"""Create a new match/scrimmage within a tryout. """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.
"""
tryout = Tryout.query.get_or_404(tryout_id) 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): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to schedule matches for this tryout.', 'danger') flash('You do not have permission to schedule matches for this tryout.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -304,8 +193,6 @@ def create_match(tryout_id):
registrations = TryoutRegistration.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 = [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) 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', '') prefill_date = request.args.get('date', '')
if request.method == 'POST': if request.method == 'POST':
@@ -317,26 +204,25 @@ def create_match(tryout_id):
location = request.form.get('location') location = request.form.get('location')
match_type = request.form.get('match_type') match_type = request.form.get('match_type')
# Start time is now mandatory
if not start_time_str: if not start_time_str:
flash('Start time is required. Please select a time slot.', 'danger') 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: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date
except (ValueError, TypeError): except (ValueError, TypeError):
flash('Invalid date format.', 'danger') 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 start_time = None
end_time = None end_time = None
try: try:
start_time = datetime.strptime(start_time_str, '%H:%M').time() start_time = datetime.strptime(start_time_str, '%H:%M').time()
# Auto-calculate end time if not provided (start + 30 minutes)
if end_time_str: if end_time_str:
end_time = datetime.strptime(end_time_str, '%H:%M').time() end_time = datetime.strptime(end_time_str, '%H:%M').time()
else: else:
# Auto-calculate end time as start + 30 minutes
start_dt = datetime.combine(date_obj, start_time) start_dt = datetime.combine(date_obj, start_time)
end_dt = start_dt + timedelta(minutes=30) end_dt = start_dt + timedelta(minutes=30)
end_time = end_dt.time() end_time = end_dt.time()
@@ -345,30 +231,21 @@ def create_match(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)
match = Match( match = Match(
tryout_id=tryout_id, tryout_id=tryout_id, title=title, description=description,
title=title, date=date_obj, start_time=start_time, end_time=end_time,
description=description, location=location, match_type=match_type, created_by=current_user.id,
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.add(match)
db.session.flush() # Get match.id before commit db.session.flush()
# Collect player IDs for Discord notifications
notified_player_ids = [] notified_player_ids = []
notified_participant_ids = []
# Handle team vs team matches
if match_type == 'team_vs_team': if match_type == 'team_vs_team':
team1_id = request.form.get('team1_id') team1_id = request.form.get('team1_id')
team2_id = request.form.get('team2_id') team2_id = request.form.get('team2_id')
match.team1_id = int(team1_id) if team1_id else None match.team1_id = int(team1_id) if team1_id else None
match.team2_id = int(team2_id) if team2_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: if match.team1_id:
for m in TeamMember.query.filter_by(team_id=match.team1_id).all(): 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) 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() db.session.flush()
notified_participant_ids.append(participant.id) notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id) notified_player_ids.append(m.player_id)
# Handle player vs player matches
elif match_type == 'player_vs_player': elif match_type == 'player_vs_player':
team1_player_ids = request.form.get('team1_player_ids', '') team1_player_ids = request.form.get('team1_player_ids', '')
team2_player_ids = request.form.get('team2_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 [] 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 [] 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: for pid in team1_ids:
participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1) participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1)
db.session.add(participant) db.session.add(participant)
@@ -402,11 +276,8 @@ def create_match(tryout_id):
db.session.flush() db.session.flush()
notified_participant_ids.append(participant.id) notified_participant_ids.append(participant.id)
notified_player_ids = team1_ids + team2_ids notified_player_ids = team1_ids + team2_ids
# Handle player scrim matches
elif match_type == 'player_scrim': elif match_type == 'player_scrim':
player_ids = request.form.getlist('player_ids') player_ids = request.form.getlist('player_ids')
notified_participant_ids = []
for pid in player_ids: for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid)) participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant) db.session.add(participant)
@@ -416,54 +287,28 @@ def create_match(tryout_id):
db.session.commit() 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_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' 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): for i, player_id in enumerate(notified_player_ids):
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
send_schedule_notification( send_schedule_notification(
user_id=player_id, user_id=player_id, event_type='match', event_title=match.title,
event_type='match', event_date=event_date_str, event_time=event_time_str,
event_title=match.title, reference_id=reference_id,
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
) )
flash('Match scheduled successfully!', 'success') flash('Match scheduled successfully!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 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('/<int:match_id>/edit', methods=['GET', 'POST']) @matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit_match(match_id): def edit_match(match_id):
"""Edit an existing match. """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.
"""
match = Match.query.get_or_404(match_id) match = Match.query.get_or_404(match_id)
tryout = match.tryout tryout = match.tryout
@@ -472,12 +317,10 @@ def edit_match(match_id):
return redirect(url_for('matches.calendar')) return redirect(url_for('matches.calendar'))
teams = Team.query.filter_by(tryout_id=tryout.id).all() 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() 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 = [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) 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()] 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()] 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()] team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()]
@@ -494,20 +337,21 @@ def edit_match(match_id):
match.date = datetime.strptime(date_str, '%Y-%m-%d').date() match.date = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError): except (ValueError, TypeError):
flash('Invalid date format.', 'danger') 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) 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
if not start_time_str: if not start_time_str:
flash('Start time is required. Please select a time slot.', 'danger') 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) return render_template('pages/match_form.html', match=match, tryout=tryout,
teams=teams, all_players=all_players,
current_player_ids=current_player_ids)
try: try:
match.start_time = datetime.strptime(start_time_str, '%H:%M').time() 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: if end_time_str:
match.end_time = datetime.strptime(end_time_str, '%H:%M').time() match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
else: else:
# Auto-calculate end time as start + 30 minutes
start_dt = datetime.combine(match.date, match.start_time) start_dt = datetime.combine(match.date, match.start_time)
end_dt = start_dt + timedelta(minutes=30) end_dt = start_dt + timedelta(minutes=30)
match.end_time = end_dt.time() match.end_time = end_dt.time()
@@ -518,18 +362,15 @@ def edit_match(match_id):
if status in ['scheduled', 'completed', 'cancelled']: if status in ['scheduled', 'completed', 'cancelled']:
match.status = status match.status = status
# Collect player IDs for Discord notifications
notified_player_ids = [] notified_player_ids = []
# Handle team vs team matches
notified_participant_ids = [] notified_participant_ids = []
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
team1_id = request.form.get('team1_id') team1_id = request.form.get('team1_id')
team2_id = request.form.get('team2_id') team2_id = request.form.get('team2_id')
new_team1_id = int(team1_id) if team1_id else None new_team1_id = int(team1_id) if team1_id else None
new_team2_id = int(team2_id) if team2_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: if new_team1_id != match.team1_id or new_team2_id != match.team2_id:
MatchParticipant.query.filter_by(match_id=match.id).delete() MatchParticipant.query.filter_by(match_id=match.id).delete()
match.team1_id = new_team1_id match.team1_id = new_team1_id
@@ -549,35 +390,30 @@ def edit_match(match_id):
notified_participant_ids.append(participant.id) notified_participant_ids.append(participant.id)
notified_player_ids.append(m.player_id) notified_player_ids.append(m.player_id)
else: else:
# Teams didn't change, still get notified player IDs
if match.team1_id: if match.team1_id:
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()]) notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all()])
if match.team2_id: if match.team2_id:
notified_player_ids.extend([m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all()]) 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': elif match.match_type == 'player_vs_player':
MatchParticipant.query.filter_by(match_id=match.id).delete() MatchParticipant.query.filter_by(match_id=match.id).delete()
team1_player_ids = request.form.getlist('team1_player_ids') team1_str = request.form.get('team1_player_ids', '')
team2_player_ids = request.form.getlist('team2_player_ids') team2_str = request.form.get('team2_player_ids', '')
notified_participant_ids = [] t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else []
for pid in team1_player_ids: 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) participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1)
db.session.add(participant) db.session.add(participant)
db.session.flush() db.session.flush()
notified_participant_ids.append(participant.id) 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) participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2)
db.session.add(participant) db.session.add(participant)
db.session.flush() db.session.flush()
notified_participant_ids.append(participant.id) 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] notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids]
# Handle player scrim matches - update participants
elif match.match_type == 'player_scrim': elif match.match_type == 'player_scrim':
MatchParticipant.query.filter_by(match_id=match.id).delete() MatchParticipant.query.filter_by(match_id=match.id).delete()
player_ids = request.form.getlist('player_ids') player_ids = request.form.getlist('player_ids')
notified_participant_ids = []
for pid in player_ids: for pid in player_ids:
participant = MatchParticipant(match_id=match.id, player_id=int(pid)) participant = MatchParticipant(match_id=match.id, player_id=int(pid))
db.session.add(participant) db.session.add(participant)
@@ -587,9 +423,8 @@ def edit_match(match_id):
db.session.commit() db.session.commit()
# Send Discord notifications to players # Discord notifications
if match.match_type in ['player_vs_player', 'player_scrim']: end_time_val = match.end_time or (match.start_time if match.start_time else None)
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: 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')}" event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}"
else: else:
@@ -598,67 +433,34 @@ def edit_match(match_id):
for i, player_id in enumerate(notified_player_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 reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else match.id
send_schedule_notification( send_schedule_notification(
user_id=player_id, user_id=player_id, event_type='match', event_title=match.title,
event_type='match', event_date=event_date_str, event_time=event_time_str,
event_title=match.title, reference_id=reference_id,
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
) )
flash('Match updated successfully!', 'success') flash('Match updated successfully!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
# Build participant attendance map for the template
participants_map = {} participants_map = {}
for p in match.participants.all(): for p in match.participants.all():
participants_map[p.player_id] = { participants_map[p.player_id] = {
'participant_id': p.id, 'participant_id': p.id,
'attendance_confirmed': p.attendance_confirmed, '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, return render_template('pages/match_form.html', match=match, tryout=tryout,
all_players=all_players, current_player_ids=current_player_ids, teams=teams, all_players=all_players,
team1_player_ids=team1_player_ids, team2_player_ids=team2_player_ids, current_player_ids=current_player_ids,
team1_player_ids=team1_player_ids,
team2_player_ids=team2_player_ids,
participants_map=participants_map) participants_map=participants_map)
@matches_bp.route('/api/manageable-tryouts') @matches_bp.route('/api/manageable-tryouts')
@login_required @login_required
def api_manageable_tryouts(): def api_manageable_tryouts():
"""API endpoint returning tryouts the current user can manage. """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}.
"""
if not can_schedule_match(): if not can_schedule_match():
return jsonify([]) return jsonify([])
@@ -667,9 +469,8 @@ def api_manageable_tryouts():
for t in tryouts: for t in tryouts:
if current_user.can_manage_this_tryout(t): if current_user.can_manage_this_tryout(t):
manageable.append({ manageable.append({
'id': t.id, 'id': t.id, 'title': t.title,
'title': t.title, 'date': t.date.strftime('%Y-%m-%d'),
'date': t.date.strftime('%Y-%m-%d')
}) })
return jsonify(manageable) return jsonify(manageable)
@@ -677,21 +478,12 @@ def api_manageable_tryouts():
@matches_bp.route('/<int:match_id>/delete', methods=['POST']) @matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@login_required @login_required
def delete_match(match_id): def delete_match(match_id):
"""Delete a match. """Delete a match."""
Args:
match_id: The ID of the match to delete.
Returns:
Response: Redirect to tryout view with status message.
"""
match = Match.query.get_or_404(match_id) match = Match.query.get_or_404(match_id)
tryout = match.tryout tryout = match.tryout
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to delete this match.', 'danger') flash('You do not have permission to delete this match.', 'danger')
return redirect(url_for('matches.calendar')) return redirect(url_for('matches.calendar'))
db.session.delete(match) db.session.delete(match)
db.session.commit() db.session.commit()
flash('Match deleted successfully.', 'success') flash('Match deleted successfully.', 'success')
@@ -699,72 +491,38 @@ def delete_match(match_id):
def get_players_available_at_time(date_str, time_str): def get_players_available_at_time(date_str, time_str):
"""Get list of player IDs available at a specific date and time. """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.
"""
try: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
time_obj = datetime.strptime(time_str, '%H:%M').time() time_obj = datetime.strptime(time_str, '%H:%M').time()
except (ValueError, TypeError): except (ValueError, TypeError):
return [] return []
# Calculate day of week (Python: 0=Monday, 6=Sunday) date_for_day = datetime.strptime(date_str, '%Y-%m-%d')
# JavaScript: 0=Sunday, 6=Saturday, so we convert day_of_week = date_for_day.weekday()
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
players = User.query.filter_by(role='player', is_active_account=True).all() players = User.query.filter_by(role='player', is_active_account=True).all()
available_players = [] available_players = []
for player in players: for player in players:
# Check if player has disponibility at this time
disponibilities = PlayerDisponibility.query.filter_by( disponibilities = PlayerDisponibility.query.filter_by(
player_id=player.id, player_id=player.id, day_of_week=day_of_week,
day_of_week=day_of_week
).all() ).all()
for disp in disponibilities: for disp in disponibilities:
# Check if time falls within disponibility block
disp_start = disp.start_time.hour * 60 + disp.start_time.minute disp_start = disp.start_time.hour * 60 + disp.start_time.minute
disp_end = disp.end_time.hour * 60 + disp.end_time.minute disp_end = disp.end_time.hour * 60 + disp.end_time.minute
match_time = time_obj.hour * 60 + time_obj.minute match_time = time_obj.hour * 60 + time_obj.minute
if disp_start <= match_time < disp_end: if disp_start <= match_time < disp_end:
available_players.append(player.id) available_players.append(player.id)
break break
return available_players return available_players
@matches_bp.route('/api/available_players/<date>/<time>') @matches_bp.route('/api/available_players/<date>/<time>')
@login_required @login_required
def api_available_players(date, time): def api_available_players(date, time):
"""API endpoint to get players available at a specific date/time slot. """API endpoint to get players available at a specific date/time slot."""
Args:
date: Date in YYYY-MM-DD format.
time: Time in HH:MM format.
Returns:
Response: JSON with list of available player IDs.
"""
if not current_user.can_manage_teams() and not current_user.can_schedule_matches(): if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
player_ids = get_players_available_at_time(date, time) player_ids = get_players_available_at_time(date, time)
return jsonify({'available_player_ids': player_ids}) return jsonify({'available_player_ids': player_ids})
@@ -772,17 +530,7 @@ def api_available_players(date, time):
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST']) @matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@login_required @login_required
def toggle_presence(match_id, participant_id): def toggle_presence(match_id, participant_id):
"""Toggle the attendance_confirmed status for a match participant. """Toggle attendance_confirmed for a match participant."""
Accessible to tryout managers AND the participant themselves.
Args:
match_id: The ID of the match.
participant_id: The ID of the MatchParticipant record.
Returns:
Response: JSON with new status.
"""
match = Match.query.get_or_404(match_id) match = Match.query.get_or_404(match_id)
tryout = match.tryout tryout = match.tryout
@@ -790,16 +538,14 @@ def toggle_presence(match_id, participant_id):
if participant.match_id != match_id: if participant.match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400 return jsonify({'error': 'Participant does not belong to this match'}), 400
# Allow the participant themselves OR a tryout manager
is_self = participant.player_id == current_user.id is_self = participant.player_id == current_user.id
if not is_self and not current_user.can_manage_this_tryout(tryout): if not is_self and not current_user.can_manage_this_tryout(tryout):
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
participant.attendance_confirmed = not participant.attendance_confirmed participant.attendance_confirmed = not participant.attendance_confirmed
db.session.commit() db.session.commit()
return jsonify({ return jsonify({
'participant_id': participant.id, 'participant_id': participant.id,
'attendance_confirmed': participant.attendance_confirmed, 'attendance_confirmed': participant.attendance_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown' 'player_name': participant.player.username if participant.player else 'Unknown',
}) })
@@ -1,30 +1,28 @@
"""Team match management routes for regular season matches. """Team match management routes for regular season matches.
This module handles CRUD operations for team-specific matches that are Uses polymorphic isinstance checks instead of role-string comparisons.
not tied to tryouts. Players are pre-filled from the team roster.
""" """
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from extensions import db from app.extensions import db
from models import OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer from app.models import (
Admin, Manager, Coach, Player,
OrgTeam, User, TeamMatch, TeamMatchParticipant, TeamPlayer,
)
from datetime import datetime, timedelta from datetime import datetime, timedelta
from discord_bot import send_schedule_notification from app.discord_bot import send_schedule_notification
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches') team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
def can_manage_team_match(team): def can_manage_team_match(team):
"""Check if current user can manage matches for this team. """Check if current user can manage matches for this team."""
if isinstance(current_user, Admin):
Returns:
bool: True if user is president, manager, or a coach of this team.
"""
if current_user.role in ['president']:
return True return True
if current_user.role == 'manager': if isinstance(current_user, Manager):
return True return True
if current_user.role == 'coach': if isinstance(current_user, Coach):
if team.coaches.filter_by(id=current_user.id).first(): if team.coaches.filter_by(id=current_user.id).first():
return True return True
if team.coach_id == current_user.id: if team.coach_id == current_user.id:
@@ -35,105 +33,75 @@ def can_manage_team_match(team):
@team_matches_bp.route('') @team_matches_bp.route('')
@login_required @login_required
def list_matches(): def list_matches():
"""List all team matches visible to the current user. """List all team matches visible to the current user."""
Supports optional ?team_id= query param to pre-filter by team.
Returns:
Response: Rendered team matches list template.
"""
# Optional pre-filter by team_id from query param
filter_team_id = request.args.get('team_id', type=int) filter_team_id = request.args.get('team_id', type=int)
if current_user.role == 'president': if isinstance(current_user, Admin):
teams = OrgTeam.query.order_by(OrgTeam.name).all() teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query matches_query = TeamMatch.query
elif current_user.role == 'manager': elif isinstance(current_user, Manager):
teams = OrgTeam.query.order_by(OrgTeam.name).all() teams = OrgTeam.query.order_by(OrgTeam.name).all()
matches_query = TeamMatch.query matches_query = TeamMatch.query
elif current_user.role == 'coach': elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter( teams = OrgTeam.query.filter(
db.or_( db.or_(
OrgTeam.coaches.any(id=current_user.id), OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id OrgTeam.coach_id == current_user.id,
) )
).order_by(OrgTeam.name).all() ).order_by(OrgTeam.name).all()
team_ids = [t.id for t in teams] team_ids = [t.id for t in teams]
matches_query = TeamMatch.query.filter( matches_query = TeamMatch.query.filter(
TeamMatch.org_team_id.in_(team_ids) TeamMatch.org_team_id.in_(team_ids),
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1) ) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
elif current_user.role == 'player': elif isinstance(current_user, Player):
player_team_ids = [tp.org_team_id for tp in current_user.team_placements] player_team_ids = [tp.org_team_id for tp in current_user.team_placements]
teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else [] teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else []
matches_query = TeamMatch.query.filter( matches_query = TeamMatch.query.filter(
TeamMatch.org_team_id.in_(player_team_ids) TeamMatch.org_team_id.in_(player_team_ids),
) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1) ) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
else: else:
teams = [] teams = []
matches_query = TeamMatch.query.filter(TeamMatch.id == -1) matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
# Apply team_id filter if provided
if filter_team_id: if filter_team_id:
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id) matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
matches = matches_query.order_by(TeamMatch.date.desc()).all() matches = matches_query.order_by(TeamMatch.date.desc()).all()
# Build participants map for each match
match_data = [] match_data = []
for tm in matches: for tm in matches:
confirmed, total = tm.get_confirmed_count() confirmed, total = tm.get_confirmed_count()
participants = [] participants = []
for p in tm.participants.all(): for p in tm.participants.all():
participants.append({ participants.append({
'id': p.id, 'id': p.id, 'player': p.player,
'player': p.player, 'is_confirmed': p.is_confirmed,
'is_confirmed': p.is_confirmed
}) })
match_data.append({ match_data.append({
'match': tm, 'match': tm, 'participants': participants,
'participants': participants, 'confirmed_count': confirmed, 'total_count': total,
'confirmed_count': confirmed,
'total_count': total
}) })
return render_template('pages/team_matches.html', return render_template('pages/team_matches.html',
teams=teams, teams=teams, match_data=match_data,
match_data=match_data,
now=datetime.utcnow()) now=datetime.utcnow())
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST']) @team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
@login_required @login_required
def create_match(team_id): def create_match(team_id):
"""Create a new team match (regular season). """Create a new regular-season team match."""
GET: Render the match creation form with pre-filled team roster.
POST: Create the match with all team players as participants.
Args:
team_id: The ID of the org team to create a match for.
Returns:
Response: Create form or redirect to team matches list.
"""
team = OrgTeam.query.get_or_404(team_id) team = OrgTeam.query.get_or_404(team_id)
if not can_manage_team_match(team): if not can_manage_team_match(team):
flash('You do not have permission to schedule matches for this team.', 'danger') flash('You do not have permission to schedule matches for this team.', 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()] team_players = [tp for tp in TeamPlayer.query.filter_by(org_team_id=team_id).all()]
# Allow pre-filling the date from query param (e.g., from calendar click)
prefill_date = request.args.get('date', '') prefill_date = request.args.get('date', '')
# Check if this is a practice (no opponent)
is_practice = request.args.get('type') == 'practice' is_practice = request.args.get('type') == 'practice'
default_title = 'Practice' if is_practice else f'Team Match — {team.name}' default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
# For practices, render the tryout match form (with availability calendar)
if is_practice and request.method == 'GET': if is_practice and request.method == 'GET':
# Build a lightweight proxy for the tryout object the template expects
class TryoutProxy: class TryoutProxy:
def __init__(self, team_obj): def __init__(self, team_obj):
self.id = 0 self.id = 0
@@ -146,13 +114,9 @@ def create_match(team_id):
all_players = [tp.player for tp in team_players if tp.player] all_players = [tp.player for tp in team_players if tp.player]
return render_template('pages/match_form.html', return render_template('pages/match_form.html',
tryout=proxy_tryout, tryout=proxy_tryout, teams=[], all_players=all_players,
teams=[], prefill_date=prefill_date, is_practice=True,
all_players=all_players, team_id=team_id, team=team)
prefill_date=prefill_date,
is_practice=True,
team_id=team_id,
team=team)
if request.method == 'POST': if request.method == 'POST':
title = request.form.get('title', default_title) title = request.form.get('title', default_title)
@@ -165,13 +129,16 @@ def create_match(team_id):
if not date_str: if not date_str:
flash('Date is required.', 'danger') flash('Date is required.', 'danger')
return render_template('pages/team_match_form.html', team=team, team_players=team_players, prefill_date=prefill_date) return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date)
try: try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError): except (ValueError, TypeError):
flash('Invalid date format.', 'danger') flash('Invalid date format.', 'danger')
return render_template('pages/team_match_form.html', team=team, team_players=team_players, prefill_date=prefill_date, is_practice=is_practice) return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date,
is_practice=is_practice)
start_time = None start_time = None
end_time = None end_time = None
@@ -186,28 +153,24 @@ def create_match(team_id):
end_time = end_dt.time() end_time = end_dt.time()
except ValueError: except ValueError:
flash('Invalid time format.', 'danger') flash('Invalid time format.', 'danger')
return render_template('pages/team_match_form.html', team=team, team_players=team_players, prefill_date=prefill_date, is_practice=is_practice) return render_template('pages/team_match_form.html', team=team,
team_players=team_players, prefill_date=prefill_date,
is_practice=is_practice)
team_match = TeamMatch( team_match = TeamMatch(
org_team_id=team_id, org_team_id=team_id, title=title,
title=title,
description=description or None, description=description or None,
opponent=opponent or None, opponent=opponent or None,
date=date_obj, date=date_obj, start_time=start_time, end_time=end_time,
start_time=start_time, location=location or None, created_by=current_user.id,
end_time=end_time,
location=location or None,
created_by=current_user.id
) )
db.session.add(team_match) db.session.add(team_match)
db.session.flush() # Get team_match.id db.session.flush()
# Auto-add all team players as participants
notified_participant_ids = [] notified_participant_ids = []
for tp in team_players: for tp in team_players:
participant = TeamMatchParticipant( participant = TeamMatchParticipant(
team_match_id=team_match.id, team_match_id=team_match.id, player_id=tp.player_id,
player_id=tp.player_id
) )
db.session.add(participant) db.session.add(participant)
db.session.flush() db.session.flush()
@@ -215,19 +178,17 @@ def create_match(team_id):
db.session.commit() db.session.commit()
# Send Discord notifications to players # Discord notifications
event_date_str = date_obj.strftime('%Y-%m-%d') 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' event_time_str = f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD'
for i, tp in enumerate(team_players): for i, tp in enumerate(team_players):
reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id reference_id = notified_participant_ids[i] if i < len(notified_participant_ids) else team_match.id
send_schedule_notification( send_schedule_notification(
user_id=tp.player_id, user_id=tp.player_id, event_type='match',
event_type='match',
event_title=team_match.title, event_title=team_match.title,
event_date=event_date_str, event_date=event_date_str, event_time=event_time_str,
event_time=event_time_str, reference_id=reference_id,
reference_id=reference_id
) )
flash(f'Team match "{title}" scheduled successfully!', 'success') flash(f'Team match "{title}" scheduled successfully!', 'success')
@@ -239,17 +200,7 @@ def create_match(team_id):
@team_matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST']) @team_matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit_match(match_id): def edit_match(match_id):
"""Edit an existing team match. """Edit an existing team match."""
GET: Render the edit form.
POST: Update match details.
Args:
match_id: The ID of the team match to edit.
Returns:
Response: Edit form or redirect to team matches list.
"""
team_match = TeamMatch.query.get_or_404(match_id) team_match = TeamMatch.query.get_or_404(match_id)
team = team_match.org_team team = team_match.org_team
@@ -285,7 +236,6 @@ def edit_match(match_id):
pass pass
team_match.location = request.form.get('location', '') or None team_match.location = request.form.get('location', '') or None
status = request.form.get('status') status = request.form.get('status')
if status in ['scheduled', 'completed', 'cancelled']: if status in ['scheduled', 'completed', 'cancelled']:
team_match.status = status team_match.status = status
@@ -295,29 +245,18 @@ def edit_match(match_id):
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
return render_template('pages/team_match_form.html', return render_template('pages/team_match_form.html',
match=team_match, match=team_match, team=team, team_players=[])
team=team,
team_players=[])
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST']) @team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
@login_required @login_required
def delete_match(match_id): def delete_match(match_id):
"""Delete a team match. """Delete a team match."""
Args:
match_id: The ID of the team match to delete.
Returns:
Response: Redirect to team matches list.
"""
team_match = TeamMatch.query.get_or_404(match_id) team_match = TeamMatch.query.get_or_404(match_id)
team = team_match.org_team team = team_match.org_team
if not can_manage_team_match(team): if not can_manage_team_match(team):
flash('You do not have permission to delete this match.', 'danger') flash('You do not have permission to delete this match.', 'danger')
return redirect(url_for('team_matches.list_matches')) return redirect(url_for('team_matches.list_matches'))
db.session.delete(team_match) db.session.delete(team_match)
db.session.commit() db.session.commit()
flash('Match deleted successfully.', 'success') flash('Match deleted successfully.', 'success')
@@ -327,23 +266,17 @@ def delete_match(match_id):
@team_matches_bp.route('/api/manageable-teams') @team_matches_bp.route('/api/manageable-teams')
@login_required @login_required
def api_manageable_teams(): def api_manageable_teams():
"""API endpoint returning teams the current user can schedule matches for. """API endpoint returning teams the current user can schedule matches for."""
Used by the calendar's "Create Event" modal.
Returns:
Response: JSON array of {id, name}.
"""
if not current_user.can_schedule_matches(): if not current_user.can_schedule_matches():
return jsonify([]) return jsonify([])
if current_user.role in ['president', 'manager']: if isinstance(current_user, (Admin, Manager)):
teams = OrgTeam.query.order_by(OrgTeam.name).all() teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif current_user.role == 'coach': elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter( teams = OrgTeam.query.filter(
db.or_( db.or_(
OrgTeam.coaches.any(id=current_user.id), OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id OrgTeam.coach_id == current_user.id,
) )
).order_by(OrgTeam.name).all() ).order_by(OrgTeam.name).all()
else: else:
@@ -355,17 +288,7 @@ def api_manageable_teams():
@team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST']) @team_matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
@login_required @login_required
def toggle_presence(match_id, participant_id): def toggle_presence(match_id, participant_id):
"""Toggle the is_confirmed status for a team match participant. """Toggle is_confirmed for a team match participant."""
Accessible to team managers/coaches AND the player themselves.
Args:
match_id: The ID of the team match.
participant_id: The ID of the TeamMatchParticipant record.
Returns:
Response: JSON with new status.
"""
team_match = TeamMatch.query.get_or_404(match_id) team_match = TeamMatch.query.get_or_404(match_id)
team = team_match.org_team team = team_match.org_team
@@ -373,16 +296,14 @@ def toggle_presence(match_id, participant_id):
if participant.team_match_id != match_id: if participant.team_match_id != match_id:
return jsonify({'error': 'Participant does not belong to this match'}), 400 return jsonify({'error': 'Participant does not belong to this match'}), 400
# Allow manager, coach, president, or the player themselves
can_toggle = can_manage_team_match(team) or participant.player_id == current_user.id can_toggle = can_manage_team_match(team) or participant.player_id == current_user.id
if not can_toggle: if not can_toggle:
return jsonify({'error': 'Unauthorized'}), 403 return jsonify({'error': 'Unauthorized'}), 403
participant.is_confirmed = not participant.is_confirmed participant.is_confirmed = not participant.is_confirmed
db.session.commit() db.session.commit()
return jsonify({ return jsonify({
'participant_id': participant.id, 'participant_id': participant.id,
'is_confirmed': participant.is_confirmed, 'is_confirmed': participant.is_confirmed,
'player_name': participant.player.username if participant.player else 'Unknown' 'player_name': participant.player.username if participant.player else 'Unknown',
}) })
+67 -91
View File
@@ -1,12 +1,17 @@
"""Organization team management routes. """Organization team management routes.
This module handles CRUD operations for organization teams and player assignments. Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user from flask_login import login_required, current_user
from extensions import db from app.extensions import db
from models import OrgTeam, User, Team, TeamMember, PersonalNote, TeamNote, Tryout, TeamPlayer from app.models import (
Admin, Manager, Coach, Player,
OrgTeam, User, Team, TeamMember,
PersonalNote, TeamNote, Tryout, TeamPlayer,
)
from datetime import datetime
teams_bp = Blueprint('teams', __name__, url_prefix='/teams') teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@@ -14,31 +19,26 @@ teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@teams_bp.route('') @teams_bp.route('')
@login_required @login_required
def list_teams(): def list_teams():
"""List all organization teams visible to the current user. """List all organization teams visible to the current user."""
Coaches and managers see only their assigned teams.
President sees all teams.
Returns:
Response: Rendered teams list template.
"""
can_manage = current_user.can_manage_teams() can_manage = current_user.can_manage_teams()
if current_user.role == 'coach': if isinstance(current_user, Admin):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach):
teams = OrgTeam.query.filter( teams = OrgTeam.query.filter(
db.or_( db.or_(
OrgTeam.coaches.any(id=current_user.id), OrgTeam.coaches.any(id=current_user.id),
OrgTeam.coach_id == current_user.id OrgTeam.coach_id == current_user.id,
) )
).order_by(OrgTeam.name).all() ).order_by(OrgTeam.name).all()
elif current_user.role == 'manager': elif isinstance(current_user, Manager):
teams = OrgTeam.query.filter( teams = OrgTeam.query.filter(
db.or_( db.or_(
OrgTeam.managers.any(id=current_user.id), OrgTeam.managers.any(id=current_user.id),
OrgTeam.manager_id == current_user.id OrgTeam.manager_id == current_user.id,
) )
).order_by(OrgTeam.name).all() ).order_by(OrgTeam.name).all()
elif current_user.role in ['player', 'scout']: elif isinstance(current_user, Player):
flash('Use My Team(s) to view your teams.', 'info') flash('Use My Team(s) to view your teams.', 'info')
return redirect(url_for('teams.my_teams')) return redirect(url_for('teams.my_teams'))
else: else:
@@ -48,77 +48,56 @@ def list_teams():
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all() coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all() managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
all_players = User.query.filter_by(role='player').order_by(User.username).all() all_players = User.query.filter_by(role='player').order_by(User.username).all()
return render_template('pages/teams.html', teams=teams, coaches=coaches, managers=managers, all_players=all_players, can_manage=can_manage) return render_template('pages/teams.html', teams=teams, coaches=coaches,
managers=managers, all_players=all_players, can_manage=can_manage)
@teams_bp.route('/my-teams') @teams_bp.route('/my-teams')
@login_required @login_required
def my_teams(): def my_teams():
"""View the player's own teams with upcoming matches. """View the player's own teams with upcoming matches."""
if not isinstance(current_user, Player):
Players can see their team rosters, coaches, managers, and
upcoming team matches with presence confirmation toggles.
Returns:
Response: Rendered my_teams template.
"""
if current_user.role != 'player':
flash('This page is for players.', 'info') flash('This page is for players.', 'info')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
from models import TeamMatch, TeamMatchParticipant from app.models import TeamMatch, TeamMatchParticipant
from datetime import datetime
player_teams = current_user.get_org_teams() player_teams = current_user.get_org_teams()
team_data = []
now = datetime.utcnow() now = datetime.utcnow()
team_data = []
for org_team in player_teams: for org_team in player_teams:
matches = TeamMatch.query.filter( matches = TeamMatch.query.filter(
TeamMatch.org_team_id == org_team.id, TeamMatch.org_team_id == org_team.id,
TeamMatch.status == 'scheduled' TeamMatch.status == 'scheduled',
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all() ).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all()
matches_data = [] matches_data = []
for tm in matches: for tm in matches:
confirmed, total = tm.get_confirmed_count() confirmed, total = tm.get_confirmed_count()
participant = TeamMatchParticipant.query.filter_by( participant = TeamMatchParticipant.query.filter_by(
team_match_id=tm.id, team_match_id=tm.id, player_id=current_user.id,
player_id=current_user.id
).first() ).first()
matches_data.append({ matches_data.append({
'match': tm, 'match': tm,
'participant_id': participant.id if participant else None, 'participant_id': participant.id if participant else None,
'is_confirmed': participant.is_confirmed if participant else False, 'is_confirmed': participant.is_confirmed if participant else False,
'confirmed_count': confirmed, 'confirmed_count': confirmed, 'total_count': total,
'total_count': total
}) })
team_data.append({ team_data.append({
'team': org_team, 'team': org_team, 'matches': matches_data,
'matches': matches_data,
'coaches': org_team.get_coaches(), 'coaches': org_team.get_coaches(),
'managers': org_team.get_managers() 'managers': org_team.get_managers(),
}) })
return render_template('pages/my_teams.html', return render_template('pages/my_teams.html', team_data=team_data, now=now)
team_data=team_data,
now=now)
@teams_bp.route('/create', methods=['POST']) @teams_bp.route('/create', methods=['POST'])
@login_required @login_required
def create_team(): def create_team():
"""Create a new organization team. """Create a new organization team."""
Args:
name: Team name from form.
coach_id: Optional coach assignment from form.
manager_id: Optional manager assignment from form.
Returns:
Response: Redirect to teams list with status message.
"""
if not current_user.can_manage_teams(): if not current_user.can_manage_teams():
flash('You do not have permission to create teams.', 'danger') flash('You do not have permission to create teams.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -140,7 +119,7 @@ def create_team():
name=name, name=name,
coach_id=int(coach_id) if coach_id else None, coach_id=int(coach_id) if coach_id else None,
manager_id=int(manager_id) if manager_id else None, manager_id=int(manager_id) if manager_id else None,
created_by=current_user.id created_by=current_user.id,
) )
db.session.add(team) db.session.add(team)
db.session.flush() db.session.flush()
@@ -181,7 +160,28 @@ def edit_team(team_id):
flash(f'Team "{name}" already exists.', 'danger') flash(f'Team "{name}" already exists.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
team.name = name if request.form.get('sync_staff') == '1':
coach_ids = request.form.getlist('coach_ids')
manager_ids = request.form.getlist('manager_ids')
team.coaches = []
for cid in coach_ids:
if cid and cid.strip():
coach_user = User.query.get(int(cid))
if coach_user and isinstance(coach_user, Coach):
team.coaches.append(coach_user)
coach_list = team.coaches.all()
team.coach_id = coach_list[0].id if coach_list else None
team.managers = []
for mid in manager_ids:
if mid and mid.strip():
manager_user = User.query.get(int(mid))
if manager_user and isinstance(manager_user, Manager):
team.managers.append(manager_user)
manager_list = team.managers.all()
team.manager_id = manager_list[0].id if manager_list else None
else:
team.coach_id = int(coach_id) if coach_id else None team.coach_id = int(coach_id) if coach_id else None
team.manager_id = int(manager_id) if manager_id else None team.manager_id = int(manager_id) if manager_id else None
@@ -210,9 +210,7 @@ def delete_team(team_id):
team = OrgTeam.query.get_or_404(team_id) team = OrgTeam.query.get_or_404(team_id)
name = team.name name = team.name
from models import Tryout
tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all() tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all()
if tryouts:
for t in tryouts: for t in tryouts:
t.target_org_team_id = None t.target_org_team_id = None
db.session.commit() db.session.commit()
@@ -229,7 +227,7 @@ def delete_team(team_id):
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST']) @teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
@login_required @login_required
def add_coach(team_id): def add_coach(team_id):
"""Add a coach to an organization team (many-to-many).""" """Add a coach to an organization team."""
team = OrgTeam.query.get_or_404(team_id) team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
@@ -241,7 +239,7 @@ def add_coach(team_id):
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
coach = User.query.get_or_404(int(coach_id)) coach = User.query.get_or_404(int(coach_id))
if coach.role != 'coach': if not isinstance(coach, Coach):
flash('Only coaches can be assigned as coach.', 'danger') flash('Only coaches can be assigned as coach.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -260,7 +258,7 @@ def add_coach(team_id):
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST']) @teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
@login_required @login_required
def add_manager(team_id): def add_manager(team_id):
"""Add a manager to an organization team (many-to-many).""" """Add a manager to an organization team."""
team = OrgTeam.query.get_or_404(team_id) team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
@@ -272,7 +270,7 @@ def add_manager(team_id):
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
manager = User.query.get_or_404(int(manager_id)) manager = User.query.get_or_404(int(manager_id))
if manager.role != 'manager': if not isinstance(manager, Manager):
flash('Only managers can be assigned as manager.', 'danger') flash('Only managers can be assigned as manager.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -346,15 +344,15 @@ def add_player(team_id):
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
player_id = request.form.get('player_id') player_id = request.form.get('player_id')
status = request.form.get('status', 'starter') status = request.form.get('status', 'starter')
if not player_id: if not player_id:
flash('Please select a player.', 'danger') flash('Please select a player.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(int(player_id)) player = User.query.get_or_404(int(player_id))
if player.role != 'player': if not isinstance(player, Player):
flash('Can only assign players to teams.', 'danger') flash('Can only assign players to teams.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -363,11 +361,7 @@ def add_player(team_id):
flash(f'{player.username} is already on {team.name}.', 'info') flash(f'{player.username} is already on {team.name}.', 'info')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
tp = TeamPlayer( tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
player_id=player.id,
org_team_id=team.id,
status=status
)
db.session.add(tp) db.session.add(tp)
db.session.commit() db.session.commit()
flash(f'{player.username} added to {team.name}!', 'success') flash(f'{player.username} added to {team.name}!', 'success')
@@ -384,7 +378,6 @@ def remove_player(team_id, player_id):
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id) player = User.query.get_or_404(player_id)
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first() tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
if not tp: if not tp:
flash(f'{player.username} is not on {team.name}.', 'danger') flash(f'{player.username} is not on {team.name}.', 'danger')
@@ -410,52 +403,41 @@ def toggle_player_status(team_id, player_id):
tp.status = 'substitute' if tp.status == 'starter' else 'starter' tp.status = 'substitute' if tp.status == 'starter' else 'starter'
db.session.commit() db.session.commit()
return jsonify({ return jsonify({
'success': True, 'success': True, 'player_id': player_id,
'player_id': player_id, 'new_status': tp.status, 'player_name': tp.player.username,
'new_status': tp.status,
'player_name': tp.player.username
}) })
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST']) @teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
@login_required @login_required
def add_team_note(team_id): def add_team_note(team_id):
"""Add a team improvement note from the team page (for coaches).""" """Add a team improvement note (coaches only)."""
team = OrgTeam.query.get_or_404(team_id) team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to add notes to this team.', 'danger') flash('You do not have permission to add notes to this team.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip() content = request.form.get('content', '').strip()
if content: if content:
note = TeamNote( note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
org_team_id=team_id,
coach_id=current_user.id,
content=content
)
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash('Team notes added successfully!', 'success') flash('Team notes added successfully!', 'success')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST']) @teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
@login_required @login_required
def add_player_note(team_id, player_id): def add_player_note(team_id, player_id):
"""Add a personal note for a player from the team page (for coaches).""" """Add a personal note for a player (coaches only)."""
team = OrgTeam.query.get_or_404(team_id) team = OrgTeam.query.get_or_404(team_id)
if not current_user.can_manage_this_org_team(team): if not current_user.can_manage_this_org_team(team):
flash('You do not have permission to add notes to this team.', 'danger') flash('You do not have permission to add notes to this team.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(player_id) player = User.query.get_or_404(player_id)
if player.role != 'player': if not isinstance(player, Player):
flash('Can only add notes for players.', 'danger') flash('Can only add notes for players.', 'danger')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
@@ -465,15 +447,9 @@ def add_player_note(team_id, player_id):
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
content = request.form.get('content', '').strip() content = request.form.get('content', '').strip()
if content: if content:
note = PersonalNote( note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
player_id=player_id,
coach_id=current_user.id,
content=content
)
db.session.add(note) db.session.add(note)
db.session.commit() db.session.commit()
flash(f'Note added for {player.username}!', 'success') flash(f'Note added for {player.username}!', 'success')
return redirect(url_for('teams.list_teams')) return redirect(url_for('teams.list_teams'))
+86 -217
View File
@@ -1,24 +1,26 @@
"""Tryout management routes for creating, viewing, and managing tryout events. """Tryout management routes for creating, viewing, and managing tryout events.
This module handles CRUD operations for tryouts and player registrations. This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons.
""" """
from flask import Blueprint, render_template, redirect, url_for, flash, request from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from extensions import db from app.extensions import db
from models import User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember, OrgTeam, Match, MatchParticipant, ESPORT_GAMES, GAME_POSITIONS from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
OrgTeam, Match, MatchParticipant,
ESPORT_GAMES, GAME_POSITIONS,
)
from datetime import datetime from datetime import datetime
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts') tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
def can_manage(): def can_manage():
"""Check if current user can manage tryouts. """Check if current user can manage tryouts (Admin or Manager)."""
return isinstance(current_user, (Admin, Manager))
Returns:
bool: True if user is president or manager.
"""
return current_user.role in ['president', 'manager']
@tryouts_bp.route('') @tryouts_bp.route('')
@@ -26,48 +28,16 @@ def can_manage():
def list_tryouts(): def list_tryouts():
"""List all tryouts visible to the current user. """List all tryouts visible to the current user.
Shows tryouts filtered by user's role: Delegates to the polymorphic User subclass's get_visible_tryouts() method.
- President: All tryouts
- Manager: Only their created tryouts
- Coach: Tryouts targeting their org team
- Player: Only tryouts they are registered for or participating in
Returns:
Response: Rendered tryouts list template.
""" """
if current_user.role == 'president': tryouts = current_user.get_visible_tryouts()
tryouts = Tryout.query.order_by(Tryout.date.desc()).all()
elif current_user.role == 'manager':
tryouts = Tryout.query.filter_by(created_by=current_user.id).order_by(Tryout.date.desc()).all()
elif current_user.role == 'coach':
# Coaches see tryouts targeting their org team
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
if org_team:
tryouts = Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date.desc()).all()
else:
tryouts = []
elif current_user.role == 'player':
# Players only see tryouts they are registered for or participating in matches
from routes.matches import get_visible_tryouts_for_user
tryouts = get_visible_tryouts_for_user()
else:
tryouts = Tryout.query.order_by(Tryout.date.desc()).all()
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow()) return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
@tryouts_bp.route('/create', methods=['GET', 'POST']) @tryouts_bp.route('/create', methods=['GET', 'POST'])
@login_required @login_required
def create_tryout(): def create_tryout():
"""Create a new tryout event. """Create a new tryout event. Requires Admin or Manager."""
GET: Render the tryout creation form.
POST: Create a tryout with the submitted details.
Requires president or manager role.
Returns:
Response: Create form or redirect to the new tryout.
"""
if not can_manage(): if not can_manage():
flash('You do not have permission to create tryouts.', 'danger') flash('You do not have permission to create tryouts.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -91,48 +61,33 @@ def create_tryout():
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError): except (ValueError, TypeError):
flash('Invalid date format.', 'danger') flash('Invalid date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
tryout = Tryout( tryout = Tryout(
title=title, title=title, description=description, game=game, date=date_obj,
description=description,
game=game,
date=date_obj,
location=location, location=location,
max_players=int(max_players) if max_players else None, max_players=int(max_players) if max_players else None,
created_by=current_user.id, created_by=current_user.id, status='upcoming',
status='upcoming',
target_org_team_id=int(target_org_team_id) if target_org_team_id else None, target_org_team_id=int(target_org_team_id) if target_org_team_id else None,
manager_id=int(manager_id) if manager_id else None, manager_id=int(manager_id) if manager_id else None,
coach_id=int(coach_id) if coach_id else None coach_id=int(coach_id) if coach_id else None,
) )
db.session.add(tryout) db.session.add(tryout)
db.session.commit() db.session.commit()
flash('Tryout created successfully!', 'success') flash('Tryout created successfully!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST']) @tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit_tryout(tryout_id): def edit_tryout(tryout_id):
"""Edit an existing tryout event. """Edit an existing tryout event. Permission based on can_manage_this_tryout."""
GET: Render the tryout edit form with current data.
POST: Update the tryout with submitted changes.
Permission based on can_manage_this_tryout check.
Args:
tryout_id: The ID of the tryout to edit.
Returns:
Response: Edit form or redirect to tryout view.
"""
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
# Permission: president, manager (own tryouts), or coach (targets their team)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to edit this tryout.', 'danger') flash('You do not have permission to edit this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
@@ -156,7 +111,8 @@ def edit_tryout(tryout_id):
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError): except (ValueError, TypeError):
flash('Invalid date format.', 'danger') flash('Invalid date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
tryout.title = title tryout.title = title
tryout.description = description tryout.description = description
@@ -171,45 +127,34 @@ def edit_tryout(tryout_id):
flash('Tryout updated successfully!', 'success') flash('Tryout updated successfully!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
@tryouts_bp.route('/<int:tryout_id>') @tryouts_bp.route('/<int:tryout_id>')
@login_required @login_required
def view_tryout(tryout_id): def view_tryout(tryout_id):
"""View a specific tryout with all details. """View a specific tryout with all details. Permission via polymorphic dispatch."""
Displays tryout information, registered players, evaluations, teams,
matches, and evaluation status information.
Args:
tryout_id: The ID of the tryout to view.
Returns:
Response: Rendered tryout detail template.
"""
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
# Check if user has permission to view this tryout
can_view = False can_view = False
if current_user.role == 'president': if isinstance(current_user, Admin):
can_view = True can_view = True
elif current_user.role == 'manager' and tryout.created_by == current_user.id: elif isinstance(current_user, Manager) and tryout.created_by == current_user.id:
can_view = True can_view = True
elif current_user.role == 'coach': elif isinstance(current_user, Coach):
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first() org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
if org_team and tryout.target_org_team_id == org_team.id: if org_team and tryout.target_org_team_id == org_team.id:
can_view = True can_view = True
elif current_user.role == 'player': elif isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by( 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
).first() is not None
player_in_match = MatchParticipant.query.join(Match).filter( player_in_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == current_user.id, MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id Match.tryout_id == tryout_id,
).first() is not None ).first() is not None
can_view = is_registered or player_in_match can_view = is_registered or player_in_match
elif current_user.role == 'scout': elif isinstance(current_user, Scout):
can_view = True can_view = True
if not can_view: if not can_view:
@@ -220,22 +165,16 @@ def view_tryout(tryout_id):
registered_players = [User.query.get(r.player_id) for r in registrations if r.player_id] registered_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all() evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all()
evaluator_ids = set(e.evaluator_id for e in evaluations)
player_ids_with_eval = set(e.player_id for e in evaluations)
# Check if current user has evaluated each player
player_eval_status = {} player_eval_status = {}
if current_user.can_evaluate(): if current_user.can_evaluate():
for p in registered_players: for p in registered_players:
existing = Evaluation.query.filter_by( existing = Evaluation.query.filter_by(
tryout_id=tryout_id, tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
player_id=p.id,
evaluator_id=current_user.id
).first() ).first()
player_eval_status[p.id] = existing is not None player_eval_status[p.id] = existing is not None
is_registered = TryoutRegistration.query.filter_by( 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 ).first() is not None
teams = Team.query.filter_by(tryout_id=tryout_id).all() teams = Team.query.filter_by(tryout_id=tryout_id).all()
@@ -244,97 +183,83 @@ def view_tryout(tryout_id):
members = TeamMember.query.filter_by(team_id=team.id).all() members = TeamMember.query.filter_by(team_id=team.id).all()
team_data.append({ team_data.append({
'team': team, 'team': team,
'members': [{'player': User.query.get(m.player_id), 'position': m.position} for m in members] 'members': [{'player': User.query.get(m.player_id), 'position': m.position}
for m in members],
}) })
# Determine if current user can edit this tryout
can_edit = current_user.can_manage_this_tryout(tryout) can_edit = current_user.can_manage_this_tryout(tryout)
# Determine if current user can view the calendar (managers/coaches can always see it)
# Players need to be registered or participating in a match
can_view_calendar = can_edit can_view_calendar = can_edit
if current_user.role == 'player': if isinstance(current_user, Player):
# Check if player is participating in any matches for this tryout
player_in_match = MatchParticipant.query.join(Match).filter( player_in_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == current_user.id, MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id Match.tryout_id == tryout_id,
).first() is not None ).first() is not None
can_view_calendar = is_registered or player_in_match can_view_calendar = is_registered or player_in_match
# Only expose all_players to users who can manage players in this tryout
all_players = None all_players = None
if can_edit: if can_edit:
all_players = User.query.filter_by(role='player').order_by(User.username).all() all_players = User.query.filter_by(role='player').order_by(User.username).all()
# Get matches for this tryout with participant info
matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all() matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
match_data = [] match_data = []
for match in matches: for match in matches:
# Calculate presence stats
all_participants = list(match.participants.all()) all_participants = list(match.participants.all())
confirmed_count = sum(1 for p in all_participants if p.attendance_confirmed) confirmed_count = sum(1 for p in all_participants if p.attendance_confirmed)
total_count = len(all_participants) total_count = len(all_participants)
player_presence = []
for p in all_participants:
if p.player:
player_presence.append({
'participant_id': p.id, 'player_id': p.player_id,
'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
})
if match.match_type == 'team_vs_team': if match.match_type == 'team_vs_team':
participants = { participants = {
'team1': match.team1.name if match.team1 else 'TBD', 'team1': match.team1.name if match.team1 else 'TBD',
'team2': match.team2.name if match.team2 else 'TBD', 'team2': match.team2.name if match.team2 else 'TBD',
'team1_players': [{'name': m.player.username, 'position': m.position} for m in match.team1.members.all()] if match.team1 else [], 'team1_players': [{'name': m.player.username, 'position': m.position}
'team2_players': [{'name': m.player.username, 'position': m.position} for m in match.team2.members.all()] if match.team2 else [] for m in match.team1.members.all()] if match.team1 else [],
'team2_players': [{'name': m.player.username, 'position': m.position}
for m in match.team2.members.all()] if match.team2 else [],
} }
elif match.match_type == 'player_vs_player': elif match.match_type == 'player_vs_player':
team1_players = [{'name': p.player.username, 'position': p.position} for p in match.participants.filter_by(team_side=1).all() if p.player] team1_players = [{'name': p.player.username, 'position': p.position}
team2_players = [{'name': p.player.username, 'position': p.position} for p in match.participants.filter_by(team_side=2).all() if p.player] for p in match.participants.filter_by(team_side=1).all() if p.player]
team2_players = [{'name': p.player.username, 'position': p.position}
for p in match.participants.filter_by(team_side=2).all() if p.player]
participants = { participants = {
'team1': 'Team 1', 'team1': 'Team 1', 'team2': 'Team 2',
'team2': 'Team 2', 'team1_players': team1_players, 'team2_players': team2_players,
'team1_players': team1_players,
'team2_players': team2_players
} }
else: else:
participants = [p.player.username for p in match.participants.all()] participants = [p.player.username for p in match.participants.all()]
match_data.append({ match_data.append({
'match': match, 'match': match, 'participants': participants,
'participants': participants, 'confirmed_count': confirmed_count, 'total_count': total_count,
'confirmed_count': confirmed_count, 'player_presence': player_presence,
'total_count': total_count
}) })
return render_template('pages/view_tryout.html', return render_template('pages/view_tryout.html',
tryout=tryout, tryout=tryout, registered_players=registered_players,
registered_players=registered_players, evaluations=evaluations, player_eval_status=player_eval_status,
evaluations=evaluations, is_registered=is_registered, registrations=registrations,
player_eval_status=player_eval_status, team_data=team_data, can_edit=can_edit,
is_registered=is_registered, can_view_calendar=can_view_calendar, all_players=all_players,
registrations=registrations, matches=matches, match_data=match_data,
team_data=team_data, game_positions=GAME_POSITIONS, now=datetime.utcnow())
can_edit=can_edit,
can_view_calendar=can_view_calendar,
all_players=all_players,
matches=matches,
match_data=match_data,
game_positions=GAME_POSITIONS,
now=datetime.utcnow())
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
@login_required @login_required
def register_for_tryout(tryout_id): def register_for_tryout(tryout_id):
"""Register a player for a tryout. """Register a player for a tryout. Only Players can self-register."""
Allows players to register for tryouts. Validates that the tryout
is accepting registrations and not at capacity.
Args:
tryout_id: The ID of the tryout to register for.
Returns:
Response: Redirect to tryout view with status message.
"""
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
if current_user.role != 'player': if not isinstance(current_user, Player):
flash('Only players can register for tryouts.', 'danger') flash('Only players can register for tryouts.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -342,7 +267,8 @@ def register_for_tryout(tryout_id):
flash('This tryout is not accepting registrations.', 'danger') flash('This tryout is not accepting registrations.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=current_user.id).first() existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first()
if existing: if existing:
flash('You are already registered for this tryout.', 'info') flash('You are already registered for this tryout.', 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -363,16 +289,7 @@ def register_for_tryout(tryout_id):
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
@login_required @login_required
def update_status(tryout_id): def update_status(tryout_id):
"""Update the status of a tryout. """Update the status of a tryout."""
Changes tryout status between upcoming, in_progress, and completed.
Args:
tryout_id: The ID of the tryout to update.
Returns:
Response: Redirect to tryout view.
"""
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
@@ -388,21 +305,14 @@ def update_status(tryout_id):
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
@login_required @login_required
def update_registration_status(tryout_id, player_id): def update_registration_status(tryout_id, player_id):
"""Update the attendance status of a tryout registration. """Update a registration's attendance status."""
Args:
tryout_id: The ID of the tryout.
player_id: The ID of the player whose status to update.
Returns:
Response: Redirect to tryout view.
"""
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.list_tryouts')) return redirect(url_for('tryouts.list_tryouts'))
registration = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first_or_404() registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first_or_404()
new_status = request.form.get('status') new_status = request.form.get('status')
if new_status in ['registered', 'attended', 'no_show']: if new_status in ['registered', 'attended', 'no_show']:
registration.status = new_status registration.status = new_status
@@ -414,32 +324,23 @@ def update_registration_status(tryout_id, player_id):
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
@login_required @login_required
def register_player(tryout_id): def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches). """Manually register a player for a tryout (by managers/coaches)."""
Allows authorized users to register players on their behalf.
Args:
tryout_id: The ID of the tryout.
Returns:
Response: Redirect to tryout view with status message.
"""
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = request.form.get('player_id') player_id = request.form.get('player_id')
if not player_id: if not player_id:
flash('Please select a player.', 'danger') flash('Please select a player.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(int(player_id)) player = User.query.get_or_404(int(player_id))
if player.role != 'player': if not isinstance(player, Player):
flash('Can only register players.', 'danger') flash('Can only register players.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first() existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player.id).first()
if existing: if existing:
flash(f'{player.username} is already registered for this tryout.', 'info') flash(f'{player.username} is already registered for this tryout.', 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@@ -460,18 +361,7 @@ def register_player(tryout_id):
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
@login_required @login_required
def remove_player(tryout_id, player_id): def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout. """Remove a registered player from a tryout (cascades to teams/matches)."""
Also removes the player from any tryout teams and match participants
within this tryout.
Args:
tryout_id: The ID of the tryout.
player_id: The ID of the player to remove.
Returns:
Response: Redirect to tryout view with status message.
"""
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
@@ -479,27 +369,23 @@ def remove_player(tryout_id, player_id):
player = User.query.get_or_404(player_id) player = User.query.get_or_404(player_id)
# Remove the tryout registration
registration = TryoutRegistration.query.filter_by( registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id tryout_id=tryout_id, player_id=player_id).first()
).first()
if registration: if registration:
db.session.delete(registration) db.session.delete(registration)
# Remove from tryout teams within this tryout
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()] team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
if team_ids: if team_ids:
TeamMember.query.filter( TeamMember.query.filter(
TeamMember.team_id.in_(team_ids), TeamMember.team_id.in_(team_ids),
TeamMember.player_id == player_id TeamMember.player_id == player_id,
).delete(synchronize_session=False) ).delete(synchronize_session=False)
# Remove from match participants in this tryout
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()] match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids: if match_ids:
MatchParticipant.query.filter( MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids), MatchParticipant.match_id.in_(match_ids),
MatchParticipant.player_id == player_id MatchParticipant.player_id == player_id,
).delete(synchronize_session=False) ).delete(synchronize_session=False)
db.session.commit() db.session.commit()
@@ -510,14 +396,7 @@ def remove_player(tryout_id, player_id):
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
@login_required @login_required
def create_team(tryout_id): def create_team(tryout_id):
"""Create a tryout-specific team. """Create a tryout-specific team."""
Args:
tryout_id: The ID of the tryout to create the team for.
Returns:
Response: Redirect to tryout view with status message.
"""
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger') flash('Permission denied.', 'danger')
@@ -535,15 +414,7 @@ def create_team(tryout_id):
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST']) @tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
@login_required @login_required
def add_to_team(tryout_id, team_id): def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team. """Add a player to a tryout team."""
Args:
tryout_id: The ID of the tryout.
team_id: The ID of the team to add the player to.
Returns:
Response: Redirect to tryout view with status message.
"""
team = Team.query.get_or_404(team_id) team = Team.query.get_or_404(team_id)
tryout = Tryout.query.get_or_404(tryout_id) tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout): if not current_user.can_manage_this_tryout(tryout):
@@ -552,7 +423,6 @@ def add_to_team(tryout_id, team_id):
player_id = request.form.get('player_id') player_id = request.form.get('player_id')
position = request.form.get('position', '') position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing: if existing:
flash('Player is already on this team.', 'info') flash('Player is already on this team.', 'info')
@@ -561,5 +431,4 @@ def add_to_team(tryout_id, team_id):
db.session.add(member) db.session.add(member)
db.session.commit() db.session.commit()
flash('Player added to team!', 'success') flash('Player added to team!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
+770
View File
@@ -0,0 +1,770 @@
"""User management routes for profiles, disponibilities, and contracts.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
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 app.extensions import db, hash_password, csrf
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, USER_TYPES, 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 app.validators import (
CreateUserSchema, EditUserSchema, EditProfileSchema,
UploadContractSchema, OneOnOneRequestSchema,
)
import requests
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
ALLOWED_SIGNED_EXTENSIONS = {'pdf'}
users_bp = Blueprint('users', __name__, url_prefix='/users')
# ---------------------------------------------------------------------------
# Gamertag helper (shared)
# ---------------------------------------------------------------------------
def update_user_gamertags(user, selected_games):
"""Update gamertags for a user based on form input."""
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)
for game in existing_gamertags:
if game not in selected_games:
db.session.delete(existing_gamertags[game])
# ---------------------------------------------------------------------------
# USER_TYPE → Model mapping for create_user
# ---------------------------------------------------------------------------
_USER_CLASS_MAP = {
'admin': Admin,
'manager': Manager,
'coach': Coach,
'player': Player,
'scout': Scout,
}
# ===========================================================================
# ROUTES
# ===========================================================================
@users_bp.route('')
@login_required
def list_users():
"""List all users for management (Admin only)."""
if not isinstance(current_user, 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=USER_TYPES)
@users_bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_user(user_id):
"""Edit an existing user (Admin only)."""
if not isinstance(current_user, 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 USER_TYPES:
flash('Invalid role selected.', 'danger')
return render_template('pages/edit_user.html', user=user, roles=USER_TYPES,
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_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=USER_TYPES,
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS,
user_gamertags=user_gamertags)
@users_bp.route('/<int:user_id>/delete', methods=['POST'])
@login_required
def delete_user(user_id):
"""Delete a user (Admin only)."""
if not isinstance(current_user, 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)
Evaluation.query.filter(
db.or_(Evaluation.evaluator_id == user_id, Evaluation.player_id == user_id),
).delete(synchronize_session=False)
PlayerDisponibility.query.filter_by(player_id=user_id).delete()
CoachAvailability.query.filter_by(coach_id=user_id).delete()
PersonalNote.query.filter(
db.or_(PersonalNote.player_id == user_id, PersonalNote.coach_id == user_id),
).delete(synchronize_session=False)
TeamNote.query.filter_by(coach_id=user_id).delete()
OneOnOneRequest.query.filter(
db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id),
).delete(synchronize_session=False)
UserGamertag.query.filter_by(user_id=user_id).delete()
Contract.query.filter_by(player_id=user_id).delete()
TryoutRegistration.query.filter_by(player_id=user_id).delete()
TeamPlayer.query.filter_by(player_id=user_id).delete()
TeamMember.query.filter_by(player_id=user_id).delete()
MatchParticipant.query.filter_by(player_id=user_id).delete()
OrgTeam.query.filter_by(coach_id=user_id).update({'coach_id': None})
OrgTeam.query.filter_by(manager_id=user_id).update({'manager_id': None})
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 (Admin only). Uses the correct polymorphic subclass."""
if not isinstance(current_user, 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 USER_TYPES:
flash('Invalid role selected.', 'danger')
return render_template('pages/create_user.html', roles=USER_TYPES)
if User.query.filter_by(username=username).first():
flash('Username already exists.', 'danger')
return render_template('pages/create_user.html', roles=USER_TYPES)
if User.query.filter_by(email=email).first():
flash('Email already registered.', 'danger')
return render_template('pages/create_user.html', roles=USER_TYPES)
hashed_password = hash_password(password)
user_cls = _USER_CLASS_MAP.get(role, Player)
user = user_cls(
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=USER_TYPES)
@users_bp.route('/<int:user_id>/view')
@login_required
def view_user(user_id):
"""View a public profile for any user."""
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."""
contracts = None
if isinstance(current_user, 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."""
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=current_user.get_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=current_user.get_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_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'))
return render_template('pages/edit_profile.html', user=current_user,
esport_games=ESPORT_GAMES, game_platforms=GAME_PLATFORMS,
user_gamertags=current_user.get_gamertags())
# ---------------------------------------------------------------------------
# Disponibilities
# ---------------------------------------------------------------------------
DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
def add_30_minutes(t):
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."""
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."""
disponibilities = PlayerDisponibility.query.filter_by(player_id=current_user.id).all()
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."""
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."""
data = request.get_json()
slots = data.get('slots', [])
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)
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'),
})
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."""
PlayerDisponibility.query.filter_by(player_id=current_user.id).delete()
db.session.commit()
return jsonify({'success': True})
@users_bp.route('/disponibilities/<int:disponibility_id>/delete', methods=['POST'])
@login_required
def delete_disponibility(disponibility_id):
"""Delete a disponibility block."""
disponibility = PlayerDisponibility.query.get_or_404(disponibility_id)
if disponibility.player_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
db.session.delete(disponibility)
db.session.commit()
return jsonify({'success': True})
# ---------------------------------------------------------------------------
# Contracts
# ---------------------------------------------------------------------------
def can_manage_player_contract(user, player_id):
"""Check if a user can upload contracts for a specific player."""
if isinstance(user, Admin):
return True
if isinstance(user, Manager):
return True
if isinstance(user, 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 or players they manage."""
contracts = None
players = None
if isinstance(current_user, Player):
contracts = Contract.query.filter_by(
player_id=current_user.id,
).order_by(Contract.uploaded_at.desc()).all()
elif isinstance(current_user, (Admin, Manager, Coach)):
players = []
if isinstance(current_user, 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 = 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 isinstance(current_user, (Admin, Manager, Coach)) else None)
@users_bp.route('/contracts/upload', methods=['GET', 'POST'])
@login_required
def upload_contract():
"""Upload a contract for a player."""
if not isinstance(current_user, (Admin, Manager, Coach)):
flash('Only presidents, managers, and coaches can upload contracts.', 'danger')
return redirect(url_for('users.list_contracts'))
if isinstance(current_user, 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':
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'))
if not file.filename.lower().endswith('.pdf'):
flash('Only PDF files are allowed for contracts.', 'danger')
return redirect(url_for('users.upload_contract'))
upload_dir = os.path.join(os.getcwd(), 'documents', 'contrats signés')
os.makedirs(upload_dir, exist_ok=True)
player = User.query.get_or_404(player_id)
player_teams = player.get_org_teams()
team = player_teams[0] if player_teams else None
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
original_filename = secure_filename(file.filename)
file_uuid = str(uuid.uuid4())
stored_filename = f"{file_uuid}.pdf"
file_path = os.path.join(final_dir, stored_filename)
file.save(file_path)
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/<int:contract_id>/upload_signed', methods=['POST'])
@login_required
def upload_signed_contract(contract_id):
"""Upload a signed contract (player only)."""
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'))
signed_filename = f"signed_{contract.stored_filename}"
file.save(contract.file_path.replace(contract.stored_filename, signed_filename))
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/<int:contract_id>/download')
@login_required
def download_contract(contract_id):
"""Download a contract file."""
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/<int:contract_id>/download_signed')
@login_required
def download_signed_contract(contract_id):
"""Download a signed contract file."""
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
# ---------------------------------------------------------------------------
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."""
import logging
logger = logging.getLogger(__name__)
if coach_discord_id:
try:
from app.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}")
if DISCORD_WEBHOOK_URL:
try:
from app.discord_bot import send_one_on_one_dm
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():
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."""
if not isinstance(current_user, Player):
flash('Only players can request One on One sessions.', 'danger')
return redirect(url_for('main.dashboard'))
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')
team_notes = []
if org_team:
team_notes = TeamNote.query.filter_by(org_team_id=org_team.id).order_by(TeamNote.created_at.desc()).all()
personal_notes = PersonalNote.query.filter_by(player_id=current_user.id).order_by(PersonalNote.created_at.desc()).all()
coach_availability = []
if coach:
coach_availability = CoachAvailability.query.filter_by(coach_id=coach.id).all()
if request.method == 'POST':
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'))
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_date = datetime.strptime(date_str, '%Y-%m-%d')
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'))
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(
player_name=current_user.full_name,
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 'Unknown Team',
coach_name=coach.full_name,
coach_discord=coach.discord_username or '',
coach_discord_id=coach.discord_user_id or '',
request_id=request_obj.id,
)
flash('Your One on One request has been submitted!', 'success')
return redirect(url_for('users.one_on_one'))
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)
@@ -1231,9 +1231,9 @@ a:hover { color: var(--primary-dark); }
} }
.merged-disponibility-time-block.selected { .merged-disponibility-time-block.selected {
background: var(--success); background: #6366f1 !important;
color: white; color: white !important;
border-color: var(--success); border-color: #4f46e5 !important;
} }
.merged-disponibility-time-block.high-availability { .merged-disponibility-time-block.high-availability {
@@ -1266,10 +1266,10 @@ a:hover { color: var(--primary-dark); }
/* Then specific states override the generic rule */ /* Then specific states override the generic rule */
[data-theme="dark"] .merged-disponibility-time-block.selected { [data-theme="dark"] .merged-disponibility-time-block.selected {
background: var(--success) !important; background: #6366f1 !important;
color: white !important; color: white !important;
border-color: var(--success) !important; border-color: #818cf8 !important;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.5); box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.5);
} }
[data-theme="dark"] .merged-disponibility-time-block.high-availability { [data-theme="dark"] .merged-disponibility-time-block.high-availability {
+1
View File
@@ -0,0 +1 @@
# supporting scripts package
@@ -17,7 +17,7 @@ import subprocess
import ssl import ssl
import sys import sys
from waitress.server import create_server from waitress.server import create_server
from app import create_app from app.app import create_app
CERT_FILE = 'certs/localhost.pem' CERT_FILE = 'certs/localhost.pem'
KEY_FILE = 'certs/localhost-key.pem' KEY_FILE = 'certs/localhost-key.pem'
@@ -264,7 +264,7 @@ def check_flask_config():
all_ok = True all_ok = True
try: try:
from app import create_app from app.app import create_app
app = create_app() app = create_app()
# Check session cookie settings # Check session cookie settings
+460
View File
@@ -0,0 +1,460 @@
"""Database seeding script for Team Tryouts application.
Creates sample data using the polymorphic User subclasses:
Admin, Manager, Coach, Player, Scout.
"""
from app.extensions import db, hash_password
from app.models import (
Admin, Manager, Coach, Player, Scout,
Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
OrgTeam, TeamPlayer,
PlayerDisponibility, CoachAvailability,
UserGamertag, TeamNote, PersonalNote,
Match, MatchParticipant,
BaseAvailability, BaseMatch, BaseParticipant,
)
from datetime import datetime, timedelta, time
import random
def seed_database():
"""Seed the database with sample data for development and testing."""
# Clear existing data (order matters for FK constraints)
for table in ['player_disponibilities', 'coach_availabilities',
'match_participants', 'team_match_participants',
'matches', 'team_matches',
'team_players', 'team_members', 'teams',
'evaluations', 'tryout_registrations', 'tryouts',
'org_team_coaches', 'org_team_managers',
'org_teams', 'user_gamertags',
'personal_notes', 'team_notes',
'one_on_one_requests',
'users']:
db.session.execute(db.text(f'DELETE FROM {table}'))
db.session.commit()
# -----------------------------------------------------------------------
# Staff users (Admin, Manager, Coach, Scout)
# -----------------------------------------------------------------------
admin = Admin(
username='admin', password_hash=hash_password('password'),
role='admin', full_name='Sarah Johnson',
email='[email protected]', phone='555-0101')
db.session.add(admin)
manager1 = Manager(
username='manager1', password_hash=hash_password('password'),
role='manager', full_name='Mike Williams',
email='[email protected]', phone='555-0102')
db.session.add(manager1)
manager2 = Manager(
username='manager2', password_hash=hash_password('password'),
role='manager', full_name='Emily Davis',
email='[email protected]', phone='555-0103')
db.session.add(manager2)
coach1 = Coach(
username='coach1', password_hash=hash_password('password'),
role='coach', full_name='Coach Thompson',
email='[email protected]', phone='555-0104',
discord_user_id='484107446298738689')
db.session.add(coach1)
coach2 = Coach(
username='coach2', password_hash=hash_password('password'),
role='coach', full_name='Coach Martinez',
email='[email protected]', phone='555-0105')
db.session.add(coach2)
coach3 = Coach(
username='coach3', password_hash=hash_password('password'),
role='coach', full_name='Coach Anderson',
email='[email protected]', phone='555-0106')
db.session.add(coach3)
scout = Scout(
username='scout1', password_hash=hash_password('password'),
role='scout', full_name='Alex Rivera',
email='[email protected]', phone='555-0107')
db.session.add(scout)
# -----------------------------------------------------------------------
# Players
# -----------------------------------------------------------------------
player_data = [
{'username': 'jplayer1', 'full_name': 'nordjan', 'email': '[email protected]',
'games': 'Valorant, Counter-Strike 2, Rainbow Six Siege, Rocket League, Overwatch 2',
'discord_username': 'nordjan', 'discord_user_id': '484107446298738689',
'league_os_profile': 'https://leagueos.gg/player/nordjan'},
{'username': 'jplayer2', 'full_name': 'Emma Garcia', 'email': '[email protected]',
'games': 'League of Legends,Valorant',
'discord_username': 'EmmaG#4452', 'discord_user_id': '',
'league_os_profile': 'https://leagueos.gg/player/emmagarcia'},
{'username': 'jplayer3', 'full_name': 'Liam Brown', 'email': '[email protected]',
'games': 'Apex Legends,Fortnite',
'discord_username': 'LiamB#8103', 'discord_user_id': '',
'league_os_profile': 'https://leagueos.gg/player/liambrown'},
{'username': 'jplayer4', 'full_name': 'Sophia Lee', 'email': '[email protected]',
'games': 'Overwatch 2,Valorant',
'discord_username': 'SophiaL#3327', 'discord_user_id': '',
'league_os_profile': 'https://leagueos.gg/player/sophialee'},
{'username': 'jplayer5', 'full_name': 'Noah Taylor', 'email': '[email protected]',
'games': 'Counter-Strike 2,Rainbow Six Siege',
'discord_username': 'NoahT#6614', 'discord_user_id': '',
'league_os_profile': 'https://leagueos.gg/player/noahtaylor'},
{'username': 'jplayer6', 'full_name': 'Olivia Martin', 'email': '[email protected]',
'games': 'Rocket League,Fortnite',
'discord_username': 'OliviaM#2298', 'discord_user_id': '',
'league_os_profile': 'https://leagueos.gg/player/oliviamartin'},
{'username': 'jplayer7', 'full_name': 'Ethan Clark', 'email': '[email protected]',
'games': 'Valorant,Apex Legends',
'discord_username': 'EthanC#7743', 'discord_user_id': '',
'league_os_profile': 'https://leagueos.gg/player/ethanclark'},
{'username': 'jplayer8', 'full_name': 'Ava White', 'email': '[email protected]',
'games': 'League of Legends,Counter-Strike 2',
'discord_username': 'AvaW#5561', 'discord_user_id': '',
'league_os_profile': 'https://leagueos.gg/player/avawhite'},
{'username': 'jplayer9', 'full_name': 'Mason Hall', 'email': '[email protected]',
'games': 'Call of Duty,Rocket League',
'discord_username': 'MasonH#1189', 'discord_user_id': '',
'league_os_profile': 'https://leagueos.gg/player/masonhall'},
{'username': 'jplayer10', 'full_name': 'Isabella Adams', 'email': '[email protected]',
'games': 'Overwatch 2,Dota 2',
'discord_username': 'IsabellaA#4437', 'discord_user_id': '',
'league_os_profile': 'https://leagueos.gg/player/isabellaadams'},
]
players = []
for i, data in enumerate(player_data, start=10):
p = Player(
username=data['username'], password_hash=hash_password('password'),
role='player', full_name=data['full_name'],
email=data['email'], phone=f'555-01{i:02d}',
games=data['games'],
discord_username=data['discord_username'],
discord_user_id=data['discord_user_id'],
league_os_profile=data['league_os_profile'])
db.session.add(p)
players.append(p)
db.session.commit()
print(f"[OK] Created {7 + 10} users (7 staff + 10 players)")
# Map for easy reference
coaches = [coach1, coach2, coach3]
# -----------------------------------------------------------------------
# Gamertags
# -----------------------------------------------------------------------
gt_map = [
(players[0], 'Valorant', 'nordjan#bad', None),
(players[0], 'Counter-Strike 2', 'nordjan', None),
(players[0], 'Rainbow Six Siege', 'nordjan', 'Ubisoft'),
(players[0], 'Rocket League', 'nordjiano', 'Epic'),
(players[0], 'Overwatch 2', 'nordjan', 'PC'),
(players[1], 'League of Legends', 'emmagarcia_lol', None),
(players[1], 'Valorant', 'emmagarcia_val', None),
(players[2], 'Apex Legends', 'liambrown_apex', 'PC'),
(players[2], 'Fortnite', 'liambrown_fn', 'PC'),
(players[3], 'Overwatch 2', 'sophialee_ow', 'PC'),
(players[3], 'Valorant', 'sophialee_val', None),
(players[4], 'Counter-Strike 2', 'noahtaylor_cs', None),
(players[4], 'Rainbow Six Siege', 'noahtaylor_r6', 'Ubisoft'),
(players[5], 'Rocket League', 'oliviamartin_rl', 'Epic'),
(players[5], 'Fortnite', 'oliviamartin_fn', 'PC'),
(players[6], 'Valorant', 'ethanclark_val', None),
(players[6], 'Apex Legends', 'ethanclark_apex', 'PC'),
(players[7], 'League of Legends', 'avawhite_lol', None),
(players[7], 'Counter-Strike 2', 'avawhite_cs', None),
(players[8], 'Call of Duty', 'masonhall_cod', 'PC'),
(players[8], 'Rocket League', 'masonhall_rl', 'Epic'),
(players[9], 'Overwatch 2', 'isabellaadams_ow', 'PC'),
(players[9], 'Dota 2', 'isabellaadams_dota', None),
]
for p, game, tag, platform in gt_map:
db.session.add(UserGamertag(user_id=p.id, game=game, gamertag=tag, platform=platform))
db.session.commit()
print(f"[OK] Created {len(gt_map)} gamertags")
# -----------------------------------------------------------------------
# Organisation Teams
# -----------------------------------------------------------------------
org_teams_data = [
{'name': 'Rocket League main', 'coach': coaches[0], 'creator': admin},
{'name': 'CS2', 'coach': coaches[1], 'creator': admin},
{'name': 'Valorant', 'coach': coaches[2], 'creator': admin},
{'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)} organisation teams")
# -----------------------------------------------------------------------
# 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")
# -----------------------------------------------------------------------
# Registrations
# -----------------------------------------------------------------------
reg_assignments = [
(tryouts[0], players[:3]),
(tryouts[1], players[3:5]),
(tryouts[2], players),
]
regs = []
for tryout, plist in reg_assignments:
for player in plist:
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")
# -----------------------------------------------------------------------
# Evaluations
# -----------------------------------------------------------------------
eval_count = 0
rl_positions = ['None needed', 'None', 'N/A']
for player in players[:8]:
for coach in coaches:
if random.random() > 0.3:
ms = [random.randint(4, 10) for _ in range(9)]
overall = round(sum(ms) / 9, 1)
db.session.add(Evaluation(
tryout_id=tryouts[0].id, player_id=player.id,
evaluator_id=coach.id,
mecanics_score=ms[0], cohesion_score=ms[1],
communication_score=ms[2], gamesense_score=ms[3],
versatility_score=ms[4], discipline_score=ms[5],
analysis_score=ms[6], sport_ethics_score=ms[7],
mental_score=ms[8], 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)))
eval_count += 1
val_positions = ['Controller', 'Initiator', 'Duelist', 'Sentinel']
for player in players[:6]:
for coach in coaches[:2]:
ms = [random.randint(3, 10) for _ in range(9)]
overall = round(sum(ms) / 9, 1)
db.session.add(Evaluation(
tryout_id=tryouts[2].id, player_id=player.id,
evaluator_id=coach.id,
mecanics_score=ms[0], cohesion_score=ms[1],
communication_score=ms[2], gamesense_score=ms[3],
versatility_score=ms[4], discipline_score=ms[5],
analysis_score=ms[6], sport_ethics_score=ms[7],
mental_score=ms[8], overall_score=overall,
comments=f"{'Excellent' if overall > 8 else 'Solid'} display of skills during the camp.",
position_recommendation=random.choice(val_positions)))
eval_count += 1
db.session.commit()
print(f"[OK] Created {eval_count} evaluations")
# -----------------------------------------------------------------------
# Tryout-specific teams
# -----------------------------------------------------------------------
team1 = Team(tryout_id=tryouts[2].id, name='Alpha Team', created_by=admin.id)
team2 = Team(tryout_id=tryouts[2].id, name='Bravo Team', created_by=admin.id)
db.session.add(team1)
db.session.add(team2)
db.session.commit()
val_positions = ['Controller', 'Initiator', 'Duelist', 'Sentinel']
team_members_tuples = [
(team1, players[0]), (team1, players[1]),
(team1, players[2]), (team1, players[3]),
(team2, players[4]), (team2, players[5]),
]
for t, p in team_members_tuples:
db.session.add(TeamMember(team_id=t.id, player_id=p.id,
position=random.choice(val_positions)))
db.session.commit()
print("[OK] Created 2 tryout-specific teams with player assignments")
# -----------------------------------------------------------------------
# Org-team player assignments
# -----------------------------------------------------------------------
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 team, player, status in assignments:
db.session.add(TeamPlayer(player_id=player.id, org_team_id=team.id, status=status))
db.session.commit()
print(f"[OK] Created {len(assignments)} org-team player assignments")
# -----------------------------------------------------------------------
# Disponibilities
# -----------------------------------------------------------------------
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)]
disp_count = 0
for player in players:
for day in range(7):
num = random.randint(3, 8)
for hour, minute in random.sample(time_slots, min(num, len(time_slots))):
end_h, end_m = hour, minute + 30
if end_m >= 60:
end_m -= 60; end_h += 1
db.session.add(PlayerDisponibility(
player_id=player.id, day_of_week=day,
start_time=time(hour, minute), end_time=time(end_h, end_m)))
disp_count += 1
db.session.commit()
print(f"[OK] Created {disp_count} player disponibilities")
# -----------------------------------------------------------------------
# Coach availabilities
# -----------------------------------------------------------------------
coach_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)]
ca_count = 0
for coach, _ in zip(coaches[:3], org_teams[:3]):
days = [0, 1, 2, 3, 4] if coach.id == coaches[2].id else [0, 1, 2, 3, 4, 5]
for day in days:
num = random.randint(3, 5)
for hour, minute in random.sample(coach_slots, min(num, len(coach_slots))):
end_h, end_m = hour, minute + 30
if end_m >= 60:
end_m -= 60; end_h += 1
db.session.add(CoachAvailability(
coach_id=coach.id, day_of_week=day,
start_time=time(hour, minute), end_time=time(end_h, end_m)))
ca_count += 1
db.session.commit()
print(f"[OK] Created {ca_count} coach availabilities")
# -----------------------------------------------------------------------
# Team notes
# -----------------------------------------------------------------------
notes = [
(org_teams[0], coaches[0],
'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!'),
(org_teams[1], coaches[1],
'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.'),
(org_teams[2], coaches[2],
'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 team, coach, content in notes:
db.session.add(TeamNote(org_team_id=team.id, coach_id=coach.id, content=content))
db.session.commit()
print(f"[OK] Created {len(notes)} team notes")
# -----------------------------------------------------------------------
# Personal notes
# -----------------------------------------------------------------------
pnotes = [
(players[0], coaches[0], 'Your mechanics are improving! Focus on staying calm during high-pressure situations. Keep practicing those flip resets.'),
(players[0], coaches[0], 'Good positioning in last scrim. Work on your kickoffs - consistency will help the team.'),
(players[1], coaches[0], 'Your aerial game is strong. Try to be more aggressive on the ball when you have space.'),
(players[3], coaches[1], 'Need to work on your smoke grenade placement. Practice pre-aiming and strafe stopping.'),
(players[4], coaches[1], 'Good clutch performance! Keep your utility management consistent throughout rounds.'),
(players[6], coaches[2], 'Your aim trainer routine is paying off. Work on your agent abilities usage timing.'),
(players[7], coaches[2], 'Focus on communication in matches. Call out enemy positions clearly and ask for help when needed.'),
]
for player, coach, content in pnotes:
db.session.add(PersonalNote(player_id=player.id, coach_id=coach.id, content=content))
db.session.commit()
print(f"[OK] Created {len(pnotes)} personal notes")
# -----------------------------------------------------------------------
# Matches
# -----------------------------------------------------------------------
team1 = Team.query.filter_by(name='Alpha Team').first()
team2 = Team.query.filter_by(name='Bravo Team').first()
m1 = Match(tryout_id=tryouts[0].id, title='Alpha vs Bravo',
date=tryouts[0].date, start_time=time(18, 0), end_time=time(18, 30),
match_type='team_vs_team', created_by=admin.id,
team1_id=team1.id if team1 else None, team2_id=team2.id if team2 else None)
m2 = Match(tryout_id=tryouts[0].id, title='Bravo vs Alpha',
date=tryouts[0].date, start_time=time(19, 0), end_time=time(19, 30),
match_type='team_vs_team', created_by=admin.id,
team1_id=team2.id if team2 else None, team2_id=team1.id if team1 else None)
m3 = Match(tryout_id=tryouts[1].id, title='Scrimmage',
date=tryouts[1].date, start_time=time(17, 0), end_time=time(17, 30),
match_type='player_scrim', created_by=admin.id)
m4 = Match(tryout_id=tryouts[2].id, title='Team Alpha Scrim',
date=tryouts[2].date, start_time=time(18, 30), end_time=time(19, 0),
match_type='player_vs_player', created_by=admin.id)
db.session.add_all([m1, m2, m3, m4])
db.session.commit()
print("[OK] Created 4 matches")
# Match participants
for player in players[3:5]:
db.session.add(MatchParticipant(match_id=m3.id, player_id=player.id))
for player in players[:2]:
db.session.add(MatchParticipant(match_id=m4.id, player_id=player.id, team_side=1))
for player in players[2:4]:
db.session.add(MatchParticipant(match_id=m4.id, player_id=player.id, team_side=2))
db.session.commit()
print("[OK] Created match participants")
print("\n[SUCCESS] Database seeded successfully!")
print("\n=== Login Credentials ===")
print("Admin: username='admin', password='password'")
print("Manager: username='manager1', password='password'")
print("Coach: username='coach1', password='password'")
print("Player: username='jplayer1', password='password'")
print("Scout: username='scout1', password='password'")
if __name__ == '__main__':
from app.app import create_app
app = create_app()
with app.app_context():
seed_database()
@@ -4,7 +4,7 @@
{% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Contracts</span>{% endblock %} {% block breadcrumb %}<span class="breadcrumb">Home / <a href="{{ url_for('users.profile') }}">Profile</a> / Contracts</span>{% endblock %}
{% block header_actions %} {% block header_actions %}
{% if current_user.role in ['president', 'manager', 'coach'] %} {% if current_user.role in ['admin', 'manager', 'coach'] %}
<a href="{{ url_for('users.upload_contract') }}" class="btn btn-primary"> <a href="{{ url_for('users.upload_contract') }}" class="btn btn-primary">
<i class="fas fa-upload"></i> Upload Contract <i class="fas fa-upload"></i> Upload Contract
</a> </a>
@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="dashboard"> <div class="dashboard">
{% if user.role == 'president' %} {% if user.role == 'admin' %}
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon bg-primary"> <div class="stat-icon bg-primary">
@@ -21,7 +21,7 @@
</th> </th>
{% endmacro %} {% endmacro %}
{% if current_user.role == 'president' and player_scores %} {% if current_user.role == 'admin' and player_scores %}
<div class="stats-grid mb-4"> <div class="stats-grid mb-4">
{% for pid, data in player_scores.items() %} {% for pid, data in player_scores.items() %}
<div class="stat-card stat-card-sm"> <div class="stat-card stat-card-sm">
@@ -75,7 +75,12 @@
{% if current_user.can_manage_teams() or current_user.can_schedule_matches() %} {% if current_user.can_manage_teams() or current_user.can_schedule_matches() %}
<hr class="section-divider"> <hr class="section-divider">
<h4 class="section-title"><i class="fas fa-clock"></i> Select Match Time</h4> <h4 class="section-title"><i class="fas fa-clock"></i> Select Match Time</h4>
<p class="text-muted small">Click time slots consecutively to set match duration. Players available in all selected time blocks are shown below.</p> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<p class="text-muted small" style="margin:0;">Click time slots consecutively to set match duration. Players available in all selected time blocks are shown below.</p>
<button type="button" class="btn btn-sm btn-outline" onclick="clearTimeSelection()" title="Reset time selection">
<i class="fas fa-undo"></i> Reset Time
</button>
</div>
<div id="merged-disponibility-grid" class="merged-disponibility-grid"> <div id="merged-disponibility-grid" class="merged-disponibility-grid">
<p class="text-muted">Loading...</p> <p class="text-muted">Loading...</p>
@@ -77,12 +77,11 @@
<!-- Player presence details --> <!-- Player presence details -->
<div class="presence-players"> <div class="presence-players">
{% for p in item.participants %} {% for p in item.participants %}
<a href="{{ url_for('users.view_user', user_id=p.player.id) }}" class="presence-player-tag {% if p.is_confirmed %}confirmed{% else %}pending{% endif %}" <span class="presence-player-tag {% if p.is_confirmed %}confirmed{% else %}pending{% endif %}"
title="{{ p.player.username }}{% if p.is_confirmed %} - Confirmed{% else %} - Pending{% endif %}" title="{{ p.player.username }}{% if p.is_confirmed %} - Confirmed{% else %} - Pending{% endif %}">
style="text-decoration: none;">
{{ p.player.username[:2] | upper }} {{ p.player.username }} {{ p.player.username[:2] | upper }} {{ p.player.username }}
{% if p.is_confirmed %}✅{% else %}⏳{% endif %} {% if p.is_confirmed %}✅{% else %}⏳{% endif %}
</a> </span>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@@ -93,7 +92,7 @@
<span class="badge badge-{{ m.status }}">{{ m.status }}</span> <span class="badge badge-{{ m.status }}">{{ m.status }}</span>
</td> </td>
<td class="eval-actions"> <td class="eval-actions">
{% set can_manage_this = (current_user.role in ['president', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %} {% set can_manage_this = (current_user.role in ['admin', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %}
{% if can_manage_this %} {% if can_manage_this %}
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="Edit Match"> <a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="Edit Match">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
@@ -124,36 +124,10 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if can_manage %}
<div class="staff-add-forms">
<form method="POST" action="{{ url_for('teams.add_coach', team_id=team.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="coach_id" class="form-select form-select-sm" onchange="if(this.value) this.form.submit()">
<option value="">+ Add Coach</option>
{% for c in coaches %}
{% if c.id not in (team_coaches | map(attribute='id') | list) %}
<option value="{{ c.id }}">{{ c.username }}</option>
{% endif %}
{% endfor %}
</select>
</form>
<form method="POST" action="{{ url_for('teams.add_manager', team_id=team.id) }}" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<select name="manager_id" class="form-select form-select-sm" onchange="if(this.value) this.form.submit()">
<option value="">+ Add Manager</option>
{% for m in managers %}
{% if m.id not in (team_managers | map(attribute='id') | list) %}
<option value="{{ m.id }}">{{ m.username }}</option>
{% endif %}
{% endfor %}
</select>
</form>
</div>
{% endif %}
<a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="View Team Matches"> <a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="View Team Matches">
<i class="fas fa-futbol"></i> Matches <i class="fas fa-futbol"></i> Matches
</a> </a>
{% if current_user.can_schedule_matches() and (current_user.role in ['president', 'manager'] or (current_user.role == 'coach' and team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and team.coach_id == current_user.id)) %} {% if current_user.can_schedule_matches() and (current_user.role in ['admin', 'manager'] or (current_user.role == 'coach' and team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and team.coach_id == current_user.id)) %}
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="Schedule Team Match"> <a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="Schedule Team Match">
<i class="fas fa-plus"></i> Match <i class="fas fa-plus"></i> Match
</a> </a>
@@ -286,28 +260,37 @@
<div class="modal-body"> <div class="modal-body">
<form id="editTeamForm" method="POST" action="" class="form"> <form id="editTeamForm" method="POST" action="" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="sync_staff" value="1"/>
<div class="form-group"> <div class="form-group">
<label for="edit_name">Team Name</label> <label for="edit_name">Team Name</label>
<input type="text" id="edit_name" name="name" required> <input type="text" id="edit_name" name="name" required>
</div> </div>
<hr class="section-divider">
<!-- Manage Coaches -->
<h5 class="mb-2"><i class="fas fa-chalkboard-teacher"></i> Coaches</h5>
<div class="form-group"> <div class="form-group">
<label for="edit_coach_id">Assigned Coach</label> <select name="coach_ids" id="edit-coach-select" class="form-select" multiple style="min-height: 100px; width: 100%;">
<select id="edit_coach_id" name="coach_id" class="form-select">
<option value="">-- No coach assigned --</option>
{% for coach in coaches %} {% for coach in coaches %}
<option value="{{ coach.id }}">{{ coach.username }}</option> <option value="{{ coach.id }}" class="coach-option">{{ coach.username }}</option>
{% endfor %} {% endfor %}
</select> </select>
<small class="form-text">Hold Ctrl/Cmd to select multiple. Only unassigned coaches shown.</small>
</div> </div>
<!-- Manage Managers -->
<h5 class="mb-2"><i class="fas fa-user-tie"></i> Managers</h5>
<div class="form-group"> <div class="form-group">
<label for="edit_manager_id">Assigned Manager</label> <select name="manager_ids" id="edit-manager-select" class="form-select" multiple style="min-height: 100px; width: 100%;">
<select id="edit_manager_id" name="manager_id" class="form-select">
<option value="">-- No manager assigned --</option>
{% for manager in managers %} {% for manager in managers %}
<option value="{{ manager.id }}">{{ manager.username }}</option> <option value="{{ manager.id }}" class="manager-option">{{ manager.username }}</option>
{% endfor %} {% endfor %}
</select> </select>
<small class="form-text">Hold Ctrl/Cmd to select multiple. Only unassigned managers shown.</small>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="hideEditForm()">Cancel</button> <button type="button" class="btn btn-secondary" onclick="hideEditForm()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
@@ -355,28 +338,68 @@ function toggleStatus(btn) {
}); });
} }
var currentEditTeamId = null;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.edit-team-btn').forEach(function(btn) { document.querySelectorAll('.edit-team-btn').forEach(function(btn) {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
var teamId = this.getAttribute('data-team-id'); currentEditTeamId = this.getAttribute('data-team-id');
var teamName = this.getAttribute('data-team-name'); var teamName = this.getAttribute('data-team-name');
var coachId = this.getAttribute('data-coach-id'); document.getElementById('editTeamForm').action = '/teams/' + currentEditTeamId + '/edit';
var managerId = this.getAttribute('data-manager-id');
document.getElementById('editTeamForm').action = '/teams/' + teamId + '/edit';
document.getElementById('edit_name').value = teamName; document.getElementById('edit_name').value = teamName;
document.getElementById('edit_coach_id').value = coachId || '';
document.getElementById('edit_manager_id').value = managerId || '';
document.getElementById('editTeamModal').classList.remove('hidden'); document.getElementById('editTeamModal').classList.remove('hidden');
populateEditSelects(currentEditTeamId);
}); });
}); });
}); });
function showEditForm(teamId, teamName, coachId, managerId) { function getCurrentStaffIds(teamId, type) {
document.getElementById('editTeamForm').action = '/teams/' + teamId + '/edit'; // Get currently assigned coach/manager IDs from the staff bar pills
document.getElementById('edit_name').value = teamName; var ids = [];
document.getElementById('edit_coach_id').value = coachId || ''; var teamCard = document.querySelector('[data-team-id="' + teamId + '"]');
document.getElementById('edit_manager_id').value = managerId || ''; if (!teamCard) return ids;
document.getElementById('editTeamModal').classList.remove('hidden');
var staffBar = teamCard.closest('.card').querySelector('.team-staff-bar');
if (!staffBar) return ids;
var groupIndex = type === 'coach' ? 0 : 1;
var group = staffBar.querySelectorAll('.staff-group')[groupIndex];
if (!group) return ids;
group.querySelectorAll('.staff-tag form input[name="coach_id"], .staff-tag form input[name="manager_id"]').forEach(function(input) {
ids.push(input.value);
});
return ids;
}
function populateEditSelects(teamId) {
var coachSelect = document.getElementById('edit-coach-select');
var managerSelect = document.getElementById('edit-manager-select');
var currentCoachIds = getCurrentStaffIds(teamId, 'coach');
var currentManagerIds = getCurrentStaffIds(teamId, 'manager');
// Show all coaches, but pre-select current ones and hide non-assigned
// Actually: show only currently-assigned options (pre-selected)
coachSelect.querySelectorAll('.coach-option').forEach(function(opt) {
var isAssigned = currentCoachIds.includes(opt.value);
opt.selected = isAssigned;
// Always show all options so user can add/remove
opt.style.display = '';
});
managerSelect.querySelectorAll('.manager-option').forEach(function(opt) {
var isAssigned = currentManagerIds.includes(opt.value);
opt.selected = isAssigned;
opt.style.display = '';
});
// Also update text to reflect current count
document.querySelector('#edit-coach-select + .form-text').textContent =
'Currently assigned: ' + currentCoachIds.length + '. Hold Ctrl/Cmd to select multiple.';
document.querySelector('#edit-manager-select + .form-text').textContent =
'Currently assigned: ' + currentManagerIds.length + '. Hold Ctrl/Cmd to select multiple.';
} }
function hideEditForm() { function hideEditForm() {

Some files were not shown because too many files have changed in this diff Show More