This commit is contained in:
cedrick2711
2026-08-08 10:18:51 -04:00
19 changed files with 698 additions and 320 deletions
+76 -28
View File
@@ -7,6 +7,7 @@ This module provides a persistent bot that handles:
""" """
import os import os
import json
import logging import logging
import asyncio import asyncio
import threading import threading
@@ -26,6 +27,9 @@ DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# File for persisting pending requests across bot restarts
PENDING_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'discord_pending.json')
# Emoji constants # Emoji constants
CHECK_EMOJI = '' # Green checkmark CHECK_EMOJI = '' # Green checkmark
CROSS_EMOJI = '' # Red X CROSS_EMOJI = '' # Red X
@@ -53,8 +57,31 @@ class TeamTryoutsBot(commands.Bot):
self.scheduler = AsyncIOScheduler() self.scheduler = AsyncIOScheduler()
self.timezone = ZoneInfo('America/Toronto') # EDT timezone self.timezone = ZoneInfo('America/Toronto') # EDT timezone
def _load_pending(self):
"""Load pending requests from the JSON file."""
try:
if os.path.exists(PENDING_FILE):
with open(PENDING_FILE, 'r') as f:
data = json.load(f)
# Convert string keys back to int
self.pending_requests = {int(k): v for k, v in data.items()}
logger.info(f"Loaded {len(self.pending_requests)} pending requests from {PENDING_FILE}")
else:
logger.info("No pending requests file found, starting fresh.")
except Exception as e:
logger.error(f"Error loading pending requests: {e}")
def _save_pending(self):
"""Save pending requests to the JSON file."""
try:
with open(PENDING_FILE, 'w') as f:
json.dump(self.pending_requests, f, indent=2)
except Exception as e:
logger.error(f"Error saving pending requests: {e}")
async def setup_hook(self): async def setup_hook(self):
"""Called when the bot is ready.""" """Called when the bot is ready."""
self._load_pending()
logger.info(f'TeamTryoutsBot logged in as {self.user}') logger.info(f'TeamTryoutsBot logged in as {self.user}')
async def on_ready(self): async def on_ready(self):
@@ -114,21 +141,36 @@ class TeamTryoutsBot(commands.Bot):
"""Check if a channel is a DM channel.""" """Check if a channel is a DM channel."""
return hasattr(channel, 'recipient') or hasattr(channel, 'recipients') return hasattr(channel, 'recipient') or hasattr(channel, 'recipients')
async def on_reaction_add(self, reaction, user): async def on_raw_reaction_add(self, payload):
"""Handle when a reaction is added to a message.""" """Handle when a reaction is added to a message (works even after bot restart)."""
if user.bot: # Ignore bot's own reactions
if payload.user_id == self.user.id:
return return
if not self.is_dm_channel(reaction.message.channel): # Check if this is a pending request we're tracking
if payload.message_id not in self.pending_requests:
return return
message_id = reaction.message.id # Fetch the channel and check if it's a DM
try:
if message_id not in self.pending_requests: channel = await self.fetch_channel(payload.channel_id)
except Exception:
return return
request_info = self.pending_requests[message_id] if not self.is_dm_channel(channel):
emoji_str = str(reaction.emoji) return
# Fetch the user who reacted
try:
user = await self.fetch_user(payload.user_id)
except Exception:
return
if user is None:
return
request_info = self.pending_requests[payload.message_id]
emoji_str = str(payload.emoji)
handler_type = request_info.get('type') handler_type = request_info.get('type')
request_id = request_info.get('id') request_id = request_info.get('id')
@@ -137,28 +179,28 @@ class TeamTryoutsBot(commands.Bot):
if handler_type == 'one_on_one': if handler_type == 'one_on_one':
if self.flask_app: if self.flask_app:
with self.flask_app.app_context(): with self.flask_app.app_context():
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message) await self.handle_one_on_one_approve(user, payload.message_id, request_id, channel)
else: else:
await self.handle_one_on_one_approve(user, message_id, request_id, reaction.message) await self.handle_one_on_one_approve(user, payload.message_id, request_id, channel)
elif handler_type == 'schedule_addition': elif handler_type == 'schedule_addition':
if self.flask_app: if self.flask_app:
with self.flask_app.app_context(): with self.flask_app.app_context():
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message) await self.handle_attendance_confirm(user, payload.message_id, request_id, channel)
else: else:
await self.handle_attendance_confirm(user, message_id, request_id, reaction.message) await self.handle_attendance_confirm(user, payload.message_id, request_id, channel)
elif emoji_str == CROSS_EMOJI: elif emoji_str == CROSS_EMOJI:
if handler_type == 'one_on_one': if handler_type == 'one_on_one':
if self.flask_app: if self.flask_app:
with self.flask_app.app_context(): with self.flask_app.app_context():
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message) await self.handle_one_on_one_reject(user, payload.message_id, request_id, channel)
else: else:
await self.handle_one_on_one_reject(user, message_id, request_id, reaction.message) await self.handle_one_on_one_reject(user, payload.message_id, request_id, channel)
elif handler_type == 'schedule_addition': elif handler_type == 'schedule_addition':
if self.flask_app: if self.flask_app:
with self.flask_app.app_context(): with self.flask_app.app_context():
await self.handle_attendance_decline(user, message_id, request_id, reaction.message) await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
else: else:
await self.handle_attendance_decline(user, message_id, request_id, reaction.message) await self.handle_attendance_decline(user, payload.message_id, request_id, channel)
async def _send_one_on_one_dm(self, coach_name: str, coach_discord_id: str, player_name: str, async def _send_one_on_one_dm(self, coach_name: str, coach_discord_id: str, player_name: str,
team_name: str, date_str: str, start_time: str, end_time: str, team_name: str, date_str: str, start_time: str, end_time: str,
@@ -193,6 +235,7 @@ class TeamTryoutsBot(commands.Bot):
# Track this pending request # Track this pending request
self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id} self.pending_requests[msg.id] = {'type': 'one_on_one', 'id': request_id}
self._save_pending()
logger.info(f"Sent One on One DM with reactions, message_id={msg.id}") logger.info(f"Sent One on One DM with reactions, message_id={msg.id}")
return msg.id return msg.id
@@ -251,6 +294,7 @@ class TeamTryoutsBot(commands.Bot):
# Track this pending request # Track this pending request
self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type} self.pending_requests[msg.id] = {'type': 'schedule_addition', 'id': reference_id, 'event_type': event_type}
self._save_pending()
logger.info(f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}") logger.info(f"Sent {event_type} schedule notification to {db_user.username}, message_id={msg.id}")
return msg.id return msg.id
@@ -259,7 +303,7 @@ class TeamTryoutsBot(commands.Bot):
logger.error(f"Error sending schedule notification: {e}") logger.error(f"Error sending schedule notification: {e}")
return None return None
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, channel):
"""Handle coach approving a One on One request.""" """Handle coach approving a One on One request."""
try: try:
from app.models import OneOnOneRequest from app.models import OneOnOneRequest
@@ -270,7 +314,7 @@ class TeamTryoutsBot(commands.Bot):
return return
if request.coach.discord_user_id != str(coach.id): if request.coach.discord_user_id != str(coach.id):
await original_message.channel.send("⚠️ You are not the intended recipient.") await channel.send("⚠️ You are not the intended recipient.")
return return
# Re-attach to current session (object may be detached across app contexts) # Re-attach to current session (object may be detached across app contexts)
@@ -285,7 +329,7 @@ class TeamTryoutsBot(commands.Bot):
request.responded_at = datetime.utcnow() request.responded_at = datetime.utcnow()
db.session.commit() db.session.commit()
await original_message.channel.send( await channel.send(
f"✅ You have **approved** the One on One session with {player_full_name}." f"✅ You have **approved** the One on One session with {player_full_name}."
) )
@@ -299,11 +343,12 @@ class TeamTryoutsBot(commands.Bot):
approved=True approved=True
) )
del self.pending_requests[message_id] del self.pending_requests[message_id]
self._save_pending()
except Exception as e: except Exception as e:
logger.error(f"Error handling approval: {e}\n{traceback.format_exc()}") logger.error(f"Error handling approval: {e}\n{traceback.format_exc()}")
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, channel):
"""Handle coach rejecting a One on One request.""" """Handle coach rejecting a One on One request."""
try: try:
from app.models import OneOnOneRequest from app.models import OneOnOneRequest
@@ -314,7 +359,7 @@ class TeamTryoutsBot(commands.Bot):
return return
if request.coach.discord_user_id != str(coach.id): if request.coach.discord_user_id != str(coach.id):
await original_message.channel.send("⚠️ You are not the intended recipient.") await channel.send("⚠️ You are not the intended recipient.")
return return
# Re-attach to current session (object may be detached across app contexts) # Re-attach to current session (object may be detached across app contexts)
@@ -326,7 +371,7 @@ class TeamTryoutsBot(commands.Bot):
refusal_note = None refusal_note = None
try: try:
async for reply in original_message.channel.history(limit=20): async for reply in channel.history(limit=20):
if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id: if reply.author.id == coach.id and reply.reference and reply.reference.message_id == message_id:
refusal_note = reply.content refusal_note = reply.content
break break
@@ -345,7 +390,7 @@ class TeamTryoutsBot(commands.Bot):
else: else:
rejection_msg += "\n\n️ The player has been notified that you are not available." rejection_msg += "\n\n️ The player has been notified that you are not available."
await original_message.channel.send(rejection_msg) await channel.send(rejection_msg)
if player_discord_id: if player_discord_id:
await self.notify_player_about_one_on_one_direct( await self.notify_player_about_one_on_one_direct(
player_discord_id=player_discord_id, player_discord_id=player_discord_id,
@@ -356,11 +401,12 @@ class TeamTryoutsBot(commands.Bot):
refusal_note=refusal_note refusal_note=refusal_note
) )
del self.pending_requests[message_id] del self.pending_requests[message_id]
self._save_pending()
except Exception as e: except Exception as e:
logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}") logger.error(f"Error handling rejection: {e}\n{traceback.format_exc()}")
async def handle_attendance_confirm(self, player, message_id, reference_id, original_message): async def handle_attendance_confirm(self, player, message_id, reference_id, channel):
"""Handle player confirming attendance for a match/tryout.""" """Handle player confirming attendance for a match/tryout."""
try: try:
from app.models import MatchParticipant, TryoutRegistration, Match, Tryout from app.models import MatchParticipant, TryoutRegistration, Match, Tryout
@@ -382,13 +428,14 @@ class TeamTryoutsBot(commands.Bot):
db.session.commit() db.session.commit()
await original_message.channel.send("✅ Your attendance has been confirmed!") await channel.send("✅ Your attendance has been confirmed!")
del self.pending_requests[message_id] del self.pending_requests[message_id]
self._save_pending()
except Exception as e: except Exception as e:
logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}") logger.error(f"Error handling attendance confirmation: {e}\n{traceback.format_exc()}")
async def handle_attendance_decline(self, player, message_id, reference_id, original_message): async def handle_attendance_decline(self, player, message_id, reference_id, channel):
"""Handle player declining attendance for a match/tryout.""" """Handle player declining attendance for a match/tryout."""
try: try:
from app.models import MatchParticipant, TryoutRegistration, Match, Tryout from app.models import MatchParticipant, TryoutRegistration, Match, Tryout
@@ -410,8 +457,9 @@ class TeamTryoutsBot(commands.Bot):
db.session.commit() db.session.commit()
await original_message.channel.send("❌ Your attendance has been declined.") await channel.send("❌ Your attendance has been declined.")
del self.pending_requests[message_id] del self.pending_requests[message_id]
self._save_pending()
except Exception as e: except Exception as e:
logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}") logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}")
+7
View File
@@ -16,3 +16,10 @@ org_team_managers = db.Table('org_team_managers',
db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
primary_key=True), primary_key=True),
) )
tryout_coaches = db.Table('tryout_coaches',
db.Column('tryout_id', db.Integer, db.ForeignKey('tryouts.id', ondelete='CASCADE'),
primary_key=True),
db.Column('coach_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
primary_key=True),
)
+6
View File
@@ -52,6 +52,12 @@ PLATFORM_CODES = {
'Epic': 'epic', 'Epic': 'epic',
} }
PLATFORM_DEFAULTS = {
'Apex Legends': 'pc',
'Rainbow Six Siege': 'ubi',
'Rocket League': 'epic'
}
TRN_URLS = { TRN_URLS = {
'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}', 'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}',
'League of Legends': 'https://tracker.gg/lol/profile/{username}', 'League of Legends': 'https://tracker.gg/lol/profile/{username}',
+15 -2
View File
@@ -1,5 +1,6 @@
"""Tryout event for player evaluations and team formation.""" """Tryout event for player evaluations and team formation."""
from app.extensions import db from app.extensions import db
from app.models._associations import tryout_coaches
from datetime import datetime from datetime import datetime
@@ -11,19 +12,31 @@ class Tryout(db.Model):
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
game = db.Column(db.String(50), nullable=False) game = db.Column(db.String(50), nullable=False)
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False)
end_date = db.Column(db.Date, nullable=True)
location = db.Column(db.String(200), nullable=True) location = db.Column(db.String(200), nullable=True)
status = db.Column(db.String(20), default='upcoming') status = db.Column(db.String(20), default='upcoming')
max_players = db.Column(db.Integer, nullable=True) max_players = db.Column(db.Integer, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 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) 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) manager_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) coach_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # deprecated, kept for migration
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts') creator = db.relationship('User', foreign_keys=[created_by], backref='created_tryouts')
manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts') manager = db.relationship('User', foreign_keys=[manager_id], backref='managed_tryouts')
coach = db.relationship('User', foreign_keys=[coach_id], backref='coached_tryouts') coach = db.relationship('User', foreign_keys=[coach_id], backref='_deprecated_coached_tryouts')
coaches = db.relationship('User', secondary=tryout_coaches, backref='coached_tryouts')
registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic') registrations = db.relationship('TryoutRegistration', backref='tryout', lazy='dynamic')
evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic') evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic')
teams = db.relationship('Team', 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]) target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id])
@property
def is_ended(self):
"""Tryout is considered ended after its end_date passes.
Falls back to date if end_date is not set."""
from datetime import date as date_type
today = date_type.today()
if self.end_date is not None:
return self.end_date < today
return self.date < today
+8 -4
View File
@@ -1,6 +1,6 @@
"""Store gamertag per game for each user.""" """Store gamertag per game for each user."""
from app.extensions import db from app.extensions import db
from app.models._constants import TRN_URLS, PLATFORM_CODES from app.models._constants import TRN_URLS, PLATFORM_CODES, PLATFORM_DEFAULTS
from urllib.parse import quote from urllib.parse import quote
@@ -24,15 +24,19 @@ class UserGamertag(db.Model):
return None return None
url = TRN_URLS[self.game] url = TRN_URLS[self.game]
encoded_gamertag = quote(self.gamertag, safe='') encoded_gamertag = quote(self.gamertag, safe='')
# Resolve platform: use user's selection, or fall back to game default
platform = self.platform
if not platform:
platform = PLATFORM_DEFAULTS.get(self.game, '')
if '{platform_code}' in url and '{username}' in url: if '{platform_code}' in url and '{username}' in url:
platform_code = PLATFORM_CODES.get( platform_code = PLATFORM_CODES.get(
self.platform, platform,
self.platform.lower().replace(' ', '-') if self.platform else '', platform.lower().replace(' ', '-') if platform else '',
) )
return url.format(platform_code=platform_code, username=encoded_gamertag) return url.format(platform_code=platform_code, username=encoded_gamertag)
elif '{platform}' in url and '{username}' in url: elif '{platform}' in url and '{username}' in url:
return url.format( return url.format(
platform=self.platform.lower().replace(' ', '-'), platform=platform.lower().replace(' ', '-') if platform else '',
username=encoded_gamertag, username=encoded_gamertag,
) )
elif '{username}' in url: elif '{username}' in url:
+8
View File
@@ -25,6 +25,10 @@ class Coach(User):
).first() is not None ).first() is not None
if is_coach_of_target: if is_coach_of_target:
return True return True
# Check many-to-many coaches relationship
if any(c.id == self.id for c in tryout.coaches):
return True
# Backward compat: check deprecated coach_id
if tryout.coach_id == self.id: if tryout.coach_id == self.id:
return True return True
return False return False
@@ -38,9 +42,13 @@ class Coach(User):
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
from app.models._associations import tryout_coaches
team_ids = [t.id for t in self.coached_org_teams.all()] team_ids = [t.id for t in self.coached_org_teams.all()]
conditions = [] conditions = []
if team_ids: if team_ids:
conditions.append(Tryout.target_org_team_id.in_(team_ids)) conditions.append(Tryout.target_org_team_id.in_(team_ids))
# Check many-to-many coaches
conditions.append(Tryout.coaches.any(id=self.id))
# Backward compat: check deprecated coach_id
conditions.append(Tryout.coach_id == self.id) conditions.append(Tryout.coach_id == self.id)
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all() return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
+4 -1
View File
@@ -26,4 +26,7 @@ class Manager(User):
def get_visible_tryouts(self): def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout from app.models.tryout.tryout import Tryout
return Tryout.query.filter_by(created_by=self.id).order_by(Tryout.date).all() from sqlalchemy import or_
return Tryout.query.filter(
or_(Tryout.created_by == self.id, Tryout.manager_id == self.id)
).order_by(Tryout.date).all()
+13 -25
View File
@@ -46,19 +46,6 @@ def api_events():
tryouts = get_visible_tryouts_for_user() tryouts = get_visible_tryouts_for_user()
for tryout in tryouts: for tryout in tryouts:
events.append({
'id': f'tryout_{tryout.id}',
'title': tryout.title,
'date': tryout.date.strftime('%Y-%m-%d'),
'type': 'tryout', 'color': '#3b82f6',
'extendedProps': {
'location': tryout.location or 'TBD',
'status': tryout.status,
'description': tryout.description or '',
'tryout_id': tryout.id,
},
})
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'
match_desc = match.description or '' match_desc = match.description or ''
@@ -158,18 +145,7 @@ def api_events_for_tryout(tryout_id):
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 = []
'id': f'tryout_{tryout.id}',
'title': f'Tryout: {tryout.title}',
'date': tryout.date.strftime('%Y-%m-%d'),
'type': 'tryout', 'color': '#3b82f6',
'extendedProps': {
'location': tryout.location or 'TBD',
'status': tryout.status,
'description': tryout.description or '',
'tryout_id': tryout.id,
},
}]
for match in tryout.matches: for match in tryout.matches:
match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b' match_color = '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b'
@@ -221,6 +197,10 @@ def create_match(tryout_id):
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))
if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
teams = Team.query.filter_by(tryout_id=tryout_id).all() teams = Team.query.filter_by(tryout_id=tryout_id).all()
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)]
@@ -348,6 +328,10 @@ def edit_match(match_id):
flash('You do not have permission to edit this match.', 'danger') flash('You do not have permission to edit this match.', 'danger')
return redirect(url_for('matches.calendar')) return redirect(url_for('matches.calendar'))
if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be created or modified.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
teams = Team.query.filter_by(tryout_id=tryout.id).all() teams = Team.query.filter_by(tryout_id=tryout.id).all()
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]
@@ -503,6 +487,7 @@ def api_manageable_tryouts():
manageable.append({ manageable.append({
'id': t.id, 'title': t.title, 'id': t.id, 'title': t.title,
'date': t.date.strftime('%Y-%m-%d'), 'date': t.date.strftime('%Y-%m-%d'),
'end_date': t.end_date.strftime('%Y-%m-%d') if t.end_date else None,
}) })
return jsonify(manageable) return jsonify(manageable)
@@ -516,6 +501,9 @@ def delete_match(match_id):
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'))
if tryout.is_ended:
flash('This tryout has ended. Matches can no longer be deleted.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
db.session.delete(match) db.session.delete(match)
db.session.commit() db.session.commit()
flash('Match deleted successfully.', 'success') flash('Match deleted successfully.', 'success')
+94 -8
View File
@@ -51,29 +51,50 @@ def create_tryout():
description = request.form.get('description') description = request.form.get('description')
game = request.form.get('game') game = request.form.get('game')
date_str = request.form.get('date') date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location') location = request.form.get('location')
max_players = request.form.get('max_players') max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id') target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id') manager_id = request.form.get('manager_id')
coach_id = request.form.get('coach_id') coach_ids = request.form.getlist('coach_ids')
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 start date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
end_date_obj = None
if end_date_str:
try:
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash('End date cannot be before start date.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
except (ValueError, TypeError):
flash('Invalid end date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams, return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
tryout = Tryout( tryout = Tryout(
title=title, description=description, game=game, date=date_obj, title=title, description=description, game=game, date=date_obj,
end_date=end_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, status='upcoming', created_by=current_user.id, 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,
) )
db.session.add(tryout) db.session.add(tryout)
db.session.flush()
# Assign coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
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))
@@ -92,6 +113,10 @@ def edit_tryout(tryout_id):
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'))
if tryout.is_ended:
flash('This tryout has ended and can no longer be modified.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all() org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all() managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all() coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
@@ -101,16 +126,30 @@ def edit_tryout(tryout_id):
description = request.form.get('description') description = request.form.get('description')
game = request.form.get('game') game = request.form.get('game')
date_str = request.form.get('date') date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location') location = request.form.get('location')
max_players = request.form.get('max_players') max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id') target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id') manager_id = request.form.get('manager_id')
coach_id = request.form.get('coach_id') coach_ids = request.form.getlist('coach_ids')
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 start date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
end_date_obj = None
if end_date_str:
try:
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash('End date cannot be before start date.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
except (ValueError, TypeError):
flash('Invalid end date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams, return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
@@ -118,11 +157,19 @@ def edit_tryout(tryout_id):
tryout.description = description tryout.description = description
tryout.game = game tryout.game = game
tryout.date = date_obj tryout.date = date_obj
tryout.end_date = end_date_obj
tryout.location = location tryout.location = location
tryout.max_players = int(max_players) if max_players else None tryout.max_players = int(max_players) if max_players else None
tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None
tryout.manager_id = int(manager_id) if manager_id else None tryout.manager_id = int(manager_id) if manager_id else None
tryout.coach_id = int(coach_id) if coach_id else None
# Update coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
else:
tryout.coaches = []
db.session.commit() db.session.commit()
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))
@@ -140,8 +187,8 @@ def view_tryout(tryout_id):
can_view = False can_view = False
if isinstance(current_user, Admin): if isinstance(current_user, Admin):
can_view = True can_view = True
elif isinstance(current_user, Manager) and tryout.created_by == current_user.id: elif isinstance(current_user, Manager):
can_view = True can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
elif isinstance(current_user, Coach): elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout) can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player): elif isinstance(current_user, Player):
@@ -430,3 +477,42 @@ def add_to_team(tryout_id, team_id):
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))
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
@login_required
def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to delete this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
# Delete match participants for all matches in this tryout
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids)
).delete(synchronize_session=False)
# Delete matches
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
# Delete team members for all teams in this tryout
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
if team_ids:
TeamMember.query.filter(
TeamMember.team_id.in_(team_ids)
).delete(synchronize_session=False)
# Delete teams
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
# Delete registrations
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
# Delete evaluations
Evaluation.query.filter_by(tryout_id=tryout_id).delete()
db.session.delete(tryout)
db.session.commit()
flash('Tryout deleted successfully.', 'success')
return redirect(url_for('tryouts.list_tryouts'))
+6 -3
View File
@@ -259,12 +259,15 @@ def profile():
contracts = Contract.query.filter_by( contracts = Contract.query.filter_by(
player_id=current_user.id, player_id=current_user.id,
).order_by(Contract.uploaded_at.desc()).all() ).order_by(Contract.uploaded_at.desc()).all()
elif isinstance(current_user, Coach):
coach_availability = CoachAvailability.query.filter_by( existing_availability = None
if isinstance(current_user, Coach):
existing_availability = CoachAvailability.query.filter_by(
coach_id=current_user.id, coach_id=current_user.id,
).all() ).all()
return render_template('pages/profile.html', user=current_user, contracts=contracts, return render_template('pages/profile.html', user=current_user, contracts=contracts,
coach_availability=coach_availability) existing_availability=existing_availability)
@users_bp.route('/profile/edit', methods=['GET', 'POST']) @users_bp.route('/profile/edit', methods=['GET', 'POST'])
+34 -13
View File
@@ -197,7 +197,6 @@ function goToTeamMatch() {
} }
function goToCreateTryout() { function goToCreateTryout() {
// Tryout creation doesn't support pre-filling date easily, just navigate
window.location.href = '/tryouts/create'; window.location.href = '/tryouts/create';
} }
@@ -208,8 +207,13 @@ function fetchTryoutOptions() {
.then(function(data) { .then(function(data) {
var sel = document.getElementById('createTryoutSelect'); var sel = document.getElementById('createTryoutSelect');
sel.innerHTML = '<option value="">-- Select a tryout --</option>'; sel.innerHTML = '<option value="">-- Select a tryout --</option>';
var today = new Date().toISOString().split('T')[0];
data.forEach(function(t) { data.forEach(function(t) {
// Only show tryouts that haven't ended
var tryoutEndDate = t.end_date || t.date;
if (tryoutEndDate >= today) {
sel.innerHTML += '<option value="' + t.id + '">' + t.title + ' (' + t.date + ')</option>'; sel.innerHTML += '<option value="' + t.id + '">' + t.title + ' (' + t.date + ')</option>';
}
}); });
}) })
.catch(function() {}); .catch(function() {});
@@ -236,8 +240,8 @@ function showEventModal(event) {
var content = '<div class="detail-grid">'; var content = '<div class="detail-grid">';
content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">'; content += '<div class="detail-item"><span class="detail-label">Type</span><span class="detail-value">';
content += '<span class="badge badge-' + (type === 'tryout' ? 'info' : (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player' ? 'success' : 'warning')) + '">'; content += '<span class="badge badge-' + (props.match_type === 'team_vs_team' || props.match_type === 'player_vs_player' ? 'success' : 'warning') + '">';
content += (type === 'tryout' ? 'Tryout' : (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim'))) + '</span>'; content += (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim')) + '</span>';
content += '</span></div>'; content += '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>'; content += '<div class="detail-item"><span class="detail-label">Title</span><span class="detail-value">' + title + '</span></div>';
content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>'; content += '<div class="detail-item"><span class="detail-label">Date</span><span class="detail-value">' + date + '</span></div>';
@@ -250,7 +254,6 @@ function showEventModal(event) {
if (type === 'match' && props.participants) { if (type === 'match' && props.participants) {
content += '<div class="detail-item full-width"><span class="detail-label">Teams</span><span class="detail-value">'; content += '<div class="detail-item full-width"><span class="detail-label">Teams</span><span class="detail-value">';
if (props.match_type === 'team_vs_team') { if (props.match_type === 'team_vs_team') {
// For team vs team, participants is "Team1 vs Team2"
var teams = props.participants.split(' vs '); var teams = props.participants.split(' vs ');
if (teams.length >= 2) { if (teams.length >= 2) {
content += '<div class="match-teams">'; content += '<div class="match-teams">';
@@ -262,8 +265,6 @@ function showEventModal(event) {
content += props.participants; content += props.participants;
} }
} else if (props.match_type === 'player_vs_player') { } else if (props.match_type === 'player_vs_player') {
// For player vs player, we need to parse the participants
// The format is "player1, player2 vs player3, player4"
var parts = props.participants.split(' vs '); var parts = props.participants.split(' vs ');
if (parts.length >= 2) { if (parts.length >= 2) {
content += '<div class="match-teams">'; content += '<div class="match-teams">';
@@ -275,7 +276,6 @@ function showEventModal(event) {
content += props.participants; content += props.participants;
} }
} else { } else {
// For player scrim, just show the list
content += props.participants; content += props.participants;
} }
content += '</span></div>'; content += '</span></div>';
@@ -286,7 +286,7 @@ function showEventModal(event) {
} }
content += '</div>'; content += '</div>';
document.getElementById('modalTitle').textContent = type === 'tryout' ? 'Tryout Details' : 'Match Details'; document.getElementById('modalTitle').textContent = 'Match Details';
document.getElementById('modalContent').innerHTML = content; document.getElementById('modalContent').innerHTML = content;
// Reset buttons // Reset buttons
@@ -301,11 +301,11 @@ function showEventModal(event) {
document.getElementById('editMatchBtn').onclick = function() { document.getElementById('editMatchBtn').onclick = function() {
window.location.href = '/matches/' + props.match_id + '/edit'; window.location.href = '/matches/' + props.match_id + '/edit';
}; };
} else if (type === 'tryout' && canScheduleMatches) { document.getElementById('deleteMatchBtn').style.display = 'inline-flex';
document.getElementById('modalActions').style.display = 'flex'; document.getElementById('deleteMatchBtn').onclick = function() {
document.getElementById('viewTryoutBtn').style.display = 'inline-flex'; if (confirm('Are you sure you want to delete this match?')) {
document.getElementById('viewTryoutBtn').onclick = function() { deleteCalendarMatch(props.match_id);
window.location.href = '/tryouts/' + props.tryout_id; }
}; };
} else { } else {
document.getElementById('modalActions').style.display = 'none'; document.getElementById('modalActions').style.display = 'none';
@@ -329,6 +329,27 @@ function showEventModal(event) {
document.getElementById('eventModal').classList.remove('hidden'); document.getElementById('eventModal').classList.remove('hidden');
} }
function deleteCalendarMatch(matchId) {
fetch('/matches/' + matchId + '/delete', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(r) { return r.json().catch(function() { return {}; }); })
.then(function() {
hideEventModal();
if (window.fcCalendar) {
window.fcCalendar.refetchEvents();
}
})
.catch(function(err) {
console.error('Error deleting match:', err);
alert('Failed to delete match.');
});
}
function toggleCalendarPresence(matchId, participantId, btn) { function toggleCalendarPresence(matchId, participantId, btn) {
fetch('/matches/' + matchId + '/toggle-presence/' + participantId, { fetch('/matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST', method: 'POST',
+265 -208
View File
@@ -223,10 +223,219 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Disponibilities Card (Players only) -->
{% if user.role == 'player' %}
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">
<h3><i class="fas fa-clock"></i> My Disponibilities</h3>
</div>
<div class="card-body">
<p class="text-muted small">Select your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.</p>
<div id="disponibilities-grid">
<p class="text-muted">Loading...</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="saveDisponibilities()">
<i class="fas fa-save"></i> Save Disponibilities
</button>
<button type="button" class="btn btn-secondary" onclick="clearDisponibilities()">
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
</div>
{% endif %}
<!-- Coach Availability Card (Coaches only) -->
{% if user.role == 'coach' %}
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">
<h3><i class="fas fa-clock"></i> My Coaching Availability</h3>
<p class="text-muted small">Select time slots when you're available for One on One sessions (8am to 10pm).</p>
</div>
<div class="card-body">
<div class="availability-grid" id="availability-grid">
<p class="text-muted">Loading availability grid...</p>
</div>
<div class="form-actions mt-3">
<button type="button" class="btn btn-secondary" onclick="clearAllAvailability()">
<i class="fas fa-trash"></i> Clear All
</button>
</div>
</div>
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% if user.role == 'coach' %}
<style>
.availability-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12px;
margin-top: 10px;
}
.day-column {
background: var(--bg-secondary);
border-radius: 8px;
padding: 10px;
min-height: 300px;
}
.day-header {
text-align: center;
font-weight: 600;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 10px;
color: var(--primary);
}
.time-slot {
padding: 6px 8px;
margin: 4px 0;
border-radius: 4px;
font-size: 0.8rem;
text-align: center;
cursor: pointer;
transition: var(--transition);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.time-slot:hover { background: var(--primary-light); border-color: var(--primary); }
.time-slot.selected { background: var(--primary); color: white; border-color: var(--primary-dark); }
.time-slot.selected:hover { background: var(--danger); }
@media (max-width: 768px) { .availability-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 480px) { .availability-grid { grid-template-columns: 1fr; } }
</style>
<script>
const COACH_TIME_SLOTS = [];
for (let h = 8; h <= 22; h++) {
for (let m = 0; m < 60; m += 30) {
const timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
const displayHour = h > 12 ? h - 12 : h;
const displayAmpm = h >= 12 ? 'PM' : 'AM';
const displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
COACH_TIME_SLOTS.push({ time: timeStr, display: displayTime });
}
}
const COACH_DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
let coachSelectedSlots = {};
// Drag state for coach grid
let coachDragMode = false;
let coachDragAction = null;
function applyCoachSlotAction(dayOfWeek, timeStr, element) {
if (!coachSelectedSlots[dayOfWeek]) coachSelectedSlots[dayOfWeek] = [];
const index = coachSelectedSlots[dayOfWeek].indexOf(timeStr);
if (coachDragAction === 'select' && index === -1) {
coachSelectedSlots[dayOfWeek].push(timeStr);
element.classList.add('selected');
} else if (coachDragAction === 'deselect' && index > -1) {
coachSelectedSlots[dayOfWeek].splice(index, 1);
element.classList.remove('selected');
}
}
function loadCoachAvailability() {
{% for av in existing_availability %}
if (!coachSelectedSlots[{{ av.day_of_week }}]) coachSelectedSlots[{{ av.day_of_week }}] = [];
coachSelectedSlots[{{ av.day_of_week }}].push('{{ av.start_time.strftime('%H:%M') }}');
{% endfor %}
}
function renderCoachGrid() {
const grid = document.getElementById('availability-grid');
let html = '';
COACH_DAYS.forEach((day, dayIndex) => {
html += '<div class="day-column">';
html += '<div class="day-header">' + day.substring(0, 3) + '</div>';
COACH_TIME_SLOTS.forEach(slot => {
const isSelected = coachSelectedSlots[dayIndex] && coachSelectedSlots[dayIndex].includes(slot.time);
const cssClass = isSelected ? 'time-slot selected' : 'time-slot';
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '">' + slot.display + '</div>';
});
html += '</div>';
});
grid.innerHTML = html;
// Drag event listeners
grid.addEventListener('mousedown', function(e) {
const slot = e.target.closest('.time-slot');
if (!slot) return;
e.preventDefault();
coachDragMode = true;
const day = parseInt(slot.dataset.day);
const time = slot.dataset.time;
if (!coachSelectedSlots[day]) coachSelectedSlots[day] = [];
const isSelected = coachSelectedSlots[day].indexOf(time) > -1;
coachDragAction = isSelected ? 'deselect' : 'select';
applyCoachSlotAction(day, time, slot);
});
grid.addEventListener('mousemove', function(e) {
if (!coachDragMode) return;
const slot = e.target.closest('.time-slot');
if (!slot) return;
applyCoachSlotAction(parseInt(slot.dataset.day), slot.dataset.time, slot);
});
document.addEventListener('mouseup', function() {
if (coachDragMode) {
coachDragMode = false;
coachDragAction = null;
saveCoachAvailability();
}
});
}
function saveCoachAvailability() {
const slots = [];
for (let day in coachSelectedSlots) {
coachSelectedSlots[day].forEach(time => {
slots.push({ day_of_week: parseInt(day), start_time: time });
});
}
fetch('{{ url_for("users.manage_coach_availability") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slots: slots })
})
.then(r => r.json())
.then(data => {
if (data.success) {
const msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<i class="fas fa-check"></i> Availability saved!';
document.getElementById('availability-grid').appendChild(msg);
setTimeout(() => msg.remove(), 3000);
}
})
.catch(err => console.error('Save error:', err));
}
function clearAllAvailability() {
if (!confirm('Are you sure you want to clear all your availability slots?')) return;
fetch('{{ url_for("users.clear_coach_availability") }}', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
coachSelectedSlots = {};
renderCoachGrid();
}
});
}
document.addEventListener('DOMContentLoaded', function() {
loadCoachAvailability();
renderCoachGrid();
});
</script>
{% endif %}
{% if user.role == 'player' %} {% if user.role == 'player' %}
<script> <script>
// Generate time slots from 5pm (17:00) to 12am (24:00) // Generate time slots from 5pm (17:00) to 12am (24:00)
@@ -262,11 +471,30 @@ var DAYS = [
{ value: 6, name: 'Sunday' } { value: 6, name: 'Sunday' }
]; ];
// Store selected slots: {day: [time, time, ...]}
var selectedSlots = {}; var selectedSlots = {};
// Drag state
var dispDragMode = false;
var dispDragAction = null; // 'select' or 'deselect'
function applySlotAction(block, action) {
var day = parseInt(block.dataset.day);
var time = block.dataset.time;
if (!selectedSlots[day]) selectedSlots[day] = [];
var index = selectedSlots[day].indexOf(time);
if (action === 'select' && index === -1) {
selectedSlots[day].push(time);
block.classList.add('selected');
} else if (action === 'deselect' && index > -1) {
selectedSlots[day].splice(index, 1);
block.classList.remove('selected');
}
}
function renderDisponibilityGrid() { function renderDisponibilityGrid() {
var grid = document.getElementById('disponibilities-grid'); var grid = document.getElementById('disponibilities-grid');
grid.innerHTML = '<div style="margin-bottom: 10px;"><strong>Click time blocks to select your available hours</strong></div>'; grid.innerHTML = '<div style="margin-bottom: 10px;"><strong>Click or click-and-drag to select your available hours</strong></div>';
var container = document.createElement('div'); var container = document.createElement('div');
container.className = 'disponibility-grid'; container.className = 'disponibility-grid';
@@ -289,9 +517,6 @@ function renderDisponibilityGrid() {
block.dataset.day = day.value; block.dataset.day = day.value;
block.dataset.time = slot.time; block.dataset.time = slot.time;
block.textContent = slot.display; block.textContent = slot.display;
block.onclick = function() {
toggleSlot(day.value, slot.time, block);
};
timeBlocks.appendChild(block); timeBlocks.appendChild(block);
}); });
@@ -299,23 +524,39 @@ function renderDisponibilityGrid() {
container.appendChild(dayRow); container.appendChild(dayRow);
}); });
// Drag event listeners on the container
container.addEventListener('mousedown', function(e) {
var block = e.target.closest('.disponibility-time-block');
if (!block) return;
e.preventDefault();
dispDragMode = true;
var day = parseInt(block.dataset.day);
var time = block.dataset.time;
if (!selectedSlots[day]) selectedSlots[day] = [];
var isSelected = selectedSlots[day].indexOf(time) > -1;
dispDragAction = isSelected ? 'deselect' : 'select';
applySlotAction(block, dispDragAction);
});
container.addEventListener('mousemove', function(e) {
if (!dispDragMode) return;
var block = e.target.closest('.disponibility-time-block');
if (!block) return;
applySlotAction(block, dispDragAction);
});
document.addEventListener('mouseup', function() {
if (dispDragMode) {
dispDragMode = false;
dispDragAction = null;
saveDisponibilities();
}
});
grid.appendChild(container); grid.appendChild(container);
loadMyDisponibilities(); loadMyDisponibilities();
} }
function toggleSlot(day, time, element) {
if (!selectedSlots[day]) selectedSlots[day] = [];
var index = selectedSlots[day].indexOf(time);
if (index > -1) {
selectedSlots[day].splice(index, 1);
element.classList.remove('selected');
} else {
selectedSlots[day].push(time);
element.classList.add('selected');
}
}
function loadMyDisponibilities() { function loadMyDisponibilities() {
fetch('{{ url_for("users.get_my_disponibilities") }}') fetch('{{ url_for("users.get_my_disponibilities") }}')
.then(function(response) { return response.json(); }) .then(function(response) { return response.json(); })
@@ -356,7 +597,8 @@ function saveDisponibilities() {
fetch('{{ url_for("users.add_disponibilities_bulk") }}', { fetch('{{ url_for("users.add_disponibilities_bulk") }}', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}, },
body: JSON.stringify({ slots: slots }) body: JSON.stringify({ slots: slots })
}) })
@@ -380,7 +622,10 @@ function clearDisponibilities() {
if (!confirm('Are you sure you want to clear all your disponibilities?')) return; if (!confirm('Are you sure you want to clear all your disponibilities?')) return;
fetch('{{ url_for("users.clear_disponibilities") }}', { fetch('{{ url_for("users.clear_disponibilities") }}', {
method: 'POST' method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}'
}
}) })
.then(function(response) { return response.json(); }) .then(function(response) { return response.json(); })
.then(function(data) { .then(function(data) {
@@ -393,198 +638,10 @@ function clearDisponibilities() {
}); });
} }
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
renderDisponibilityGrid(); renderDisponibilityGrid();
}); });
</script> </script>
{% endif %} {% endif %}
{% if user.role == 'coach' %}
<style>
.availability-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12px;
margin-top: 10px;
}
.day-column {
background: var(--bg-secondary);
border-radius: 8px;
padding: 10px;
min-height: 300px;
}
.day-header {
text-align: center;
font-weight: 600;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
margin-bottom: 10px;
color: var(--primary);
}
.time-slot {
padding: 6px 8px;
margin: 4px 0;
border-radius: 4px;
font-size: 0.8rem;
text-align: center;
cursor: pointer;
transition: var(--transition);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.time-slot:hover {
background: var(--primary-light);
border-color: var(--primary);
}
.time-slot.selected {
background: var(--primary);
color: white;
border-color: var(--primary-dark);
}
.time-slot.selected:hover {
background: var(--danger);
}
@media (max-width: 768px) {
.availability-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 480px) {
.availability-grid {
grid-template-columns: 1fr;
}
}
</style>
<script>
const COACH_TIME_SLOTS = [];
for (let h = 8; h <= 22; h++) {
for (let m = 0; m < 60; m += 30) {
const timeStr = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
const displayHour = h > 12 ? h - 12 : h;
const displayAmpm = h >= 12 ? 'PM' : 'AM';
const displayTime = displayHour + ':' + (m < 10 ? '0' : '') + m + ' ' + displayAmpm;
COACH_TIME_SLOTS.push({ time: timeStr, display: displayTime });
}
}
const COACH_DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
let coachSelectedSlots = {};
function loadExistingAvailability() {
{% for av in coach_availability %}
if (!coachSelectedSlots[{{ av.day_of_week }}]) {
coachSelectedSlots[{{ av.day_of_week }}] = [];
}
coachSelectedSlots[{{ av.day_of_week }}].push('{{ av.start_time.strftime('%H:%M') }}');
{% endfor %}
}
function renderCoachGrid() {
const grid = document.getElementById('availability-grid');
let html = '';
COACH_DAYS.forEach((day, dayIndex) => {
html += '<div class="day-column">';
html += '<div class="day-header">' + day.substring(0, 3) + '</div>';
COACH_TIME_SLOTS.forEach(slot => {
const isSelected = coachSelectedSlots[dayIndex] && coachSelectedSlots[dayIndex].includes(slot.time);
const cssClass = isSelected ? 'time-slot selected' : 'time-slot';
html += '<div class="' + cssClass + '" data-day="' + dayIndex + '" data-time="' + slot.time + '" onclick="toggleCoachSlot(' + dayIndex + ', \'' + slot.time + '\', this)">' + slot.display + '</div>';
});
html += '</div>';
});
grid.innerHTML = html;
}
function toggleCoachSlot(dayOfWeek, timeStr, element) {
if (!coachSelectedSlots[dayOfWeek]) {
coachSelectedSlots[dayOfWeek] = [];
}
const index = coachSelectedSlots[dayOfWeek].indexOf(timeStr);
if (index === -1) {
coachSelectedSlots[dayOfWeek].push(timeStr);
element.classList.add('selected');
} else {
coachSelectedSlots[dayOfWeek].splice(index, 1);
element.classList.remove('selected');
}
}
function saveCoachAvailability() {
const slots = [];
for (let day in coachSelectedSlots) {
coachSelectedSlots[day].forEach(time => {
slots.push({ day_of_week: parseInt(day), start_time: time });
});
}
fetch('{{ url_for("users.manage_coach_availability") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slots: slots })
})
.then(response => response.json())
.then(data => {
if (data.success) {
var msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<i class="fas fa-check"></i> Availability saved!';
document.getElementById('availability-grid').parentElement.appendChild(msg);
setTimeout(function() { msg.remove(); }, 3000);
}
})
.catch(function(error) {
console.error('Save error:', error);
});
}
function clearAllAvailability() {
if (!confirm('Are you sure you want to clear all your availability slots?')) {
return;
}
fetch('{{ url_for("users.clear_coach_availability") }}', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
coachSelectedSlots = {};
renderCoachGrid();
var msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<i class="fas fa-check"></i> Availability cleared!';
document.getElementById('availability-grid').parentElement.appendChild(msg);
setTimeout(function() { msg.remove(); }, 3000);
}
});
}
let coachSaveTimeout;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('time-slot')) {
clearTimeout(coachSaveTimeout);
coachSaveTimeout = setTimeout(saveCoachAvailability, 1000);
}
});
document.addEventListener('DOMContentLoaded', function() {
loadExistingAvailability();
renderCoachGrid();
});
</script>
{% endif %}
{% endblock %} {% endblock %}
+22 -9
View File
@@ -34,10 +34,15 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="form-group col-6"> <div class="form-group col-3">
<label for="date">Date</label> <label for="date">Start Date</label>
<input type="date" id="date" name="date" value="{{ tryout.date.strftime('%Y-%m-%d') if tryout else '' }}" required> <input type="date" id="date" name="date" value="{{ tryout.date.strftime('%Y-%m-%d') if tryout else '' }}" required>
</div> </div>
<div class="form-group col-3">
<label for="end_date">End Date</label>
<input type="date" id="end_date" name="end_date" value="{{ tryout.end_date.strftime('%Y-%m-%d') if tryout and tryout.end_date else '' }}">
<small class="text-muted">Optional. Leave blank for single-day tryout.</small>
</div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-6"> <div class="form-group col-6">
@@ -75,15 +80,23 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-12"> <div class="form-group col-12">
<label for="coach_id">Assigned Coach</label> <label>Assigned Coaches</label>
<select id="coach_id" name="coach_id" class="form-select"> <div class="checkbox-grid">
<option value="">-- No coach assigned --</option>
{% for coach in coaches %} {% for coach in coaches %}
<option value="{{ coach.id }}" {% if tryout and tryout.coach_id == coach.id %}selected{% endif %}> {% set is_checked = false %}
{{ coach.username }} {% if tryout %}
</option> {% for c in tryout.coaches %}
{% if c.id == coach.id %}{% set is_checked = true %}{% endif %}
{% endfor %} {% endfor %}
</select> {% if tryout.coach_id == coach.id %}{% set is_checked = true %}{% endif %}
{% endif %}
<label class="checkbox-label">
<input type="checkbox" name="coach_ids" value="{{ coach.id }}" {% if is_checked %}checked{% endif %}>
<span>{{ coach.username }}</span>
</label>
{% endfor %}
</div>
<small class="text-muted">Select one or more coaches for this tryout.</small>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
+7 -1
View File
@@ -29,7 +29,13 @@
<div class="info-item"> <div class="info-item">
<i class="fas fa-calendar info-icon"></i> <i class="fas fa-calendar info-icon"></i>
<span class="info-label">Date</span> <span class="info-label">Date</span>
<span class="info-value">{{ tryout.date.strftime('%b %d, %Y') }}</span> <span class="info-value">
{% if tryout.end_date and tryout.end_date != tryout.date %}
{{ tryout.date.strftime('%b %d') }} - {{ tryout.end_date.strftime('%b %d, %Y') }}
{% else %}
{{ tryout.date.strftime('%b %d, %Y') }}
{% endif %}
</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<i class="fas fa-map-marker-alt info-icon"></i> <i class="fas fa-map-marker-alt info-icon"></i>
+1 -1
View File
@@ -31,7 +31,7 @@
<td> <td>
<div class="user-mini"> <div class="user-mini">
<div class="avatar-sm">{{ u.username[:2] | upper }}</div> <div class="avatar-sm">{{ u.username[:2] | upper }}</div>
<span>{{ u.username }}</span> <a href="{{ url_for('users.view_user', user_id=u.id) }}">{{ u.username }}</a>
</div> </div>
</td> </td>
<td>{{ u.username }}</td> <td>{{ u.username }}</td>
+50 -10
View File
@@ -9,7 +9,7 @@
<div class="card-header"> <div class="card-header">
<h3>Tryout Details</h3> <h3>Tryout Details</h3>
<div class="card-actions"> <div class="card-actions">
{% if can_edit %} {% if can_edit and not tryout.is_ended %}
<a href="{{ url_for('matches.create_match', tryout_id=tryout.id) }}" class="btn btn-sm btn-success"> <a href="{{ url_for('matches.create_match', tryout_id=tryout.id) }}" class="btn btn-sm btn-success">
<i class="fas fa-futbol"></i> Schedule Match <i class="fas fa-futbol"></i> Schedule Match
</a> </a>
@@ -24,11 +24,19 @@
</select> </select>
</form> </form>
{% endif %} {% endif %}
{% if can_edit %} {% if can_edit and not tryout.is_ended %}
<a href="{{ url_for('tryouts.edit_tryout', tryout_id=tryout.id) }}" class="btn btn-sm btn-primary"> <a href="{{ url_for('tryouts.edit_tryout', tryout_id=tryout.id) }}" class="btn btn-sm btn-primary">
<i class="fas fa-edit"></i> Edit <i class="fas fa-edit"></i> Edit
</a> </a>
{% endif %} {% endif %}
{% if can_edit %}
<form method="POST" action="{{ url_for('tryouts.delete_tryout', tryout_id=tryout.id) }}" class="inline-form" onsubmit="return confirm('Are you sure you want to delete this entire tryout? This will remove all matches, teams, registrations, and evaluations.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger">
<i class="fas fa-trash"></i> Delete Tryout
</button>
</form>
{% endif %}
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -39,7 +47,13 @@
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Date</span> <span class="detail-label">Date</span>
<span class="detail-value">{{ tryout.date.strftime('%A, %B %d, %Y') }}</span> <span class="detail-value">
{% if tryout.end_date and tryout.end_date != tryout.date %}
{{ tryout.date.strftime('%B %d') }} - {{ tryout.end_date.strftime('%B %d, %Y') }}
{% else %}
{{ tryout.date.strftime('%A, %B %d, %Y') }}
{% endif %}
</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Location</span> <span class="detail-label">Location</span>
@@ -60,8 +74,16 @@
<span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span> <span class="detail-value">{{ tryout.manager.username if tryout.manager else 'Not assigned' }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Coach</span> <span class="detail-label">Coaches</span>
<span class="detail-value">{{ tryout.coach.username if tryout.coach else 'Not assigned' }}</span> <span class="detail-value">
{% if tryout.coaches %}
{{ tryout.coaches | map(attribute='username') | join(', ') }}
{% elif tryout.coach %}
{{ tryout.coach.username }}
{% else %}
Not assigned
{% endif %}
</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">Registered Players</span> <span class="detail-label">Registered Players</span>
@@ -90,7 +112,7 @@
</div> </div>
{% endif %} {% endif %}
{% if can_edit %} {% if can_edit and not tryout.is_ended %}
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-user-plus"></i> Add Player to Tryout</h3> <h3><i class="fas fa-user-plus"></i> Add Player to Tryout</h3>
@@ -176,12 +198,14 @@
<i class="fas fa-sticky-note"></i> <i class="fas fa-sticky-note"></i>
</a> </a>
{% endif %} {% endif %}
{% if not tryout.is_ended %}
<form method="POST" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" onsubmit="return confirm('Remove {{ p.username }} from this tryout? This will also remove them from all teams and matches within this tryout.');"> <form method="POST" action="{{ url_for('tryouts.remove_player', tryout_id=tryout.id, player_id=p.id) }}" class="inline-form" onsubmit="return confirm('Remove {{ p.username }} from this tryout? This will also remove them from all teams and matches within this tryout.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="Remove player from tryout"> <button type="submit" class="btn btn-sm btn-danger" title="Remove player from tryout">
<i class="fas fa-user-minus"></i> Remove <i class="fas fa-user-minus"></i> Remove
</button> </button>
</form> </form>
{% endif %}
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
@@ -201,14 +225,14 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-users-cog"></i> Teams</h3> <h3><i class="fas fa-users-cog"></i> Teams</h3>
{% if can_edit %} {% if can_edit and not tryout.is_ended %}
<button class="btn btn-sm btn-primary" onclick="showCreateTeam()"> <button class="btn btn-sm btn-primary" onclick="showCreateTeam()">
<i class="fas fa-plus"></i> New Team <i class="fas fa-plus"></i> New Team
</button> </button>
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
{% if can_edit %} {% if can_edit and not tryout.is_ended %}
<div id="createTeamForm" class="hidden mb-3"> <div id="createTeamForm" class="hidden mb-3">
<form method="POST" action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline"> <form method="POST" action="{{ url_for('tryouts.create_team', tryout_id=tryout.id) }}" class="form-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -235,7 +259,7 @@
<li class="text-muted">No players assigned yet.</li> <li class="text-muted">No players assigned yet.</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% if can_edit and registered_players %} {% if can_edit and not tryout.is_ended and registered_players %}
{% set positions = game_positions.get(tryout.game, []) %} {% set positions = game_positions.get(tryout.game, []) %}
<form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2"> <form method="POST" action="{{ url_for('tryouts.add_to_team', tryout_id=tryout.id, team_id=team.team.id) }}" class="form-inline mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -272,7 +296,7 @@
<div class="card tryout-schedule-card mb-4"> <div class="card tryout-schedule-card mb-4">
<div class="card-header"> <div class="card-header">
<h3><i class="fas fa-futbol"></i> Schedule</h3> <h3><i class="fas fa-futbol"></i> Schedule</h3>
{% if can_edit %} {% if can_edit and not tryout.is_ended %}
<div class="card-actions"> <div class="card-actions">
<a href="{{ url_for('matches.create_match', tryout_id=tryout.id) }}" class="btn btn-sm btn-success"> <a href="{{ url_for('matches.create_match', tryout_id=tryout.id) }}" class="btn btn-sm btn-success">
<i class="fas fa-plus"></i> Schedule Match <i class="fas fa-plus"></i> Schedule Match
@@ -350,6 +374,20 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<!-- Player self-presence toggle -->
{% if not can_edit and item.player_presence %}
{% for pp in item.player_presence %}
{% if pp.player_id == current_user.id %}
<div class="presence-players" style="margin-top:6px;">
<button class="btn btn-xs presence-toggle-btn {% if pp.attendance_confirmed %}presence-confirmed-btn{% else %}presence-pending-btn{% endif %}"
title="Toggle your attendance"
onclick="toggleTryoutPresence({{ m.id }}, {{ pp.participant_id }}, this)">
Me {% if pp.attendance_confirmed %}✅{% else %}⏳{% endif %}
</button>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% else %} {% else %}
<span class="text-muted"></span> <span class="text-muted"></span>
{% endif %} {% endif %}
@@ -412,9 +450,11 @@
</td> </td>
{% if can_edit %} {% if can_edit %}
<td> <td>
{% if not tryout.is_ended %}
<a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline"> <a href="{{ url_for('matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline">
<i class="fas fa-edit"></i> Edit <i class="fas fa-edit"></i> Edit
</a> </a>
{% endif %}
<a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="Add Note for this Match"> <a href="{{ url_for('users.add_note_from_match', match_id=m.id) }}" class="btn btn-sm btn-primary" title="Add Note for this Match">
<i class="fas fa-sticky-note"></i> Note <i class="fas fa-sticky-note"></i> Note
</a> </a>
+10
View File
@@ -34,6 +34,16 @@
<span class="detail-value">{{ profile_user.discord_username }}</span> <span class="detail-value">{{ profile_user.discord_username }}</span>
</div> </div>
{% endif %} {% endif %}
{% if profile_user.league_os_profile %}
<div class="detail-item">
<span class="detail-label"><i class="fas fa-link"></i> League OS</span>
<span class="detail-value">
<a href="{{ profile_user.league_os_profile }}" target="_blank" rel="noopener noreferrer" class="trn-link">
<i class="fas fa-external-link-alt"></i> {{ profile_user.league_os_profile }}
</a>
</span>
</div>
{% endif %}
</div> </div>
{% set gamertags = profile_user.gamertags %} {% set gamertags = profile_user.gamertags %}
+1
View File
@@ -0,0 +1 @@
{}
+64
View File
@@ -0,0 +1,64 @@
"""Migration: Create tryout_coaches association table and migrate existing data.
Run this script to create the many-to-many relationship between tryouts and coaches.
Usage: python migrations/add_tryout_coaches.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
# Check if table already exists
result = db.session.execute(text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_name = 'tryout_coaches'"
))
exists = result.scalar() > 0
if exists:
print("Table 'tryout_coaches' already exists. Skipping creation.")
else:
db.session.execute(text("""
CREATE TABLE tryout_coaches (
tryout_id INTEGER NOT NULL,
coach_id INTEGER NOT NULL,
PRIMARY KEY (tryout_id, coach_id),
FOREIGN KEY (tryout_id) REFERENCES tryouts (id) ON DELETE CASCADE,
FOREIGN KEY (coach_id) REFERENCES users (id) ON DELETE CASCADE
)
"""))
db.session.commit()
print("Created 'tryout_coaches' association table.")
# Migrate existing coach_id data into the new table
result = db.session.execute(text(
"SELECT COUNT(*) FROM tryouts WHERE coach_id IS NOT NULL"
))
count = result.scalar()
if count > 0:
# Check how many already migrated
migrated = db.session.execute(text(
"SELECT COUNT(*) FROM tryout_coaches"
)).scalar()
if migrated == 0:
db.session.execute(text("""
INSERT INTO tryout_coaches (tryout_id, coach_id)
SELECT id, coach_id FROM tryouts WHERE coach_id IS NOT NULL
"""))
db.session.commit()
print(f"Migrated {count} existing coach assignments to tryout_coaches.")
else:
print(f"Skipping data migration — {migrated} rows already exist in tryout_coaches.")
else:
print("No existing coach assignments to migrate.")
print("Migration complete.")