349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""Team representative ("joueur responsable") — the rule and the routes.
|
|
|
|
A player an admin names as their team's responsible may create, edit and
|
|
delete that team's matches and practices, and nothing else. Designation
|
|
itself is the president's decision, never the responsible's.
|
|
"""
|
|
|
|
from datetime import date, time
|
|
|
|
import pytest
|
|
|
|
from app.extensions import db
|
|
from app.models import AppSettings, OrgTeam, TeamMatch, TeamPlayer, User
|
|
from app.permissions import (
|
|
can_manage_team_matches,
|
|
is_team_representative,
|
|
representative_org_teams,
|
|
)
|
|
|
|
|
|
def _redirected(response):
|
|
return response.status_code in (301, 302)
|
|
|
|
|
|
def _build_world(app, make_user):
|
|
"""Two teams, a responsible on the first, a plain player on it too."""
|
|
with app.app_context():
|
|
admin_id = make_user('admin')
|
|
rep_id = make_user('player')
|
|
plain_id = make_user('player')
|
|
|
|
team = OrgTeam(name='Varsity', created_by=admin_id)
|
|
other = OrgTeam(name='JV', created_by=admin_id)
|
|
db.session.add_all([team, other])
|
|
db.session.flush()
|
|
|
|
# Added plain-first on purpose: the roster must still show the
|
|
# responsible at the top, not last, and a test whose world added the
|
|
# responsible first would pass without the ordering ever running.
|
|
db.session.add_all(
|
|
[
|
|
TeamPlayer(player_id=plain_id, org_team_id=team.id),
|
|
TeamPlayer(player_id=rep_id, org_team_id=team.id, is_team_representative=True),
|
|
]
|
|
)
|
|
db.session.commit()
|
|
|
|
# create/delete matches are gated on the season for non-presidents,
|
|
# and the responsible is one of them.
|
|
AppSettings.set_bool('season_active', True)
|
|
|
|
return {
|
|
'admin': admin_id,
|
|
'rep': rep_id,
|
|
'plain': plain_id,
|
|
'team': team.id,
|
|
'other': other.id,
|
|
'rep_username': db.session.get(User, rep_id).username,
|
|
'plain_username': db.session.get(User, plain_id).username,
|
|
}
|
|
|
|
|
|
def _make_match(app, world, title, team_key='team'):
|
|
with app.app_context():
|
|
match = TeamMatch(
|
|
org_team_id=world[team_key],
|
|
title=title,
|
|
date=date(2026, 10, 1),
|
|
start_time=time(10, 0),
|
|
end_time=time(11, 0),
|
|
created_by=world['admin'],
|
|
)
|
|
db.session.add(match)
|
|
db.session.commit()
|
|
return match.id
|
|
|
|
|
|
def _login_as(client, login, username):
|
|
"""Switch the test client to another existing account."""
|
|
client.post('/auth/logout', follow_redirects=False)
|
|
response = login(username)
|
|
assert _redirected(response), f'could not log in as {username}'
|
|
|
|
|
|
class TestTheRule:
|
|
"""The permission itself, without a request in the way."""
|
|
|
|
def test_a_rep_manages_their_team(self, app, make_user):
|
|
world = _build_world(app, make_user)
|
|
with app.app_context():
|
|
rep = db.session.get(User, world['rep'])
|
|
team = db.session.get(OrgTeam, world['team'])
|
|
|
|
assert is_team_representative(rep, team) is True
|
|
assert can_manage_team_matches(rep, team) is True
|
|
assert rep.can_manage_team_matches(team) is True
|
|
|
|
def test_a_rep_manages_no_other_team(self, app, make_user):
|
|
world = _build_world(app, make_user)
|
|
with app.app_context():
|
|
rep = db.session.get(User, world['rep'])
|
|
other = db.session.get(OrgTeam, world['other'])
|
|
|
|
assert is_team_representative(rep, other) is False
|
|
assert can_manage_team_matches(rep, other) is False
|
|
|
|
def test_a_rep_is_not_a_roster_manager(self, app, make_user):
|
|
"""Match rights only: the roster stays staff-only."""
|
|
world = _build_world(app, make_user)
|
|
with app.app_context():
|
|
rep = db.session.get(User, world['rep'])
|
|
team = db.session.get(OrgTeam, world['team'])
|
|
|
|
assert rep.can_manage_this_org_team(team) is False
|
|
assert rep.can_manage_teams() is False
|
|
assert rep.can_schedule_matches() is False
|
|
|
|
def test_a_plain_player_manages_nothing(self, app, make_user):
|
|
world = _build_world(app, make_user)
|
|
with app.app_context():
|
|
plain = db.session.get(User, world['plain'])
|
|
team = db.session.get(OrgTeam, world['team'])
|
|
|
|
assert can_manage_team_matches(plain, team) is False
|
|
|
|
def test_representative_org_teams_is_scoped(self, app, make_user):
|
|
world = _build_world(app, make_user)
|
|
with app.app_context():
|
|
rep = db.session.get(User, world['rep'])
|
|
assert [team.id for team in representative_org_teams(rep)] == [world['team']]
|
|
|
|
@pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout'])
|
|
def test_only_the_president_may_designate(self, app, make_user, role):
|
|
with app.app_context():
|
|
user = db.session.get(User, make_user(role))
|
|
assert user.can_designate_team_representative() == (role == 'admin')
|
|
|
|
|
|
class TestMatchManagement:
|
|
"""The routes the feature exists for: matches and practices."""
|
|
|
|
def test_a_rep_schedules_a_match(self, app, client, login, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
response = client.post(
|
|
f'/team-matches/{world["team"]}/create',
|
|
data={'title': 'Rep Match', 'date': '2026-10-01', 'start_time': '10:00'},
|
|
follow_redirects=False,
|
|
)
|
|
assert _redirected(response)
|
|
with app.app_context():
|
|
assert TeamMatch.query.filter_by(title='Rep Match').count() == 1
|
|
|
|
def test_a_rep_schedules_a_practice(self, app, client, login, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
response = client.post(
|
|
f'/team-matches/{world["team"]}/create?type=practice',
|
|
data={'title': 'Evening Scrim', 'date': '2026-10-02', 'start_time': '18:00'},
|
|
follow_redirects=False,
|
|
)
|
|
assert _redirected(response)
|
|
with app.app_context():
|
|
match = TeamMatch.query.filter_by(title='Evening Scrim').first()
|
|
assert match is not None
|
|
assert match.opponent is None
|
|
|
|
def test_a_rep_edits_a_match(self, app, client, login, make_user):
|
|
world = _build_world(app, make_user)
|
|
match_id = _make_match(app, world, title='Before')
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
response = client.post(
|
|
f'/team-matches/{match_id}/edit',
|
|
data={'title': 'After', 'date': '2026-10-03', 'start_time': '12:00'},
|
|
follow_redirects=False,
|
|
)
|
|
assert _redirected(response)
|
|
with app.app_context():
|
|
assert db.session.get(TeamMatch, match_id).title == 'After'
|
|
|
|
def test_a_rep_deletes_a_match(self, app, client, login, make_user):
|
|
world = _build_world(app, make_user)
|
|
match_id = _make_match(app, world, title='Doomed')
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
response = client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False)
|
|
assert _redirected(response)
|
|
with app.app_context():
|
|
assert db.session.get(TeamMatch, match_id) is None
|
|
|
|
def test_a_rep_does_not_schedule_for_another_team(self, app, client, login, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
client.post(
|
|
f'/team-matches/{world["other"]}/create',
|
|
data={'title': 'Sneaky', 'date': '2026-10-01', 'start_time': '10:00'},
|
|
follow_redirects=False,
|
|
)
|
|
with app.app_context():
|
|
assert TeamMatch.query.filter_by(title='Sneaky').count() == 0
|
|
|
|
def test_a_rep_does_not_delete_another_teams_match(self, app, client, login, make_user):
|
|
world = _build_world(app, make_user)
|
|
match_id = _make_match(app, world, title='Elsewhere', team_key='other')
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
client.post(f'/team-matches/{match_id}/delete', data={}, follow_redirects=False)
|
|
with app.app_context():
|
|
assert db.session.get(TeamMatch, match_id) is not None
|
|
|
|
def test_a_plain_player_does_not_schedule(self, app, client, login, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['plain_username'])
|
|
|
|
client.post(
|
|
f'/team-matches/{world["team"]}/create',
|
|
data={'title': 'Nope', 'date': '2026-10-01', 'start_time': '10:00'},
|
|
follow_redirects=False,
|
|
)
|
|
with app.app_context():
|
|
assert TeamMatch.query.filter_by(title='Nope').count() == 0
|
|
|
|
def test_a_rep_does_not_touch_the_roster(self, app, client, login, make_user):
|
|
"""Decision 3: matches only, never the roster."""
|
|
world = _build_world(app, make_user)
|
|
newcomer_id = make_user('player')
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
client.post(
|
|
f'/teams/{world["team"]}/add_player',
|
|
data={'player_id': str(newcomer_id), 'status': 'starter'},
|
|
follow_redirects=False,
|
|
)
|
|
with app.app_context():
|
|
added = TeamPlayer.query.filter_by(
|
|
player_id=newcomer_id, org_team_id=world['team']
|
|
).first()
|
|
assert added is None
|
|
|
|
def test_a_rep_does_not_toggle_a_roster_status(self, app, client, login, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
response = client.post(f'/teams/{world["team"]}/toggle_status/{world["plain"]}', json={})
|
|
assert response.status_code == 403
|
|
|
|
|
|
class TestDesignation:
|
|
"""Only the president names a responsible."""
|
|
|
|
@pytest.mark.parametrize('role', ['manager', 'coach', 'player', 'scout'])
|
|
def test_a_non_president_cannot_designate(self, app, client, as_role, make_user, role):
|
|
world = _build_world(app, make_user)
|
|
target_id = make_user('player')
|
|
as_role(role)
|
|
|
|
response = client.post(f'/teams/{world["team"]}/toggle_representative/{target_id}', json={})
|
|
assert response.status_code == 403
|
|
|
|
def test_the_president_designates_a_rep(self, app, client, as_role, make_user):
|
|
world = _build_world(app, make_user)
|
|
as_role('admin')
|
|
|
|
response = client.post(
|
|
f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={}
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.get_json()['is_team_representative'] is True
|
|
|
|
with app.app_context():
|
|
row = TeamPlayer.query.filter_by(
|
|
player_id=world['plain'], org_team_id=world['team']
|
|
).one()
|
|
assert row.is_team_representative is True
|
|
|
|
def test_a_rep_cannot_promote_another_player(self, app, client, login, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
response = client.post(
|
|
f'/teams/{world["team"]}/toggle_representative/{world["plain"]}', json={}
|
|
)
|
|
assert response.status_code == 403
|
|
with app.app_context():
|
|
row = TeamPlayer.query.filter_by(
|
|
player_id=world['plain'], org_team_id=world['team']
|
|
).one()
|
|
assert row.is_team_representative is False
|
|
|
|
|
|
class TestReachingThePage:
|
|
"""Reuse of the teams page, scoped (decision 4)."""
|
|
|
|
def test_a_plain_player_is_sent_to_their_own_view(self, client, login, app, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['plain_username'])
|
|
|
|
response = client.get('/teams', follow_redirects=False)
|
|
assert _redirected(response)
|
|
assert '/teams/my-teams' in response.headers['Location']
|
|
|
|
def test_a_rep_settles_on_the_teams_page(self, client, login, app, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
assert client.get('/teams').status_code == 200
|
|
|
|
def test_the_page_offers_matches_but_not_the_roster(self, client, login, app, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
html = client.get('/teams').get_data(as_text=True)
|
|
assert f'/team-matches/{world["team"]}/create' in html
|
|
assert f'/teams/{world["team"]}/add_player' not in html
|
|
|
|
def test_the_my_teams_page_offers_the_schedule_buttons(self, client, login, app, make_user):
|
|
"""A player's nav only has "My Team(s)", so the rep schedules there."""
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['rep_username'])
|
|
|
|
html = client.get('/teams/my-teams').get_data(as_text=True)
|
|
assert f'/team-matches/{world["team"]}/create' in html
|
|
assert 'create?type=practice' in html
|
|
|
|
def test_the_my_teams_page_is_silent_for_a_plain_player(self, client, login, app, make_user):
|
|
world = _build_world(app, make_user)
|
|
_login_as(client, login, world['plain_username'])
|
|
|
|
html = client.get('/teams/my-teams').get_data(as_text=True)
|
|
assert f'/team-matches/{world["team"]}/create' not in html
|
|
|
|
|
|
class TestRosterOrder:
|
|
"""The responsible heads the roster, on both pages that render it."""
|
|
|
|
def test_the_responsible_is_listed_first(self, app, make_user):
|
|
world = _build_world(app, make_user)
|
|
with app.app_context():
|
|
team = db.session.get(OrgTeam, world['team'])
|
|
roster = team.get_players_with_status()
|
|
|
|
assert [row['player'].id for row in roster] == [world['rep'], world['plain']]
|
|
assert roster[0]['is_team_representative'] is True
|
|
assert roster[1]['is_team_representative'] is False
|