Ajout du joueur responsable
CI - Security, Lint & Tests / validate (push) Canceled after 0s

This commit is contained in:
cedrick2711
2026-09-27 14:25:29 -04:00
parent bd9ed4bdc7
commit 2e5903b131
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]
def get_players_with_status(self):
return [
{'player': tp.player, 'status': tp.status, 'position': tp.position}
rows = [
{
'player': tp.player,
'status': tp.status,
'position': tp.position,
'is_team_representative': tp.is_team_representative,
}
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)
org_team_id = db.Column(db.Integer, db.ForeignKey('org_teams.id'), nullable=False)
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)
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):
return True
def can_designate_team_representative(self):
return True
def get_visible_tryouts(self):
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):
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):
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)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
+8 -7
View File
@@ -23,7 +23,7 @@ from app.models import (
TeamPlayer,
)
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.routes.matches import default_end_time
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):
"""Whether the current user can manage matches for this team.
Same rule as administering the team itself, so it is the same call.
This function used to restate it, and the restatement drifted.
Everyone who administers the team, plus the player named as its
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():
@@ -300,13 +301,13 @@ def delete_match(match_id):
@login_required
def api_manageable_teams():
"""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)):
teams = OrgTeam.query.order_by(OrgTeam.name).all()
elif isinstance(current_user, Coach):
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:
return jsonify([])
+35 -3
View File
@@ -26,7 +26,7 @@ from app.models import (
Tryout,
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.time_utils import utc_now_naive
from app.validators import NoteContentSchema, OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
@@ -40,10 +40,12 @@ def list_teams():
"""List all organization teams visible to the current user."""
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')
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')
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'])
@login_required
def add_team_note(team_id):
+13
View File
@@ -10,6 +10,16 @@
<div class="card mb-4">
<div class="card-header">
<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>
<!-- Staff bar -->
<div class="team-staff-bar">
@@ -61,6 +71,9 @@
<div class="user-mini">
<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>
{% if entry.is_team_representative %}
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
{% endif %}
</div>
</td>
<td>
+1 -1
View File
@@ -93,7 +93,7 @@
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
</td>
<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 %}
<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>
+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') }}">
<i class="fas fa-futbol"></i> {{ _('Matches') }}
</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') }}">
<i class="fas fa-plus"></i> {{ _('Match') }}
</a>
@@ -159,6 +159,9 @@
<div class="user-mini">
<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>
{% if entry.is_team_representative %}
<span class="badge badge-info" title="{{ _('Team representative') }}">{{ _('Representative') }}</span>
{% endif %}
</div>
</td>
<td>
@@ -180,6 +183,16 @@
<td>{{ entry.player.phone or '-' }}</td>
{% if can_manage_team %}
<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) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<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;
document.addEventListener('DOMContentLoaded', function() {
@@ -416,6 +458,7 @@ registerActions({
'hide-create-form': hideCreateForm,
'hide-edit-form': hideEditForm,
'toggle-status': toggleStatus,
'toggle-representative': toggleRepresentative,
});
</script>
<style>
Binary file not shown.
@@ -3047,6 +3047,18 @@ msgstr "View Profile"
msgid "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;"
#~ msgstr "&times;"
Binary file not shown.
@@ -3070,6 +3070,18 @@ msgstr "Voir le profil"
msgid "Availability"
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;"
#~ msgstr "&times;"