MNT-14. Chaque vue de liste faisait .all() sur sa table. L'audit evaluait l'impact a nul -- justement, a l'echelle d'une association etudiante -- et recommandait de choisir le motif maintenant plutot que de le retro-adapter plus tard. C'est ce que ceci est. Applique a list_users, list_evaluations et team_matches.list_matches. Pour cette derniere, la pagination borne aussi la boucle sur les participants, qui est le N+1 que le constat designait comme le premier a se degrader. Trois decisions, parce que ce sont celles qui se prennent deux fois differemment sinon. error_out=False : les numeros de page arrivent par l'URL, donc ?page=999 est une chose qu'on tape ou qu'un signet perime contient. Le defaut de Flask-SQLAlchemy y repond par un 404, ce qui est deroutant pour quelqu'un qui est simplement alle une page trop loin. Un plafond sur per_page : c'est aussi un parametre d'URL, et sans plafond ?per_page=100000 redonne a la main exactement la requete non bornee que la pagination existe pour empecher. page_url est un global Jinja plutot qu'une valeur que chaque vue passe. Ce qui se rate avec des liens de pagination, c'est le reste de la chaine de requete : la liste d'evaluations porte sort et order, celle des matchs d'equipe porte team_id, et un lien qui les perd reinitialise silencieusement la vue que la personne regardait. Les deux tests qui l'epinglent tombent si page_url cesse de les recopier -- verifie par mutation. Les tris sont completes par une cle unique : une requete paginee sans ORDER BY stable peut montrer la meme ligne deux fois et jamais une autre. Au passage, huit entrees fuzzy corrigees dans les catalogues, dont deux laissees par le commit SEC-16 : une entree fuzzy est ignoree a l'execution, donc ces messages retombaient en anglais. Co-Authored-By: Claude Opus 5 <[email protected]>
316 lines
11 KiB
Python
316 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.pagination import paginate
|
|
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)
|
|
|
|
# Pagination also bounds the per-match participant loop below, which is
|
|
# the N+1 the constat pointed at (MNT-10 combined with MNT-14).
|
|
matches_page = paginate(matches_query.order_by(TeamMatch.date.desc(), TeamMatch.id))
|
|
matches = matches_page.items
|
|
|
|
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,
|
|
pagination=matches_page,
|
|
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',
|
|
}
|
|
)
|