Compare commits
1
Commits
bd9ed4bdc7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5903b131 |
@@ -61,7 +61,14 @@ class OrgTeam(db.Model):
|
|||||||
return [tp.player for tp in self.team_players]
|
return [tp.player for tp in self.team_players]
|
||||||
|
|
||||||
def get_players_with_status(self):
|
def get_players_with_status(self):
|
||||||
return [
|
rows = [
|
||||||
{'player': tp.player, 'status': tp.status, 'position': tp.position}
|
{
|
||||||
|
'player': tp.player,
|
||||||
|
'status': tp.status,
|
||||||
|
'position': tp.position,
|
||||||
|
'is_team_representative': tp.is_team_representative,
|
||||||
|
}
|
||||||
for tp in self.team_players
|
for tp in self.team_players
|
||||||
]
|
]
|
||||||
|
# The responsible heads the roster; everyone else keeps roster order.
|
||||||
|
return sorted(rows, key=lambda row: not row['is_team_representative'])
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class TeamPlayer(db.Model):
|
|||||||
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
player_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
|
||||||
status = db.Column(db.String(20), nullable=False, default='starter')
|
status = db.Column(db.String(20), nullable=False, default='starter')
|
||||||
|
#: A player an admin named as their team's responsible ("joueur
|
||||||
|
#: responsable") — see app/permissions.py. Grants match-management
|
||||||
|
#: rights for that one team and nothing else.
|
||||||
|
is_team_representative = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
position = db.Column(db.String(50), nullable=True)
|
position = db.Column(db.String(50), nullable=True)
|
||||||
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
added_at = db.Column(db.DateTime, default=utc_now_naive)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class Admin(User):
|
|||||||
def can_manage_this_org_team(self, org_team):
|
def can_manage_this_org_team(self, org_team):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def can_designate_team_representative(self):
|
||||||
|
return True
|
||||||
|
|
||||||
def get_visible_tryouts(self):
|
def get_visible_tryouts(self):
|
||||||
from app.models.tryout.tryout import Tryout
|
from app.models.tryout.tryout import Tryout
|
||||||
|
|
||||||
|
|||||||
@@ -103,5 +103,20 @@ class User(UserMixin, db.Model):
|
|||||||
def can_manage_this_org_team(self, org_team):
|
def can_manage_this_org_team(self, org_team):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def can_manage_team_matches(self, org_team):
|
||||||
|
"""Whether the user may manage this team's matches and practices.
|
||||||
|
|
||||||
|
Broader than :meth:`can_manage_this_org_team`: a player named as the
|
||||||
|
team's responsible joins whoever administers it. The rule itself
|
||||||
|
lives in ``app.permissions``.
|
||||||
|
"""
|
||||||
|
from app.permissions import can_manage_team_matches
|
||||||
|
|
||||||
|
return can_manage_team_matches(self, org_team)
|
||||||
|
|
||||||
|
def can_designate_team_representative(self):
|
||||||
|
"""Whether the user may name a team's responsible. President only."""
|
||||||
|
return False
|
||||||
|
|
||||||
def get_visible_tryouts(self):
|
def get_visible_tryouts(self):
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -158,6 +158,78 @@ def can_manage_org_team(user, org_team):
|
|||||||
return bool(org_team) and user.can_manage_this_org_team(org_team)
|
return bool(org_team) and user.can_manage_this_org_team(org_team)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Team representatives ("joueurs responsables")
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def is_team_representative(user, org_team):
|
||||||
|
"""Whether the user is the named responsible of this organisation team.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The acting user.
|
||||||
|
org_team: The team concerned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool
|
||||||
|
"""
|
||||||
|
if not user or not org_team:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from app.models import TeamPlayer
|
||||||
|
|
||||||
|
return (
|
||||||
|
TeamPlayer.query.filter_by(
|
||||||
|
player_id=user.id,
|
||||||
|
org_team_id=org_team.id,
|
||||||
|
is_team_representative=True,
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def representative_org_teams(user):
|
||||||
|
"""Organisation teams the user is the responsible of, ordered by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The acting user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[OrgTeam]: Possibly empty.
|
||||||
|
"""
|
||||||
|
from app.models import OrgTeam, TeamPlayer
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return []
|
||||||
|
return (
|
||||||
|
OrgTeam.query.join(TeamPlayer, TeamPlayer.org_team_id == OrgTeam.id)
|
||||||
|
.filter(
|
||||||
|
TeamPlayer.player_id == user.id,
|
||||||
|
TeamPlayer.is_team_representative.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(OrgTeam.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_team_matches(user, org_team):
|
||||||
|
"""Whether the user may manage this team's matches and practices.
|
||||||
|
|
||||||
|
The staff rule (see :func:`can_manage_org_team`) *plus* the player named
|
||||||
|
as the team's responsible. Deliberately narrower than
|
||||||
|
``can_manage_org_team``: a responsible schedules and edits matches, but
|
||||||
|
does not administer the roster.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The acting user.
|
||||||
|
org_team: The team concerned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool
|
||||||
|
"""
|
||||||
|
return can_manage_org_team(user, org_team) or is_team_representative(user, org_team)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Reach over players
|
# Reach over players
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from app.models import (
|
|||||||
TeamPlayer,
|
TeamPlayer,
|
||||||
)
|
)
|
||||||
from app.pagination import paginate
|
from app.pagination import paginate
|
||||||
from app.permissions import can_manage_org_team, coach_org_teams, visible_org_teams
|
from app.permissions import coach_org_teams, representative_org_teams, visible_org_teams
|
||||||
from app.responders import respond_after_change
|
from app.responders import respond_after_change
|
||||||
from app.routes.matches import default_end_time
|
from app.routes.matches import default_end_time
|
||||||
from app.services.scheduling import notify_participants, zip_participants
|
from app.services.scheduling import notify_participants, zip_participants
|
||||||
@@ -36,10 +36,11 @@ team_matches_bp = Blueprint('team_matches', __name__, url_prefix='/team-matches'
|
|||||||
def can_manage_team_match(team):
|
def can_manage_team_match(team):
|
||||||
"""Whether the current user can manage matches for this team.
|
"""Whether the current user can manage matches for this team.
|
||||||
|
|
||||||
Same rule as administering the team itself, so it is the same call.
|
Everyone who administers the team, plus the player named as its
|
||||||
This function used to restate it, and the restatement drifted.
|
responsible. Narrower than administering the team itself: a responsible
|
||||||
|
schedules and edits matches, but does not touch the roster.
|
||||||
"""
|
"""
|
||||||
return can_manage_org_team(current_user, team)
|
return current_user.can_manage_team_matches(team)
|
||||||
|
|
||||||
|
|
||||||
def season_locked():
|
def season_locked():
|
||||||
@@ -300,13 +301,13 @@ def delete_match(match_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def api_manageable_teams():
|
def api_manageable_teams():
|
||||||
"""API endpoint returning teams the current user can schedule matches for."""
|
"""API endpoint returning teams the current user can schedule matches for."""
|
||||||
if not current_user.can_schedule_matches():
|
|
||||||
return jsonify([])
|
|
||||||
|
|
||||||
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 = coach_org_teams(current_user)
|
teams = coach_org_teams(current_user)
|
||||||
|
elif isinstance(current_user, Player):
|
||||||
|
# A responsible schedules matches for their own team(s) only.
|
||||||
|
teams = representative_org_teams(current_user)
|
||||||
else:
|
else:
|
||||||
return jsonify([])
|
return jsonify([])
|
||||||
|
|
||||||
|
|||||||
+35
-3
@@ -26,7 +26,7 @@ from app.models import (
|
|||||||
Tryout,
|
Tryout,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.permissions import visible_org_teams
|
from app.permissions import representative_org_teams, visible_org_teams
|
||||||
from app.responders import respond_after_change
|
from app.responders import respond_after_change
|
||||||
from app.time_utils import utc_now_naive
|
from app.time_utils import utc_now_naive
|
||||||
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
|
||||||
@@ -40,10 +40,12 @@ 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, Player):
|
# A player reaches this page only as a team's responsible; anyone else
|
||||||
|
# is sent to their own view.
|
||||||
|
if isinstance(current_user, Player) and not representative_org_teams(current_user):
|
||||||
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'))
|
||||||
if not isinstance(current_user, (Admin, Coach, Manager)):
|
if not isinstance(current_user, (Admin, Coach, Manager, Player)):
|
||||||
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'))
|
||||||
|
|
||||||
@@ -563,6 +565,36 @@ def toggle_player_status(team_id, player_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@teams_bp.route('/<int:team_id>/toggle_representative/<int:player_id>', methods=['POST'])
|
||||||
|
@json_endpoint
|
||||||
|
@login_required
|
||||||
|
def toggle_player_representative(team_id, player_id):
|
||||||
|
"""Name or un-name a player as their team's responsible (president only).
|
||||||
|
|
||||||
|
The responsible gains match-management rights for this one team. The
|
||||||
|
flag itself stays a president's decision: this is deliberately the one
|
||||||
|
roster action a responsible cannot perform on their own team.
|
||||||
|
"""
|
||||||
|
db.get_or_404(OrgTeam, team_id)
|
||||||
|
if not current_user.can_designate_team_representative():
|
||||||
|
return jsonify({'error': 'Permission denied'}), 403
|
||||||
|
|
||||||
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
||||||
|
if not tp:
|
||||||
|
return jsonify({'error': 'Player not found on this team'}), 404
|
||||||
|
|
||||||
|
tp.is_team_representative = not tp.is_team_representative
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
'success': True,
|
||||||
|
'player_id': player_id,
|
||||||
|
'is_team_representative': tp.is_team_representative,
|
||||||
|
'player_name': tp.player.username,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_team_note(team_id):
|
def add_team_note(team_id):
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3><i class="fas fa-users"></i> {{ team.name }}</h3>
|
<h3><i class="fas fa-users"></i> {{ team.name }}</h3>
|
||||||
|
<div class="card-actions">
|
||||||
|
{% if current_user.can_manage_team_matches(team) %}
|
||||||
|
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}">
|
||||||
|
<i class="fas fa-plus"></i> {{ _('Match') }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('team_matches.create_match', team_id=team.id, type='practice') }}" class="btn btn-sm btn-info" title="{{ _('Schedule Practice') }}">
|
||||||
|
<i class="fas fa-dumbbell"></i> {{ _('Practice') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Staff bar -->
|
<!-- Staff bar -->
|
||||||
<div class="team-staff-bar">
|
<div class="team-staff-bar">
|
||||||
@@ -61,6 +71,9 @@
|
|||||||
<div class="user-mini">
|
<div class="user-mini">
|
||||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||||
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
|
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
|
||||||
|
{% if entry.is_team_representative %}
|
||||||
|
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
|
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="eval-actions">
|
<td class="eval-actions">
|
||||||
{% set can_manage_this = (current_user.role in ['admin', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %}
|
{% set can_manage_this = current_user.can_manage_team_matches(m.org_team) %}
|
||||||
{% if can_manage_this %}
|
{% if can_manage_this %}
|
||||||
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}">
|
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
<a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="{{ _('View Team Matches') }}">
|
<a href="{{ url_for('team_matches.list_matches') }}?team_id={{ team.id }}" class="btn btn-sm btn-outline" title="{{ _('View Team Matches') }}">
|
||||||
<i class="fas fa-futbol"></i> {{ _('Matches') }}
|
<i class="fas fa-futbol"></i> {{ _('Matches') }}
|
||||||
</a>
|
</a>
|
||||||
{% if current_user.can_schedule_matches() and (current_user.role in ['admin', 'manager'] or (current_user.role == 'coach' and team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and team.coach_id == current_user.id)) %}
|
{% if current_user.can_manage_team_matches(team) %}
|
||||||
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}">
|
<a href="{{ url_for('team_matches.create_match', team_id=team.id) }}" class="btn btn-sm btn-success" title="{{ _('Schedule Team Match') }}">
|
||||||
<i class="fas fa-plus"></i> {{ _('Match') }}
|
<i class="fas fa-plus"></i> {{ _('Match') }}
|
||||||
</a>
|
</a>
|
||||||
@@ -159,6 +159,9 @@
|
|||||||
<div class="user-mini">
|
<div class="user-mini">
|
||||||
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
<div class="avatar-sm">{{ entry.player.username[:2] | upper }}</div>
|
||||||
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
|
<a href="{{ url_for('users.view_user', user_id=entry.player.id) }}">{{ entry.player.username }}</a>
|
||||||
|
{% if entry.is_team_representative %}
|
||||||
|
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -180,6 +183,16 @@
|
|||||||
<td>{{ entry.player.phone or '-' }}</td>
|
<td>{{ entry.player.phone or '-' }}</td>
|
||||||
{% if can_manage_team %}
|
{% if can_manage_team %}
|
||||||
<td>
|
<td>
|
||||||
|
{% if current_user.can_designate_team_representative() %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm representative-toggle-btn {% if entry.is_team_representative %}btn-warning{% else %}btn-outline{% endif %}"
|
||||||
|
data-team-id="{{ team.id }}"
|
||||||
|
data-player-id="{{ entry.player.id }}"
|
||||||
|
data-action="toggle-representative"
|
||||||
|
title="{{ _('Name as team representative') }}">
|
||||||
|
<i class="fas fa-star"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
|
<form method="POST" data-ajax action="{{ url_for('teams.remove_player', team_id=team.id, player_id=entry.player.id) }}" class="inline-form" data-confirm="{{ _('Remove %(player)s from %(team)s?', player=entry.player.username, team=team.name) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-danger">
|
<button type="submit" class="btn btn-sm btn-danger">
|
||||||
@@ -341,6 +354,35 @@ function toggleStatus(btn) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleRepresentative(btn) {
|
||||||
|
var teamId = btn.getAttribute('data-team-id');
|
||||||
|
var playerId = btn.getAttribute('data-player-id');
|
||||||
|
|
||||||
|
fetch('/teams/' + teamId + '/toggle_representative/' + playerId, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': '{{ csrf_token() }}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
if (data.is_team_representative) {
|
||||||
|
btn.classList.remove('btn-outline');
|
||||||
|
btn.classList.add('btn-warning');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('btn-warning');
|
||||||
|
btn.classList.add('btn-outline');
|
||||||
|
}
|
||||||
|
window.TT.refreshPage(window.location.pathname);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var currentEditTeamId = null;
|
var currentEditTeamId = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -416,6 +458,7 @@ registerActions({
|
|||||||
'hide-create-form': hideCreateForm,
|
'hide-create-form': hideCreateForm,
|
||||||
'hide-edit-form': hideEditForm,
|
'hide-edit-form': hideEditForm,
|
||||||
'toggle-status': toggleStatus,
|
'toggle-status': toggleStatus,
|
||||||
|
'toggle-representative': toggleRepresentative,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Binary file not shown.
@@ -3047,6 +3047,18 @@ msgstr "View Profile"
|
|||||||
msgid "Availability"
|
msgid "Availability"
|
||||||
msgstr "Availability"
|
msgstr "Availability"
|
||||||
|
|
||||||
|
#: app/templates/pages/my_teams.html:65 app/templates/pages/teams.html:163
|
||||||
|
msgid "Representative"
|
||||||
|
msgstr "Representative"
|
||||||
|
|
||||||
|
#: app/templates/pages/my_teams.html:65 app/templates/pages/teams.html:163
|
||||||
|
msgid "Team representative"
|
||||||
|
msgstr "Team representative"
|
||||||
|
|
||||||
|
#: app/templates/pages/teams.html:192
|
||||||
|
msgid "Name as team representative"
|
||||||
|
msgstr "Name as team representative"
|
||||||
|
|
||||||
#~ msgid "×"
|
#~ msgid "×"
|
||||||
#~ msgstr "×"
|
#~ msgstr "×"
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -3070,6 +3070,18 @@ msgstr "Voir le profil"
|
|||||||
msgid "Availability"
|
msgid "Availability"
|
||||||
msgstr "Disponibilités"
|
msgstr "Disponibilités"
|
||||||
|
|
||||||
|
#: app/templates/pages/my_teams.html:65 app/templates/pages/teams.html:163
|
||||||
|
msgid "Representative"
|
||||||
|
msgstr "Responsable"
|
||||||
|
|
||||||
|
#: app/templates/pages/my_teams.html:65 app/templates/pages/teams.html:163
|
||||||
|
msgid "Team representative"
|
||||||
|
msgstr "Responsable d’équipe"
|
||||||
|
|
||||||
|
#: app/templates/pages/teams.html:192
|
||||||
|
msgid "Name as team representative"
|
||||||
|
msgstr "Nommer comme responsable d’équipe"
|
||||||
|
|
||||||
#~ msgid "×"
|
#~ msgid "×"
|
||||||
#~ msgstr "×"
|
#~ msgstr "×"
|
||||||
|
|
||||||
|
|||||||
@@ -128,3 +128,18 @@ Générer la migration initiale depuis les modèles « pour avancer en
|
|||||||
attendant ». Ça produit un dépôt qui a l'air d'avoir des migrations, une
|
attendant ». Ça produit un dépôt qui a l'air d'avoir des migrations, une
|
||||||
production estampillée sur un état qu'elle n'a pas, et la dérive préservée
|
production estampillée sur un état qu'elle n'a pas, et la dérive préservée
|
||||||
sous une couche de plus.
|
sous une couche de plus.
|
||||||
|
|
||||||
|
## Ajouts de colonnes faits à la main
|
||||||
|
|
||||||
|
En attendant Alembic, une colonne ajoutée à un modèle doit être rattrapée sur
|
||||||
|
les bases existantes par un script, en plus du modèle (pour les installations
|
||||||
|
neuves, via `create_all()`). `migrations/add_tryout_coaches.py` a ouvert la
|
||||||
|
voie ; `migrations/add_team_representative.py` ajoute
|
||||||
|
`team_players.is_team_representative` de la même façon : lecture du catalogue,
|
||||||
|
`ALTER TABLE ... ADD COLUMN` idempotent, rien si la colonne est déjà là.
|
||||||
|
|
||||||
|
Le script trouve sa base tout seul : `DATABASE_URL` s'il est défini (production,
|
||||||
|
CI), sinon les valeurs `LOCAL_PG*` de `app/.env` — le serveur de développement
|
||||||
|
est réutilisé s'il répond, sinon (re)démarré puis arrêté à la fin du script :
|
||||||
|
|
||||||
|
python migrations/add_team_representative.py
|
||||||
|
|||||||
@@ -64,6 +64,31 @@ voulue, elle n'est écrite nulle part ailleurs qu'ici.
|
|||||||
| Coach | rattaché à l'équipe, par la relation m2m **ou** par la colonne héritée |
|
| Coach | rattaché à l'équipe, par la relation m2m **ou** par la colonne héritée |
|
||||||
| Recruteur, Joueur | toujours faux |
|
| Recruteur, Joueur | toujours faux |
|
||||||
|
|
||||||
|
### `can_manage_team_matches(team)`
|
||||||
|
|
||||||
|
Plus étroite que `can_manage_this_org_team`, et c'est voulu : elle répond à
|
||||||
|
« peut-on gérer les matchs et les entraînements de cette équipe ? ».
|
||||||
|
|
||||||
|
| Rôle | Règle |
|
||||||
|
|---|---|
|
||||||
|
| Président, Gérant | toujours vrai |
|
||||||
|
| Coach | rattaché à l'équipe (m2m **ou** colonne héritée) |
|
||||||
|
| Joueur responsable | vrai pour **sa seule** équipe |
|
||||||
|
| Recruteur, Joueur | faux |
|
||||||
|
|
||||||
|
### Le joueur responsable
|
||||||
|
|
||||||
|
Un **joueur responsable** est un joueur qu'un **président** désigne, depuis la
|
||||||
|
page Équipes, sur la ligne de son équipe (étoile à côté du bouton de retrait).
|
||||||
|
Il peut planifier, modifier et supprimer les **matchs et entraînements** de
|
||||||
|
cette équipe, et rien d'autre : ni l'effectif, ni les notes, ni le statut
|
||||||
|
partant/remplaçant, qui restent réservés au personnel.
|
||||||
|
|
||||||
|
Le drapeau vit sur `team_players.is_team_representative` (voir
|
||||||
|
`docs/database-schema.md`). La désignation est la décision du président —
|
||||||
|
`can_designate_team_representative()`, vrai seulement pour `Admin` — pour
|
||||||
|
qu'un responsable ne puisse pas se reproduire lui-même.
|
||||||
|
|
||||||
## Le piège : deux façons d'être rattaché
|
## Le piège : deux façons d'être rattaché
|
||||||
|
|
||||||
C'est le point le plus coûteux de tout le modèle d'autorisation, et il est
|
C'est le point le plus coûteux de tout le modèle d'autorisation, et il est
|
||||||
@@ -117,6 +142,9 @@ contexte de requête.
|
|||||||
| `attached_org_teams(user)` | Les équipes auxquelles cette personne est rattachée, quel que soit son rôle |
|
| `attached_org_teams(user)` | Les équipes auxquelles cette personne est rattachée, quel que soit son rôle |
|
||||||
| `visible_org_teams(user)` | Ce qu'elle a le droit de voir |
|
| `visible_org_teams(user)` | Ce qu'elle a le droit de voir |
|
||||||
| `can_manage_org_team(user, team)` | Peut-elle administrer cette équipe |
|
| `can_manage_org_team(user, team)` | Peut-elle administrer cette équipe |
|
||||||
|
| `can_manage_team_matches(user, team)` | Peut-elle gérer les matchs et entraînements de cette équipe |
|
||||||
|
| `is_team_representative(user, team)` | Est-elle le responsable désigné de cette équipe |
|
||||||
|
| `representative_org_teams(user)` | Les équipes dont elle est le responsable |
|
||||||
| `coach_player_ids(coach)` | Les joueurs qu'il encadre |
|
| `coach_player_ids(coach)` | Les joueurs qu'il encadre |
|
||||||
| `coach_can_access_player(coach, id)` | Peut-il lire la fiche de ce joueur |
|
| `coach_can_access_player(coach, id)` | Peut-il lire la fiche de ce joueur |
|
||||||
| `can_manage_player_contract(user, id)` | Peut-elle déposer un contrat pour lui |
|
| `can_manage_player_contract(user, id)` | Peut-elle déposer un contrat pour lui |
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Migration: add the `is_team_representative` flag to `team_players`.
|
||||||
|
|
||||||
|
A team's responsible ("joueur responsable") is a player an admin names to
|
||||||
|
manage that team's matches and practices. The flag lives on the roster row,
|
||||||
|
beside `status` and `position`.
|
||||||
|
|
||||||
|
`db.create_all()` creates missing tables and never alters an existing one, so
|
||||||
|
a database that already has `team_players` needs this script. Fresh installs
|
||||||
|
get the column from the model.
|
||||||
|
|
||||||
|
Where it connects
|
||||||
|
-----------------
|
||||||
|
The script finds its own database, so there is no URL to remember:
|
||||||
|
|
||||||
|
1. If `DATABASE_URL` is already set, it is used and nothing else is touched.
|
||||||
|
That is the production and CI path.
|
||||||
|
2. Otherwise the local values from `app/.env` are used. If the development
|
||||||
|
server in `instance/pgdata/` answers a connection it is reused as-is; if it
|
||||||
|
does not, it is (re)started and stopped again when this script exits. This
|
||||||
|
is the `LOCAL_PG=true` block of `app/.env.example`.
|
||||||
|
3. If neither is available the script explains what to set and stops.
|
||||||
|
|
||||||
|
So a laptop with the `LOCAL_PG=true` block runs the migration with a single
|
||||||
|
command:
|
||||||
|
|
||||||
|
python migrations/add_team_representative.py
|
||||||
|
|
||||||
|
Usage: python migrations/add_team_representative.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
|
# Loaded before anything else reads the environment: app.local_postgres reads
|
||||||
|
# LOCAL_PG_PORT / LOCAL_PG_USER / LOCAL_PG_DATABASE at import time, and without
|
||||||
|
# this they would fall back to that module's defaults instead of app/.env.
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
_ENV_FILE = os.path.join(PROJECT_ROOT, 'app', '.env')
|
||||||
|
load_dotenv(_ENV_FILE if os.path.exists(_ENV_FILE) else None)
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
def _local_database():
|
||||||
|
"""The development URL and port, read from the LOCAL_PG_* variables."""
|
||||||
|
from app.local_postgres import DATABASE_URL, PORT
|
||||||
|
|
||||||
|
return DATABASE_URL, PORT
|
||||||
|
|
||||||
|
|
||||||
|
def _postgres_answers(url):
|
||||||
|
"""Whether `url` is a working PostgreSQL connection right now.
|
||||||
|
|
||||||
|
A bare TCP probe is not enough here: a server killed part-way through its
|
||||||
|
startup can leave the port listening while every connection is refused,
|
||||||
|
and trusting that evidence fails the migration for a reason that looks
|
||||||
|
nothing like its cause.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
|
from app.app import normalise_database_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = create_engine(
|
||||||
|
normalise_database_url(url), connect_args={'connect_timeout': 2}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with engine.connect():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_local_server():
|
||||||
|
"""Fast-stop the local development server, if a postmaster is alive.
|
||||||
|
|
||||||
|
Called only before (re)starting it: `pg_ctl status` calls a half-dead
|
||||||
|
postmaster "running", so `start()` would leave the broken one in place.
|
||||||
|
"""
|
||||||
|
from app.local_postgres import DATADIR, _find_binary
|
||||||
|
|
||||||
|
pg_ctl = _find_binary('pg_ctl')
|
||||||
|
if not pg_ctl:
|
||||||
|
return
|
||||||
|
subprocess.run(
|
||||||
|
[str(pg_ctl), 'stop', '-m', 'fast', '-D', str(DATADIR)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_database_url():
|
||||||
|
"""Pick the database to migrate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[str, str]: the connection URL and a note saying where it came
|
||||||
|
from, for the log line.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: when neither a URL nor a usable local server is available.
|
||||||
|
"""
|
||||||
|
explicit = os.environ.get('DATABASE_URL')
|
||||||
|
if explicit:
|
||||||
|
return explicit, 'DATABASE_URL'
|
||||||
|
|
||||||
|
url, port = _local_database()
|
||||||
|
if _postgres_answers(url):
|
||||||
|
return url, f'local PostgreSQL already running on port {port}'
|
||||||
|
|
||||||
|
if os.getenv('LOCAL_PG', '').strip().lower() == 'true':
|
||||||
|
from app.local_postgres import start
|
||||||
|
|
||||||
|
_stop_local_server()
|
||||||
|
return start(), f'local PostgreSQL started on port {port}'
|
||||||
|
|
||||||
|
raise SystemExit(
|
||||||
|
'No database to migrate.\n'
|
||||||
|
' Set DATABASE_URL to a connection string, or set LOCAL_PG=true in\n'
|
||||||
|
' app/.env to use the local development server (see app/.env.example,\n'
|
||||||
|
' DEVELOPMENT block).'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# A migration must never start the Discord bot: two instances fight over
|
||||||
|
# the same gateway session, and neither belongs to a script whose only job
|
||||||
|
# is to run an ALTER.
|
||||||
|
os.environ['ENABLE_DISCORD_BOT'] = 'false'
|
||||||
|
|
||||||
|
url, source = resolve_database_url()
|
||||||
|
os.environ['DATABASE_URL'] = url
|
||||||
|
|
||||||
|
# Imported here, after the environment is settled, so create_app() reads
|
||||||
|
# the URL this script resolved rather than a missing one.
|
||||||
|
from app.app import create_app
|
||||||
|
|
||||||
|
print(f'[INFO] Database: {source}.')
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
already_there = db.session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'team_players' "
|
||||||
|
"AND column_name = 'is_team_representative'"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
if already_there:
|
||||||
|
print("Column 'team_players.is_team_representative' already exists. Skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
db.session.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE team_players "
|
||||||
|
"ADD COLUMN is_team_representative BOOLEAN NOT NULL DEFAULT FALSE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
print("Added 'team_players.is_team_representative'.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user