"""Match scheduling routes for managing scrimmages and matches within tryouts. Uses polymorphic isinstance checks instead of role-string comparisons. """ from datetime import datetime, timedelta from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask_babel import gettext as _ from flask_login import current_user, login_required from marshmallow import ValidationError from sqlalchemy.orm import joinedload from app.extensions import db from app.forms import flash_validation_errors, form_payload from app.models import ( Admin, Coach, Manager, Match, MatchParticipant, OneOnOneRequest, PersonalNote, Player, PlayerDisponibility, Scout, Team, TeamMember, Tryout, TryoutRegistration, User, ) from app.services.scheduling import notify_participants, zip_participants from app.validators import MatchEditSchema, MatchSchema matches_bp = Blueprint('matches', __name__, url_prefix='/matches') def match_form_payload(): """The match form, shaped for marshmallow. `player_ids` is a repeated checkbox, so it needs getlist(); `games` — the default list field — has nothing to do with this form. """ return form_payload(list_fields=('player_ids',), optional_blank=()) #: How long a match lasts when the form gives a start and no end. DEFAULT_MATCH_MINUTES = 30 def default_end_time(date, start_time): """End time for a match whose form left it blank.""" return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time() def create_participants(match, data): """Attach participants to a match, per its type. Was written out twice, in create_match and in edit_match, and had already drifted: the copy in edit_match kept its player ids as strings and called int() on them one line later, the one in create_match did not (ARCH-005). Returns: tuple: (player ids to notify, the participant rows created). """ sides = [] if match.match_type == 'team_vs_team': for side, team_id in ((1, match.team1_id), (2, match.team2_id)): if team_id: members = TeamMember.query.filter_by(team_id=team_id).all() sides.append((side, [m.player_id for m in members])) elif match.match_type == 'player_vs_player': sides = [(1, data['team1_player_ids']), (2, data['team2_player_ids'])] elif match.match_type == 'player_scrim': sides = [(None, data['player_ids'])] player_ids = [] participant_ids = [] for side, ids in sides: for player_id in ids: participant = MatchParticipant(match_id=match.id, player_id=player_id, team_side=side) db.session.add(participant) db.session.flush() participant_ids.append(participant.id) player_ids.append(player_id) return player_ids, participant_ids 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') def calendar_window(args): """The date range FullCalendar is asking about, if it said. A URL event source appends `start` and `end` automatically, in ISO 8601 with an offset (`2026-08-01T00:00:00-04:00`). Only the date part is needed here, and a value that does not parse is treated as absent rather than as an error: a calendar that shows too much is a performance problem, one that 400s is a broken page. Args: args: request.args. Returns: tuple[date | None, date | None]: Inclusive bounds. """ def _parse(value): if not value: return None try: return datetime.strptime(value[:10], '%Y-%m-%d').date() except (ValueError, TypeError): return None return _parse(args.get('start')), _parse(args.get('end')) @matches_bp.route('/api/events') @login_required def api_events(): """Calendar events for FullCalendar. Bounded and batched (PERF-002). This used to walk `tryout.matches` for every visible tryout — every tryout the club has ever run, for a president — and then issue one MatchParticipant query per match to find out whether the viewer was in it. The calendar's cost grew with the whole history, on every navigation. """ events = [] tryouts = get_visible_tryouts_for_user() tryouts_by_id = {tryout.id: tryout for tryout in tryouts} if tryouts_by_id: window_start, window_end = calendar_window(request.args) query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id)) if window_start: query = query.filter(Match.date >= window_start) if window_end: query = query.filter(Match.date <= window_end) matches = query.all() # Participants for every match in the window, in one query rather # than one per match. `participants` is a dynamic relationship, so # eager loading options do not apply to it. match_ids = [match.id for match in matches] participants_by_match = {} mine_by_match = {} if match_ids: rows = ( MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids)) .options(joinedload(MatchParticipant.player)) .all() ) for row in rows: participants_by_match.setdefault(row.match_id, []).append(row) if row.player_id == current_user.id: mine_by_match[row.match_id] = row for match in matches: tryout = tryouts_by_id[match.tryout_id] 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 = [ p.player.username if p.player else 'Unknown Player' for p in participants_by_match.get(match.id, []) ] 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 = mine_by_match.get(match.id) 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', '') def rerender(): return render_template( 'pages/match_form.html', tryout=tryout, teams=teams, all_players=all_players, prefill_date=prefill_date, ) if request.method == 'POST': payload = match_form_payload() # A tryout match with no date of its own happens on the tryout's day. payload.setdefault('date', tryout.date.isoformat()) try: data = MatchSchema().load(payload) except ValidationError as err: flash_validation_errors(err) return rerender() match = Match( tryout_id=tryout_id, title=data['title'], description=data['description'], date=data['date'], start_time=data['start_time'], end_time=data['end_time'] or default_end_time(data['date'], data['start_time']), location=data['location'], match_type=data['match_type'], created_by=current_user.id, ) db.session.add(match) db.session.flush() if data['match_type'] == 'team_vs_team': match.team1_id = data['team1_id'] match.team2_id = data['team2_id'] notified_player_ids, notified_participant_ids = create_participants(match, data) db.session.commit() notify_participants( title=match.title, date=match.date, start_time=match.start_time, end_time=match.end_time, participants=zip_participants(notified_player_ids, notified_participant_ids), fallback_id=match.id, ) flash(_('Match scheduled successfully!'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) return rerender() @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()] def rerender(): """The form, with everything the template needs. One context, used by the GET and by a rejected POST alike. The rejection paths used to pass a shorter list, and match_form.html serialises participants_map into a