"""Match scheduling routes for managing scrimmages and matches within tryouts.
This module handles calendar views, match creation, and player availability.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from extensions import db
from models import User, Tryout, Match, MatchParticipant, Team, TeamMember, OrgTeam, TryoutRegistration, PlayerDisponibility
from datetime import datetime, time, timedelta
from discord_bot import send_schedule_notification
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
def can_schedule_match():
"""Check if user can schedule matches (coaches and above).
Returns:
bool: True if user is president, manager, coach, or scout.
"""
return current_user.role in ['president', 'manager', 'coach', 'scout']
@matches_bp.route('/calendar')
@login_required
def calendar():
"""Render the calendar view showing all tryouts and matches.
Returns:
Response: Rendered calendar template.
"""
return render_template('pages/calendar.html')
@matches_bp.route('/api/events')
@login_required
def api_events():
"""API endpoint returning calendar events for FullCalendar.
Returns tryout events and match events with participant information.
Returns:
Response: JSON array of calendar events.
"""
events = []
# Get tryouts based on user permissions (this already filters by user's role)
tryouts = get_visible_tryouts_for_user()
for tryout in tryouts:
events.append({
'id': f'tryout_{tryout.id}',
'title': tryout.title,
'date': tryout.date.strftime('%Y-%m-%d'),
'type': 'tryout',
'color': '#3b82f6', # Blue for tryouts
'extendedProps': {
'location': tryout.location or 'TBD',
'status': tryout.status,
'description': tryout.description or '',
'tryout_id': tryout.id
}
})
# Add matches for this tryout
for match in tryout.matches:
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
# Build match description with participants
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 scrim - show all participants
player_names = []
for p in match.participants.all():
player_name = p.player.full_name if p.player else 'Unknown Player'
player_names.append(player_name)
participants_str = ', '.join(player_names) if player_names else 'No players'
match_desc = participants_str + (f" {match.description}" if match.description else '')
# Include time for calendar display
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,
'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
}
})
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.
Args:
tryout_id: The ID of the tryout to get events for.
Returns:
Response: JSON array of calendar events for the tryout.
"""
tryout = Tryout.query.get_or_404(tryout_id)
# Check if user can view this tryout
can_view = current_user.can_manage_this_tryout(tryout)
# For players, check if they're registered or participating in a match
is_registered = False
player_in_match = False
if current_user.role == 'player':
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id
).first() is not None
# Check if player is participating in any matches for this tryout
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
# Non-participating players cannot see the calendar
if not can_view and not is_registered and not player_in_match:
return jsonify([])
events = []
# Add tryout date as an event (read-only, for context)
events.append({
'id': f'tryout_{tryout.id}',
'title': f'Tryout: {tryout.title}',
'date': tryout.date.strftime('%Y-%m-%d'),
'type': 'tryout',
'color': '#3b82f6', # Blue for tryouts
'extendedProps': {
'location': tryout.location or 'TBD',
'status': tryout.status,
'description': tryout.description or '',
'tryout_id': tryout.id
}
})
for match in tryout.matches:
# Determine color based on match type
if match.match_type == 'team_vs_team' or match.match_type == 'player_vs_player':
match_color = '#10b981' # Green for team matches
else:
match_color = '#f59e0b' # Orange for scrims
# Build participant string with proper grouping
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':
# Get players grouped by team side
team1_players = []
for p in match.participants.filter_by(team_side=1).all():
if p.player:
team1_players.append(p.player.full_name)
team2_players = []
for p in match.participants.filter_by(team_side=2).all():
if p.player:
team2_players.append(p.player.full_name)
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 = []
for p in match.participants.all():
player_name = p.player.full_name if p.player else 'Unknown Player'
player_names.append(player_name)
participants_str = ', '.join(player_names) if player_names else 'No players'
# Include time for calendar display
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)
def get_visible_tryouts_for_user():
"""Get tryouts that the current user can see based on their role.
Permission hierarchy:
- President: All tryouts
- Manager: Only their created tryouts
- Coach: Tryouts targeting their org team
- Player: Tryouts they're registered for or matches they're in
- Scout: All tryouts
Returns:
list: Query result of Tryout objects.
"""
if current_user.role == 'president':
return Tryout.query.order_by(Tryout.date).all()
elif current_user.role == 'manager':
return Tryout.query.filter_by(created_by=current_user.id).order_by(Tryout.date).all()
elif current_user.role == 'coach':
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
if org_team:
return Tryout.query.filter_by(target_org_team_id=org_team.id).order_by(Tryout.date).all()
return []
elif current_user.role == 'player':
# Get tryouts player is registered for
player_tryout_ids = [r.tryout_id for r in current_user.tryout_registrations.all()]
tryouts = Tryout.query.filter(Tryout.id.in_(player_tryout_ids)).order_by(Tryout.date).all() if player_tryout_ids else []
# Also include matches where player is participating
player_matches = Match.query.join(MatchParticipant).filter(
MatchParticipant.player_id == current_user.id
).all()
player_match_tryout_ids = list(set(m.tryout_id for m in player_matches))
additional_tryouts = Tryout.query.filter(
Tryout.id.in_(player_match_tryout_ids)
).order_by(Tryout.date).all() if player_match_tryout_ids else []
# Combine and deduplicate
all_tryouts = tryouts + [t for t in additional_tryouts if t.id not in player_tryout_ids]
return all_tryouts
else: # scout
return Tryout.query.order_by(Tryout.date).all()
@matches_bp.route('/create/', methods=['GET', 'POST'])
@login_required
def create_match(tryout_id):
"""Create a new match/scrimmage within a tryout.
GET: Render the match creation form.
POST: Create a match with submitted details.
Args:
tryout_id: The ID of the tryout to create the match for.
Returns:
Response: Create form or redirect to tryout view.
"""
tryout = Tryout.query.get_or_404(tryout_id)
# Check if user can manage this tryout (president, manager, or coach)
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))
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.full_name)
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')
# Start time is now mandatory
if not start_time_str:
flash('Start time is required. Please select a time slot.', 'danger')
return render_template('pages/create_match.html', tryout=tryout, teams=teams, all_players=all_players)
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/create_match.html', tryout=tryout, teams=teams, all_players=all_players)
start_time = None
end_time = None
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
# Auto-calculate end time if not provided (start + 30 minutes)
if end_time_str:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
# Auto-calculate end time as start + 30 minutes
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/create_match.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() # Get match.id before commit
# Collect player IDs for Discord notifications
notified_player_ids = []
# Handle team vs team matches
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
# Get players from both teams
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()])
# Handle player vs player matches
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 []
notified_participant_ids = []
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
# Handle player scrim matches
elif match_type == 'player_scrim':
player_ids = request.form.getlist('player_ids')
notified_participant_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()
# Send Discord notifications to players (one per participant for proper attendance tracking)
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 team_vs_team, notify all players but use match.id (no individual confirmation)
# For player_vs_player/player_scrim, notify each player individually with participant ID
if match_type == 'team_vs_team':
for player_id in notified_player_ids:
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=match.id
)
else:
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/create_match.html', tryout=tryout, teams=teams, all_players=all_players)
@matches_bp.route('//edit', methods=['GET', 'POST'])
@login_required
def edit_match(match_id):
"""Edit an existing match.
GET: Render the match edit form.
POST: Update match with submitted changes.
Args:
match_id: The ID of the match to edit.
Returns:
Response: Edit form or redirect to tryout view.
"""
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'))
teams = Team.query.filter_by(tryout_id=tryout.id).all()
all_players = User.query.filter_by(role='player').order_by(User.full_name).all()
current_player_ids = [p.player_id for p in match.participants.all()]
# Get players grouped by team side for player_vs_player matches
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/edit_match.html', match=match, tryout=tryout, teams=teams, all_players=all_players, current_player_ids=current_player_ids)
# Start time is now mandatory
if not start_time_str:
flash('Start time is required. Please select a time slot.', 'danger')
return render_template('pages/edit_match.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()
# Auto-calculate end time if not provided (start + 30 minutes)
if end_time_str:
match.end_time = datetime.strptime(end_time_str, '%H:%M').time()
else:
# Auto-calculate end time as start + 30 minutes
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
# Collect player IDs for Discord notifications
notified_player_ids = []
# Handle team vs team matches
if match.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
# Get players from both teams
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()])
# Handle player vs player matches - update participants
elif match.match_type == 'player_vs_player':
MatchParticipant.query.filter_by(match_id=match.id).delete()
team1_player_ids = request.form.getlist('team1_player_ids')
team2_player_ids = request.form.getlist('team2_player_ids')
notified_participant_ids = []
for pid in team1_player_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 team2_player_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 team1_player_ids] + [int(p) for p in team2_player_ids]
# Handle player scrim matches - update participants
elif match.match_type == 'player_scrim':
MatchParticipant.query.filter_by(match_id=match.id).delete()
player_ids = request.form.getlist('player_ids')
notified_participant_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()
# Send Discord notifications to players
if match.match_type in ['player_vs_player', 'player_scrim']:
end_time_val = match.end_time if match.end_time else 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
)
elif match.match_type == 'team_vs_team':
event_date_str = match.date.strftime('%Y-%m-%d')
end_time_val = match.end_time if match.end_time else match.start_time if match.start_time else None
event_time_str = f"{match.start_time.strftime('%I:%M %p')} - {end_time_val.strftime('%I:%M %p')}" if match.start_time and end_time_val else 'TBD'
for player_id in notified_player_ids:
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=match.id
)
flash('Match updated successfully!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/edit_match.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)
@matches_bp.route('//delete', methods=['POST'])
@login_required
def delete_match(match_id):
"""Delete a match.
Args:
match_id: The ID of the match to delete.
Returns:
Response: Redirect to tryout view with status message.
"""
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'))
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.
Checks player disponibility records to find who is available
during the specified time block.
Args:
date_str: Date in YYYY-MM-DD format.
time_str: Time in HH:MM format.
Returns:
list: List of available player IDs.
"""
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
time_obj = datetime.strptime(time_str, '%H:%M').time()
except (ValueError, TypeError):
return []
# Calculate day of week (Python: 0=Monday, 6=Sunday)
# JavaScript: 0=Sunday, 6=Saturday, so we convert
date_parts = date_str.split('-')
date_for_day = datetime(int(date_parts[0]), int(date_parts[1]), int(date_parts[2]))
js_day = date_for_day.weekday()
# Convert Python weekday (Mon=0) to our format (Mon=0)
day_of_week = js_day
# Get all active players
players = User.query.filter_by(role='player', is_active_account=True).all()
available_players = []
for player in players:
# Check if player has disponibility at this time
disponibilities = PlayerDisponibility.query.filter_by(
player_id=player.id,
day_of_week=day_of_week
).all()
for disp in disponibilities:
# Check if time falls within disponibility block
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//