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:
+8
-6
@@ -10,8 +10,9 @@ from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, Evaluation, TryoutRegistration, TeamMember,
|
||||
Match, MatchParticipant, OrgTeam,
|
||||
Match, MatchParticipant,
|
||||
)
|
||||
from app.permissions import coach_tryout_ids
|
||||
from sqlalchemy import func
|
||||
from datetime import date
|
||||
|
||||
@@ -97,13 +98,14 @@ def dashboard():
|
||||
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
|
||||
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
||||
today = date.today()
|
||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(
|
||||
target_org_team_id=org_team.id).all()] if org_team else []
|
||||
# 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_(coach_tryout_ids),
|
||||
Match.tryout_id.in_(tryout_ids),
|
||||
Match.status == 'scheduled', Match.date >= today,
|
||||
).order_by(Match.date, Match.start_time).limit(5).all() if coach_tryout_ids else []
|
||||
).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()
|
||||
|
||||
+13
-34
@@ -11,6 +11,7 @@ from app.models import (
|
||||
Admin, Manager, Coach, Player,
|
||||
OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
||||
)
|
||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||
from datetime import datetime, timedelta
|
||||
from app.discord_bot import send_schedule_notification
|
||||
|
||||
@@ -18,17 +19,12 @@ team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches'
|
||||
|
||||
|
||||
def can_manage_team_match(team):
|
||||
"""Check if current user can manage matches for this team."""
|
||||
if isinstance(current_user, Admin):
|
||||
return True
|
||||
if isinstance(current_user, Manager):
|
||||
return True
|
||||
if isinstance(current_user, Coach):
|
||||
if team.coaches.filter_by(id=current_user.id).first():
|
||||
return True
|
||||
if team.coach_id == current_user.id:
|
||||
return True
|
||||
return False
|
||||
"""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('')
|
||||
@@ -37,29 +33,17 @@ def list_matches():
|
||||
"""List all team matches visible to the current user."""
|
||||
filter_team_id = request.args.get('team_id', type=int)
|
||||
|
||||
if isinstance(current_user, Admin):
|
||||
# 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, Manager):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
matches_query = TeamMatch.query
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
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)
|
||||
elif isinstance(current_user, Player):
|
||||
player_team_ids = [tp.org_team_id for tp in current_user.team_placements]
|
||||
teams = OrgTeam.query.filter(OrgTeam.id.in_(player_team_ids)).all() if player_team_ids else []
|
||||
matches_query = TeamMatch.query.filter(
|
||||
TeamMatch.org_team_id.in_(player_team_ids),
|
||||
) if player_team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
else:
|
||||
teams = []
|
||||
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||
@@ -274,12 +258,7 @@ def api_manageable_teams():
|
||||
if isinstance(current_user, (Admin, Manager)):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
teams = coach_org_teams(current_user)
|
||||
else:
|
||||
return jsonify([])
|
||||
|
||||
|
||||
+5
-18
@@ -12,6 +12,7 @@ from app.models import (
|
||||
OrgTeam, User, PersonalNote, TeamNote, Tryout, TeamPlayer,
|
||||
TeamMatch, Contract, OneOnOneRequest,
|
||||
)
|
||||
from app.permissions import visible_org_teams
|
||||
from datetime import datetime
|
||||
|
||||
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
@@ -23,29 +24,15 @@ def list_teams():
|
||||
"""List all organization teams visible to the current user."""
|
||||
can_manage = current_user.can_manage_teams()
|
||||
|
||||
if isinstance(current_user, Admin):
|
||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Coach):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.coaches.any(id=current_user.id),
|
||||
OrgTeam.coach_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Manager):
|
||||
teams = OrgTeam.query.filter(
|
||||
db.or_(
|
||||
OrgTeam.managers.any(id=current_user.id),
|
||||
OrgTeam.manager_id == current_user.id,
|
||||
)
|
||||
).order_by(OrgTeam.name).all()
|
||||
elif isinstance(current_user, Player):
|
||||
if isinstance(current_user, Player):
|
||||
flash(_('Use My Team(s) to view your teams.'), 'info')
|
||||
return redirect(url_for('teams.my_teams'))
|
||||
else:
|
||||
if not isinstance(current_user, (Admin, Coach, Manager)):
|
||||
flash(_('You do not have permission to view teams.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
teams = visible_org_teams(current_user)
|
||||
|
||||
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
||||
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
||||
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
||||
|
||||
+45
-47
@@ -26,7 +26,10 @@ from app.validators import (
|
||||
UploadContractSchema,
|
||||
)
|
||||
from app.logging_config import log_auth_event
|
||||
from app.permissions import coach_can_access_player
|
||||
from app.permissions import (
|
||||
can_manage_player_contract, coach_can_access_player, coach_org_teams,
|
||||
coach_player_ids,
|
||||
)
|
||||
import requests
|
||||
|
||||
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
||||
@@ -575,19 +578,19 @@ def delete_disponibility(disponibility_id):
|
||||
# Contracts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def can_manage_player_contract(user, player_id):
|
||||
"""Check if a user can upload contracts for a specific player."""
|
||||
if isinstance(user, Admin):
|
||||
return True
|
||||
if isinstance(user, Manager):
|
||||
return True
|
||||
if isinstance(user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
||||
if org_team:
|
||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=org_team.id).first()
|
||||
if tp:
|
||||
return True
|
||||
return False
|
||||
def _manageable_players():
|
||||
"""Players the current user may attach a contract to.
|
||||
|
||||
A coach used to see the squad of one team — the first row matching the
|
||||
legacy coach_id column — so a coach of two teams could file a contract
|
||||
for half of their players and no more, and a coach attached only by the
|
||||
many-to-many relationship for none at all.
|
||||
"""
|
||||
if isinstance(current_user, Coach):
|
||||
player_ids = coach_player_ids(current_user)
|
||||
return User.query.filter(User.id.in_(player_ids)).order_by(
|
||||
User.username).all() if player_ids else []
|
||||
return User.query.filter_by(role='player').order_by(User.username).all()
|
||||
|
||||
|
||||
@users_bp.route('/contracts')
|
||||
@@ -602,14 +605,7 @@ def list_contracts():
|
||||
player_id=current_user.id,
|
||||
).order_by(Contract.uploaded_at.desc()).all()
|
||||
elif isinstance(current_user, (Admin, Manager, Coach)):
|
||||
players = []
|
||||
if isinstance(current_user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
||||
if org_team:
|
||||
player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()]
|
||||
players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else []
|
||||
else:
|
||||
players = User.query.filter_by(role='player').all()
|
||||
players = _manageable_players()
|
||||
|
||||
if players:
|
||||
player_ids = [p.id for p in players]
|
||||
@@ -629,15 +625,7 @@ def upload_contract():
|
||||
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
|
||||
if isinstance(current_user, Coach):
|
||||
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
||||
if org_team:
|
||||
player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()]
|
||||
players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else []
|
||||
else:
|
||||
players = []
|
||||
else:
|
||||
players = User.query.filter_by(role='player').all()
|
||||
players = _manageable_players()
|
||||
|
||||
if request.method == 'POST':
|
||||
contract_schema = UploadContractSchema()
|
||||
@@ -826,7 +814,12 @@ def one_on_one():
|
||||
|
||||
org_teams = current_user.get_org_teams()
|
||||
org_team = org_teams[0] if org_teams else None
|
||||
coach = User.query.get(org_team.coach_id) if org_team and org_team.coach_id else None
|
||||
# Reading org_team.coach_id directly told every player whose team lists
|
||||
# its coaches through the many-to-many relationship — the newer of the
|
||||
# two ways — that they had no coach, and closed the page to them.
|
||||
# get_coaches() falls back to the legacy column when the list is empty.
|
||||
team_coaches = org_team.get_coaches() if org_team else []
|
||||
coach = team_coaches[0] if team_coaches else None
|
||||
|
||||
if not coach:
|
||||
flash(_('You do not have a coach assigned to your team.'), 'info')
|
||||
@@ -1123,15 +1116,18 @@ def notes_dashboard():
|
||||
flash(_('Only coaches can access the notes dashboard.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
||||
# The team-notes panel is still written against a single team; the
|
||||
# player list is not, and used to be narrowed to one team's squad while
|
||||
# the POST routes accepted every player the coach works with. The form
|
||||
# offered fewer players than the handler would take.
|
||||
org_teams = coach_org_teams(current_user)
|
||||
org_team = org_teams[0] if org_teams else None
|
||||
|
||||
players = []
|
||||
if org_team:
|
||||
player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()]
|
||||
players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else []
|
||||
player_ids = coach_player_ids(current_user)
|
||||
players = User.query.filter(User.id.in_(player_ids)).order_by(
|
||||
User.username).all() if player_ids else []
|
||||
|
||||
team_notes = []
|
||||
personal_notes = []
|
||||
latest_team_note = None
|
||||
if org_team:
|
||||
team_notes = TeamNote.query.filter_by(
|
||||
@@ -1139,16 +1135,16 @@ def notes_dashboard():
|
||||
).order_by(TeamNote.created_at.desc()).all()
|
||||
latest_team_note = team_notes[0] if team_notes else None
|
||||
|
||||
personal_notes = PersonalNote.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
).order_by(PersonalNote.created_at.desc()).all()
|
||||
# A coach's own notes belong to them whether or not they hold a team;
|
||||
# this list was gated on org_team and came back empty without one.
|
||||
personal_notes = PersonalNote.query.filter_by(
|
||||
coach_id=current_user.id,
|
||||
).order_by(PersonalNote.created_at.desc()).all()
|
||||
|
||||
# One on One requests from team players
|
||||
one_on_one_requests = []
|
||||
if org_team and players:
|
||||
player_ids_list = [p.id for p in players]
|
||||
if player_ids:
|
||||
one_on_one_requests = OneOnOneRequest.query.filter(
|
||||
OneOnOneRequest.player_id.in_(player_ids_list)
|
||||
OneOnOneRequest.player_id.in_(player_ids)
|
||||
).order_by(OneOnOneRequest.created_at.desc()).all()
|
||||
|
||||
# For context selectors in the form
|
||||
@@ -1184,10 +1180,12 @@ def manage_team_notes():
|
||||
flash(_('Only coaches can manage team notes.'), 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
||||
if not org_team:
|
||||
# Same team the dashboard displays notes for, resolved the same way.
|
||||
org_teams = coach_org_teams(current_user)
|
||||
if not org_teams:
|
||||
flash(_('You are not assigned to a team.'), 'danger')
|
||||
return redirect(url_for('users.notes_dashboard'))
|
||||
org_team = org_teams[0]
|
||||
|
||||
content = request.form.get('content', '').strip()
|
||||
if content:
|
||||
|
||||
Reference in New Issue
Block a user