"""Match scheduling routes for managing scrimmages and matches within tryouts. Uses polymorphic isinstance checks instead of role-string comparisons. """ from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask_login import login_required, current_user from flask_babel import gettext as _ from app.extensions import db from app.models import ( Admin, Manager, Coach, Player, Scout, User, Tryout, Match, MatchParticipant, Team, TeamMember, TryoutRegistration, PlayerDisponibility, OneOnOneRequest, PersonalNote, ) from datetime import datetime, timedelta from app.discord_bot import send_schedule_notification matches_bp = Blueprint('matches', __name__, url_prefix='/matches') def can_schedule_match(): """Check if user can schedule matches (Admin, Manager, Coach, Scout).""" return isinstance(current_user, (Admin, Manager, Coach, Scout)) def get_visible_tryouts_for_user(): """Get tryouts that the current user can see based on their role. Delegates to the polymorphic User subclass. """ return current_user.get_visible_tryouts() @matches_bp.route('/calendar') @login_required def calendar(): """Render the calendar view.""" return render_template('pages/calendar.html') @matches_bp.route('/api/events') @login_required def api_events(): """API endpoint returning calendar events for FullCalendar.""" events = [] tryouts = get_visible_tryouts_for_user() for tryout in tryouts: for match in tryout.matches: match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b' # 'description' used to be participants_str + '
' + description. # Building presentation markup inside a JSON field is what carried # the stored XSS: the browser dropped it straight into innerHTML, # and player usernames travelled through it unescaped. The two # values are already separate keys, so the concatenation also made # the modal show the participants twice. participants_str = '' if match.match_type == 'team_vs_team': teams = [] if match.team1: teams.append(match.team1.name) if match.team2: teams.append(match.team2.name) participants_str = ' vs '.join(teams) else: player_names = [] for p in match.participants.all(): player_names.append(p.player.username if p.player else 'Unknown Player') participants_str = ', '.join(player_names) if player_names else 'No players' start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None user_participant = MatchParticipant.query.filter_by( match_id=match.id, player_id=current_user.id, ).first() events.append( { 'id': f'match_{match.id}', 'title': match.title, 'date': match.date.strftime('%Y-%m-%d'), 'type': 'match', 'color': match_color, 'extendedProps': { 'location': match.location or tryout.location or 'TBD', 'status': match.status, 'description': match.description or '', 'match_type': match.match_type, 'tryout_id': tryout.id, 'match_id': match.id, 'start_time': start_time_str, 'end_time': end_time_str, 'participants': participants_str, 'user_participant_id': user_participant.id if user_participant else None, 'user_attendance_confirmed': user_participant.attendance_confirmed if user_participant else False, }, } ) # Add approved One on One sessions for the current user (player or coach) if isinstance(current_user, Player): one_on_ones = OneOnOneRequest.query.filter_by( player_id=current_user.id, status='approved' ).all() elif isinstance(current_user, Coach): one_on_ones = OneOnOneRequest.query.filter_by( coach_id=current_user.id, status='approved' ).all() else: one_on_ones = [] for ooo in one_on_ones: events.append( { 'id': f'one_on_one_{ooo.id}', 'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}', 'date': ooo.date.strftime('%Y-%m-%d'), 'type': 'one_on_one', 'color': '#8b5cf6', 'extendedProps': { 'location': 'Discord / Voice Chat', 'status': 'approved', 'description': ooo.points or 'One on One session', 'start_time': ooo.start_time.strftime('%H:%M') if ooo.start_time else None, 'end_time': ooo.end_time.strftime('%H:%M') if ooo.end_time else None, 'participants': f"{ooo.player.full_name} with {ooo.coach.full_name}", }, } ) return jsonify(events) @matches_bp.route('/api/events/') @login_required def api_events_for_tryout(tryout_id): """API endpoint returning calendar events for a specific tryout.""" tryout = Tryout.query.get_or_404(tryout_id) can_view = current_user.can_manage_this_tryout(tryout) is_registered = False player_in_match = False if isinstance(current_user, Player): is_registered = ( TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=current_user.id, ).first() is not None ) player_matches = ( Match.query.join(MatchParticipant) .filter( MatchParticipant.player_id == current_user.id, Match.tryout_id == tryout_id, ) .all() ) player_in_match = len(player_matches) > 0 if not can_view and not is_registered and not player_in_match: return jsonify([]) events = [] for match in tryout.matches: match_color = ( '#10b981' if match.match_type in ('team_vs_team', 'player_vs_player') else '#f59e0b' ) participants_str = '' if match.match_type == 'team_vs_team': teams = [] if match.team1: teams.append(match.team1.name) if match.team2: teams.append(match.team2.name) participants_str = f"{' vs '.join(teams)}" elif match.match_type == 'player_vs_player': team1_players = [ p.player.username for p in match.participants.filter_by(team_side=1).all() if p.player ] team2_players = [ p.player.username for p in match.participants.filter_by(team_side=2).all() if p.player ] if team1_players and team2_players: participants_str = f"{', '.join(team1_players)} vs {', '.join(team2_players)}" else: participants_str = 'TBD vs TBD' else: player_names = [p.player.username for p in match.participants.all() if p.player] participants_str = ', '.join(player_names) if player_names else 'No players' start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None events.append( { 'id': f'match_{match.id}', 'title': match.title, 'date': match.date.strftime('%Y-%m-%d'), 'type': 'match', 'color': match_color, 'extendedProps': { 'location': match.location or tryout.location or 'TBD', 'status': match.status, 'match_type': match.match_type, 'tryout_id': tryout.id, 'match_id': match.id, 'participants': participants_str, 'start_time': start_time_str, 'end_time': end_time_str, }, } ) return jsonify(events) @matches_bp.route('/create/', methods=['GET', 'POST']) @login_required def create_match(tryout_id): """Create a new match / scrimmage within a tryout.""" tryout = Tryout.query.get_or_404(tryout_id) if not current_user.can_manage_this_tryout(tryout): 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) ] all_players = sorted([p for p in all_players if p], key=lambda x: x.username) prefill_date = request.args.get('date', '') if request.method == 'POST': title = request.form.get('title') description = request.form.get('description') date_str = request.form.get('date') start_time_str = request.form.get('start_time') end_time_str = request.form.get('end_time') location = request.form.get('location') match_type = request.form.get('match_type') if not start_time_str: flash(_('Start time is required. Please select a time slot.'), 'danger') return render_template( 'pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players, prefill_date=prefill_date, ) try: date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else tryout.date except (ValueError, TypeError): flash(_('Invalid date format.'), 'danger') return render_template( 'pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players, prefill_date=prefill_date, ) start_time = None end_time = None try: start_time = datetime.strptime(start_time_str, '%H:%M').time() if end_time_str: end_time = datetime.strptime(end_time_str, '%H:%M').time() else: start_dt = datetime.combine(date_obj, start_time) end_dt = start_dt + timedelta(minutes=30) end_time = end_dt.time() except ValueError: flash(_('Invalid time format.'), 'danger') return render_template( 'pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players ) match = Match( tryout_id=tryout_id, title=title, description=description, date=date_obj, start_time=start_time, end_time=end_time, location=location, match_type=match_type, created_by=current_user.id, ) db.session.add(match) db.session.flush() notified_player_ids = [] notified_participant_ids = [] if match_type == 'team_vs_team': team1_id = request.form.get('team1_id') team2_id = request.form.get('team2_id') match.team1_id = int(team1_id) if team1_id else None match.team2_id = int(team2_id) if team2_id else None if match.team1_id: for m in TeamMember.query.filter_by(team_id=match.team1_id).all(): participant = MatchParticipant( match_id=match.id, player_id=m.player_id, team_side=1 ) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids.append(m.player_id) if match.team2_id: for m in TeamMember.query.filter_by(team_id=match.team2_id).all(): participant = MatchParticipant( match_id=match.id, player_id=m.player_id, team_side=2 ) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids.append(m.player_id) elif match_type == 'player_vs_player': team1_player_ids = request.form.get('team1_player_ids', '') team2_player_ids = request.form.get('team2_player_ids', '') team1_ids = ( [int(p) for p in team1_player_ids.split(',') if p] if team1_player_ids else [] ) team2_ids = ( [int(p) for p in team2_player_ids.split(',') if p] if team2_player_ids else [] ) for pid in team1_ids: participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=1) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) for pid in team2_ids: participant = MatchParticipant(match_id=match.id, player_id=pid, team_side=2) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids = team1_ids + team2_ids elif match_type == 'player_scrim': player_ids = request.form.getlist('player_ids') for pid in player_ids: participant = MatchParticipant(match_id=match.id, player_id=int(pid)) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids = [int(p) for p in player_ids] db.session.commit() # Discord notifications event_date_str = date_obj.strftime('%Y-%m-%d') event_time_str = ( f"{start_time.strftime('%I:%M %p')} - {end_time.strftime('%I:%M %p')}" if start_time and end_time else 'TBD' ) for i, player_id in enumerate(notified_player_ids): reference_id = ( notified_participant_ids[i] if i < len(notified_participant_ids) else match.id ) send_schedule_notification( user_id=player_id, event_type='match', event_title=match.title, event_date=event_date_str, event_time=event_time_str, reference_id=reference_id, ) flash(_('Match scheduled successfully!'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return render_template( 'pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players, prefill_date=prefill_date, ) @matches_bp.route('//edit', methods=['GET', 'POST']) @login_required def edit_match(match_id): """Edit an existing match.""" match = Match.query.get_or_404(match_id) tryout = match.tryout if not current_user.can_manage_this_tryout(tryout): 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] all_players = sorted([p for p in all_players if p], key=lambda x: x.username) current_player_ids = [p.player_id for p in match.participants.all()] team1_player_ids = [p.player_id for p in match.participants.filter_by(team_side=1).all()] team2_player_ids = [p.player_id for p in match.participants.filter_by(team_side=2).all()] if request.method == 'POST': match.title = request.form.get('title') match.description = request.form.get('description') date_str = request.form.get('date') start_time_str = request.form.get('start_time') end_time_str = request.form.get('end_time') location = request.form.get('location') status = request.form.get('status') try: match.date = datetime.strptime(date_str, '%Y-%m-%d').date() except (ValueError, TypeError): flash(_('Invalid date format.'), 'danger') return render_template( 'pages/match_form.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids, ) if not start_time_str: flash(_('Start time is required.'), 'danger') return render_template( 'pages/match_form.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids, ) try: match.start_time = datetime.strptime(start_time_str, '%H:%M').time() if end_time_str: match.end_time = datetime.strptime(end_time_str, '%H:%M').time() else: start_dt = datetime.combine(match.date, match.start_time) end_dt = start_dt + timedelta(minutes=30) match.end_time = end_dt.time() except ValueError: match.start_time = None match.location = location if status in ['scheduled', 'completed', 'cancelled']: match.status = status notified_player_ids = [] notified_participant_ids = [] if match.match_type == 'team_vs_team': team1_id = request.form.get('team1_id') team2_id = request.form.get('team2_id') new_team1_id = int(team1_id) if team1_id else None new_team2_id = int(team2_id) if team2_id else None if new_team1_id != match.team1_id or new_team2_id != match.team2_id: MatchParticipant.query.filter_by(match_id=match.id).delete() match.team1_id = new_team1_id match.team2_id = new_team2_id if match.team1_id: for m in TeamMember.query.filter_by(team_id=match.team1_id).all(): participant = MatchParticipant( match_id=match.id, player_id=m.player_id, team_side=1 ) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids.append(m.player_id) if match.team2_id: for m in TeamMember.query.filter_by(team_id=match.team2_id).all(): participant = MatchParticipant( match_id=match.id, player_id=m.player_id, team_side=2 ) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids.append(m.player_id) else: if match.team1_id: notified_player_ids.extend( [ m.player_id for m in TeamMember.query.filter_by(team_id=match.team1_id).all() ] ) if match.team2_id: notified_player_ids.extend( [ m.player_id for m in TeamMember.query.filter_by(team_id=match.team2_id).all() ] ) elif match.match_type == 'player_vs_player': MatchParticipant.query.filter_by(match_id=match.id).delete() team1_str = request.form.get('team1_player_ids', '') team2_str = request.form.get('team2_player_ids', '') t1_ids = [p for p in team1_str.split(',') if p.strip()] if team1_str else [] t2_ids = [p for p in team2_str.split(',') if p.strip()] if team2_str else [] for pid in t1_ids: participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=1) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) for pid in t2_ids: participant = MatchParticipant(match_id=match.id, player_id=int(pid), team_side=2) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids = [int(p) for p in t1_ids] + [int(p) for p in t2_ids] elif match.match_type == 'player_scrim': MatchParticipant.query.filter_by(match_id=match.id).delete() player_ids = request.form.getlist('player_ids') for pid in player_ids: participant = MatchParticipant(match_id=match.id, player_id=int(pid)) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) notified_player_ids = [int(p) for p in player_ids] db.session.commit() # Discord notifications end_time_val = match.end_time or (match.start_time if match.start_time else None) if match.start_time and end_time_val: event_time_str = ( f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}" ) else: event_time_str = 'TBD' event_date_str = match.date.strftime('%Y-%m-%d') for i, player_id in enumerate(notified_player_ids): reference_id = ( notified_participant_ids[i] if i < len(notified_participant_ids) else match.id ) send_schedule_notification( user_id=player_id, event_type='match', event_title=match.title, event_date=event_date_str, event_time=event_time_str, reference_id=reference_id, ) flash(_('Match updated successfully!'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) participants_map = {} for p in match.participants.all(): participants_map[p.player_id] = { 'participant_id': p.id, 'attendance_confirmed': p.attendance_confirmed, 'team_side': p.team_side, } return render_template( 'pages/match_form.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids, team1_player_ids=team1_player_ids, team2_player_ids=team2_player_ids, participants_map=participants_map, ) @matches_bp.route('/api/manageable-tryouts') @login_required def api_manageable_tryouts(): """API endpoint returning tryouts the current user can manage.""" if not can_schedule_match(): return jsonify([]) tryouts = get_visible_tryouts_for_user() manageable = [] for t in tryouts: if current_user.can_manage_this_tryout(t): 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) @matches_bp.route('//delete', methods=['POST']) @login_required def delete_match(match_id): """Delete a match.""" match = Match.query.get_or_404(match_id) tryout = match.tryout 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)) # Notes outlive the match they were taken during: a coach's observation # keeps its value, and deleting it here would destroy unrelated content. # Only the context link is dropped. Participants go through the # relationship's delete-orphan cascade. PersonalNote.query.filter_by(match_id=match_id).update( {'match_id': None}, synchronize_session=False ) db.session.delete(match) db.session.commit() flash(_('Match deleted successfully.'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) def get_players_available_at_time(date_str, time_str): """Get list of player IDs available at a specific date and time.""" try: parsed_date = datetime.strptime(date_str, '%Y-%m-%d') time_obj = datetime.strptime(time_str, '%H:%M').time() except (ValueError, TypeError): return [] day_of_week = parsed_date.weekday() players = User.query.filter_by(role='player', is_active_account=True).all() available_players = [] for player in players: disponibilities = PlayerDisponibility.query.filter_by( player_id=player.id, day_of_week=day_of_week, ).all() for disp in disponibilities: disp_start = disp.start_time.hour * 60 + disp.start_time.minute disp_end = disp.end_time.hour * 60 + disp.end_time.minute match_time = time_obj.hour * 60 + time_obj.minute if disp_start <= match_time < disp_end: available_players.append(player.id) break return available_players @matches_bp.route('/api/available_players//