This commit is contained in:
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "×"
|
||||
#~ msgstr "×"
|
||||
|
||||
|
||||
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 "×"
|
||||
#~ msgstr "×"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user