"""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 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 sqlalchemy import select from app.extensions import db from app.forms import flash_validation_errors, form_payload from app.models import ( ESPORT_GAMES, GAME_POSITIONS, Admin, AppSettings, Coach, Evaluation, Manager, Match, MatchParticipant, OrgTeam, PersonalNote, Player, Scout, Team, TeamMember, Tryout, TryoutRegistration, User, ) from app.responders import respond_after_change from app.time_utils import utc_now_naive from app.validators import ( PlayerSelectionSchema, TryoutRegistrationStatusSchema, TryoutSchema, TryoutStatusSchema, TryoutTeamMemberPositionSchema, TryoutTeamMemberSchema, TryoutTeamSchema, ) 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 tryouts_locked(): """Return True when tryouts are globally closed to coaches/managers. Admins are always allowed to bypass the lock. Coaches and managers can only make changes when the global tryout switch is open. """ if isinstance(current_user, Admin): return False return not AppSettings.get_bool('tryouts_open', default=True) def tryout_form_payload(): """The tryout form, shaped for marshmallow (ARCH-005).""" return form_payload(list_fields=('coach_ids', 'manager_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 managers_from_ids(manager_ids): """The manager accounts behind these ids, filtered by role.""" if not manager_ids: return [] return User.query.filter(User.id.in_(manager_ids), User.role == 'manager').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()} def registration_lock_statement(tryout_id): """The PostgreSQL row lock used by both registration entry points.""" return select(Tryout).where(Tryout.id == tryout_id).with_for_update() def locked_tryout_or_404(tryout_id): """Load and row-lock a tryout while a registration slot is decided. PostgreSQL serializes concurrent registration attempts on this row. The duplicate check, capacity count and insert that follow therefore form one decision instead of three independently racing statements. SQLite ignores ``FOR UPDATE`` in tests, but production does not. """ tryout = db.session.execute(registration_lock_statement(tryout_id)).scalar_one_or_none() if tryout is None: abort(404) return tryout @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=utc_now_naive()) @tryouts_bp.route('/create', methods=['GET', 'POST']) @login_required def create_tryout(): """Create a new tryout event. Requires Admin or Manager.""" if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return redirect(url_for('tryouts.list_tryouts')) 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'], ) db.session.add(tryout) db.session.flush() tryout.coaches = coaches_from_ids(data['coach_ids']) tryout.managers = managers_from_ids(data['manager_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 = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return redirect(url_for('tryouts.view_tryout', tryout_id=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'] # Only update staff lists when the form explicitly sends them. # An absent checkbox group (all unchecked or JS failed) means # "don't change", not "remove everyone". if 'coach_ids' in request.form: tryout.coaches = coaches_from_ids(data['coach_ids']) if 'manager_ids' in request.form: tryout.managers = managers_from_ids(data['manager_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 = db.get_or_404(Tryout, tryout_id) can_view = False if isinstance(current_user, Admin): can_view = True elif isinstance(current_user, (Manager, 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, 'member_id': row.id, 'player_id': row.player_id, } ) 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=utc_now_naive(), ) @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.""" if not isinstance(current_user, Player): flash(_('Only players can register for tryouts.'), 'danger') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) tryout = locked_tryout_or_404(tryout_id) if tryout.status not in ['upcoming', 'in_progress']: flash(_('This tryout is not accepting registrations.'), 'danger') return respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) try: data = TryoutStatusSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) tryout.status = data['status'] db.session.commit() flash(_('Tryout status updated to %(new_status)s.', new_status=data['status']), 'success') return respond_after_change(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 = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=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() try: data = TryoutRegistrationStatusSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) registration.status = data['status'] db.session.commit() flash(_('Registration status updated.'), 'success') return respond_after_change(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 = locked_tryout_or_404(tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) try: data = PlayerSelectionSchema().load(form_payload()) except ValidationError as err: flash_validation_errors(err) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not data['player_id']: flash(_('Please select a player.'), 'danger') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) # Same two checks as the team roster (SEC-16): the right role, and an # account that has not been deactivated. The select this comes from now # filters both, but the select is not the control. player = db.session.get(User, data['player_id']) if not player or not isinstance(player, Player) or not player.is_active_account: flash(_('Can only register players.'), 'danger') return respond_after_change(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 respond_after_change(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 respond_after_change(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 respond_after_change(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 = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return redirect(url_for('tryouts.list_tryouts')) player = db.get_or_404(User, 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 respond_after_change(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 = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) try: data = TryoutTeamSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) team = Team(tryout_id=tryout_id, name=data['team_name'], created_by=current_user.id) db.session.add(team) db.session.commit() flash(_('Team "%(team_name)s" created!', team_name=data['team_name']), 'success') return respond_after_change(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 = db.get_or_404(Team, team_id) tryout = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return respond_after_change(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) try: data = TryoutTeamMemberSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) player_id = data['player_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 respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) 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=data['position']) db.session.add(member) db.session.commit() flash(_('Player added to team!'), 'success') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team//edit', methods=['POST']) @login_required def edit_team(tryout_id, team_id): """Rename a tryout-specific team.""" team = db.get_or_404(Team, team_id) tryout = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if team.tryout_id != tryout_id: abort(404) try: data = TryoutTeamSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) team.name = data['team_name'] db.session.commit() flash(_('Team renamed to "%(team_name)s".', team_name=data['team_name']), 'success') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team//delete', methods=['POST']) @login_required def delete_team(tryout_id, team_id): """Delete a tryout-specific team and its memberships.""" team = db.get_or_404(Team, team_id) tryout = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if team.tryout_id != tryout_id: abort(404) name = team.name # Personal note references outlive the team. PersonalNote.query.filter_by(team_id=team_id).update( {'team_id': None}, synchronize_session=False ) # Matches referencing this team: null the FK so the match survives. Match.query.filter_by(team1_id=team_id).update({'team1_id': None}, synchronize_session=False) Match.query.filter_by(team2_id=team_id).update({'team2_id': None}, synchronize_session=False) # Clean participants for those matches (they are no longer in that team). for match in Match.query.filter( db.or_(Match.team1_id == team_id, Match.team2_id == team_id) ).all(): MatchParticipant.query.filter_by(match_id=match.id).delete(synchronize_session=False) TeamMember.query.filter_by(team_id=team_id).delete(synchronize_session=False) db.session.delete(team) db.session.commit() flash(_('Team "%(name)s" deleted.', name=name), 'success') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route('//team//remove/', methods=['POST']) @login_required def remove_from_team(tryout_id, team_id, player_id): """Remove a player from a tryout team.""" team = db.get_or_404(Team, team_id) tryout = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if team.tryout_id != tryout_id: abort(404) TeamMember.query.filter_by(team_id=team_id, player_id=player_id).delete( synchronize_session=False ) # Also remove the player from any match participants that were tied to this team. 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(_('Player removed from team.'), 'success') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) @tryouts_bp.route( '//team//member//position', methods=['POST'] ) @login_required def update_member_position(tryout_id, team_id, member_id): """Set or clear a team member's in-game position (role).""" team = db.get_or_404(Team, team_id) tryout = db.get_or_404(Tryout, tryout_id) member = db.get_or_404(TeamMember, member_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if not current_user.can_manage_this_tryout(tryout): flash(_('Permission denied.'), 'danger') return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) if team.tryout_id != tryout_id or member.team_id != team_id: abort(404) try: data = TryoutTeamMemberPositionSchema().load(form_payload(list_fields=())) except ValidationError as err: flash_validation_errors(err) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id)) member.position = data['position'] or None db.session.commit() flash(_('Position updated.'), 'success') return respond_after_change(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 = db.get_or_404(Tryout, tryout_id) if tryouts_locked(): flash( 'Tryouts are currently closed. An admin must open tryouts before changes can be made.', 'danger', ) return respond_after_change(url_for('tryouts.view_tryout', tryout_id=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'))