diff --git a/app/models/org_team/org_team.py b/app/models/org_team/org_team.py index efb601e..68a0ab5 100644 --- a/app/models/org_team/org_team.py +++ b/app/models/org_team/org_team.py @@ -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']) diff --git a/app/models/org_team/team_player.py b/app/models/org_team/team_player.py index 04c3242..61d3c92 100644 --- a/app/models/org_team/team_player.py +++ b/app/models/org_team/team_player.py @@ -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) diff --git a/app/models/user_model/admin.py b/app/models/user_model/admin.py index 6505b60..f9ce10f 100644 --- a/app/models/user_model/admin.py +++ b/app/models/user_model/admin.py @@ -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 diff --git a/app/models/user_model/user.py b/app/models/user_model/user.py index bcdff98..9c15322 100644 --- a/app/models/user_model/user.py +++ b/app/models/user_model/user.py @@ -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 [] diff --git a/app/permissions.py b/app/permissions.py index b0d39ae..8cd93d2 100644 --- a/app/permissions.py +++ b/app/permissions.py @@ -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 # --------------------------------------------------------------------------- diff --git a/app/routes/team_matches.py b/app/routes/team_matches.py index d5d993a..31c79a0 100644 --- a/app/routes/team_matches.py +++ b/app/routes/team_matches.py @@ -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([]) diff --git a/app/routes/teams.py b/app/routes/teams.py index 179445c..0ff40be 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -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('//toggle_representative/', 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('//add-team-note', methods=['POST']) @login_required def add_team_note(team_id): diff --git a/app/templates/pages/my_teams.html b/app/templates/pages/my_teams.html index bd8be43..28dd0e9 100644 --- a/app/templates/pages/my_teams.html +++ b/app/templates/pages/my_teams.html @@ -10,6 +10,16 @@

{{ team.name }}

+
+ {% if current_user.can_manage_team_matches(team) %} + + {{ _('Match') }} + + + {{ _('Practice') }} + + {% endif %} +
@@ -61,6 +71,9 @@
{{ entry.player.username[:2] | upper }}
{{ entry.player.username }} + {% if entry.is_team_representative %} + {{ _('Representative') }} + {% endif %}
diff --git a/app/templates/pages/team_matches.html b/app/templates/pages/team_matches.html index 390be1c..f07eaa6 100644 --- a/app/templates/pages/team_matches.html +++ b/app/templates/pages/team_matches.html @@ -93,7 +93,7 @@ {{ m.status }} - {% 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 %} diff --git a/app/templates/pages/teams.html b/app/templates/pages/teams.html index 3e83399..ae61d2f 100644 --- a/app/templates/pages/teams.html +++ b/app/templates/pages/teams.html @@ -127,7 +127,7 @@ {{ _('Matches') }} - {% 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) %} {{ _('Match') }} @@ -159,6 +159,9 @@
{{ entry.player.username[:2] | upper }}
{{ entry.player.username }} + {% if entry.is_team_representative %} + {{ _('Representative') }} + {% endif %}
@@ -180,6 +183,16 @@ {{ entry.player.phone or '-' }} {% if can_manage_team %} + {% if current_user.can_designate_team_representative() %} + + {% endif %}