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]>
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""Team match management routes for regular season matches.
|
|
|
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
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 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,
|
|
OrgTeam,
|
|
Player,
|
|
TeamMatch,
|
|
TeamMatchParticipant,
|
|
TeamPlayer,
|
|
)
|
|
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
|
from app.routes.matches import default_end_time
|
|
from app.services.scheduling import notify_participants, zip_participants
|
|
from app.validators import TeamMatchSchema
|
|
|
|
team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches')
|
|
|
|
|
|
def can_manage_team_match(team):
|
|
"""Whether the current user can manage matches for this team.
|
|
|
|
Same rule as administering the team itself, so it is the same call.
|
|
This function used to restate it, and the restatement drifted.
|
|
"""
|
|
return can_manage_org_team(current_user, team)
|
|
|
|
|
|
@team_matches_bp.route('')
|
|
@login_required
|
|
def list_matches():
|
|
"""List all team matches visible to the current user."""
|
|
filter_team_id = request.args.get('team_id', type=int)
|
|
|
|
# A manager administers every team, so the listing shows them all;
|
|
# visible_org_teams() only reports the teams they are attached to.
|
|
if isinstance(current_user, (Admin, Manager)):
|
|
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
|
matches_query = TeamMatch.query
|
|
elif isinstance(current_user, (Coach, Player)):
|
|
teams = visible_org_teams(current_user)
|
|
team_ids = [t.id for t in teams]
|
|
matches_query = (
|
|
TeamMatch.query.filter(
|
|
TeamMatch.org_team_id.in_(team_ids),
|
|
)
|
|
if team_ids
|
|
else TeamMatch.query.filter(TeamMatch.id == -1)
|
|
)
|
|
else:
|
|
teams = []
|
|
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
|
|
|
if filter_team_id:
|
|
matches_query = matches_query.filter(TeamMatch.org_team_id == filter_team_id)
|
|
|
|
matches = matches_query.order_by(TeamMatch.date.desc()).all()
|
|
|
|
match_data = []
|
|
for tm in matches:
|
|
confirmed, total = tm.get_confirmed_count()
|
|
participants = []
|
|
for p in tm.participants.all():
|
|
participants.append(
|
|
{
|
|
'id': p.id,
|
|
'player': p.player,
|
|
'is_confirmed': p.is_confirmed,
|
|
}
|
|
)
|
|
match_data.append(
|
|
{
|
|
'match': tm,
|
|
'participants': participants,
|
|
'confirmed_count': confirmed,
|
|
'total_count': total,
|
|
}
|
|
)
|
|
|
|
return render_template(
|
|
'pages/team_matches.html', teams=teams, match_data=match_data, now=datetime.utcnow()
|
|
)
|
|
|
|
|
|
@team_matches_bp.route('/<int:team_id>/create', methods=['GET', 'POST'])
|
|
@login_required
|
|
def create_match(team_id):
|
|
"""Create a new regular-season team match."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not can_manage_team_match(team):
|
|
flash(_('You do not have permission to schedule matches for this team.'), 'danger')
|
|
return redirect(url_for('team_matches.list_matches'))
|
|
|
|
team_players = TeamPlayer.query.filter_by(org_team_id=team_id).all()
|
|
prefill_date = request.args.get('date', '')
|
|
is_practice = request.args.get('type') == 'practice'
|
|
default_title = 'Practice' if is_practice else f'Team Match — {team.name}'
|
|
|
|
if is_practice and request.method == 'GET':
|
|
|
|
class TryoutProxy:
|
|
def __init__(self, team_obj):
|
|
self.id = 0
|
|
self.title = team_obj.name
|
|
self.date = ''
|
|
self.game = ''
|
|
self.target_org_team = team_obj
|
|
|
|
proxy_tryout = TryoutProxy(team)
|
|
all_players = [tp.player for tp in team_players if tp.player]
|
|
|
|
return render_template(
|
|
'pages/match_form.html',
|
|
tryout=proxy_tryout,
|
|
teams=[],
|
|
all_players=all_players,
|
|
prefill_date=prefill_date,
|
|
is_practice=True,
|
|
team_id=team_id,
|
|
team=team,
|
|
)
|
|
|
|
if request.method == 'POST':
|
|
payload = form_payload(list_fields=(), optional_blank=())
|
|
# A practice has no opponent, whatever the form sent.
|
|
payload.setdefault('title', default_title)
|
|
if is_practice:
|
|
payload.pop('opponent', None)
|
|
|
|
try:
|
|
data = TeamMatchSchema().load(payload)
|
|
except ValidationError as err:
|
|
flash_validation_errors(err)
|
|
return render_template(
|
|
'pages/team_match_form.html',
|
|
team=team,
|
|
team_players=team_players,
|
|
prefill_date=prefill_date,
|
|
is_practice=is_practice,
|
|
)
|
|
|
|
start_time = data['start_time']
|
|
end_time = data['end_time'] or default_end_time(data['date'], start_time)
|
|
|
|
team_match = TeamMatch(
|
|
org_team_id=team_id,
|
|
title=data['title'],
|
|
description=data['description'],
|
|
opponent=data['opponent'],
|
|
date=data['date'],
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
location=data['location'],
|
|
created_by=current_user.id,
|
|
)
|
|
db.session.add(team_match)
|
|
db.session.flush()
|
|
|
|
notified_participant_ids = []
|
|
for tp in team_players:
|
|
participant = TeamMatchParticipant(
|
|
team_match_id=team_match.id,
|
|
player_id=tp.player_id,
|
|
)
|
|
db.session.add(participant)
|
|
db.session.flush()
|
|
notified_participant_ids.append(participant.id)
|
|
|
|
db.session.commit()
|
|
|
|
notify_participants(
|
|
title=team_match.title,
|
|
date=team_match.date,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
participants=zip_participants(
|
|
[tp.player_id for tp in team_players], notified_participant_ids
|
|
),
|
|
fallback_id=team_match.id,
|
|
)
|
|
|
|
flash(
|
|
_('Team match "%(title)s" scheduled successfully!', title=team_match.title), 'success'
|
|
)
|
|
return redirect(url_for('team_matches.list_matches'))
|
|
|
|
return render_template('pages/team_match_form.html', team=team, team_players=team_players)
|
|
|
|
|
|
@team_matches_bp.route('/<int:match_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def edit_match(match_id):
|
|
"""Edit an existing team match."""
|
|
team_match = TeamMatch.query.get_or_404(match_id)
|
|
team = team_match.org_team
|
|
|
|
if not can_manage_team_match(team):
|
|
flash(_('You do not have permission to edit this match.'), 'danger')
|
|
return redirect(url_for('team_matches.list_matches'))
|
|
|
|
if request.method == 'POST':
|
|
# The three date and time fields used to be checked one at a time,
|
|
# each flashing and redirecting on its own: a form with two mistakes
|
|
# took two round trips to be told about both. One schema now, every
|
|
# problem reported at once and in place.
|
|
#
|
|
# Known limit: the re-render reads the stored record, so what was
|
|
# typed is not echoed back. Repopulating the form from the
|
|
# submission is a separate change to the template.
|
|
try:
|
|
data = TeamMatchSchema().load(form_payload(list_fields=(), optional_blank=()))
|
|
except ValidationError as err:
|
|
flash_validation_errors(err)
|
|
return render_template(
|
|
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
|
)
|
|
|
|
team_match.title = data['title']
|
|
team_match.description = data['description']
|
|
team_match.opponent = data['opponent']
|
|
team_match.date = data['date']
|
|
team_match.start_time = data['start_time']
|
|
team_match.end_time = data['end_time'] or default_end_time(data['date'], data['start_time'])
|
|
team_match.location = data['location']
|
|
team_match.status = data['status']
|
|
|
|
db.session.commit()
|
|
flash(_('Match updated successfully!'), 'success')
|
|
return redirect(url_for('team_matches.list_matches'))
|
|
|
|
return render_template(
|
|
'pages/team_match_form.html', match=team_match, team=team, team_players=[]
|
|
)
|
|
|
|
|
|
@team_matches_bp.route('/<int:match_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_match(match_id):
|
|
"""Delete a team match."""
|
|
team_match = TeamMatch.query.get_or_404(match_id)
|
|
team = team_match.org_team
|
|
if not can_manage_team_match(team):
|
|
flash(_('You do not have permission to delete this match.'), 'danger')
|
|
return redirect(url_for('team_matches.list_matches'))
|
|
db.session.delete(team_match)
|
|
db.session.commit()
|
|
flash(_('Match deleted successfully.'), 'success')
|
|
return redirect(url_for('team_matches.list_matches'))
|
|
|
|
|
|
@team_matches_bp.route('/api/manageable-teams')
|
|
@json_endpoint
|
|
@login_required
|
|
def api_manageable_teams():
|
|
"""API endpoint returning teams the current user can schedule matches for."""
|
|
if not current_user.can_schedule_matches():
|
|
return jsonify([])
|
|
|
|
if isinstance(current_user, (Admin, Manager)):
|
|
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
|
elif isinstance(current_user, Coach):
|
|
teams = coach_org_teams(current_user)
|
|
else:
|
|
return jsonify([])
|
|
|
|
return jsonify([{'id': t.id, 'name': t.name} for t in teams])
|
|
|
|
|
|
@team_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 is_confirmed for a team match participant."""
|
|
team_match = TeamMatch.query.get_or_404(match_id)
|
|
team = team_match.org_team
|
|
|
|
participant = TeamMatchParticipant.query.get_or_404(participant_id)
|
|
if participant.team_match_id != match_id:
|
|
return jsonify({'error': 'Participant does not belong to this match'}), 400
|
|
|
|
can_toggle = can_manage_team_match(team) or participant.player_id == current_user.id
|
|
if not can_toggle:
|
|
return jsonify({'error': 'Unauthorized'}), 403
|
|
|
|
participant.is_confirmed = not participant.is_confirmed
|
|
db.session.commit()
|
|
return jsonify(
|
|
{
|
|
'participant_id': participant.id,
|
|
'is_confirmed': participant.is_confirmed,
|
|
'player_name': participant.player.username if participant.player else 'Unknown',
|
|
}
|
|
)
|