ARCH-002. La question "a quelles equipes ce coach est-il rattache ?" etait
posee a huit endroits, de cinq facons differentes, et trois d entre elles
donnaient une mauvaise reponse en production.
Le motif fautif, present tel quel dans six routes :
OrgTeam.query.filter_by(coach_id=user.id).first()
Il repond au plus une equipe, et seulement par la colonne heritee. Deux
pannes en decoulaient, silencieuses -- les pages s affichaient, vides :
- un coach rattache uniquement par la relation many-to-many n avait
aucune equipe, donc aucun joueur, aucun contrat, aucune note d equipe,
aucun match a venir sur son tableau de bord ;
- un coach de deux equipes n en voyait qu une. Le formulaire de contrat
lui proposait la moitie de son effectif, alors que la route POST
acceptait l autre moitie.
app/permissions.py devient le module ou la question se pose une fois :
coach_org_teams, manager_org_teams, attached_org_teams, visible_org_teams,
can_manage_org_team, org_team_player_ids, coach_player_ids,
can_manage_player_contract, coach_tryouts, coach_manages_tryout. Toutes
lisent les deux rattachements et toutes les equipes.
Le meme ecart existait dans le modele : Coach.get_visible_tryouts ne lisait
que la relation m2m -- calendrier vide pour un coach rattache par la
colonne -- et can_manage_this_tryout ignorait la colonne pour l equipe
cible. Les deux delegent desormais.
Corrections de portee, au passage
- one_on_one lisait org_team.coach_id : un joueur dont l equipe declare
ses coachs par la relation etait informe qu il n avait pas de coach, et
le formulaire de demande restait ferme. Passe par get_coaches(), qui
retombe deja sur la colonne heritee.
- notes_dashboard conditionnait les notes personnelles du coach a
l existence d une equipe : un coach sans equipe ne voyait pas ses
propres notes.
- can_manage_team_match reformulait can_manage_this_org_team ; la
reformulation avait derive. Elle appelle maintenant la regle.
Limite assumee : le panneau de notes d equipe reste ecrit pour une seule
equipe et affiche donc la premiere. La resolution est corrigee, la mise en
page multi-equipes ne l est pas -- c est un choix produit, pas un bug.
14 tests ajoutes. Cinq echouent sur le code d avant, verifie en remettant
les routes et le modele a leur etat precedent.
ARCH-001 fera disparaitre la colonne heritee ; cela demande une migration
de donnees, donc Alembic. D ici la, ce module est ce qui rend la
duplication inoffensive.
Co-Authored-By: Claude Opus 5 <[email protected]>
458 lines
18 KiB
Python
458 lines
18 KiB
Python
"""Organization team management routes.
|
|
|
|
Uses polymorphic isinstance checks instead of role-string comparisons.
|
|
"""
|
|
|
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
|
|
from flask_login import login_required, current_user
|
|
from flask_babel import gettext as _
|
|
from app.extensions import db
|
|
from app.models import (
|
|
Admin, Manager, Coach, Player,
|
|
OrgTeam, User, PersonalNote, TeamNote, Tryout, TeamPlayer,
|
|
TeamMatch, Contract, OneOnOneRequest,
|
|
)
|
|
from app.permissions import visible_org_teams
|
|
from datetime import datetime
|
|
|
|
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
|
|
|
|
|
|
@teams_bp.route('')
|
|
@login_required
|
|
def list_teams():
|
|
"""List all organization teams visible to the current user."""
|
|
can_manage = current_user.can_manage_teams()
|
|
|
|
if isinstance(current_user, Player):
|
|
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)):
|
|
flash(_('You do not have permission to view teams.'), 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
teams = visible_org_teams(current_user)
|
|
|
|
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
|
|
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
|
|
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
|
return render_template('pages/teams.html', teams=teams, coaches=coaches,
|
|
managers=managers, all_players=all_players, can_manage=can_manage)
|
|
|
|
|
|
@teams_bp.route('/my-teams')
|
|
@login_required
|
|
def my_teams():
|
|
"""View the player's own teams with upcoming matches."""
|
|
if not isinstance(current_user, Player):
|
|
flash(_('This page is for players.'), 'info')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
from app.models import TeamMatch, TeamMatchParticipant
|
|
|
|
player_teams = current_user.get_org_teams()
|
|
now = datetime.utcnow()
|
|
team_data = []
|
|
|
|
for org_team in player_teams:
|
|
matches = TeamMatch.query.filter(
|
|
TeamMatch.org_team_id == org_team.id,
|
|
TeamMatch.status == 'scheduled',
|
|
).order_by(TeamMatch.date.asc(), TeamMatch.start_time.asc()).all()
|
|
|
|
matches_data = []
|
|
for tm in matches:
|
|
confirmed, total = tm.get_confirmed_count()
|
|
participant = TeamMatchParticipant.query.filter_by(
|
|
team_match_id=tm.id, player_id=current_user.id,
|
|
).first()
|
|
matches_data.append({
|
|
'match': tm,
|
|
'participant_id': participant.id if participant else None,
|
|
'is_confirmed': participant.is_confirmed if participant else False,
|
|
'confirmed_count': confirmed, 'total_count': total,
|
|
})
|
|
|
|
team_data.append({
|
|
'team': org_team, 'matches': matches_data,
|
|
'coaches': org_team.get_coaches(),
|
|
'managers': org_team.get_managers(),
|
|
})
|
|
|
|
return render_template('pages/my_teams.html', team_data=team_data, now=now)
|
|
|
|
|
|
@teams_bp.route('/create', methods=['POST'])
|
|
@login_required
|
|
def create_team():
|
|
"""Create a new organization team."""
|
|
if not current_user.can_manage_teams():
|
|
flash(_('You do not have permission to create teams.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
name = request.form.get('name')
|
|
coach_id = request.form.get('coach_id')
|
|
manager_id = request.form.get('manager_id')
|
|
|
|
if not name:
|
|
flash(_('Team name is required.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
existing = OrgTeam.query.filter_by(name=name).first()
|
|
if existing:
|
|
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
team = OrgTeam(
|
|
name=name,
|
|
coach_id=int(coach_id) if coach_id else None,
|
|
manager_id=int(manager_id) if manager_id else None,
|
|
created_by=current_user.id,
|
|
)
|
|
db.session.add(team)
|
|
db.session.flush()
|
|
|
|
if coach_id:
|
|
coach_user = User.query.get(int(coach_id))
|
|
if coach_user:
|
|
team.coaches.append(coach_user)
|
|
if manager_id:
|
|
manager_user = User.query.get(int(manager_id))
|
|
if manager_user:
|
|
team.managers.append(manager_user)
|
|
|
|
db.session.commit()
|
|
flash(_('Team "%(name)s" created successfully!', name=name), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/edit', methods=['POST'])
|
|
@login_required
|
|
def edit_team(team_id):
|
|
"""Edit an existing organization team."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
flash(_('You do not have permission to edit this team.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
name = request.form.get('name')
|
|
coach_id = request.form.get('coach_id')
|
|
manager_id = request.form.get('manager_id')
|
|
|
|
if not name:
|
|
flash(_('Team name is required.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
existing = OrgTeam.query.filter(OrgTeam.name == name, OrgTeam.id != team_id).first()
|
|
if existing:
|
|
flash(_('Team "%(name)s" already exists.', name=name), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
if request.form.get('sync_staff') == '1':
|
|
coach_ids = request.form.getlist('coach_ids')
|
|
manager_ids = request.form.getlist('manager_ids')
|
|
|
|
team.coaches = []
|
|
for cid in coach_ids:
|
|
if cid and cid.strip():
|
|
coach_user = User.query.get(int(cid))
|
|
if coach_user and isinstance(coach_user, Coach):
|
|
team.coaches.append(coach_user)
|
|
coach_list = team.coaches.all()
|
|
team.coach_id = coach_list[0].id if coach_list else None
|
|
|
|
team.managers = []
|
|
for mid in manager_ids:
|
|
if mid and mid.strip():
|
|
manager_user = User.query.get(int(mid))
|
|
if manager_user and isinstance(manager_user, Manager):
|
|
team.managers.append(manager_user)
|
|
manager_list = team.managers.all()
|
|
team.manager_id = manager_list[0].id if manager_list else None
|
|
else:
|
|
team.coach_id = int(coach_id) if coach_id else None
|
|
team.manager_id = int(manager_id) if manager_id else None
|
|
|
|
if coach_id:
|
|
coach_user = User.query.get(int(coach_id))
|
|
if coach_user and not team.coaches.filter_by(id=coach_user.id).first():
|
|
team.coaches.append(coach_user)
|
|
if manager_id:
|
|
manager_user = User.query.get(int(manager_id))
|
|
if manager_user and not team.managers.filter_by(id=manager_user.id).first():
|
|
team.managers.append(manager_user)
|
|
|
|
db.session.commit()
|
|
flash(_('Team "%(name)s" updated successfully!', name=name), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_team(team_id):
|
|
"""Delete an organization team."""
|
|
if not current_user.can_manage_teams():
|
|
flash(_('You do not have permission to delete teams.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
name = team.name
|
|
|
|
# One transaction. This used to commit three times, so a failure at the
|
|
# third step left the tryouts detached and the players removed without
|
|
# the team being deleted — an inconsistent state nothing could undo.
|
|
#
|
|
# TeamNote.org_team_id and TeamMatch.org_team_id are NOT NULL, and were
|
|
# not handled at all: deleting a team that had ever been used raised
|
|
# IntegrityError. Contract.team_id and OneOnOneRequest.org_team_id are
|
|
# nullable, and the rows outlive the team, so they are only detached.
|
|
|
|
# Entities that only make sense as part of the team.
|
|
TeamNote.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
|
|
for team_match in TeamMatch.query.filter_by(org_team_id=team_id).all():
|
|
db.session.delete(team_match) # participants follow by cascade
|
|
TeamPlayer.query.filter_by(org_team_id=team_id).delete(synchronize_session=False)
|
|
|
|
# Entities that survive it.
|
|
Tryout.query.filter_by(target_org_team_id=team_id).update(
|
|
{'target_org_team_id': None}, synchronize_session=False)
|
|
Contract.query.filter_by(team_id=team_id).update(
|
|
{'team_id': None}, synchronize_session=False)
|
|
OneOnOneRequest.query.filter_by(org_team_id=team_id).update(
|
|
{'org_team_id': None}, synchronize_session=False)
|
|
|
|
db.session.delete(team)
|
|
db.session.commit()
|
|
flash(_('Team "%(name)s" deleted successfully.', name=name), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/add_coach', methods=['POST'])
|
|
@login_required
|
|
def add_coach(team_id):
|
|
"""Add a coach to an organization team."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
flash(_('Permission denied.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
coach_id = request.form.get('coach_id')
|
|
if not coach_id:
|
|
flash(_('Please select a coach.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
coach = User.query.get_or_404(int(coach_id))
|
|
if not isinstance(coach, Coach):
|
|
flash(_('Only coaches can be assigned as coach.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
if team.coaches.filter_by(id=coach.id).first():
|
|
flash(_('%(username)s is already a coach of %(name)s.', username=coach.username, name=team.name), 'info')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
team.coaches.append(coach)
|
|
if not team.coach_id:
|
|
team.coach_id = coach.id
|
|
db.session.commit()
|
|
flash(_('%(username)s added as coach of %(name)s.', username=coach.username, name=team.name), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/add_manager', methods=['POST'])
|
|
@login_required
|
|
def add_manager(team_id):
|
|
"""Add a manager to an organization team."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
flash(_('Permission denied.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
manager_id = request.form.get('manager_id')
|
|
if not manager_id:
|
|
flash(_('Please select a manager.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
manager = User.query.get_or_404(int(manager_id))
|
|
if not isinstance(manager, Manager):
|
|
flash(_('Only managers can be assigned as manager.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
if team.managers.filter_by(id=manager.id).first():
|
|
flash(_('%(username)s is already a manager of %(name)s.', username=manager.username, name=team.name), 'info')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
team.managers.append(manager)
|
|
if not team.manager_id:
|
|
team.manager_id = manager.id
|
|
db.session.commit()
|
|
flash(_('%(username)s added as manager of %(name)s.', username=manager.username, name=team.name), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/remove_coach', methods=['POST'])
|
|
@login_required
|
|
def remove_coach(team_id):
|
|
"""Remove a coach from an organization team."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
flash(_('Permission denied.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
coach_id = request.form.get('coach_id')
|
|
if coach_id:
|
|
coach = User.query.get(int(coach_id))
|
|
if coach and team.coaches.filter_by(id=coach.id).first():
|
|
team.coaches.remove(coach)
|
|
if team.coach_id == coach.id:
|
|
team.coach_id = None
|
|
else:
|
|
team.coaches = []
|
|
team.coach_id = None
|
|
|
|
db.session.commit()
|
|
flash(_('Coach removed from %(name)s.', name=team.name), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/remove_manager', methods=['POST'])
|
|
@login_required
|
|
def remove_manager(team_id):
|
|
"""Remove a manager from an organization team."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
flash(_('Permission denied.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
manager_id = request.form.get('manager_id')
|
|
if manager_id:
|
|
manager = User.query.get(int(manager_id))
|
|
if manager and team.managers.filter_by(id=manager.id).first():
|
|
team.managers.remove(manager)
|
|
if team.manager_id == manager.id:
|
|
team.manager_id = None
|
|
else:
|
|
team.managers = []
|
|
team.manager_id = None
|
|
|
|
db.session.commit()
|
|
flash(_('Manager removed from %(name)s.', name=team.name), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/add_player', methods=['POST'])
|
|
@login_required
|
|
def add_player(team_id):
|
|
"""Add a player to an organization team."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
flash(_('Permission denied.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
player_id = request.form.get('player_id')
|
|
status = request.form.get('status', 'starter')
|
|
if not player_id:
|
|
flash(_('Please select a player.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
player = User.query.get_or_404(int(player_id))
|
|
if not isinstance(player, Player):
|
|
flash(_('Can only assign players to teams.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
existing = TeamPlayer.query.filter_by(player_id=player.id, org_team_id=team.id).first()
|
|
if existing:
|
|
flash(_('%(username)s is already on %(name)s.', username=player.username, name=team.name), 'info')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
tp = TeamPlayer(player_id=player.id, org_team_id=team.id, status=status)
|
|
db.session.add(tp)
|
|
db.session.commit()
|
|
flash(_('%(username)s added to %(name)s!', username=player.username, name=team.name), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/remove_player/<int:player_id>', methods=['POST'])
|
|
@login_required
|
|
def remove_player(team_id, player_id):
|
|
"""Remove a player from an organization team."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
flash(_('Permission denied.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
player = User.query.get_or_404(player_id)
|
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
|
if not tp:
|
|
flash(_('%(username)s is not on %(name)s.', username=player.username, name=team.name), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
db.session.delete(tp)
|
|
db.session.commit()
|
|
flash(_('%(username)s removed from %(name)s.', username=player.username, name=team.name), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/toggle_status/<int:player_id>', methods=['POST'])
|
|
@login_required
|
|
def toggle_player_status(team_id, player_id):
|
|
"""Toggle a player's status between starter and substitute."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
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.status = 'substitute' if tp.status == 'starter' else 'starter'
|
|
db.session.commit()
|
|
return jsonify({
|
|
'success': True, 'player_id': player_id,
|
|
'new_status': tp.status, 'player_name': tp.player.username,
|
|
})
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/add-team-note', methods=['POST'])
|
|
@login_required
|
|
def add_team_note(team_id):
|
|
"""Add a team improvement note (coaches only)."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
content = request.form.get('content', '').strip()
|
|
if content:
|
|
note = TeamNote(org_team_id=team_id, coach_id=current_user.id, content=content)
|
|
db.session.add(note)
|
|
db.session.commit()
|
|
flash(_('Team notes added successfully!'), 'success')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
|
|
@teams_bp.route('/<int:team_id>/add-player-note/<int:player_id>', methods=['POST'])
|
|
@login_required
|
|
def add_player_note(team_id, player_id):
|
|
"""Add a personal note for a player (coaches only)."""
|
|
team = OrgTeam.query.get_or_404(team_id)
|
|
if not current_user.can_manage_this_org_team(team):
|
|
flash(_('You do not have permission to add notes to this team.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
player = User.query.get_or_404(player_id)
|
|
if not isinstance(player, Player):
|
|
flash(_('Can only add notes for players.'), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
tp = TeamPlayer.query.filter_by(player_id=player_id, org_team_id=team_id).first()
|
|
if not tp:
|
|
flash(_('%(username)s is not on %(name)s.', username=player.username, name=team.name), 'danger')
|
|
return redirect(url_for('teams.list_teams'))
|
|
|
|
content = request.form.get('content', '').strip()
|
|
if content:
|
|
note = PersonalNote(player_id=player_id, coach_id=current_user.id, content=content)
|
|
db.session.add(note)
|
|
db.session.commit()
|
|
flash(_('Note added for %(username)s!', username=player.username), 'success')
|
|
return redirect(url_for('teams.list_teams')) |