Files
GGThedandClaude Opus 5 7cec18c139 style: formater le depot avec ruff format
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]>
2026-08-08 15:53:10 -04:00

467 lines
17 KiB
Python

"""Coach ↔ player access rules.
SEC-AUTHZ-004 and SEC-AUTHZ-005. The same question — "may this coach act on
this player?" — was answered five different ways across the codebase:
teams.py:add_player_note checked TeamPlayer membership
users.py, four note routes checked nothing beyond isinstance(Coach)
contract.py:can_view checked the legacy coach_id column, and
treated a null team_id as a wildcard
app/permissions.py now holds one rule, and it reads both the many-to-many
relationship and the legacy column — so the second coach of a team is no
longer invisible to it (ARCH-002).
"""
from datetime import date
import pytest
from app.extensions import db
from app.models import (
Contract,
OrgTeam,
PersonalNote,
TeamPlayer,
Tryout,
TryoutRegistration,
)
from app.permissions import (
can_manage_player_contract,
coach_can_access_player,
coach_org_team_ids,
coach_player_ids,
visible_org_teams,
)
@pytest.fixture
def team_factory(app):
def _make(name, *, coach_id=None, legacy_coach_id=None, player_ids=()):
with app.app_context():
from app.models import User
team = OrgTeam(
name=name, created_by=coach_id or legacy_coach_id, coach_id=legacy_coach_id
)
db.session.add(team)
db.session.flush()
if coach_id:
team.coaches.append(db.session.get(User, coach_id))
for player_id in player_ids:
db.session.add(TeamPlayer(player_id=player_id, org_team_id=team.id))
db.session.commit()
return team.id
return _make
class TestTeamResolution:
def test_a_coach_attached_by_the_relationship_is_found(self, app, make_user, team_factory):
coach_id = make_user('coach')
team_id = team_factory('Varsity', coach_id=coach_id)
with app.app_context():
from app.models import User
coach = db.session.get(User, coach_id)
assert coach_org_team_ids(coach) == [team_id]
def test_a_coach_attached_by_the_legacy_column_is_found(self, app, make_user, team_factory):
coach_id = make_user('coach')
team_id = team_factory('JV', legacy_coach_id=coach_id)
with app.app_context():
from app.models import User
coach = db.session.get(User, coach_id)
assert coach_org_team_ids(coach) == [team_id]
def test_an_unattached_coach_has_no_team(self, app, make_user):
coach_id = make_user('coach')
with app.app_context():
from app.models import User
assert coach_org_team_ids(db.session.get(User, coach_id)) == []
class TestPlayerAccess:
def test_a_coach_reaches_a_player_on_their_team(self, app, make_user, team_factory):
coach_id = make_user('coach')
player_id = make_user('player')
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
with app.app_context():
from app.models import User
assert coach_can_access_player(db.session.get(User, coach_id), player_id)
def test_a_second_coach_of_the_team_also_reaches_the_player(self, app, make_user, team_factory):
"""The case that used to fail everywhere users.py looked at coach_id."""
first_coach = make_user('coach')
second_coach = make_user('coach')
player_id = make_user('player')
team_id = team_factory('Varsity', legacy_coach_id=first_coach, player_ids=[player_id])
with app.app_context():
from app.models import User
team = db.session.get(OrgTeam, team_id)
team.coaches.append(db.session.get(User, second_coach))
db.session.commit()
assert coach_can_access_player(db.session.get(User, second_coach), player_id)
def test_a_coach_does_not_reach_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
assert not coach_can_access_player(db.session.get(User, coach_id), stranger_id)
def test_a_coach_reaches_a_player_registered_in_their_tryout(self, app, make_user):
coach_id = make_user('coach')
player_id = make_user('player')
with app.app_context():
from app.models import User
tryout = Tryout(
title='Open tryout', game='Valorant', date=date(2030, 3, 1), created_by=coach_id
)
db.session.add(tryout)
db.session.flush()
tryout.coaches.append(db.session.get(User, coach_id))
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
db.session.commit()
assert coach_can_access_player(db.session.get(User, coach_id), player_id)
def test_a_missing_player_id_is_refused(self, app, make_user):
coach_id = make_user('coach')
with app.app_context():
from app.models import User
assert not coach_can_access_player(db.session.get(User, coach_id), None)
class TestPersonalNoteRoutes:
"""SEC-AUTHZ-004 — every coach could write a nominative note about
every player of the club. The notes are visible to the player."""
def test_a_coach_cannot_note_an_unrelated_player(
self, app, client, as_role, make_user, team_factory
):
stranger_id = make_user('player')
coach_id = as_role('coach')
team_factory('Varsity', coach_id=coach_id)
client.post(
'/users/personal-notes/manage',
data={
'player_id': stranger_id,
'content': 'Unrelated observation',
},
follow_redirects=True,
)
with app.app_context():
assert PersonalNote.query.filter_by(player_id=stranger_id).count() == 0
def test_a_coach_can_still_note_their_own_player(
self, app, client, as_role, make_user, team_factory
):
"""Guard against over-correcting."""
player_id = make_user('player')
coach_id = as_role('coach')
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
client.post(
'/users/personal-notes/manage',
data={
'player_id': player_id,
'content': 'Good positioning today',
},
follow_redirects=True,
)
with app.app_context():
note = PersonalNote.query.filter_by(player_id=player_id).one()
assert note.content == 'Good positioning today'
class TestContractVisibility:
"""SEC-AUTHZ-005 — `not self.team_id` was a wildcard, and upload_contract
leaves team_id null whenever the player has no team."""
@staticmethod
def _contract(app, player_id, uploader_id, team_id=None):
with app.app_context():
contract = Contract(
player_id=player_id,
team_id=team_id,
uploaded_by_id=uploader_id,
original_filename='c.pdf',
stored_filename='uuid.pdf',
file_path='/tmp/uuid.pdf',
)
db.session.add(contract)
db.session.commit()
return contract.id
def test_a_teamless_contract_is_not_visible_to_every_coach(self, app, make_user, team_factory):
coach_id = make_user('coach')
stranger_id = make_user('player')
admin_id = make_user('admin')
team_factory('Varsity', legacy_coach_id=coach_id)
contract_id = self._contract(app, stranger_id, admin_id, team_id=None)
with app.app_context():
from app.models import User
contract = db.session.get(Contract, contract_id)
assert not contract.can_view(db.session.get(User, coach_id))
def test_a_coach_sees_the_contract_of_their_own_player(self, app, make_user, team_factory):
coach_id = make_user('coach')
player_id = make_user('player')
admin_id = make_user('admin')
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
contract_id = self._contract(app, player_id, admin_id)
with app.app_context():
from app.models import User
contract = db.session.get(Contract, contract_id)
assert contract.can_view(db.session.get(User, coach_id))
def test_the_player_always_sees_their_own_contract(self, app, make_user):
player_id = make_user('player')
admin_id = make_user('admin')
contract_id = self._contract(app, player_id, admin_id)
with app.app_context():
from app.models import User
contract = db.session.get(Contract, contract_id)
assert contract.can_view(db.session.get(User, player_id))
def test_an_admin_sees_every_contract(self, app, make_user):
player_id = make_user('player')
admin_id = make_user('admin')
contract_id = self._contract(app, player_id, admin_id)
with app.app_context():
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