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]>
This commit is contained in:
GGThed
2026-08-08 14:08:02 -04:00
co-authored by Claude Opus 5
parent 37f70c89e3
commit 92d72e4d48
7 changed files with 549 additions and 168 deletions
+16 -31
View File
@@ -1,10 +1,18 @@
"""Coach — evaluates, schedules matches, manages their own org team."""
"""Coach — evaluates, schedules matches, manages their own org teams."""
from app.models.user_model.user import User
from app.extensions import db
class Coach(User):
"""Coach — evaluates, schedules matches, manages their own org team."""
"""Coach — evaluates, schedules matches, manages their own org teams.
Every question about *which* teams or tryouts belong to this coach is
delegated to ``app.permissions``. Two of the three methods below used to
answer it themselves, each considering a different subset of the two
ways a coach can be attached to a team: ``can_manage_this_tryout``
ignored the legacy ``coach_id`` column when checking the target team,
and ``get_visible_tryouts`` ignored it entirely. A coach attached only
by that column therefore saw an empty calendar (ARCH-002).
"""
__mapper_args__ = {'polymorphic_identity': 'coach'}
def can_evaluate(self):
@@ -17,37 +25,14 @@ class Coach(User):
return True
def can_manage_this_tryout(self, tryout):
from app.models.org_team.org_team import OrgTeam
if tryout.target_org_team_id:
is_coach_of_target = OrgTeam.query.filter(
OrgTeam.id == tryout.target_org_team_id,
OrgTeam.coaches.any(id=self.id),
).first() is not None
if is_coach_of_target:
return True
# Check many-to-many coaches relationship
if any(c.id == self.id for c in tryout.coaches):
return True
# Backward compat: check deprecated coach_id
if tryout.coach_id == self.id:
return True
return False
from app.permissions import coach_manages_tryout
return coach_manages_tryout(self, tryout)
def can_manage_this_org_team(self, org_team):
if org_team.coaches.filter_by(id=self.id).first():
return True
if org_team.coach_id == self.id:
return True
return False
return org_team.coach_id == self.id
def get_visible_tryouts(self):
from app.models.tryout.tryout import Tryout
team_ids = [t.id for t in self.coached_org_teams.all()]
conditions = []
if team_ids:
conditions.append(Tryout.target_org_team_id.in_(team_ids))
# Check many-to-many coaches
conditions.append(Tryout.coaches.any(id=self.id))
# Backward compat: check deprecated coach_id
conditions.append(Tryout.coach_id == self.id)
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
from app.permissions import coach_tryouts
return coach_tryouts(self)