QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun changement de comportement, aucune ligne de logique touchee. 72 fichiers, 4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur les commits voisins reste lisible. `quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui evite le brassage guillemets simples / doubles : le diff porte sur les retours a la ligne, l indentation des appels longs et les virgules finales, pas sur le style de chaine. Verification : 263 tests passent avant et apres, ruff check propre. L activation en CI arrive dans le commit suivant, separement, pour que ce diff-ci ne contienne rien d autre. Co-Authored-By: Claude Opus 5 <[email protected]>
347 lines
9.7 KiB
Python
347 lines
9.7 KiB
Python
"""Shared access-control rules — the single point of truth (ARCH-002).
|
|
|
|
Authorisation used to live inline in eight route modules, and the same
|
|
question could get a different answer depending on which URL you reached.
|
|
This module now holds the rules themselves; routes and models call it.
|
|
|
|
The subtlety it hides from callers: a coach or a manager can be attached to
|
|
a team two different ways.
|
|
|
|
OrgTeam.coach_id the original single-coach column
|
|
OrgTeam.coaches the many-to-many relationship added later
|
|
|
|
Both are still populated. Reading only ``coach_id`` — which most of
|
|
users.py did — silently locked out every coach who was not the first one on
|
|
their team, and reading ``.first()`` on top of it locked a coach out of
|
|
every team but one. Both defects were live in production. Every function
|
|
here considers both attachment routes and every team, so the fix applies
|
|
once instead of at each call site.
|
|
|
|
ARCH-001 will collapse the two columns into one for good; that needs a data
|
|
migration, so until then this module is what makes the duplication
|
|
harmless.
|
|
|
|
Functions take the acting user explicitly rather than reading
|
|
``current_user``: it keeps them callable from models, from the Discord bot,
|
|
and from tests without a request context.
|
|
"""
|
|
|
|
from app.extensions import db
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Team attachment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def coach_org_teams(coach):
|
|
"""Organisation teams a coach is attached to, ordered by name.
|
|
|
|
Considers the many-to-many relationship *and* the legacy column, so the
|
|
second coach of a team is not treated as belonging to nothing.
|
|
|
|
Args:
|
|
coach: The user to inspect.
|
|
|
|
Returns:
|
|
list[OrgTeam]: Possibly empty.
|
|
"""
|
|
from app.models import OrgTeam
|
|
|
|
return (
|
|
OrgTeam.query.filter(
|
|
db.or_(
|
|
OrgTeam.coaches.any(id=coach.id),
|
|
OrgTeam.coach_id == coach.id,
|
|
)
|
|
)
|
|
.order_by(OrgTeam.name)
|
|
.all()
|
|
)
|
|
|
|
|
|
def coach_org_team_ids(coach):
|
|
"""IDs of the organisation teams a coach is attached to.
|
|
|
|
Args:
|
|
coach: The user to inspect.
|
|
|
|
Returns:
|
|
list[int]: Team IDs, possibly empty.
|
|
"""
|
|
return [team.id for team in coach_org_teams(coach)]
|
|
|
|
|
|
def manager_org_teams(manager):
|
|
"""Organisation teams a manager is attached to, ordered by name.
|
|
|
|
Symmetric to :func:`coach_org_teams`: ``manager_id`` and ``managers``
|
|
carry the same duplication.
|
|
|
|
Args:
|
|
manager: The user to inspect.
|
|
|
|
Returns:
|
|
list[OrgTeam]: Possibly empty.
|
|
"""
|
|
from app.models import OrgTeam
|
|
|
|
return (
|
|
OrgTeam.query.filter(
|
|
db.or_(
|
|
OrgTeam.managers.any(id=manager.id),
|
|
OrgTeam.manager_id == manager.id,
|
|
)
|
|
)
|
|
.order_by(OrgTeam.name)
|
|
.all()
|
|
)
|
|
|
|
|
|
def attached_org_teams(user):
|
|
"""Teams the user is personally attached to, whatever their role.
|
|
|
|
A president is attached to none in particular — they administer them
|
|
all — so this returns an empty list for them. Callers that want "the
|
|
teams to display" want :func:`visible_org_teams` instead.
|
|
|
|
Args:
|
|
user: The acting user.
|
|
|
|
Returns:
|
|
list[OrgTeam]: Possibly empty.
|
|
"""
|
|
from app.models import Coach, Manager, Player
|
|
|
|
if isinstance(user, Coach):
|
|
return coach_org_teams(user)
|
|
if isinstance(user, Manager):
|
|
return manager_org_teams(user)
|
|
if isinstance(user, Player):
|
|
return user.get_org_teams()
|
|
return []
|
|
|
|
|
|
def visible_org_teams(user):
|
|
"""Organisation teams the user may see, ordered by name.
|
|
|
|
A president sees every team; a coach or a manager sees the ones they
|
|
are attached to; a player sees the ones they play on; anyone else sees
|
|
none.
|
|
|
|
Args:
|
|
user: The acting user.
|
|
|
|
Returns:
|
|
list[OrgTeam]: Possibly empty.
|
|
"""
|
|
from app.models import Admin, OrgTeam
|
|
|
|
if isinstance(user, Admin):
|
|
return OrgTeam.query.order_by(OrgTeam.name).all()
|
|
return attached_org_teams(user)
|
|
|
|
|
|
def can_manage_org_team(user, org_team):
|
|
"""Whether the user may administer this organisation team.
|
|
|
|
Delegates to the polymorphic model method, which is the role-level
|
|
rule; this wrapper exists so route code has one name to call and never
|
|
has to know which subclass it is holding.
|
|
|
|
Args:
|
|
user: The acting user.
|
|
org_team: The team concerned.
|
|
|
|
Returns:
|
|
bool
|
|
"""
|
|
return bool(org_team) and user.can_manage_this_org_team(org_team)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reach over players
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def org_team_player_ids(team_ids):
|
|
"""IDs of the players placed on any of these teams.
|
|
|
|
Args:
|
|
team_ids: Team primary keys.
|
|
|
|
Returns:
|
|
list[int]: Player IDs, possibly empty, without duplicates.
|
|
"""
|
|
from app.models import TeamPlayer
|
|
|
|
if not team_ids:
|
|
return []
|
|
rows = TeamPlayer.query.filter(TeamPlayer.org_team_id.in_(team_ids)).all()
|
|
return list({row.player_id for row in rows})
|
|
|
|
|
|
def coach_player_ids(coach):
|
|
"""IDs of the players on *all* of a coach's teams.
|
|
|
|
The routes this replaces looked at one team — the first row matching
|
|
the legacy column — so a coach of two teams could act on half of their
|
|
squad and no more.
|
|
|
|
Args:
|
|
coach: The acting coach.
|
|
|
|
Returns:
|
|
list[int]: Player IDs, possibly empty.
|
|
"""
|
|
return org_team_player_ids(coach_org_team_ids(coach))
|
|
|
|
|
|
def coach_can_access_player(coach, player_id):
|
|
"""Whether a coach may read or write information about a player.
|
|
|
|
True when the player sits on one of the coach's teams, or takes part in
|
|
a tryout the coach manages. Anything else means the two have no working
|
|
relationship, and a note or a contract about that player is none of the
|
|
coach's business.
|
|
|
|
Args:
|
|
coach: The acting coach.
|
|
player_id: Primary key of the player concerned.
|
|
|
|
Returns:
|
|
bool
|
|
"""
|
|
from app.models import Match, MatchParticipant, TeamPlayer, TryoutRegistration
|
|
|
|
if not player_id:
|
|
return False
|
|
|
|
team_ids = coach_org_team_ids(coach)
|
|
if team_ids:
|
|
on_team = TeamPlayer.query.filter(
|
|
TeamPlayer.player_id == player_id,
|
|
TeamPlayer.org_team_id.in_(team_ids),
|
|
).first()
|
|
if on_team:
|
|
return True
|
|
|
|
tryout_ids = coach_tryout_ids(coach, team_ids=team_ids)
|
|
if not tryout_ids:
|
|
return False
|
|
|
|
registered = TryoutRegistration.query.filter(
|
|
TryoutRegistration.player_id == player_id,
|
|
TryoutRegistration.tryout_id.in_(tryout_ids),
|
|
).first()
|
|
if registered:
|
|
return True
|
|
|
|
plays_a_match = (
|
|
MatchParticipant.query.join(Match)
|
|
.filter(
|
|
MatchParticipant.player_id == player_id,
|
|
Match.tryout_id.in_(tryout_ids),
|
|
)
|
|
.first()
|
|
)
|
|
return plays_a_match is not None
|
|
|
|
|
|
def can_manage_player_contract(user, player_id):
|
|
"""Whether the user may upload or replace a contract for this player.
|
|
|
|
Presidents and managers may do so for anyone. A coach may do so for the
|
|
players on their teams — the working relationship a contract implies.
|
|
|
|
Args:
|
|
user: The acting user.
|
|
player_id: Primary key of the player concerned.
|
|
|
|
Returns:
|
|
bool
|
|
"""
|
|
from app.models import Admin, Coach, Manager
|
|
|
|
if not player_id:
|
|
return False
|
|
if isinstance(user, (Admin, Manager)):
|
|
return True
|
|
if isinstance(user, Coach):
|
|
return player_id in coach_player_ids(user)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tryouts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def coach_tryouts(coach, team_ids=None):
|
|
"""Tryouts a coach manages, ordered by date.
|
|
|
|
A coach reaches a tryout through any of the three routes the model
|
|
supports: it targets one of their teams, they are named on the
|
|
many-to-many relationship, or the deprecated ``coach_id`` points at
|
|
them.
|
|
|
|
Args:
|
|
coach: The acting coach.
|
|
team_ids: Pre-computed team IDs, to avoid querying twice when the
|
|
caller already has them.
|
|
|
|
Returns:
|
|
list[Tryout]: Possibly empty.
|
|
"""
|
|
from app.models import Tryout
|
|
|
|
if team_ids is None:
|
|
team_ids = coach_org_team_ids(coach)
|
|
|
|
conditions = [
|
|
Tryout.coaches.any(id=coach.id),
|
|
Tryout.coach_id == coach.id,
|
|
]
|
|
if team_ids:
|
|
conditions.append(Tryout.target_org_team_id.in_(team_ids))
|
|
|
|
return Tryout.query.filter(db.or_(*conditions)).order_by(Tryout.date).all()
|
|
|
|
|
|
def coach_tryout_ids(coach, team_ids=None):
|
|
"""IDs of the tryouts a coach manages.
|
|
|
|
Args:
|
|
coach: The acting coach.
|
|
team_ids: Pre-computed team IDs, see :func:`coach_tryouts`.
|
|
|
|
Returns:
|
|
list[int]: Possibly empty.
|
|
"""
|
|
return [tryout.id for tryout in coach_tryouts(coach, team_ids=team_ids)]
|
|
|
|
|
|
def coach_manages_tryout(coach, tryout):
|
|
"""Whether a coach manages this particular tryout.
|
|
|
|
Same three routes as :func:`coach_tryouts`, asked about one row.
|
|
Written as a membership test rather than a query so that a tryout not
|
|
yet flushed to the database still answers correctly.
|
|
|
|
Args:
|
|
coach: The acting coach.
|
|
tryout: The tryout concerned.
|
|
|
|
Returns:
|
|
bool
|
|
"""
|
|
if tryout is None:
|
|
return False
|
|
if tryout.coach_id == coach.id:
|
|
return True
|
|
if any(c.id == coach.id for c in tryout.coaches):
|
|
return True
|
|
if tryout.target_org_team_id:
|
|
return tryout.target_org_team_id in coach_org_team_ids(coach)
|
|
return False
|