828 lines
31 KiB
Python
828 lines
31 KiB
Python
"""Match scheduling routes for managing scrimmages and matches within tryouts.
|
|
|
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
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 sqlalchemy.orm import joinedload
|
|
|
|
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,
|
|
Coach,
|
|
Manager,
|
|
Match,
|
|
MatchParticipant,
|
|
OneOnOneRequest,
|
|
PersonalNote,
|
|
Player,
|
|
PlayerDisponibility,
|
|
Scout,
|
|
Team,
|
|
TeamMatch,
|
|
TeamMember,
|
|
Tryout,
|
|
TryoutRegistration,
|
|
User,
|
|
)
|
|
from app.permissions import visible_org_teams
|
|
from app.responders import respond_after_change
|
|
from app.services.scheduling import notify_participants, zip_participants
|
|
from app.validators import MatchEditSchema, MatchSchema
|
|
|
|
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
|
|
|
|
|
|
def match_form_payload():
|
|
"""The match form, shaped for marshmallow.
|
|
|
|
`player_ids` is a repeated checkbox, so it needs getlist(); `games` — the
|
|
default list field — has nothing to do with this form.
|
|
"""
|
|
return form_payload(list_fields=('player_ids',), optional_blank=())
|
|
|
|
|
|
def registered_players(tryout_id):
|
|
"""Players registered for one tryout, loaded in a single query.
|
|
|
|
The create form previously called ``User.query.get`` twice per
|
|
registration (once in the filter and once in the result expression),
|
|
and the edit form called it once per row. Besides scaling linearly, both
|
|
paths could return duplicates while DB-006 is still pending. The join is
|
|
bounded and ``distinct`` preserves the form's intended one-option-per-
|
|
player contract until the database constraint lands.
|
|
"""
|
|
return (
|
|
User.query.join(TryoutRegistration, TryoutRegistration.player_id == User.id)
|
|
.filter(TryoutRegistration.tryout_id == tryout_id)
|
|
.order_by(User.username)
|
|
.distinct()
|
|
.all()
|
|
)
|
|
|
|
|
|
#: How long a match lasts when the form gives a start and no end.
|
|
DEFAULT_MATCH_MINUTES = 30
|
|
|
|
|
|
def default_end_time(date, start_time):
|
|
"""End time for a match whose form left it blank."""
|
|
return (datetime.combine(date, start_time) + timedelta(minutes=DEFAULT_MATCH_MINUTES)).time()
|
|
|
|
|
|
def calendar_event_time_fields(date, start_time, end_time):
|
|
"""FullCalendar date/time fields for a match.
|
|
|
|
A date-only event renders in the all-day row; a ``start``/``end`` with a
|
|
time places it on the clock for the same calendar day. Matches, team
|
|
matches and one-on-one sessions are stored as a date plus optional time
|
|
columns, so the feed translates that into the shape FullCalendar expects.
|
|
"""
|
|
if not start_time:
|
|
return {'date': date.strftime('%Y-%m-%d'), 'allDay': True}
|
|
fields = {
|
|
'start': f'{date.isoformat()}T{start_time.strftime("%H:%M:%S")}',
|
|
'allDay': False,
|
|
}
|
|
if end_time:
|
|
fields['end'] = f'{date.isoformat()}T{end_time.strftime("%H:%M:%S")}'
|
|
return fields
|
|
|
|
|
|
def create_participants(match, data):
|
|
"""Attach participants to a match, per its type.
|
|
|
|
Was written out twice, in create_match and in edit_match, and had already
|
|
drifted: the copy in edit_match kept its player ids as strings and called
|
|
int() on them one line later, the one in create_match did not (ARCH-005).
|
|
|
|
Returns:
|
|
tuple: (player ids to notify, the participant rows created).
|
|
"""
|
|
sides = []
|
|
if match.match_type == 'team_vs_team':
|
|
for side, team_id in ((1, match.team1_id), (2, match.team2_id)):
|
|
if team_id:
|
|
members = TeamMember.query.filter_by(team_id=team_id).all()
|
|
sides.append((side, [m.player_id for m in members]))
|
|
elif match.match_type == 'player_vs_player':
|
|
sides = [(1, data['team1_player_ids']), (2, data['team2_player_ids'])]
|
|
elif match.match_type == 'player_scrim':
|
|
sides = [(None, data['player_ids'])]
|
|
|
|
player_ids = []
|
|
participant_ids = []
|
|
for side, ids in sides:
|
|
for player_id in ids:
|
|
participant = MatchParticipant(match_id=match.id, player_id=player_id, team_side=side)
|
|
db.session.add(participant)
|
|
db.session.flush()
|
|
participant_ids.append(participant.id)
|
|
player_ids.append(player_id)
|
|
return player_ids, participant_ids
|
|
|
|
|
|
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')
|
|
|
|
|
|
def calendar_window(args):
|
|
"""The date range FullCalendar is asking about, if it said.
|
|
|
|
A URL event source appends `start` and `end` automatically, in ISO 8601
|
|
with an offset (`2026-08-01T00:00:00-04:00`). Only the date part is
|
|
needed here, and a value that does not parse is treated as absent
|
|
rather than as an error: a calendar that shows too much is a
|
|
performance problem, one that 400s is a broken page.
|
|
|
|
Args:
|
|
args: request.args.
|
|
|
|
Returns:
|
|
tuple[date | None, date | None]: Inclusive bounds.
|
|
"""
|
|
|
|
def _parse(value):
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.strptime(value[:10], '%Y-%m-%d').date()
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
return _parse(args.get('start')), _parse(args.get('end'))
|
|
|
|
|
|
@matches_bp.route('/api/events')
|
|
@json_endpoint
|
|
@login_required
|
|
def api_events():
|
|
"""Calendar events for FullCalendar.
|
|
|
|
Bounded and batched (PERF-002). This used to walk `tryout.matches` for
|
|
every visible tryout — every tryout the club has ever run, for a
|
|
president — and then issue one MatchParticipant query per match to find
|
|
out whether the viewer was in it. The calendar's cost grew with the
|
|
whole history, on every navigation.
|
|
|
|
Tryout matches, regular-season team matches and approved one-on-one
|
|
sessions share this feed. Timed rows carry `start`/`end` so
|
|
FullCalendar places them on the clock instead of the all-day row.
|
|
"""
|
|
events = []
|
|
tryouts = get_visible_tryouts_for_user()
|
|
window_start, window_end = calendar_window(request.args)
|
|
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
|
|
|
if tryouts_by_id:
|
|
query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
|
|
if window_start:
|
|
query = query.filter(Match.date >= window_start)
|
|
if window_end:
|
|
query = query.filter(Match.date <= window_end)
|
|
matches = query.all()
|
|
|
|
# Participants for every match in the window, in one query rather
|
|
# than one per match. `participants` is a dynamic relationship, so
|
|
# eager loading options do not apply to it.
|
|
match_ids = [match.id for match in matches]
|
|
participants_by_match = {}
|
|
mine_by_match = {}
|
|
if match_ids:
|
|
rows = (
|
|
MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids))
|
|
.options(joinedload(MatchParticipant.player))
|
|
.all()
|
|
)
|
|
for row in rows:
|
|
participants_by_match.setdefault(row.match_id, []).append(row)
|
|
if row.player_id == current_user.id:
|
|
mine_by_match[row.match_id] = row
|
|
|
|
for match in matches:
|
|
tryout = tryouts_by_id[match.tryout_id]
|
|
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
|
|
# 'description' used to be participants_str + '<br>' + description.
|
|
# Building presentation markup inside a JSON field is what carried
|
|
# the stored XSS: the browser dropped it straight into innerHTML,
|
|
# and player usernames travelled through it unescaped. The two
|
|
# values are already separate keys, so the concatenation also made
|
|
# the modal show the participants twice.
|
|
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 = ' vs '.join(teams)
|
|
else:
|
|
player_names = [
|
|
p.player.username if p.player else 'Unknown Player'
|
|
for p in participants_by_match.get(match.id, [])
|
|
]
|
|
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
|
|
|
|
user_participant = mine_by_match.get(match.id)
|
|
|
|
event = {
|
|
'id': f'match_{match.id}',
|
|
'title': match.title,
|
|
'type': 'match',
|
|
'color': match_color,
|
|
'extendedProps': {
|
|
'location': match.location or tryout.location or 'TBD',
|
|
'status': match.status,
|
|
'description': match.description or '',
|
|
'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,
|
|
},
|
|
}
|
|
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
|
|
events.append(event)
|
|
|
|
# Regular-season team matches: a separate table from tryout matches, but
|
|
# the same calendar. Visible to the roles that can see the org team.
|
|
if isinstance(current_user, (Admin, Manager)):
|
|
team_query = TeamMatch.query
|
|
elif isinstance(current_user, (Coach, Player)):
|
|
team_ids = [team.id for team in visible_org_teams(current_user)]
|
|
team_query = (
|
|
TeamMatch.query.filter(TeamMatch.org_team_id.in_(team_ids))
|
|
if team_ids
|
|
else TeamMatch.query.filter(TeamMatch.id == -1)
|
|
)
|
|
else:
|
|
team_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
|
|
|
if window_start:
|
|
team_query = team_query.filter(TeamMatch.date >= window_start)
|
|
if window_end:
|
|
team_query = team_query.filter(TeamMatch.date <= window_end)
|
|
|
|
for team_match in team_query.options(joinedload(TeamMatch.org_team)).all():
|
|
start_time_str = team_match.start_time.strftime('%H:%M') if team_match.start_time else None
|
|
end_time_str = team_match.end_time.strftime('%H:%M') if team_match.end_time else None
|
|
event = {
|
|
'id': f'team_match_{team_match.id}',
|
|
'title': team_match.title,
|
|
'type': 'team_match',
|
|
'color': '#3b82f6',
|
|
'extendedProps': {
|
|
'location': team_match.location or 'TBD',
|
|
'status': team_match.status,
|
|
'description': team_match.description or '',
|
|
'org_team': team_match.org_team.name if team_match.org_team else '',
|
|
'opponent': team_match.opponent or '',
|
|
'start_time': start_time_str,
|
|
'end_time': end_time_str,
|
|
},
|
|
}
|
|
event.update(
|
|
calendar_event_time_fields(team_match.date, team_match.start_time, team_match.end_time)
|
|
)
|
|
events.append(event)
|
|
|
|
# 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:
|
|
event = {
|
|
'id': f'one_on_one_{ooo.id}',
|
|
'title': f'1:1 - {ooo.player.full_name} & {ooo.coach.full_name}',
|
|
'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}",
|
|
},
|
|
}
|
|
event.update(calendar_event_time_fields(ooo.date, ooo.start_time, ooo.end_time))
|
|
events.append(event)
|
|
|
|
return jsonify(events)
|
|
|
|
|
|
@matches_bp.route('/api/events/<int:tryout_id>')
|
|
@json_endpoint
|
|
@login_required
|
|
def api_events_for_tryout(tryout_id):
|
|
"""API endpoint returning calendar events for a specific tryout."""
|
|
tryout = db.get_or_404(Tryout, 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
|
|
|
|
event = {
|
|
'id': f'match_{match.id}',
|
|
'title': match.title,
|
|
'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,
|
|
},
|
|
}
|
|
event.update(calendar_event_time_fields(match.date, match.start_time, match.end_time))
|
|
events.append(event)
|
|
|
|
return jsonify(events)
|
|
|
|
|
|
@matches_bp.route('/create/<int:tryout_id>', methods=['GET', 'POST'])
|
|
@login_required
|
|
def create_match(tryout_id):
|
|
"""Create a new match / scrimmage within a tryout."""
|
|
tryout = db.get_or_404(Tryout, 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 respond_after_change(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 respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
|
|
teams = Team.query.filter_by(tryout_id=tryout_id).all()
|
|
all_players = registered_players(tryout_id)
|
|
prefill_date = request.args.get('date', '')
|
|
|
|
def rerender():
|
|
return render_template(
|
|
'pages/match_form.html',
|
|
tryout=tryout,
|
|
teams=teams,
|
|
all_players=all_players,
|
|
prefill_date=prefill_date,
|
|
)
|
|
|
|
if request.method == 'POST':
|
|
payload = match_form_payload()
|
|
# A tryout match with no date of its own happens on the tryout's day.
|
|
payload.setdefault('date', tryout.date.isoformat())
|
|
|
|
try:
|
|
data = MatchSchema().load(payload)
|
|
except ValidationError as err:
|
|
flash_validation_errors(err)
|
|
return rerender()
|
|
|
|
match = Match(
|
|
tryout_id=tryout_id,
|
|
title=data['title'],
|
|
description=data['description'],
|
|
date=data['date'],
|
|
start_time=data['start_time'],
|
|
end_time=data['end_time'] or default_end_time(data['date'], data['start_time']),
|
|
location=data['location'],
|
|
match_type=data['match_type'],
|
|
created_by=current_user.id,
|
|
)
|
|
db.session.add(match)
|
|
db.session.flush()
|
|
|
|
if data['match_type'] == 'team_vs_team':
|
|
match.team1_id = data['team1_id']
|
|
match.team2_id = data['team2_id']
|
|
|
|
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
|
|
|
db.session.commit()
|
|
|
|
notify_participants(
|
|
title=match.title,
|
|
date=match.date,
|
|
start_time=match.start_time,
|
|
end_time=match.end_time,
|
|
participants=zip_participants(notified_player_ids, notified_participant_ids),
|
|
fallback_id=match.id,
|
|
)
|
|
|
|
flash(_('Match scheduled successfully!'), 'success')
|
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
|
|
|
return rerender()
|
|
|
|
|
|
@matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def edit_match(match_id):
|
|
"""Edit an existing match."""
|
|
match = db.get_or_404(Match, 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 respond_after_change(url_for('matches.calendar'))
|
|
|
|
if tryout.is_ended:
|
|
flash(_('This tryout has ended. Matches can no longer be created or modified.'), 'danger')
|
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
|
|
|
teams = Team.query.filter_by(tryout_id=tryout.id).all()
|
|
all_players = registered_players(tryout.id)
|
|
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()]
|
|
|
|
def rerender():
|
|
"""The form, with everything the template needs.
|
|
|
|
One context, used by the GET and by a rejected POST alike. The
|
|
rejection paths used to pass a shorter list, and match_form.html
|
|
serialises participants_map into a <script> block — so a rejected
|
|
edit died in `tojson` on an Undefined, turning a validation message
|
|
into a 500.
|
|
"""
|
|
participants_map = {
|
|
p.player_id: {
|
|
'participant_id': p.id,
|
|
'attendance_confirmed': p.attendance_confirmed,
|
|
'team_side': p.team_side,
|
|
}
|
|
for p in match.participants.all()
|
|
}
|
|
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,
|
|
)
|
|
|
|
if request.method == 'POST':
|
|
try:
|
|
data = MatchEditSchema().load(match_form_payload())
|
|
except ValidationError as err:
|
|
flash_validation_errors(err)
|
|
return rerender()
|
|
|
|
# Snapshot the date/time before we overwrite them, so we can detect
|
|
# whether only these fields changed and choose the right Discord message.
|
|
old_date = match.date
|
|
old_start = match.start_time
|
|
old_end = match.end_time
|
|
|
|
# Assigned only once the whole form has been accepted. Assigning as
|
|
# each field was read meant a form rejected halfway had already
|
|
# changed the record in the session.
|
|
match.title = data['title']
|
|
match.description = data['description']
|
|
match.date = data['date']
|
|
match.start_time = data['start_time']
|
|
match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
|
|
match.location = data['location']
|
|
match.status = data['status']
|
|
|
|
time_changed = (
|
|
match.date != old_date or match.start_time != old_start or match.end_time != old_end
|
|
)
|
|
|
|
# Build the desired participant set and reconcile it against the
|
|
# existing participants, rather than deleting and recreating everyone.
|
|
# This preserves attendance_confirmed for players who stay.
|
|
desired = set()
|
|
if match.match_type == 'team_vs_team':
|
|
match.team1_id = data['team1_id']
|
|
match.team2_id = data['team2_id']
|
|
for team_id in (data['team1_id'], data['team2_id']):
|
|
if team_id:
|
|
for member in TeamMember.query.filter_by(team_id=team_id).all():
|
|
desired.add(member.player_id)
|
|
elif match.match_type == 'player_vs_player':
|
|
for pid in data['team1_player_ids']:
|
|
desired.add(pid)
|
|
for pid in data['team2_player_ids']:
|
|
desired.add(pid)
|
|
else:
|
|
for pid in data['player_ids']:
|
|
desired.add(pid)
|
|
|
|
existing = {
|
|
p.player_id: p for p in MatchParticipant.query.filter_by(match_id=match.id).all()
|
|
}
|
|
|
|
removed_ids = []
|
|
notified_player_ids = []
|
|
notified_participant_ids = []
|
|
kept_ids = set()
|
|
|
|
for player_id, participant in list(existing.items()):
|
|
if player_id in desired:
|
|
kept_ids.add(player_id)
|
|
notified_player_ids.append(player_id)
|
|
notified_participant_ids.append(participant.id)
|
|
else:
|
|
removed_ids.append(participant.id)
|
|
db.session.delete(participant)
|
|
|
|
# New players get new participant rows.
|
|
for player_id in desired - set(existing.keys()):
|
|
participant = MatchParticipant(match_id=match.id, player_id=player_id)
|
|
db.session.add(participant)
|
|
db.session.flush()
|
|
notified_player_ids.append(player_id)
|
|
notified_participant_ids.append(participant.id)
|
|
kept_ids.add(player_id)
|
|
|
|
# When the date or time changed, reset attendance on everyone who
|
|
# stayed. They'll be asked to re-confirm via the modified message.
|
|
if time_changed:
|
|
for participant in MatchParticipant.query.filter_by(match_id=match.id).all():
|
|
if participant.player_id in kept_ids:
|
|
participant.attendance_confirmed = False
|
|
|
|
db.session.commit()
|
|
|
|
# Purge Discord pending entries for participants that no longer exist.
|
|
if removed_ids:
|
|
from app.discord import purge_pending_for_participants
|
|
|
|
purge_pending_for_participants(removed_ids)
|
|
|
|
# Decide whether and what to notify.
|
|
if time_changed:
|
|
# All current participants get the "modified" message.
|
|
all_current = list(zip(notified_player_ids, notified_participant_ids, strict=True))
|
|
notify_participants(
|
|
title=match.title,
|
|
date=match.date,
|
|
start_time=match.start_time,
|
|
end_time=match.end_time,
|
|
participants=all_current,
|
|
fallback_id=match.id,
|
|
change_kind='modified',
|
|
)
|
|
elif desired - set(existing.keys()):
|
|
# Only the newly added players are told about the match.
|
|
new_player_ids = desired - set(existing.keys())
|
|
new_pairs = [
|
|
(pid, nid)
|
|
for pid, nid in zip(notified_player_ids, notified_participant_ids, strict=True)
|
|
if pid in new_player_ids
|
|
]
|
|
notify_participants(
|
|
title=match.title,
|
|
date=match.date,
|
|
start_time=match.start_time,
|
|
end_time=match.end_time,
|
|
participants=new_pairs,
|
|
fallback_id=match.id,
|
|
change_kind='scheduled',
|
|
)
|
|
# else: nothing changed that needs a notification.
|
|
|
|
flash(_('Match updated successfully!'), 'success')
|
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
|
|
|
return rerender()
|
|
|
|
|
|
@matches_bp.route('/api/manageable-tryouts')
|
|
@json_endpoint
|
|
@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('/<int:match_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_match(match_id):
|
|
"""Delete a match."""
|
|
match = db.get_or_404(Match, 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 respond_after_change(url_for('matches.calendar'))
|
|
if tryout.is_ended:
|
|
flash(_('This tryout has ended. Matches can no longer be deleted.'), 'danger')
|
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
|
|
|
# Notes outlive the match they were taken during: a coach's observation
|
|
# keeps its value, and deleting it here would destroy unrelated content.
|
|
# Only the context link is dropped. Participants go through the
|
|
# relationship's delete-orphan cascade.
|
|
PersonalNote.query.filter_by(match_id=match_id).update(
|
|
{'match_id': None}, synchronize_session=False
|
|
)
|
|
|
|
db.session.delete(match)
|
|
db.session.commit()
|
|
flash(_('Match deleted successfully.'), 'success')
|
|
return respond_after_change(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
|
|
|
|
|
def get_players_available_at_time(date_str, time_str):
|
|
"""Player IDs whose weekly availability covers this date and time.
|
|
|
|
Two queries, whatever the size of the club. This used to load every
|
|
active player and then run one PlayerDisponibility query per player, on
|
|
an unindexed column — sixty players meant sixty-one round trips to
|
|
answer a question the database can answer in one (PERF-003).
|
|
|
|
Args:
|
|
date_str: 'YYYY-MM-DD'.
|
|
time_str: 'HH:MM'.
|
|
|
|
Returns:
|
|
list[int]: Player IDs, empty when the input does not parse.
|
|
"""
|
|
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()
|
|
active_player_ids = {
|
|
row.id
|
|
for row in User.query.with_entities(User.id)
|
|
.filter_by(role='player', is_active_account=True)
|
|
.all()
|
|
}
|
|
if not active_player_ids:
|
|
return []
|
|
|
|
# The comparison stays in Python: start_time and end_time are stored as
|
|
# time columns, and comparing them in SQL across three backends is not
|
|
# worth the portability risk for a single day's rows.
|
|
minutes = time_obj.hour * 60 + time_obj.minute
|
|
available = []
|
|
seen = set()
|
|
for disp in PlayerDisponibility.query.filter_by(day_of_week=day_of_week).all():
|
|
if disp.player_id in seen or disp.player_id not in active_player_ids:
|
|
continue
|
|
start = disp.start_time.hour * 60 + disp.start_time.minute
|
|
end = disp.end_time.hour * 60 + disp.end_time.minute
|
|
if start <= minutes < end:
|
|
available.append(disp.player_id)
|
|
seen.add(disp.player_id)
|
|
return available
|
|
|
|
|
|
@matches_bp.route('/api/available_players/<date>/<time>')
|
|
@json_endpoint
|
|
@login_required
|
|
def api_available_players(date, time):
|
|
"""API endpoint to get players available at a specific date/time slot."""
|
|
if not current_user.can_manage_teams() and not current_user.can_schedule_matches():
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
player_ids = get_players_available_at_time(date, time)
|
|
return jsonify({'available_player_ids': player_ids})
|
|
|
|
|
|
@matches_bp.route('/<int:match_id>/toggle-presence/<int:participant_id>', methods=['POST'])
|
|
@json_endpoint
|
|
@login_required
|
|
def toggle_presence(match_id, participant_id):
|
|
"""Toggle attendance_confirmed for a match participant."""
|
|
match = db.get_or_404(Match, match_id)
|
|
tryout = match.tryout
|
|
|
|
participant = db.get_or_404(MatchParticipant, participant_id)
|
|
if participant.match_id != match_id:
|
|
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
|
|
|
is_self = participant.player_id == current_user.id
|
|
if not is_self and not current_user.can_manage_this_tryout(tryout):
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
participant.attendance_confirmed = not participant.attendance_confirmed
|
|
db.session.commit()
|
|
return jsonify(
|
|
{
|
|
'participant_id': participant.id,
|
|
'attendance_confirmed': participant.attendance_confirmed,
|
|
'player_name': participant.player.username if participant.player else 'Unknown',
|
|
}
|
|
)
|