"""Tryout management routes for creating, viewing, and managing tryout events. This module handles CRUD operations for tryouts and player registrations. Uses polymorphic isinstance checks instead of role-string comparisons. """ from datetime import datetime from flask import Blueprint, abort, flash, 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 app.extensions import db from app.forms import flash_validation_errors, form_payload from app.models import ( ESPORT_GAMES, GAME_POSITIONS, Admin, Coach, Evaluation, Manager, Match, MatchParticipant, OrgTeam, PersonalNote, Player, Scout, Team, TeamMember, Tryout, TryoutRegistration, User, ) from app.validators import TryoutSchema tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts') def can_manage(): """Check if current user can manage tryouts (Admin or Manager).""" return isinstance(current_user, (Admin, Manager)) def tryout_form_payload(): """The tryout form, shaped for marshmallow (ARCH-005).""" return form_payload(list_fields=('coach_ids',), optional_blank=()) def coaches_from_ids(coach_ids): """The coach accounts behind these ids. Filtered by role, which the previous `User.id.in_(...)` was not: the form posts a list of ids and nothing stopped a hand-made submission from naming a player, who then appeared as a coach of the tryout and inherited every permission that comes with it. """ if not coach_ids: return [] return User.query.filter(User.id.in_(coach_ids), User.role == 'coach').all() def _users_by_id(user_ids): """Load these users in one query, keyed by id. Replaces the `User.query.get()`-inside-a-loop that view_tryout used in three separate places (PERF-001). Missing ids are simply absent from the result, which is what a per-row get() returning None amounted to. Args: user_ids: Iterable of primary keys, may repeat and may be empty. Returns: dict[int, User] """ wanted = {user_id for user_id in user_ids if user_id} if not wanted: return {} return {user.id: user for user in User.query.filter(User.id.in_(wanted)).all()} @tryouts_bp.route('') @login_required def list_tryouts(): """List all tryouts visible to the current user. Delegates to the polymorphic User subclass's get_visible_tryouts() method. """ tryouts = current_user.get_visible_tryouts() return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow()) @tryouts_bp.route('/create', methods=['GET', 'POST']) @login_required def create_tryout(): """Create a new tryout event. Requires Admin or Manager.""" if not can_manage(): flash(_('You do not have permission to create tryouts.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) org_teams = OrgTeam.query.order_by(OrgTeam.name).all() managers = ( User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all() ) coaches = ( User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all() ) def rerender(): return render_template( 'pages/tryout_form.html', tryout=None, org_teams=org_teams, managers=managers, coaches=coaches, esport_games=ESPORT_GAMES, ) if request.method == 'POST': try: data = TryoutSchema().load(tryout_form_payload()) except ValidationError as err: flash_validation_errors(err) return rerender() tryout = Tryout( title=data['title'], description=data['description'], game=data['game'], date=data['date'], end_date=data['end_date'], location=data['location'], max_players=data['max_players'], created_by=current_user.id, status='upcoming', target_org_team_id=data['target_org_team_id'], manager_id=data['manager_id'], ) db.session.add(tryout) db.session.flush() tryout.coaches = coaches_from_ids(data['coach_ids']) db.session.commit() flash(_('Tryout created successfully!'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return rerender() @tryouts_bp.route('//edit', methods=['GET', 'POST']) @login_required def edit_tryout(tryout_id): """Edit an existing tryout event. Permission based on can_manage_this_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 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() ) def rerender(): return render_template( 'pages/tryout_form.html', tryout=tryout, org_teams=org_teams, managers=managers, coaches=coaches, esport_games=ESPORT_GAMES, ) if request.method == 'POST': try: data = TryoutSchema().load(tryout_form_payload()) except ValidationError as err: flash_validation_errors(err) return rerender() tryout.title = data['title'] tryout.description = data['description'] tryout.game = data['game'] tryout.date = data['date'] tryout.end_date = data['end_date'] tryout.location = data['location'] tryout.max_players = data['max_players'] tryout.target_org_team_id = data['target_org_team_id'] tryout.manager_id = data['manager_id'] tryout.coaches = coaches_from_ids(data['coach_ids']) db.session.commit() flash(_('Tryout updated successfully!'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id)) return rerender() @tryouts_bp.route('/') @login_required def view_tryout(tryout_id): """View a specific tryout with all details. Permission via polymorphic dispatch.""" tryout = Tryout.query.get_or_404(tryout_id) can_view = False if isinstance(current_user, Admin): 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): is_registered = ( TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=current_user.id ).first() is not None ) player_in_match = ( MatchParticipant.query.join(Match) .filter( MatchParticipant.player_id == current_user.id, Match.tryout_id == tryout_id, ) .first() is not None ) can_view = is_registered or player_in_match elif isinstance(current_user, Scout): can_view = True if not can_view: flash(_('You do not have permission to view this tryout.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) # Everything below used to run one query per row (PERF-001): one # User.query.get() per registration, one Evaluation lookup per player, # one TeamMember query per team and one more User.query.get() per # member. Thirty registrants and four teams put this page well past a # hundred round trips, on unindexed columns. registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all() registered_player_ids = [r.player_id for r in registrations if r.player_id] players_by_id = _users_by_id(registered_player_ids) registered_players = [ players_by_id[player_id] for player_id in registered_player_ids if player_id in players_by_id ] evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all() player_eval_status = {} if current_user.can_evaluate(): evaluated_by_me = { row.player_id for row in evaluations if row.evaluator_id == current_user.id and row.player_id } player_eval_status = {p.id: p.id in evaluated_by_me for p in registered_players} is_registered = ( TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=current_user.id, ).first() is not None ) teams = Team.query.filter_by(tryout_id=tryout_id).all() team_ids = [team.id for team in teams] members_by_team = {} if team_ids: member_rows = TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).all() member_players = _users_by_id([m.player_id for m in member_rows if m.player_id]) for row in member_rows: members_by_team.setdefault(row.team_id, []).append( {'player': member_players.get(row.player_id), 'position': row.position} ) team_data = [{'team': team, 'members': members_by_team.get(team.id, [])} for team in teams] can_edit = current_user.can_manage_this_tryout(tryout) can_view_calendar = can_edit if isinstance(current_user, Player): player_in_match = ( MatchParticipant.query.join(Match) .filter( MatchParticipant.player_id == current_user.id, Match.tryout_id == tryout_id, ) .first() is not None ) can_view_calendar = is_registered or player_in_match all_players = None if can_edit: # is_active_account, like the manager and coach queries in this same # module. Offering a deactivated account in a roster select # contradicts the one control that says the person has left. all_players = ( User.query.filter_by(role='player', is_active_account=True) .order_by(User.username) .all() ) matches = ( Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all() ) match_data = [] for match in matches: all_participants = list(match.participants.all()) confirmed_count = sum(1 for p in all_participants if p.attendance_confirmed) total_count = len(all_participants) player_presence = [] for p in all_participants: if p.player: player_presence.append( { 'participant_id': p.id, 'player_id': p.player_id, 'player_name': p.player.username, 'attendance_confirmed': p.attendance_confirmed, } ) if match.match_type == 'team_vs_team': participants = { 'team1': match.team1.name if match.team1 else 'TBD', 'team2': match.team2.name if match.team2 else 'TBD', 'team1_players': [ {'name': m.player.username, 'position': m.position} for m in match.team1.members.all() ] if match.team1 else [], 'team2_players': [ {'name': m.player.username, 'position': m.position} for m in match.team2.members.all() ] if match.team2 else [], } elif match.match_type == 'player_vs_player': # Filtered from the list already in hand. Asking the dynamic # relationship again cost two more round trips per match for # rows that were loaded a dozen lines above. team1_players = [ {'name': p.player.username, 'position': p.position} for p in all_participants if p.team_side == 1 and p.player ] team2_players = [ {'name': p.player.username, 'position': p.position} for p in all_participants if p.team_side == 2 and p.player ] participants = { 'team1': 'Team 1', 'team2': 'Team 2', 'team1_players': team1_players, 'team2_players': team2_players, } else: participants = [p.player.username for p in match.participants.all()] match_data.append( { 'match': match, 'participants': participants, 'confirmed_count': confirmed_count, 'total_count': total_count, 'player_presence': player_presence, } ) return render_template( 'pages/view_tryout.html', tryout=tryout, registered_players=registered_players, evaluations=evaluations, player_eval_status=player_eval_status, is_registered=is_registered, registrations=registrations, team_data=team_data, can_edit=can_edit, can_view_calendar=can_view_calendar, all_players=all_players, matches=matches, match_data=match_data, game_positions=GAME_POSITIONS, now=datetime.utcnow(), ) @tryouts_bp.route('//register', methods=['POST']) @login_required def register_for_tryout(tryout_id): """Register a player for a tryout. Only Players can self-register.""" tryout = Tryout.query.get_or_404(tryout_id) if not isinstance(current_user, Player): flash(_('Only players can register for tryouts.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if tryout.status not in ['upcoming', 'in_progress']: flash(_('This tryout is not accepting registrations.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) existing = TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=current_user.id ).first() if existing: flash(_('You are already registered for this tryout.'), 'info') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if tryout.max_players: count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() if count >= tryout.max_players: flash(_('This tryout is full.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id) db.session.add(registration) db.session.commit() flash(_('Successfully registered for tryout!'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//status', methods=['POST']) @login_required def update_status(tryout_id): """Update the status of a tryout.""" tryout = Tryout.query.get_or_404(tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) new_status = request.form.get('status') if new_status in ['upcoming', 'in_progress', 'completed']: tryout.status = new_status db.session.commit() flash(_('Tryout status updated to %(new_status)s.', new_status=new_status), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//registration//status', methods=['POST']) @login_required def update_registration_status(tryout_id, player_id): """Update a registration's attendance status.""" tryout = Tryout.query.get_or_404(tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) registration = TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=player_id ).first_or_404() new_status = request.form.get('status') if new_status in ['registered', 'attended', 'no_show']: registration.status = new_status db.session.commit() flash(_('Registration status updated.'), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//register_player', methods=['POST']) @login_required def register_player(tryout_id): """Manually register a player for a tryout (by managers/coaches).""" tryout = Tryout.query.get_or_404(tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) player_id = request.form.get('player_id') if not player_id: flash(_('Please select a player.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) player = User.query.get_or_404(int(player_id)) if not isinstance(player, Player): flash(_('Can only register players.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) existing = TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player.id).first() if existing: flash( _('%(username)s is already registered for this tryout.', username=player.username), 'info', ) return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if tryout.max_players: count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count() if count >= tryout.max_players: flash(_('This tryout is full.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id) db.session.add(registration) db.session.commit() flash(_('%(username)s registered for tryout!', username=player.username), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//remove_player/', methods=['POST']) @login_required def remove_player(tryout_id, player_id): """Remove a registered player from a tryout (cascades to teams/matches).""" tryout = Tryout.query.get_or_404(tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) player = User.query.get_or_404(player_id) registration = TryoutRegistration.query.filter_by( tryout_id=tryout_id, player_id=player_id ).first() if registration: db.session.delete(registration) 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), TeamMember.player_id == player_id, ).delete(synchronize_session=False) 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), MatchParticipant.player_id == player_id, ).delete(synchronize_session=False) db.session.commit() flash(_('%(username)s removed from tryout.', username=player.username), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team/create', methods=['POST']) @login_required def create_team(tryout_id): """Create a tryout-specific team.""" tryout = Tryout.query.get_or_404(tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) team_name = request.form.get('team_name') if team_name: team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id) db.session.add(team) db.session.commit() flash(_('Team "%(team_name)s" created!', team_name=team_name), 'success') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team//add', methods=['POST']) @login_required def add_to_team(tryout_id, team_id): """Add a player to a tryout team.""" team = Team.query.get_or_404(team_id) tryout = Tryout.query.get_or_404(tryout_id) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) # The two ids arrive independently in the URL. Without this check, being # allowed to manage tryout A was enough to modify a team belonging to # tryout B, since only the tryout was authorised. if team.tryout_id != tryout_id: abort(404) player_id = request.form.get('player_id', type=int) if not player_id: flash(_('Please select a player.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) # Only players registered for this tryout may be placed on its teams. is_registered = ( TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first() is not None ) if not is_registered: flash(_('That player is not registered for this tryout.'), 'danger') return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id)) position = request.form.get('position', '') existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first() if existing: flash(_('Player is already on this team.'), 'info') else: member = TeamMember(team_id=team_id, player_id=player_id, position=position) db.session.add(member) db.session.commit() flash(_('Player added to team!'), 'success') 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')) match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()] team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()] # Personal notes outlive the tryout: they are a coach's observations # about a player, not tryout data. Only their context links are cleared. # Missing this step made the deletion fail on the foreign keys below. PersonalNote.query.filter_by(tryout_id=tryout_id).update( {'tryout_id': None}, synchronize_session=False ) if match_ids: PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update( {'match_id': None}, synchronize_session=False ) if team_ids: PersonalNote.query.filter(PersonalNote.team_id.in_(team_ids)).update( {'team_id': None}, synchronize_session=False ) if match_ids: MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids)).delete( synchronize_session=False ) Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False) if team_ids: TeamMember.query.filter(TeamMember.team_id.in_(team_ids)).delete(synchronize_session=False) Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False) TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete() 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'))