diff --git a/app/discord_bot.py b/app/discord_bot.py index e496eb8..6ce8af0 100644 --- a/app/discord_bot.py +++ b/app/discord_bot.py @@ -114,21 +114,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 +152,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, @@ -259,7 +274,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 +285,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 +300,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}." ) @@ -303,7 +318,7 @@ class TeamTryoutsBot(commands.Bot): 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 +329,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 +341,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 +360,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, @@ -360,7 +375,7 @@ class TeamTryoutsBot(commands.Bot): 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 +397,13 @@ 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] 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,7 +425,7 @@ 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] except Exception as e: 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/tryout/tryout.py b/app/models/tryout/tryout.py index cfcabde..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 @@ -18,12 +19,13 @@ class Tryout(db.Model): 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') 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/tryouts.py b/app/routes/tryouts.py index 8f0f26a..df11f97 100644 --- a/app/routes/tryouts.py +++ b/app/routes/tryouts.py @@ -56,7 +56,7 @@ def create_tryout(): 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() @@ -86,9 +86,15 @@ def create_tryout(): 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)) @@ -125,7 +131,7 @@ def edit_tryout(tryout_id): 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() @@ -156,7 +162,14 @@ def edit_tryout(tryout_id): 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)) @@ -174,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): diff --git a/app/templates/pages/profile.html b/app/templates/pages/profile.html index 663e719..5e859ef 100644 --- a/app/templates/pages/profile.html +++ b/app/templates/pages/profile.html @@ -280,6 +280,22 @@ for (let h = 8; h <= 22; h++) { 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 }}] = []; @@ -296,25 +312,40 @@ function renderCoachGrid() { COACH_TIME_SLOTS.forEach(slot => { const isSelected = coachSelectedSlots[dayIndex] && coachSelectedSlots[dayIndex].includes(slot.time); const cssClass = isSelected ? 'time-slot selected' : 'time-slot'; - html += '