Files
team-tryouts/app/routes/matches.py
T
GGThed 0308eb9eef refactor(validation): un schema a la frontiere des matchs
ARCH-005, premiere moitie. matches.py et team_matches.py lisaient une
quarantaine de champs sur request.form a la main et les croyaient tous.

Ce que ca produisait n etait pas bruyant :

- edit_match attrapait une heure invalide et faisait start_time = None,
  puis annoncait que le match etait mis a jour. Le match perdait son
  heure et le calendrier l affichait a minuit ;
- match_type etait accepte tel quel. Une valeur inconnue creait un match
  auquel aucun joueur n etait rattache, sans un mot ;
- une fin avant le debut etait enregistree telle quelle ;
- title est NOT NULL dans le modele et n etait pas verifie dans la route,
  donc un titre vide etait un 500 ;
- 'a,b' dans la selection de joueurs arrivait sur int() sans garde.

app/forms.py rassemble les deux fonctions de frontiere, qui vivaient dans
users/_shared.py parce que c est la qu elles avaient d abord servi. Elles
y restent re-exportees, donc aucun des trente appels n a bouge.

Le mixin des schemas lit desormais un champ vide comme un champ absent.
C est ce qui rendait ces formulaires invalidables : un formulaire HTML
envoie tout ce qu il affiche, donc une date optionnelle non remplie
arrive comme '' et non comme rien. Seuls les champs declares optionnels
sont concernes ; un champ requis laisse vide doit toujours echouer.

Deux duplications absorbees au passage, toutes deux nommees par l audit :
la boucle de creation des participants, ecrite deux fois et deja divergee
— la copie de edit_match gardait ses identifiants en chaines et appelait
int() une ligne plus loin — et le contexte de re-affichage du formulaire,
dont les versions courtes faisaient mourir un refus dans tojson sur un
Undefined : un message de validation devenait un 500.

Limite connue et consignee : le formulaire revient rempli avec les
valeurs enregistrees, pas avec la saisie refusee. Reafficher la
soumission demande de toucher aux gabarits, c est un autre changement.

19 tests neufs sur ces routes, qui n en avaient aucun. 447 au total.
2026-08-11 13:09:00 -04:00

673 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.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')
@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>')
@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')
@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>')
@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'])
@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',
}
)