1 Commits
Author SHA1 Message Date
cedrick2711 2e5903b131 Ajout du joueur responsable
CI - Security, Lint & Tests / validate (push) Canceled after 0s
2026-09-27 14:25:29 -04:00
18 changed files with 795 additions and 14 deletions
+9 -2
View File
@@ -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'])
+4
View File
@@ -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)
+3
View File
@@ -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
+15
View File
@@ -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 []
+72
View File
@@ -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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+8 -7
View File
@@ -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
View File
@@ -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):
+13
View File
@@ -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>
+1 -1
View File
@@ -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>
+44 -1
View File
@@ -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 "&times;" #~ msgid "&times;"
#~ msgstr "&times;" #~ msgstr "&times;"
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 "&times;" #~ msgid "&times;"
#~ msgstr "&times;" #~ msgstr "&times;"
+15
View File
@@ -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
+28
View File
@@ -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 |
+176
View File
@@ -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()
+348
View File
@@ -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