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:
@@ -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.models.user_model.user import User
|
||||||
from app.extensions import db
|
|
||||||
|
|
||||||
|
|
||||||
class Coach(User):
|
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'}
|
__mapper_args__ = {'polymorphic_identity': 'coach'}
|
||||||
|
|
||||||
def can_evaluate(self):
|
def can_evaluate(self):
|
||||||
@@ -17,37 +25,14 @@ class Coach(User):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def can_manage_this_tryout(self, tryout):
|
def can_manage_this_tryout(self, tryout):
|
||||||
from app.models.org_team.org_team import OrgTeam
|
from app.permissions import coach_manages_tryout
|
||||||
if tryout.target_org_team_id:
|
return coach_manages_tryout(self, tryout)
|
||||||
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
|
|
||||||
|
|
||||||
def can_manage_this_org_team(self, org_team):
|
def can_manage_this_org_team(self, org_team):
|
||||||
if org_team.coaches.filter_by(id=self.id).first():
|
if org_team.coaches.filter_by(id=self.id).first():
|
||||||
return True
|
return True
|
||||||
if org_team.coach_id == self.id:
|
return org_team.coach_id == self.id
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_visible_tryouts(self):
|
def get_visible_tryouts(self):
|
||||||
from app.models.tryout.tryout import Tryout
|
from app.permissions import coach_tryouts
|
||||||
team_ids = [t.id for t in self.coached_org_teams.all()]
|
return coach_tryouts(self)
|
||||||
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()
|
|
||||||
|
|||||||
+260
-31
@@ -1,28 +1,40 @@
|
|||||||
"""Shared access-control rules.
|
"""Shared access-control rules — the single point of truth (ARCH-002).
|
||||||
|
|
||||||
Authorisation logic currently lives inline in eight route modules, and the
|
Authorisation used to live inline in eight route modules, and the same
|
||||||
same question — "may this coach act on this player?" — is answered
|
question could get a different answer depending on which URL you reached.
|
||||||
differently depending on which route you reach. This module is the first
|
This module now holds the rules themselves; routes and models call it.
|
||||||
step towards a single point of truth (ARCH-002); rules move here as they
|
|
||||||
are unified, rather than in one sweeping change.
|
|
||||||
|
|
||||||
An important subtlety this module hides from callers: a coach can be
|
The subtlety it hides from callers: a coach or a manager can be attached to
|
||||||
attached to a team two different ways.
|
a team two different ways.
|
||||||
|
|
||||||
OrgTeam.coach_id the original single-coach column
|
OrgTeam.coach_id the original single-coach column
|
||||||
OrgTeam.coaches the many-to-many relationship added later
|
OrgTeam.coaches the many-to-many relationship added later
|
||||||
|
|
||||||
Both are still populated, and route code reads one or the other with no
|
Both are still populated. Reading only ``coach_id`` — which most of
|
||||||
apparent pattern. Reading only `coach_id` — which most of users.py does —
|
users.py did — silently locked out every coach who was not the first one on
|
||||||
silently locks out every coach who is not the first one on their team.
|
their team, and reading ``.first()`` on top of it locked a coach out of
|
||||||
coach_org_team_ids() always considers both.
|
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
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
def coach_org_team_ids(coach):
|
# ---------------------------------------------------------------------------
|
||||||
"""IDs of the organisation teams a coach is attached to.
|
# 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
|
Considers the many-to-many relationship *and* the legacy column, so the
|
||||||
second coach of a team is not treated as belonging to nothing.
|
second coach of a team is not treated as belonging to nothing.
|
||||||
@@ -31,17 +43,148 @@ def coach_org_team_ids(coach):
|
|||||||
coach: The user to inspect.
|
coach: The user to inspect.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[int]: Team IDs, possibly empty.
|
list[OrgTeam]: Possibly empty.
|
||||||
"""
|
"""
|
||||||
from app.models import OrgTeam
|
from app.models import OrgTeam
|
||||||
|
|
||||||
teams = OrgTeam.query.filter(
|
return OrgTeam.query.filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
OrgTeam.coaches.any(id=coach.id),
|
OrgTeam.coaches.any(id=coach.id),
|
||||||
OrgTeam.coach_id == coach.id,
|
OrgTeam.coach_id == coach.id,
|
||||||
)
|
)
|
||||||
).all()
|
).order_by(OrgTeam.name).all()
|
||||||
return [team.id for team in teams]
|
|
||||||
|
|
||||||
|
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):
|
def coach_can_access_player(coach, player_id):
|
||||||
@@ -59,9 +202,7 @@ def coach_can_access_player(coach, player_id):
|
|||||||
Returns:
|
Returns:
|
||||||
bool
|
bool
|
||||||
"""
|
"""
|
||||||
from app.models import (
|
from app.models import Match, MatchParticipant, TeamPlayer, TryoutRegistration
|
||||||
Match, MatchParticipant, TeamPlayer, Tryout, TryoutRegistration,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not player_id:
|
if not player_id:
|
||||||
return False
|
return False
|
||||||
@@ -75,16 +216,7 @@ def coach_can_access_player(coach, player_id):
|
|||||||
if on_team:
|
if on_team:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Tryouts the coach manages, through any of the three routes the model
|
tryout_ids = coach_tryout_ids(coach, team_ids=team_ids)
|
||||||
# supports: target team, many-to-many, or the deprecated coach_id.
|
|
||||||
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))
|
|
||||||
|
|
||||||
tryout_ids = [t.id for t in Tryout.query.filter(db.or_(*conditions)).all()]
|
|
||||||
if not tryout_ids:
|
if not tryout_ids:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -100,3 +232,100 @@ def coach_can_access_player(coach, player_id):
|
|||||||
Match.tryout_id.in_(tryout_ids),
|
Match.tryout_id.in_(tryout_ids),
|
||||||
).first()
|
).first()
|
||||||
return plays_a_match is not None
|
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
|
||||||
|
|||||||
+8
-6
@@ -10,8 +10,9 @@ from app.extensions import db
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
Admin, Manager, Coach, Player, Scout,
|
Admin, Manager, Coach, Player, Scout,
|
||||||
User, Tryout, Evaluation, TryoutRegistration, TeamMember,
|
User, Tryout, Evaluation, TryoutRegistration, TeamMember,
|
||||||
Match, MatchParticipant, OrgTeam,
|
Match, MatchParticipant,
|
||||||
)
|
)
|
||||||
|
from app.permissions import coach_tryout_ids
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
@@ -97,13 +98,14 @@ def dashboard():
|
|||||||
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
|
stats['my_recent_evaluations'] = Evaluation.query.filter_by(
|
||||||
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
evaluator_id=user.id).order_by(Evaluation.created_at.desc()).limit(10).all()
|
||||||
today = date.today()
|
today = date.today()
|
||||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
# Was: the first team matching the legacy coach_id column, and only
|
||||||
coach_tryout_ids = [t.id for t in Tryout.query.filter_by(
|
# the tryouts targeting it. A coach attached by the many-to-many
|
||||||
target_org_team_id=org_team.id).all()] if org_team else []
|
# relationship, or coaching a second team, saw no upcoming match.
|
||||||
|
tryout_ids = coach_tryout_ids(user)
|
||||||
stats['upcoming_matches'] = Match.query.filter(
|
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,
|
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):
|
elif isinstance(user, Player):
|
||||||
stats['my_tryouts'] = TryoutRegistration.query.filter_by(player_id=user.id).count()
|
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,
|
Admin, Manager, Coach, Player,
|
||||||
OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
OrgTeam, TeamMatch, TeamMatchParticipant, TeamPlayer,
|
||||||
)
|
)
|
||||||
|
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from app.discord_bot import send_schedule_notification
|
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):
|
def can_manage_team_match(team):
|
||||||
"""Check if current user can manage matches for this team."""
|
"""Whether the current user can manage matches for this team.
|
||||||
if isinstance(current_user, Admin):
|
|
||||||
return True
|
Same rule as administering the team itself, so it is the same call.
|
||||||
if isinstance(current_user, Manager):
|
This function used to restate it, and the restatement drifted.
|
||||||
return True
|
"""
|
||||||
if isinstance(current_user, Coach):
|
return can_manage_org_team(current_user, team)
|
||||||
if team.coaches.filter_by(id=current_user.id).first():
|
|
||||||
return True
|
|
||||||
if team.coach_id == current_user.id:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@team_matches_bp.route('')
|
@team_matches_bp.route('')
|
||||||
@@ -37,29 +33,17 @@ def list_matches():
|
|||||||
"""List all team matches visible to the current user."""
|
"""List all team matches visible to the current user."""
|
||||||
filter_team_id = request.args.get('team_id', type=int)
|
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()
|
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||||
matches_query = TeamMatch.query
|
matches_query = TeamMatch.query
|
||||||
elif isinstance(current_user, Manager):
|
elif isinstance(current_user, (Coach, Player)):
|
||||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
teams = visible_org_teams(current_user)
|
||||||
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()
|
|
||||||
team_ids = [t.id for t in teams]
|
team_ids = [t.id for t in teams]
|
||||||
matches_query = TeamMatch.query.filter(
|
matches_query = TeamMatch.query.filter(
|
||||||
TeamMatch.org_team_id.in_(team_ids),
|
TeamMatch.org_team_id.in_(team_ids),
|
||||||
) if team_ids else TeamMatch.query.filter(TeamMatch.id == -1)
|
) 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:
|
else:
|
||||||
teams = []
|
teams = []
|
||||||
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
matches_query = TeamMatch.query.filter(TeamMatch.id == -1)
|
||||||
@@ -274,12 +258,7 @@ def api_manageable_teams():
|
|||||||
if isinstance(current_user, (Admin, Manager)):
|
if isinstance(current_user, (Admin, Manager)):
|
||||||
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
teams = OrgTeam.query.order_by(OrgTeam.name).all()
|
||||||
elif isinstance(current_user, Coach):
|
elif isinstance(current_user, Coach):
|
||||||
teams = OrgTeam.query.filter(
|
teams = coach_org_teams(current_user)
|
||||||
db.or_(
|
|
||||||
OrgTeam.coaches.any(id=current_user.id),
|
|
||||||
OrgTeam.coach_id == current_user.id,
|
|
||||||
)
|
|
||||||
).order_by(OrgTeam.name).all()
|
|
||||||
else:
|
else:
|
||||||
return jsonify([])
|
return jsonify([])
|
||||||
|
|
||||||
|
|||||||
+5
-18
@@ -12,6 +12,7 @@ from app.models import (
|
|||||||
OrgTeam, User, PersonalNote, TeamNote, Tryout, TeamPlayer,
|
OrgTeam, User, PersonalNote, TeamNote, Tryout, TeamPlayer,
|
||||||
TeamMatch, Contract, OneOnOneRequest,
|
TeamMatch, Contract, OneOnOneRequest,
|
||||||
)
|
)
|
||||||
|
from app.permissions import visible_org_teams
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||||
@@ -23,29 +24,15 @@ def list_teams():
|
|||||||
"""List all organization teams visible to the current user."""
|
"""List all organization teams visible to the current user."""
|
||||||
can_manage = current_user.can_manage_teams()
|
can_manage = current_user.can_manage_teams()
|
||||||
|
|
||||||
if isinstance(current_user, Admin):
|
if isinstance(current_user, Player):
|
||||||
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):
|
|
||||||
flash(_('Use My Team(s) to view your teams.'), 'info')
|
flash(_('Use My Team(s) to view your teams.'), 'info')
|
||||||
return redirect(url_for('teams.my_teams'))
|
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')
|
flash(_('You do not have permission to view teams.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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()
|
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()
|
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()
|
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
||||||
|
|||||||
+45
-47
@@ -26,7 +26,10 @@ from app.validators import (
|
|||||||
UploadContractSchema,
|
UploadContractSchema,
|
||||||
)
|
)
|
||||||
from app.logging_config import log_auth_event
|
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
|
import requests
|
||||||
|
|
||||||
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
ALLOWED_CONTRACT_EXTENSIONS = {'pdf'}
|
||||||
@@ -575,19 +578,19 @@ def delete_disponibility(disponibility_id):
|
|||||||
# Contracts
|
# Contracts
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def can_manage_player_contract(user, player_id):
|
def _manageable_players():
|
||||||
"""Check if a user can upload contracts for a specific player."""
|
"""Players the current user may attach a contract to.
|
||||||
if isinstance(user, Admin):
|
|
||||||
return True
|
A coach used to see the squad of one team — the first row matching the
|
||||||
if isinstance(user, Manager):
|
legacy coach_id column — so a coach of two teams could file a contract
|
||||||
return True
|
for half of their players and no more, and a coach attached only by the
|
||||||
if isinstance(user, Coach):
|
many-to-many relationship for none at all.
|
||||||
org_team = OrgTeam.query.filter_by(coach_id=user.id).first()
|
"""
|
||||||
if org_team:
|
if isinstance(current_user, Coach):
|
||||||
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=org_team.id).first()
|
player_ids = coach_player_ids(current_user)
|
||||||
if tp:
|
return User.query.filter(User.id.in_(player_ids)).order_by(
|
||||||
return True
|
User.username).all() if player_ids else []
|
||||||
return False
|
return User.query.filter_by(role='player').order_by(User.username).all()
|
||||||
|
|
||||||
|
|
||||||
@users_bp.route('/contracts')
|
@users_bp.route('/contracts')
|
||||||
@@ -602,14 +605,7 @@ def list_contracts():
|
|||||||
player_id=current_user.id,
|
player_id=current_user.id,
|
||||||
).order_by(Contract.uploaded_at.desc()).all()
|
).order_by(Contract.uploaded_at.desc()).all()
|
||||||
elif isinstance(current_user, (Admin, Manager, Coach)):
|
elif isinstance(current_user, (Admin, Manager, Coach)):
|
||||||
players = []
|
players = _manageable_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()
|
|
||||||
|
|
||||||
if players:
|
if players:
|
||||||
player_ids = [p.id for p in 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')
|
flash(_('Only presidents, managers, and coaches can upload contracts.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
if isinstance(current_user, Coach):
|
players = _manageable_players()
|
||||||
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()
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
contract_schema = UploadContractSchema()
|
contract_schema = UploadContractSchema()
|
||||||
@@ -826,7 +814,12 @@ def one_on_one():
|
|||||||
|
|
||||||
org_teams = current_user.get_org_teams()
|
org_teams = current_user.get_org_teams()
|
||||||
org_team = org_teams[0] if org_teams else None
|
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:
|
if not coach:
|
||||||
flash(_('You do not have a coach assigned to your team.'), 'info')
|
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')
|
flash(_('Only coaches can access the notes dashboard.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
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 = []
|
player_ids = coach_player_ids(current_user)
|
||||||
if org_team:
|
players = User.query.filter(User.id.in_(player_ids)).order_by(
|
||||||
player_ids = [tp.player_id for tp in TeamPlayer.query.filter_by(org_team_id=org_team.id).all()]
|
User.username).all() if player_ids else []
|
||||||
players = User.query.filter(User.id.in_(player_ids)).all() if player_ids else []
|
|
||||||
|
|
||||||
team_notes = []
|
team_notes = []
|
||||||
personal_notes = []
|
|
||||||
latest_team_note = None
|
latest_team_note = None
|
||||||
if org_team:
|
if org_team:
|
||||||
team_notes = TeamNote.query.filter_by(
|
team_notes = TeamNote.query.filter_by(
|
||||||
@@ -1139,16 +1135,16 @@ def notes_dashboard():
|
|||||||
).order_by(TeamNote.created_at.desc()).all()
|
).order_by(TeamNote.created_at.desc()).all()
|
||||||
latest_team_note = team_notes[0] if team_notes else None
|
latest_team_note = team_notes[0] if team_notes else None
|
||||||
|
|
||||||
personal_notes = PersonalNote.query.filter_by(
|
# A coach's own notes belong to them whether or not they hold a team;
|
||||||
coach_id=current_user.id,
|
# this list was gated on org_team and came back empty without one.
|
||||||
).order_by(PersonalNote.created_at.desc()).all()
|
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 = []
|
one_on_one_requests = []
|
||||||
if org_team and players:
|
if player_ids:
|
||||||
player_ids_list = [p.id for p in players]
|
|
||||||
one_on_one_requests = OneOnOneRequest.query.filter(
|
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()
|
).order_by(OneOnOneRequest.created_at.desc()).all()
|
||||||
|
|
||||||
# For context selectors in the form
|
# For context selectors in the form
|
||||||
@@ -1184,10 +1180,12 @@ def manage_team_notes():
|
|||||||
flash(_('Only coaches can manage team notes.'), 'danger')
|
flash(_('Only coaches can manage team notes.'), 'danger')
|
||||||
return redirect(url_for('main.dashboard'))
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
org_team = OrgTeam.query.filter_by(coach_id=current_user.id).first()
|
# Same team the dashboard displays notes for, resolved the same way.
|
||||||
if not org_team:
|
org_teams = coach_org_teams(current_user)
|
||||||
|
if not org_teams:
|
||||||
flash(_('You are not assigned to a team.'), 'danger')
|
flash(_('You are not assigned to a team.'), 'danger')
|
||||||
return redirect(url_for('users.notes_dashboard'))
|
return redirect(url_for('users.notes_dashboard'))
|
||||||
|
org_team = org_teams[0]
|
||||||
|
|
||||||
content = request.form.get('content', '').strip()
|
content = request.form.get('content', '').strip()
|
||||||
if content:
|
if content:
|
||||||
|
|||||||
+202
-1
@@ -21,7 +21,10 @@ from app.extensions import db
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
Contract, OrgTeam, PersonalNote, TeamPlayer, Tryout, TryoutRegistration,
|
Contract, OrgTeam, PersonalNote, TeamPlayer, Tryout, TryoutRegistration,
|
||||||
)
|
)
|
||||||
from app.permissions import coach_can_access_player, coach_org_team_ids
|
from app.permissions import (
|
||||||
|
can_manage_player_contract, coach_can_access_player, coach_org_team_ids,
|
||||||
|
coach_player_ids, visible_org_teams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -236,3 +239,201 @@ class TestContractVisibility:
|
|||||||
from app.models import User
|
from app.models import User
|
||||||
contract = db.session.get(Contract, contract_id)
|
contract = db.session.get(Contract, contract_id)
|
||||||
assert contract.can_view(db.session.get(User, admin_id))
|
assert contract.can_view(db.session.get(User, admin_id))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecondTeam:
|
||||||
|
"""ARCH-002 — the routes resolved a coach's team with
|
||||||
|
|
||||||
|
OrgTeam.query.filter_by(coach_id=...).first()
|
||||||
|
|
||||||
|
which answers with at most one team, and only ever the legacy column.
|
||||||
|
A coach of two teams therefore reached half of their squad; a coach
|
||||||
|
attached by the relationship only reached none of it. Both defects were
|
||||||
|
live, and silent: the pages rendered, just empty."""
|
||||||
|
|
||||||
|
def test_a_coach_of_two_teams_reaches_both_squads(
|
||||||
|
self, app, make_user, team_factory
|
||||||
|
):
|
||||||
|
coach_id = make_user('coach')
|
||||||
|
first_player = make_user('player')
|
||||||
|
second_player = make_user('player')
|
||||||
|
team_factory('Varsity', legacy_coach_id=coach_id, player_ids=[first_player])
|
||||||
|
team_factory('JV', coach_id=coach_id, player_ids=[second_player])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models import User
|
||||||
|
coach = db.session.get(User, coach_id)
|
||||||
|
assert sorted(coach_player_ids(coach)) == sorted([first_player, second_player])
|
||||||
|
|
||||||
|
def test_a_contract_may_be_filed_for_a_player_of_the_second_team(
|
||||||
|
self, app, make_user, team_factory
|
||||||
|
):
|
||||||
|
coach_id = make_user('coach')
|
||||||
|
team_factory('Varsity', legacy_coach_id=coach_id)
|
||||||
|
second_player = make_user('player')
|
||||||
|
team_factory('JV', coach_id=coach_id, player_ids=[second_player])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models import User
|
||||||
|
coach = db.session.get(User, coach_id)
|
||||||
|
assert can_manage_player_contract(coach, second_player)
|
||||||
|
|
||||||
|
def test_a_contract_is_still_refused_for_an_unrelated_player(
|
||||||
|
self, app, make_user, team_factory
|
||||||
|
):
|
||||||
|
coach_id = make_user('coach')
|
||||||
|
stranger_id = make_user('player')
|
||||||
|
team_factory('Varsity', coach_id=coach_id)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models import User
|
||||||
|
coach = db.session.get(User, coach_id)
|
||||||
|
assert not can_manage_player_contract(coach, stranger_id)
|
||||||
|
|
||||||
|
def test_the_contract_page_lists_the_players_of_every_team(
|
||||||
|
self, app, client, as_role, make_user, team_factory
|
||||||
|
):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
first_player = make_user('player', username='alpha')
|
||||||
|
second_player = make_user('player', username='bravo')
|
||||||
|
team_factory('Varsity', legacy_coach_id=coach_id, player_ids=[first_player])
|
||||||
|
team_factory('JV', coach_id=coach_id, player_ids=[second_player])
|
||||||
|
|
||||||
|
body = client.get('/users/contracts/upload').get_data(as_text=True)
|
||||||
|
assert 'alpha' in body
|
||||||
|
assert 'bravo' in body
|
||||||
|
|
||||||
|
def test_the_notes_dashboard_lists_the_players_of_every_team(
|
||||||
|
self, app, client, as_role, make_user, team_factory
|
||||||
|
):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
first_player = make_user('player', username='charlie')
|
||||||
|
second_player = make_user('player', username='delta')
|
||||||
|
team_factory('Varsity', legacy_coach_id=coach_id, player_ids=[first_player])
|
||||||
|
team_factory('JV', coach_id=coach_id, player_ids=[second_player])
|
||||||
|
|
||||||
|
body = client.get('/users/notes-dashboard').get_data(as_text=True)
|
||||||
|
assert 'charlie' in body
|
||||||
|
assert 'delta' in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestTeamVisibility:
|
||||||
|
def test_a_coach_attached_by_the_relationship_sees_their_team(
|
||||||
|
self, app, make_user, team_factory
|
||||||
|
):
|
||||||
|
coach_id = make_user('coach')
|
||||||
|
team_factory('Varsity', coach_id=coach_id)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models import User
|
||||||
|
teams = visible_org_teams(db.session.get(User, coach_id))
|
||||||
|
assert [t.name for t in teams] == ['Varsity']
|
||||||
|
|
||||||
|
def test_a_president_sees_every_team(self, app, make_user, team_factory):
|
||||||
|
admin_id = make_user('admin')
|
||||||
|
other_coach = make_user('coach')
|
||||||
|
team_factory('Varsity', coach_id=other_coach)
|
||||||
|
team_factory('JV', legacy_coach_id=other_coach)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models import User
|
||||||
|
teams = visible_org_teams(db.session.get(User, admin_id))
|
||||||
|
assert [t.name for t in teams] == ['JV', 'Varsity']
|
||||||
|
|
||||||
|
def test_a_scout_sees_none(self, app, make_user, team_factory):
|
||||||
|
scout_id = make_user('scout')
|
||||||
|
coach_id = make_user('coach')
|
||||||
|
team_factory('Varsity', coach_id=coach_id)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models import User
|
||||||
|
assert visible_org_teams(db.session.get(User, scout_id)) == []
|
||||||
|
|
||||||
|
def test_the_team_listing_shows_the_relationship_team(
|
||||||
|
self, app, client, as_role, team_factory
|
||||||
|
):
|
||||||
|
coach_id = as_role('coach')
|
||||||
|
team_factory('Northern Lights', coach_id=coach_id)
|
||||||
|
|
||||||
|
body = client.get('/teams').get_data(as_text=True)
|
||||||
|
assert 'Northern Lights' in body
|
||||||
|
|
||||||
|
|
||||||
|
class TestTryoutVisibility:
|
||||||
|
"""Coach.get_visible_tryouts() read the many-to-many relationship only,
|
||||||
|
so a coach attached by the legacy column had an empty calendar."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tryout(app, *, creator_id, title, target_team_id=None, legacy_coach_id=None):
|
||||||
|
with app.app_context():
|
||||||
|
tryout = Tryout(title=title, game='Valorant', date=date(2030, 5, 1),
|
||||||
|
created_by=creator_id, target_org_team_id=target_team_id,
|
||||||
|
coach_id=legacy_coach_id)
|
||||||
|
db.session.add(tryout)
|
||||||
|
db.session.commit()
|
||||||
|
return tryout.id
|
||||||
|
|
||||||
|
def test_a_tryout_targeting_a_legacy_team_is_visible(
|
||||||
|
self, app, make_user, team_factory
|
||||||
|
):
|
||||||
|
coach_id = make_user('coach')
|
||||||
|
team_id = team_factory('Varsity', legacy_coach_id=coach_id)
|
||||||
|
self._tryout(app, creator_id=coach_id, title='Spring intake',
|
||||||
|
target_team_id=team_id)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models import User
|
||||||
|
coach = db.session.get(User, coach_id)
|
||||||
|
assert [t.title for t in coach.get_visible_tryouts()] == ['Spring intake']
|
||||||
|
|
||||||
|
def test_the_same_tryout_is_manageable(self, app, make_user, team_factory):
|
||||||
|
coach_id = make_user('coach')
|
||||||
|
team_id = team_factory('Varsity', legacy_coach_id=coach_id)
|
||||||
|
tryout_id = self._tryout(app, creator_id=coach_id, title='Spring intake',
|
||||||
|
target_team_id=team_id)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models import User
|
||||||
|
coach = db.session.get(User, coach_id)
|
||||||
|
assert coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id))
|
||||||
|
|
||||||
|
def test_another_coachs_tryout_stays_out_of_reach(
|
||||||
|
self, app, make_user, team_factory
|
||||||
|
):
|
||||||
|
coach_id = make_user('coach')
|
||||||
|
other_id = make_user('coach')
|
||||||
|
team_factory('Varsity', coach_id=coach_id)
|
||||||
|
other_team = team_factory('JV', coach_id=other_id)
|
||||||
|
tryout_id = self._tryout(app, creator_id=other_id, title='Their intake',
|
||||||
|
target_team_id=other_team)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
from app.models import User
|
||||||
|
coach = db.session.get(User, coach_id)
|
||||||
|
assert coach.get_visible_tryouts() == []
|
||||||
|
assert not coach.can_manage_this_tryout(db.session.get(Tryout, tryout_id))
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneOnOnePage:
|
||||||
|
"""A player whose team lists its coaches through the relationship was
|
||||||
|
told they had no coach, and the request form stayed closed."""
|
||||||
|
|
||||||
|
#: The request form and its availability payload are rendered under
|
||||||
|
#: `{% if coach %}`. Asserting on this marker rather than on wording
|
||||||
|
#: keeps the test about the capability, not about a translated string.
|
||||||
|
REQUEST_FORM = 'id="coach-availability-data"'
|
||||||
|
|
||||||
|
def test_a_player_finds_the_coach_attached_by_the_relationship(
|
||||||
|
self, app, client, as_role, make_user, team_factory
|
||||||
|
):
|
||||||
|
coach_id = make_user('coach')
|
||||||
|
player_id = as_role('player')
|
||||||
|
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
|
||||||
|
|
||||||
|
body = client.get('/users/one-on-one').get_data(as_text=True)
|
||||||
|
assert self.REQUEST_FORM in body
|
||||||
|
|
||||||
|
def test_a_teamless_player_gets_no_request_form(self, app, client, as_role):
|
||||||
|
as_role('player')
|
||||||
|
body = client.get('/users/one-on-one').get_data(as_text=True)
|
||||||
|
assert self.REQUEST_FORM not in body
|
||||||
|
|||||||
Reference in New Issue
Block a user