"""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 app.extensions import db from app.models import ( Admin, Manager, Coach, Player, Scout, User, Tryout, Match, MatchParticipant, Team, TeamMember, TryoutRegistration, PlayerDisponibility, OneOnOneRequest, ) 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' match_desc = match.description or '' 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)}" match_desc = participants_str + (f"
{match.description}" if match.description else '') 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' match_desc = participants_str + (f"
{match.description}" if match.description else '') 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_desc, '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)) 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//