"""Team match management routes for regular season matches. Uses polymorphic isinstance checks instead of role-string comparisons. """ from flask import Blueprint, flash, jsonify, 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.api import json_endpoint from app.extensions import db from app.forms import flash_validation_errors, form_payload from app.models import ( Admin, AppSettings, Coach, Manager, OrgTeam, Player, TeamMatch, TeamMatchParticipant, TeamPlayer, ) from app.pagination import paginate from app.permissions import coach_org_teams, representative_org_teams, visible_org_teams from app.responders import respond_after_change from app.routes.matches import default_end_time from app.services.scheduling import notify_participants, zip_participants from app.time_utils import utc_now_naive from app.validators import TeamMatchSchema team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches') def can_manage_team_match(team): """Whether the current user can manage matches for this team. Everyone who administers the team, plus the player named as its responsible. Narrower than administering the team itself: a responsible schedules and edits matches, but does not touch the roster. """ return current_user.can_manage_team_matches(team) def season_locked(): """Return True when the regular season is inactive for non-admins. Admins bypass the lock. Coaches and managers may only schedule regular season matches while the season is active. """ if isinstance(current_user, Admin): return False return not AppSettings.get_bool('season_active', default=False) @team_matches_bp.route('') @login_required def list_matches(): """List all team matches visible to the current user.""" filter_team_id = request.args.get('team_id', type=int) # A manager administers every team, so the listing shows them all; # visible_org_teams() only reports the teams they are attached to. if isinstance(current_user, (Admin, Manager)): teams = OrgTeam.query.order_by(OrgTeam.name).all() matches_query = TeamMatch.query elif isinstance(current_user, (Coach, Player)): teams = visible_org_teams(current_user) team_ids = [t.id for t in teams] matches_query = ( TeamMatch.query.filter( TeamMatch.org_team_id.in_(team_ids), ) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1) ) else: teams = [] matches_query = TeamMatch.query.filter(TeamMatch.id == -1) if filter_team_id: matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id) # Pagination also bounds the per-match participant loop below, which is # the N+1 the constat pointed at (MNT-10 combined with MNT-14). matches_page = paginate(matches_query.order_by(TeamMatch.date.desc(), TeamMatch.id)) matches = matches_page.items match_data = [] for tm in matches: confirmed, total = tm.get_confirmed_count() participants = [] for p in tm.participants.all(): participants.append( { 'id': p.id, 'player': p.player, 'is_confirmed': p.is_confirmed, } ) match_data.append( { 'match': tm, 'participants': participants, 'confirmed_count': confirmed, 'total_count': total, } ) return render_template( 'pages/team_matches.html', teams=teams, match_data=match_data, pagination=matches_page, now=utc_now_naive(), ) @team_matches_bp.route('//create', methods=['GET', 'POST']) @login_required def create_match(team_id): """Create a new regular-season team match.""" team = db.get_or_404(OrgTeam, team_id) if season_locked(): flash( 'The regular season is not active. Begin a season before scheduling matches.', 'warning' ) return respond_after_change(url_for('team_matches.list_matches')) if not can_manage_team_match(team): flash(_('You do not have permission to schedule matches for this team.'), 'danger') return respond_after_change(url_for('team_matches.list_matches')) team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all() prefill_date = request.args.get('date', '') is_practice = request.args.get('type') == 'practice' default_title = 'Practice' if is_practice else f'Team Match — {team.name}' if is_practice and request.method == 'GET': class TryoutProxy: def __init__(self, team_obj): self.id = 0 self.title = team_obj.name self.date = '' self.game = '' self.target_org_team = team_obj proxy_tryout = TryoutProxy(team) all_players = [tp.player for tp in team_players if tp.player] return render_template( 'pages/match_form.html', tryout=proxy_tryout, teams=[], all_players=all_players, prefill_date=prefill_date, is_practice=True, team_id=team_id, team=team, ) if request.method == 'POST': payload = form_payload(list_fields=(), optional_blank=()) # A practice has no opponent, whatever the form sent. payload.setdefault('title', default_title) if is_practice: payload.pop('opponent', None) try: data = TeamMatchSchema().load(payload) except ValidationError as err: flash_validation_errors(err) return render_template( 'pages/team_match_form.html', team=team, team_players=team_players, prefill_date=prefill_date, is_practice=is_practice, ) start_time = data['start_time'] end_time = data['end_time'] or default_end_time(data['date'], start_time) team_match = TeamMatch( org_team_id=team_id, title=data['title'], description=data['description'], opponent=data['opponent'], date=data['date'], start_time=start_time, end_time=end_time, location=data['location'], created_by=current_user.id, ) db.session.add(team_match) db.session.flush() notified_participant_ids = [] for tp in team_players: participant = TeamMatchParticipant( team_match_id=team_match.id, player_id=tp.player_id, ) db.session.add(participant) db.session.flush() notified_participant_ids.append(participant.id) db.session.commit() notify_participants( title=team_match.title, date=team_match.date, start_time=start_time, end_time=end_time, participants=zip_participants( [tp.player_id for tp in team_players], notified_participant_ids ), fallback_id=team_match.id, ) flash( _('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success' ) return respond_after_change(url_for('team_matches.list_matches')) return render_template('pages/team_match_form.html', team=team, team_players=team_players) @team_matches_bp.route('//edit', methods=['GET', 'POST']) @login_required def edit_match(match_id): """Edit an existing team match.""" team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team if not can_manage_team_match(team): flash(_('You do not have permission to edit this match.'), 'danger') return respond_after_change(url_for('team_matches.list_matches')) if request.method == 'POST': # The three date and time fields used to be checked one at a time, # each flashing and redirecting on its own: a form with two mistakes # took two round trips to be told about both. One schema now, every # problem reported at once and in place. # # Known limit: the re-render reads the stored record, so what was # typed is not echoed back. Repopulating the form from the # submission is a separate change to the template. try: data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=())) except ValidationError as err: flash_validation_errors(err) return render_template( 'pages/team_match_form.html', match=team_match, team=team, team_players=[] ) team_match.title = data['title'] team_match.description = data['description'] team_match.opponent = data['opponent'] team_match.date = data['date'] team_match.start_time = data['start_time'] team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time']) team_match.location = data['location'] team_match.status = data['status'] db.session.commit() flash(_('Match updated successfully!'), 'success') return respond_after_change(url_for('team_matches.list_matches')) return render_template( 'pages/team_match_form.html', match=team_match, team=team, team_players=[] ) @team_matches_bp.route('//delete', methods=['POST']) @login_required def delete_match(match_id): """Delete a team match.""" team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team if season_locked(): flash( 'The regular season is not active. Begin a season before deleting matches.', 'warning' ) return respond_after_change(url_for('team_matches.list_matches')) if not can_manage_team_match(team): flash(_('You do not have permission to delete this match.'), 'danger') return respond_after_change(url_for('team_matches.list_matches')) db.session.delete(team_match) db.session.commit() flash(_('Match deleted successfully.'), 'success') return respond_after_change(url_for('team_matches.list_matches')) @team_matches_bp.route('/api/manageable-teams') @json_endpoint @login_required def api_manageable_teams(): """API endpoint returning teams the current user can schedule matches for.""" if isinstance(current_user, (Admin, Manager)): teams = OrgTeam.query.order_by(OrgTeam.name).all() elif isinstance(current_user, Coach): teams = coach_org_teams(current_user) elif isinstance(current_user, Player): # A responsible schedules matches for their own team(s) only. teams = representative_org_teams(current_user) else: return jsonify([]) return jsonify([{'id': t.id, 'name': t.name} for t in teams]) @team_matches_bp.route('//toggle-presence/', methods=['POST']) @json_endpoint @login_required def toggle_presence(match_id, participant_id): """Toggle is_confirmed for a team match participant.""" team_match = db.get_or_404(TeamMatch, match_id) team = team_match.org_team participant = db.get_or_404(TeamMatchParticipant, participant_id) if participant.team_match_id != match_id: return jsonify({'error': 'Participant does not belong to this match'}), 400 can_toggle = can_manage_team_match(team) or participant.player_id == current_user.id if not can_toggle: return jsonify({'error': 'Unauthorized'}), 403 participant.is_confirmed = not participant.is_confirmed db.session.commit() return jsonify( { 'participant_id': participant.id, 'is_confirmed': participant.is_confirmed, 'player_name': participant.player.username if participant.player else 'Unknown', } )