PERF-002, PERF-003, PERF-004. Aucun changement de comportement : chaque reecriture est accompagnee de tests qui enoncent la reponse attendue, pas la methode. PERF-002 -- /matches/api/events Le flux parcourait `tryout.matches` pour chaque tryout visible -- pour un president, tout l historique du club -- puis posait une requete MatchParticipant PAR match pour savoir si la personne qui regarde y figure. Le cout du calendrier croissait avec l historique, a chaque navigation. FullCalendar envoie deja `start` et `end` sur une source d evenements de type URL. Personne ne les lisait. La requete est desormais bornee, et les participants de tous les matchs de la fenetre sont charges en une fois, joueur compris. Des bornes illisibles sont ignorees plutot que refusees : un calendrier qui en montre trop est un probleme de performance, un calendrier qui renvoie 400 est une page blanche. PERF-003 -- get_players_available_at_time Chargeait tous les joueurs actifs, puis une requete PlayerDisponibility par joueur, sur une colonne non indexee. Deux requetes desormais, quelle que soit la taille du club. Mesure dans le test : 7 requetes pour 6 joueurs avant, 2 apres. PERF-004 -- decompte des evaluations en attente Chargeait toutes les inscriptions du club et toutes les evaluations du coach, construisait deux ensembles Python et les soustrayait -- deux lectures de table entiere pour produire un entier. Un COUNT DISTINCT avec NOT EXISTS. Les tests couvrent ce que la reecriture aurait pu changer sans bruit : fin de creneau exclusive, compte desactive exclu, joueur a cheval sur deux creneaux compte une fois, evaluation d un autre coach qui ne libere pas la ligne, double inscription comptee une fois (DB-006 n a pas encore atterri, donc le cas existe). PERF-001 (view_tryout) n est pas fait : c est le plus gros des quatre, il touche la page la plus consultee et merite son propre lot. 23 tests ajoutes, 371 au total. Co-Authored-By: Claude Opus 5 <[email protected]>
241 lines
8.6 KiB
Python
241 lines
8.6 KiB
Python
"""Main dashboard routes for the Team Tryouts application.
|
|
|
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
|
"""
|
|
|
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
|
from flask_login import login_required, current_user
|
|
from flask_babel import gettext as _
|
|
from app.extensions import db
|
|
from app.models import (
|
|
Admin,
|
|
Manager,
|
|
Coach,
|
|
Player,
|
|
Scout,
|
|
User,
|
|
Tryout,
|
|
Evaluation,
|
|
TryoutRegistration,
|
|
TeamMember,
|
|
Match,
|
|
MatchParticipant,
|
|
)
|
|
from app.permissions import coach_tryout_ids
|
|
from sqlalchemy import func
|
|
from datetime import date
|
|
|
|
main_bp = Blueprint('main', __name__)
|
|
|
|
|
|
@main_bp.route('/')
|
|
def index():
|
|
"""Redirect root URL to login page."""
|
|
return redirect(url_for('auth.login'))
|
|
|
|
|
|
@main_bp.route('/lang/<locale>')
|
|
def set_language(locale):
|
|
"""Switch the interface language and return where the user came from.
|
|
|
|
Available to anonymous visitors too: the login page has to be readable
|
|
before anyone can sign in.
|
|
|
|
A GET link rather than a form: the only thing a forged request could
|
|
achieve is changing the visitor's own display language, which carries
|
|
no consequence worth a token. The redirect target is still validated —
|
|
an unchecked `Referer` would make this an open redirect.
|
|
"""
|
|
from app.i18n import set_locale
|
|
from app.routes.auth import is_safe_url
|
|
|
|
if not set_locale(locale):
|
|
flash(_('That language is not available.'), 'warning')
|
|
|
|
target = request.referrer
|
|
if target and is_safe_url(target):
|
|
return redirect(target)
|
|
return redirect(
|
|
url_for('main.dashboard') if current_user.is_authenticated else url_for('auth.login')
|
|
)
|
|
|
|
|
|
@main_bp.route('/dashboard')
|
|
@login_required
|
|
def dashboard():
|
|
"""Render the main dashboard with role-specific statistics.
|
|
|
|
Each User subclass provides its own stats view.
|
|
"""
|
|
user = current_user
|
|
stats = {}
|
|
|
|
if isinstance(user, Admin):
|
|
stats['total_users'] = User.query.count()
|
|
stats['total_players'] = User.query.filter_by(role='player').count()
|
|
stats['total_tryouts'] = Tryout.query.count()
|
|
stats['total_evaluations'] = Evaluation.query.count()
|
|
stats['active_tryouts'] = Tryout.query.filter_by(status='in_progress').count()
|
|
stats['completed_tryouts'] = Tryout.query.filter_by(status='completed').count()
|
|
stats['recent_users'] = User.query.order_by(User.created_at.desc()).limit(10).all()
|
|
stats['recent_tryouts'] = Tryout.query.order_by(Tryout.created_at.desc()).limit(10).all()
|
|
today = date.today()
|
|
stats['upcoming_matches'] = (
|
|
Match.query.filter(
|
|
Match.status == 'scheduled',
|
|
Match.date >= today,
|
|
)
|
|
.order_by(Match.date, Match.start_time)
|
|
.limit(5)
|
|
.all()
|
|
)
|
|
|
|
elif isinstance(user, Manager):
|
|
stats['total_tryouts'] = Tryout.query.filter_by(created_by=user.id).count()
|
|
stats['active_tryouts'] = Tryout.query.filter_by(
|
|
created_by=user.id, status='in_progress'
|
|
).count()
|
|
stats['total_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
|
stats['my_tryouts'] = (
|
|
Tryout.query.filter_by(created_by=user.id).order_by(Tryout.date.desc()).limit(5).all()
|
|
)
|
|
today = date.today()
|
|
manager_tryout_ids = [t.id for t in Tryout.query.filter_by(created_by=user.id).all()]
|
|
stats['upcoming_matches'] = (
|
|
Match.query.filter(
|
|
Match.tryout_id.in_(manager_tryout_ids),
|
|
Match.status == 'scheduled',
|
|
Match.date >= today,
|
|
)
|
|
.order_by(Match.date, Match.start_time)
|
|
.limit(5)
|
|
.all()
|
|
if manager_tryout_ids
|
|
else []
|
|
)
|
|
|
|
elif isinstance(user, Coach):
|
|
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
|
|
|
|
# A count, computed as a count. This used to load every registration
|
|
# row in the club and every evaluation this coach had written, build
|
|
# two Python sets and subtract them — two full table reads to produce
|
|
# one integer (PERF-004).
|
|
already_evaluated = (
|
|
db.session.query(Evaluation.player_id)
|
|
.filter(
|
|
Evaluation.evaluator_id == user.id,
|
|
Evaluation.player_id == TryoutRegistration.player_id,
|
|
)
|
|
.exists()
|
|
)
|
|
stats['pending_evaluations'] = (
|
|
db.session.query(func.count(func.distinct(TryoutRegistration.player_id)))
|
|
.filter(
|
|
TryoutRegistration.status.in_(['registered', 'attended']),
|
|
~already_evaluated,
|
|
)
|
|
.scalar()
|
|
)
|
|
stats['my_recent_evaluations'] = (
|
|
Evaluation.query.filter_by(evaluator_id=user.id)
|
|
.order_by(Evaluation.created_at.desc())
|
|
.limit(10)
|
|
.all()
|
|
)
|
|
today = date.today()
|
|
# Was: the first team matching the legacy coach_id column, and only
|
|
# the tryouts targeting it. A coach attached by the many-to-many
|
|
# relationship, or coaching a second team, saw no upcoming match.
|
|
tryout_ids = coach_tryout_ids(user)
|
|
stats['upcoming_matches'] = (
|
|
Match.query.filter(
|
|
Match.tryout_id.in_(tryout_ids),
|
|
Match.status == 'scheduled',
|
|
Match.date >= today,
|
|
)
|
|
.order_by(Match.date, Match.start_time)
|
|
.limit(5)
|
|
.all()
|
|
if tryout_ids
|
|
else []
|
|
)
|
|
|
|
elif isinstance(user, Player):
|
|
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
|
stats['my_registrations'] = (
|
|
TryoutRegistration.query.filter_by(player_id=user.id)
|
|
.order_by(TryoutRegistration.registered_at.desc())
|
|
.limit(5)
|
|
.all()
|
|
)
|
|
|
|
today = date.today()
|
|
next_matches = []
|
|
all_registrations = TryoutRegistration.query.filter_by(player_id=user.id).all()
|
|
registered_tryout_ids = [r.tryout_id for r in all_registrations]
|
|
player_participant_matches = MatchParticipant.query.filter_by(player_id=user.id).all()
|
|
player_match_ids = [p.match_id for p in player_participant_matches]
|
|
player_team_memberships = TeamMember.query.filter_by(player_id=user.id).all()
|
|
player_team_ids = [tm.team_id for tm in player_team_memberships]
|
|
|
|
upcoming_matches = (
|
|
Match.query.filter(
|
|
Match.tryout_id.in_(registered_tryout_ids),
|
|
Match.status == 'scheduled',
|
|
Match.date >= today,
|
|
)
|
|
.order_by(Match.date, Match.start_time)
|
|
.all()
|
|
)
|
|
|
|
for match in upcoming_matches:
|
|
is_participant = False
|
|
team = None
|
|
if match.match_type == 'team_vs_team':
|
|
if match.team1_id in player_team_ids:
|
|
is_participant = True
|
|
team = next(
|
|
(tm for tm in player_team_memberships if tm.team_id == match.team1_id), None
|
|
)
|
|
elif match.team2_id in player_team_ids:
|
|
is_participant = True
|
|
team = next(
|
|
(tm for tm in player_team_memberships if tm.team_id == match.team2_id), None
|
|
)
|
|
else:
|
|
if match.id in player_match_ids:
|
|
is_participant = True
|
|
|
|
if is_participant:
|
|
next_matches.append(
|
|
{
|
|
'tryout': match.tryout,
|
|
'match': match,
|
|
'team': team.team if team else None,
|
|
}
|
|
)
|
|
|
|
stats['next_matches'] = next_matches
|
|
|
|
elif isinstance(user, Scout):
|
|
stats['total_players'] = User.query.filter_by(role='player').count()
|
|
stats['total_evaluations'] = Evaluation.query.count()
|
|
stats['avg_scores'] = (
|
|
db.session.query(
|
|
Evaluation.player_id,
|
|
func.avg(Evaluation.overall_score).label('avg_score'),
|
|
)
|
|
.group_by(Evaluation.player_id)
|
|
.order_by(func.avg(Evaluation.overall_score).desc())
|
|
.limit(5)
|
|
.all()
|
|
)
|
|
stats['top_players'] = []
|
|
for row in stats['avg_scores']:
|
|
p = User.query.get(row.player_id)
|
|
if p:
|
|
stats['top_players'].append((p, round(row.avg_score, 1)))
|
|
|
|
return render_template('pages/dashboard.html', user=user, stats=stats)
|