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:
+202
-1
@@ -21,7 +21,10 @@ from app.extensions import db
|
||||
from app.models import (
|
||||
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
|
||||
@@ -236,3 +239,201 @@ class TestContractVisibility:
|
||||
from app.models import User
|
||||
contract = db.session.get(Contract, contract_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