diff --git a/app/discord_bot.py b/app/discord_bot.py index e496eb8..c1bf3c8 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -7,6 +7,7 @@ This module provides a persistent bot that handles: """ import os +import json import logging import asyncio import threading @@ -26,6 +27,9 @@ DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN') # Configure logging 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 CHECK_EMOJI = '✅' # Green checkmark CROSS_EMOJI = '❌' # Red X @@ -53,8 +57,31 @@ class TeamTryoutsBot(commands.Bot): self.scheduler = AsyncIOScheduler() 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): """Called when the bot is ready.""" + self._load_pending() logger.info(f'TeamTryoutsBot logged in as {self.user}') async def on_ready(self): @@ -114,21 +141,36 @@ class TeamTryoutsBot(commands.Bot): """Check if a channel is a DM channel.""" return hasattr(channel, 'recipient') or hasattr(channel, 'recipients') - async def on_reaction_add(self, reaction, user): - """Handle when a reaction is added to a message.""" - if user.bot: + async def on_raw_reaction_add(self, payload): + """Handle when a reaction is added to a message (works even after bot restart).""" + # Ignore bot's own reactions + if payload.user_id == self.user.id: 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 - message_id = reaction.message.id - - if message_id not in self.pending_requests: + # Fetch the channel and check if it's a DM + try: + channel = await self.fetch_channel(payload.channel_id) + except Exception: return - request_info = self.pending_requests[message_id] - emoji_str = str(reaction.emoji) + if not self.is_dm_channel(channel): + 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') request_id = request_info.get('id') @@ -137,28 +179,28 @@ class TeamTryoutsBot(commands.Bot): if handler_type == 'one_on_one': if self.flask_app: 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: - 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': if self.flask_app: 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: - 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: if handler_type == 'one_on_one': if self.flask_app: 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: - 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': if self.flask_app: 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: - 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, team_name: str, date_str: str, start_time: str, end_time: str, @@ -193,6 +235,7 @@ class TeamTryoutsBot(commands.Bot): # Track this pending request 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}") return msg.id @@ -251,6 +294,7 @@ class TeamTryoutsBot(commands.Bot): # Track this pending request 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}") return msg.id @@ -259,7 +303,7 @@ class TeamTryoutsBot(commands.Bot): logger.error(f"Error sending schedule notification: {e}") 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.""" try: from app.models import OneOnOneRequest @@ -270,7 +314,7 @@ class TeamTryoutsBot(commands.Bot): return 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 # 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() db.session.commit() - await original_message.channel.send( + await channel.send( f"✅ You have **approved** the One on One session with {player_full_name}." ) @@ -299,11 +343,12 @@ class TeamTryoutsBot(commands.Bot): approved=True ) del self.pending_requests[message_id] + self._save_pending() except Exception as e: 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.""" try: from app.models import OneOnOneRequest @@ -314,7 +359,7 @@ class TeamTryoutsBot(commands.Bot): return 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 # Re-attach to current session (object may be detached across app contexts) @@ -326,7 +371,7 @@ class TeamTryoutsBot(commands.Bot): refusal_note = None 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: refusal_note = reply.content break @@ -345,7 +390,7 @@ class TeamTryoutsBot(commands.Bot): else: 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: await self.notify_player_about_one_on_one_direct( player_discord_id=player_discord_id, @@ -356,11 +401,12 @@ class TeamTryoutsBot(commands.Bot): refusal_note=refusal_note ) del self.pending_requests[message_id] + self._save_pending() except Exception as e: 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.""" try: from app.models import MatchParticipant, TryoutRegistration, Match, Tryout @@ -382,13 +428,14 @@ class TeamTryoutsBot(commands.Bot): 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] + self._save_pending() except Exception as e: 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.""" try: from app.models import MatchParticipant, TryoutRegistration, Match, Tryout @@ -410,8 +457,9 @@ class TeamTryoutsBot(commands.Bot): 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] + self._save_pending() except Exception as e: logger.error(f"Error handling attendance decline: {e}\n{traceback.format_exc()}") diff --git a/app/models/_associations.py b/app/models/_associations.py index c0d30be..53b2775 100644 --- a/app/models/_associations.py +++ b/app/models/_associations.py @@ -15,4 +15,11 @@ org_team_managers = db.Table('org_team_managers', primary_key=True), db.Column('manager_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True), -) \ No newline at end of file +) + +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), +) diff --git a/app/models/_constants.py b/app/models/_constants.py index ca26638..180c14a 100644 --- a/app/models/_constants.py +++ b/app/models/_constants.py @@ -52,6 +52,12 @@ PLATFORM_CODES = { 'Epic': 'epic', } +PLATFORM_DEFAULTS = { + 'Apex Legends': 'pc', + 'Rainbow Six Siege': 'ubi', + 'Rocket League': 'epic' +} + TRN_URLS = { 'Valorant': 'https://tracker.gg/valorant/profile/riot/{username}', 'League of Legends': 'https://tracker.gg/lol/profile/{username}', diff --git a/app/models/tryout/tryout.py b/app/models/tryout/tryout.py index 752ae81..fed9ec5 100644 --- a/app/models/tryout/tryout.py +++ b/app/models/tryout/tryout.py @@ -1,5 +1,6 @@ """Tryout event for player evaluations and team formation.""" from app.extensions import db +from app.models._associations import tryout_coaches from datetime import datetime @@ -11,19 +12,31 @@ class Tryout(db.Model): description = db.Column(db.Text, nullable=True) game = db.Column(db.String(50), 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) 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) + 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) 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') + 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') evaluations = db.relationship('Evaluation', backref='tryout', lazy='dynamic') teams = db.relationship('Team', backref='tryout', lazy='dynamic') - target_org_team = db.relationship('OrgTeam', backref='tryouts', foreign_keys=[target_org_team_id]) \ No newline at end of file + 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 diff --git a/app/models/user_gamertag.py b/app/models/user_gamertag.py index aa85eec..ed8b729 100644 --- a/app/models/user_gamertag.py +++ b/app/models/user_gamertag.py @@ -1,6 +1,6 @@ """Store gamertag per game for each user.""" 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 @@ -24,17 +24,21 @@ class UserGamertag(db.Model): return None url = TRN_URLS[self.game] 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: platform_code = PLATFORM_CODES.get( - self.platform, - self.platform.lower().replace(' ', '-') if self.platform else '', + platform, + platform.lower().replace(' ', '-') if 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(' ', '-'), + platform=platform.lower().replace(' ', '-') if platform else '', username=encoded_gamertag, ) elif '{username}' in url: return url.format(username=encoded_gamertag) - return url \ No newline at end of file + return url diff --git a/app/models/user_model/coach.py b/app/models/user_model/coach.py index e836e0f..d05a865 100644 --- a/app/models/user_model/coach.py +++ b/app/models/user_model/coach.py @@ -25,6 +25,10 @@ class Coach(User): ).first() is not None if is_coach_of_target: 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: return True return False @@ -38,9 +42,13 @@ class Coach(User): def get_visible_tryouts(self): 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()] conditions = [] if 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) return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all() diff --git a/app/models/user_model/manager.py b/app/models/user_model/manager.py index 05944d3..7c71b57 100644 --- a/app/models/user_model/manager.py +++ b/app/models/user_model/manager.py @@ -26,4 +26,7 @@ class Manager(User): 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() + from sqlalchemy import or_ + return Tryout.query.filter( + or_(Tryout.created_by == self.id, Tryout.manager_id == self.id) + ).order_by(Tryout.date).all() diff --git a/app/routes/matches.py b/app/routes/matches.py index 649183f..457cacb 100644 --- a/app/routes/matches.py +++ b/app/routes/matches.py @@ -46,19 +46,6 @@ def api_events(): tryouts = get_visible_tryouts_for_user() for tryout in tryouts: - events.append({ - 'id': f'tryout_{tryout.id}', - 'title': tryout.title, - 'date': tryout.date.strftime('%Y-%m-%d'), - 'type': 'tryout', 'color': '#3b82f6', - 'extendedProps': { - 'location': tryout.location or 'TBD', - 'status': tryout.status, - 'description': tryout.description or '', - 'tryout_id': tryout.id, - }, - }) - for match in tryout.matches: match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b' 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: return jsonify([]) - 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, - }, - }] + events = [] for match in tryout.matches: 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') 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() 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)] @@ -348,6 +328,10 @@ def edit_match(match_id): flash('You do not have permission to edit this match.', 'danger') 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() 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] @@ -503,6 +487,7 @@ def api_manageable_tryouts(): manageable.append({ 'id': t.id, 'title': t.title, '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) @@ -516,6 +501,9 @@ def delete_match(match_id): if not current_user.can_manage_this_tryout(tryout): flash('You do not have permission to delete this match.', 'danger') return redirect(url_for('matches.calendar')) + 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.commit() flash('Match deleted successfully.', 'success') diff --git a/app/routes/tryouts.py b/app/routes/tryouts.py index c5f4a3a..df11f97 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -51,29 +51,50 @@ def create_tryout(): description = request.form.get('description') game = request.form.get('game') date_str = request.form.get('date') + end_date_str = request.form.get('end_date') location = request.form.get('location') max_players = request.form.get('max_players') target_org_team_id = request.form.get('target_org_team_id') manager_id = request.form.get('manager_id') - coach_id = request.form.get('coach_id') + coach_ids = request.form.getlist('coach_ids') try: date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() 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, + managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) + tryout = Tryout( title=title, description=description, game=game, date=date_obj, + end_date=end_date_obj, location=location, max_players=int(max_players) if max_players else None, created_by=current_user.id, status='upcoming', 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, - coach_id=int(coach_id) if coach_id else None, ) 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() flash('Tryout created successfully!', 'success') 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') 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() 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() @@ -101,28 +126,50 @@ def edit_tryout(tryout_id): description = request.form.get('description') game = request.form.get('game') date_str = request.form.get('date') + end_date_str = request.form.get('end_date') location = request.form.get('location') max_players = request.form.get('max_players') target_org_team_id = request.form.get('target_org_team_id') manager_id = request.form.get('manager_id') - coach_id = request.form.get('coach_id') + coach_ids = request.form.getlist('coach_ids') try: date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() 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, + managers=managers, coaches=coaches, esport_games=ESPORT_GAMES) + tryout.title = title tryout.description = description tryout.game = game tryout.date = date_obj + tryout.end_date = end_date_obj tryout.location = location 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.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() flash('Tryout updated successfully!', 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) @@ -140,8 +187,8 @@ def view_tryout(tryout_id): can_view = False if isinstance(current_user, Admin): can_view = True - elif isinstance(current_user, Manager) and tryout.created_by == current_user.id: - can_view = True + elif isinstance(current_user, Manager): + can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id elif isinstance(current_user, Coach): can_view = current_user.can_manage_this_tryout(tryout) elif isinstance(current_user, Player): @@ -429,4 +476,43 @@ def add_to_team(tryout_id, team_id): db.session.add(member) db.session.commit() flash('Player added to team!', 'success') - return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) \ No newline at end of file + return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) + + +@tryouts_bp.route('//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')) \ No newline at end of file diff --git a/app/routes/users.py b/app/routes/users.py index d883135..f7619f9 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -259,12 +259,15 @@ def profile(): contracts = Contract.query.filter_by( player_id=current_user.id, ).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, ).all() + 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']) diff --git a/app/templates/pages/calendar.html b/app/templates/pages/calendar.html index d434cc0..b984ad0 100644 --- a/app/templates/pages/calendar.html +++ b/app/templates/pages/calendar.html @@ -197,7 +197,6 @@ function goToTeamMatch() { } function goToCreateTryout() { - // Tryout creation doesn't support pre-filling date easily, just navigate window.location.href = '/tryouts/create'; } @@ -208,8 +207,13 @@ function fetchTryoutOptions() { .then(function(data) { var sel = document.getElementById('createTryoutSelect'); sel.innerHTML = ''; + var today = new Date().toISOString().split('T')[0]; data.forEach(function(t) { - sel.innerHTML += ''; + // Only show tryouts that haven't ended + var tryoutEndDate = t.end_date || t.date; + if (tryoutEndDate >= today) { + sel.innerHTML += ''; + } }); }) .catch(function() {}); @@ -236,8 +240,8 @@ function showEventModal(event) { var content = '
'; content += '
Type'; - content += ''; - content += (type === 'tryout' ? 'Tryout' : (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim'))) + ''; + content += ''; + content += (props.match_type === 'team_vs_team' ? 'Team Match' : (props.match_type === 'player_vs_player' ? 'Player Match' : 'Player Scrim')) + ''; content += '
'; content += '
Title' + title + '
'; content += '
Date' + date + '
'; @@ -250,7 +254,6 @@ function showEventModal(event) { if (type === 'match' && props.participants) { content += '
Teams'; if (props.match_type === 'team_vs_team') { - // For team vs team, participants is "Team1 vs Team2" var teams = props.participants.split(' vs '); if (teams.length >= 2) { content += '
'; @@ -262,8 +265,6 @@ function showEventModal(event) { content += props.participants; } } 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 '); if (parts.length >= 2) { content += '
'; @@ -275,7 +276,6 @@ function showEventModal(event) { content += props.participants; } } else { - // For player scrim, just show the list content += props.participants; } content += '
'; @@ -286,7 +286,7 @@ function showEventModal(event) { } content += '
'; - document.getElementById('modalTitle').textContent = type === 'tryout' ? 'Tryout Details' : 'Match Details'; + document.getElementById('modalTitle').textContent = 'Match Details'; document.getElementById('modalContent').innerHTML = content; // Reset buttons @@ -301,11 +301,11 @@ function showEventModal(event) { document.getElementById('editMatchBtn').onclick = function() { window.location.href = '/matches/' + props.match_id + '/edit'; }; - } else if (type === 'tryout' && canScheduleMatches) { - document.getElementById('modalActions').style.display = 'flex'; - document.getElementById('viewTryoutBtn').style.display = 'inline-flex'; - document.getElementById('viewTryoutBtn').onclick = function() { - window.location.href = '/tryouts/' + props.tryout_id; + document.getElementById('deleteMatchBtn').style.display = 'inline-flex'; + document.getElementById('deleteMatchBtn').onclick = function() { + if (confirm('Are you sure you want to delete this match?')) { + deleteCalendarMatch(props.match_id); + } }; } else { document.getElementById('modalActions').style.display = 'none'; @@ -329,6 +329,27 @@ function showEventModal(event) { 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) { fetch('/matches/' + matchId + '/toggle-presence/' + participantId, { method: 'POST', diff --git a/app/templates/pages/profile.html b/app/templates/pages/profile.html index fc51f64..1597a13 100644 --- a/app/templates/pages/profile.html +++ b/app/templates/pages/profile.html @@ -223,10 +223,219 @@
{% endif %} + + + {% if user.role == 'player' %} +
+
+

My Disponibilities

+
+
+

Select your available time blocks for matches (5pm to 12am). Green = selected, Gray = available to select.

+
+

Loading...

+
+
+ + +
+
+
+ {% endif %} + + + {% if user.role == 'coach' %} +
+
+

My Coaching Availability

+

Select time slots when you're available for One on One sessions (8am to 10pm).

+
+
+
+

Loading availability grid...

+
+
+ +
+
+
+ {% endif %} {% endblock %} {% block scripts %} +{% if user.role == 'coach' %} + + +{% endif %} + {% if user.role == 'player' %} {% endif %} - -{% if user.role == 'coach' %} - - -{% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/pages/tryout_form.html b/app/templates/pages/tryout_form.html index 6401816..ddfc2ac 100644 --- a/app/templates/pages/tryout_form.html +++ b/app/templates/pages/tryout_form.html @@ -34,10 +34,15 @@ {% endfor %} -
- +
+
+
+ + + Optional. Leave blank for single-day tryout. +
@@ -75,15 +80,23 @@
- - + {{ coach.username }} + {% endfor %} - +
+ Select one or more coaches for this tryout.
diff --git a/app/templates/pages/tryouts.html b/app/templates/pages/tryouts.html index bd49dde..ba0dd11 100644 --- a/app/templates/pages/tryouts.html +++ b/app/templates/pages/tryouts.html @@ -29,7 +29,13 @@
Date - {{ tryout.date.strftime('%b %d, %Y') }} + + {% 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 %} +
diff --git a/app/templates/pages/users.html b/app/templates/pages/users.html index 70666b8..c46c098 100644 --- a/app/templates/pages/users.html +++ b/app/templates/pages/users.html @@ -31,7 +31,7 @@
{{ u.username[:2] | upper }}
- {{ u.username }} + {{ u.username }}
{{ u.username }} diff --git a/app/templates/pages/view_tryout.html b/app/templates/pages/view_tryout.html index 565bfbd..8ec6a85 100644 --- a/app/templates/pages/view_tryout.html +++ b/app/templates/pages/view_tryout.html @@ -9,7 +9,7 @@

Tryout Details

- {% if can_edit %} + {% if can_edit and not tryout.is_ended %} Schedule Match @@ -24,11 +24,19 @@ {% endif %} - {% if can_edit %} + {% if can_edit and not tryout.is_ended %} Edit {% endif %} + {% if can_edit %} +
+ + +
+ {% endif %}
@@ -39,7 +47,13 @@
Date - {{ tryout.date.strftime('%A, %B %d, %Y') }} + + {% 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 %} +
Location @@ -60,8 +74,16 @@ {{ tryout.manager.username if tryout.manager else 'Not assigned' }}
- Coach - {{ tryout.coach.username if tryout.coach else 'Not assigned' }} + Coaches + + {% if tryout.coaches %} + {{ tryout.coaches | map(attribute='username') | join(', ') }} + {% elif tryout.coach %} + {{ tryout.coach.username }} + {% else %} + Not assigned + {% endif %} +
Registered Players @@ -90,7 +112,7 @@
{% endif %} - {% if can_edit %} + {% if can_edit and not tryout.is_ended %}

Add Player to Tryout

@@ -176,12 +198,14 @@ {% endif %} + {% if not tryout.is_ended %}
+ {% endif %} {% endif %} @@ -201,14 +225,14 @@

Teams

- {% if can_edit %} + {% if can_edit and not tryout.is_ended %} {% endif %}
- {% if can_edit %} + {% if can_edit and not tryout.is_ended %}