Files
team-tryouts/app/routes/main.py
T
GGThedandClaude Opus 5 92d72e4d48 refactor(authz): un seul point de verite pour les autorisations d equipe
ARCH-002. La question "a quelles equipes ce coach est-il rattache ?" etait
posee a huit endroits, de cinq facons differentes, et trois d entre elles
donnaient une mauvaise reponse en production.

Le motif fautif, present tel quel dans six routes :

    OrgTeam.query.filter_by(coach_id=user.id).first()

Il repond au plus une equipe, et seulement par la colonne heritee. Deux
pannes en decoulaient, silencieuses -- les pages s affichaient, vides :

  - un coach rattache uniquement par la relation many-to-many n avait
    aucune equipe, donc aucun joueur, aucun contrat, aucune note d equipe,
    aucun match a venir sur son tableau de bord ;
  - un coach de deux equipes n en voyait qu une. Le formulaire de contrat
    lui proposait la moitie de son effectif, alors que la route POST
    acceptait l autre moitie.

app/permissions.py devient le module ou la question se pose une fois :
coach_org_teams, manager_org_teams, attached_org_teams, visible_org_teams,
can_manage_org_team, org_team_player_ids, coach_player_ids,
can_manage_player_contract, coach_tryouts, coach_manages_tryout. Toutes
lisent les deux rattachements et toutes les equipes.

Le meme ecart existait dans le modele : Coach.get_visible_tryouts ne lisait
que la relation m2m -- calendrier vide pour un coach rattache par la
colonne -- et can_manage_this_tryout ignorait la colonne pour l equipe
cible. Les deux delegent desormais.

Corrections de portee, au passage
  - one_on_one lisait org_team.coach_id : un joueur dont l equipe declare
    ses coachs par la relation etait informe qu il n avait pas de coach, et
    le formulaire de demande restait ferme. Passe par get_coaches(), qui
    retombe deja sur la colonne heritee.
  - notes_dashboard conditionnait les notes personnelles du coach a
    l existence d une equipe : un coach sans equipe ne voyait pas ses
    propres notes.
  - can_manage_team_match reformulait can_manage_this_org_team ; la
    reformulation avait derive. Elle appelle maintenant la regle.

Limite assumee : le panneau de notes d equipe reste ecrit pour une seule
equipe et affiche donc la premiere. La resolution est corrigee, la mise en
page multi-equipes ne l est pas -- c est un choix produit, pas un bug.

14 tests ajoutes. Cinq echouent sur le code d avant, verifie en remettant
les routes et le modele a leur etat precedent.

ARCH-001 fera disparaitre la colonne heritee ; cela demande une migration
de donnees, donc Alembic. D ici la, ce module est ce qui rend la
duplication inoffensive.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 14:08:02 -04:00

167 lines
7.5 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()
registrations = TryoutRegistration.query.filter(
TryoutRegistration.status.in_(['registered', 'attended'])).all()
registered_player_ids = [r.player_id for r in registrations]
evaluated_player_ids = [e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()]
stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
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)