STD-09, trouve en recroisant l'audit anterieur -- celui mene sur le miroir GitHub, jamais repasse depuis qu'on a decouvert que ce n'etait pas la bonne source. Sept gestionnaires d'erreur portaient chacun leur copie d'une liste de prefixes d'URL decidant "JSON ou page HTML". Les copies avaient derive -- trois testaient /users/coach-availability, quatre non -- et toutes manquaient les memes points. Un fetch() qui recoit une page d'erreur HTML leve en la parsant : sur le calendrier, les listes de selections et d'equipes restaient vides, sans message dans la page et sans rien dans le journal. Deux choses apprises en ecrivant le test, aucune n'etait dans le constat. L'approche par prefixe ne pouvait pas etre reparee. Trois des seize vues JSON sont a des chemins qu'aucun prefixe ne distingue des pages HTML voisines -- /matches/<id>/toggle-presence/<id> et ses deux cousins, que les gabarits appellent justement en fetch(). Les vues se declarent donc elles-memes (@json_endpoint, app/api.py), et un test parcourt la carte des URL pour verifier qu'aucune vue appelant jsonify n'a ete oubliee. Et surtout : @login_required n'atteint jamais le gestionnaire 401. Flask-Login intercepte avant et redirige. Les seize points JSON repondaient donc a une session expiree par une 302 vers un formulaire HTML, quoi que dise la liste de prefixes. Reecrire la liste seule aurait eu l'air d'un correctif sans rien changer. Au passage, le message flash de ce gestionnaire etait la seule chaine de l'application qui n'avait jamais ete traduite. Co-Authored-By: Claude Opus 5 <[email protected]>
679 lines
25 KiB
Python
679 lines
25 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, 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.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,
|
|
TeamMember,
|
|
Tryout,
|
|
TryoutRegistration,
|
|
User,
|
|
)
|
|
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=())
|
|
|
|
|
|
#: 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 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.
|
|
"""
|
|
events = []
|
|
tryouts = get_visible_tryouts_for_user()
|
|
tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
|
|
|
|
if tryouts_by_id:
|
|
window_start, window_end = calendar_window(request.args)
|
|
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)
|
|
|
|
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.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,
|
|
},
|
|
}
|
|
)
|
|
|
|
# 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/<int:tryout_id>')
|
|
@json_endpoint
|
|
@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/<int:tryout_id>', 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', '')
|
|
|
|
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 redirect(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 = 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()]
|
|
|
|
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()
|
|
|
|
# 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']
|
|
|
|
notified_player_ids = []
|
|
notified_participant_ids = []
|
|
|
|
if match.match_type == 'team_vs_team':
|
|
teams_changed = data['team1_id'] != match.team1_id or data['team2_id'] != match.team2_id
|
|
if teams_changed:
|
|
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
|
match.team1_id = data['team1_id']
|
|
match.team2_id = data['team2_id']
|
|
notified_player_ids, notified_participant_ids = create_participants(match, data)
|
|
else:
|
|
# Same teams: the roster stands, but everyone is told again,
|
|
# because the date or the time may have moved.
|
|
for team_id in (match.team1_id, match.team2_id):
|
|
if team_id:
|
|
notified_player_ids.extend(
|
|
m.player_id for m in TeamMember.query.filter_by(team_id=team_id).all()
|
|
)
|
|
else:
|
|
MatchParticipant.query.filter_by(match_id=match.id).delete()
|
|
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 updated successfully!'), 'success')
|
|
return redirect(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 = 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))
|
|
|
|
# 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 redirect(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 = Match.query.get_or_404(match_id)
|
|
tryout = match.tryout
|
|
|
|
participant = MatchParticipant.query.get_or_404(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',
|
|
}
|
|
)
|