Files
team-tryouts/app/routes/tryouts.py
T
GGThedandClaude Opus 5 d15a3de2a1 fix(data): reparer les trois suppressions cassees
DATA-004, DATA-005, DATA-006. L'audit les classait "forte probabilite" faute
de pouvoir les executer. Les tests les confirment : ce sont des bugs averes,
declenchables par tout manager ou administrateur depuis l'interface.

Erreurs reellement obtenues avant correction :
  NOT NULL constraint failed: match_participants.match_id
  NOT NULL constraint failed: team_matches.org_team_id

Supprimer un match (DATA-004)
  Match.participants n'avait pas de cascade. SQLAlchemy tentait donc de
  detacher les participants en mettant match_id a NULL, ce que la colonne
  refuse. Tout match ayant eu des participants etait indestructible.
  TeamMatch.participants declarait deja delete-orphan ; Match non.

Supprimer un tryout (DATA-006)
  Les PersonalNote pointant vers ses matchs, equipes ou vers lui-meme
  n'etaient pas traitees.

Supprimer une equipe (DATA-005)
  TeamNote.org_team_id et TeamMatch.org_team_id sont NOT NULL et n'etaient
  pas traites du tout. De plus la fonction validait trois fois : un echec au
  troisieme temps laissait les tryouts detaches et les joueurs retires sans
  que l'equipe soit supprimee -- un etat incoherent que rien ne rattrapait.
  Une seule transaction desormais.

Regle appliquee, uniforme
  Ce qui n'a de sens que dans le parent est supprime avec lui : participants,
  membres, notes d'equipe, matchs de saison.
  Ce qui lui survit est seulement detache : les notes personnelles sont les
  observations d'un coach sur un joueur, pas des donnees de tryout. Les
  supprimer avec le tryout detruirait du contenu sans rapport. Idem pour les
  contrats et les demandes de rencontre individuelle.

Fidelite des tests
  conftest.py active PRAGMA foreign_keys=ON. SQLite ignore les cles
  etrangeres par defaut ; PostgreSQL les applique toujours. Sans ce reglage,
  la suite pouvait valider une suppression qui echoue en production --
  precisement la classe de bug corrigee ici. Les 146 tests passent avec les
  contraintes actives.

9 tests, dont trois qui verifient que les entites survivantes survivent
vraiment : une note garde son contenu et perd son contexte, un tryout
survit a l'equipe qu'il visait.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:37:04 -04:00

541 lines
24 KiB
Python

"""Tryout management routes for creating, viewing, and managing tryout events.
This module handles CRUD operations for tryouts and player registrations.
Uses polymorphic isinstance checks instead of role-string comparisons.
"""
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort
from flask_login import login_required, current_user
from app.extensions import db
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
OrgTeam, Match, MatchParticipant, PersonalNote,
ESPORT_GAMES, GAME_POSITIONS,
)
from datetime import datetime
tryouts_bp = Blueprint('tryouts', __name__, url_prefix='/tryouts')
def can_manage():
"""Check if current user can manage tryouts (Admin or Manager)."""
return isinstance(current_user, (Admin, Manager))
@tryouts_bp.route('')
@login_required
def list_tryouts():
"""List all tryouts visible to the current user.
Delegates to the polymorphic User subclass's get_visible_tryouts() method.
"""
tryouts = current_user.get_visible_tryouts()
return render_template('pages/tryouts.html', tryouts=tryouts, now=datetime.utcnow())
@tryouts_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_tryout():
"""Create a new tryout event. Requires Admin or Manager."""
if not can_manage():
flash('You do not have permission to create tryouts.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.username).all()
if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_ids = request.form.getlist('coach_ids')
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid start date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
end_date_obj = None
if end_date_str:
try:
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash('End date cannot be before start date.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
except (ValueError, TypeError):
flash('Invalid end date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
tryout = Tryout(
title=title, description=description, game=game, date=date_obj,
end_date=end_date_obj,
location=location,
max_players=int(max_players) if max_players else None,
created_by=current_user.id, status='upcoming',
target_org_team_id=int(target_org_team_id) if target_org_team_id else None,
manager_id=int(manager_id) if manager_id else None,
)
db.session.add(tryout)
db.session.flush()
# Assign coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
db.session.commit()
flash('Tryout created successfully!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=None, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
@tryouts_bp.route('/<int:tryout_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_tryout(tryout_id):
"""Edit an existing tryout event. Permission based on can_manage_this_tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to edit this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
if tryout.is_ended:
flash('This tryout has ended and can no longer be modified.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
org_teams = OrgTeam.query.order_by(OrgTeam.name).all()
managers = User.query.filter_by(role='manager', is_active_account=True).order_by(User.full_name).all()
coaches = User.query.filter_by(role='coach', is_active_account=True).order_by(User.full_name).all()
if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
game = request.form.get('game')
date_str = request.form.get('date')
end_date_str = request.form.get('end_date')
location = request.form.get('location')
max_players = request.form.get('max_players')
target_org_team_id = request.form.get('target_org_team_id')
manager_id = request.form.get('manager_id')
coach_ids = request.form.getlist('coach_ids')
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
flash('Invalid start date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
end_date_obj = None
if end_date_str:
try:
end_date_obj = datetime.strptime(end_date_str, '%Y-%m-%d').date()
if end_date_obj < date_obj:
flash('End date cannot be before start date.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
except (ValueError, TypeError):
flash('Invalid end date format.', 'danger')
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
tryout.title = title
tryout.description = description
tryout.game = game
tryout.date = date_obj
tryout.end_date = end_date_obj
tryout.location = location
tryout.max_players = int(max_players) if max_players else None
tryout.target_org_team_id = int(target_org_team_id) if target_org_team_id else None
tryout.manager_id = int(manager_id) if manager_id else None
# Update coaches via many-to-many
if coach_ids:
coach_users = User.query.filter(User.id.in_([int(c) for c in coach_ids])).all()
tryout.coaches = coach_users
else:
tryout.coaches = []
db.session.commit()
flash('Tryout updated successfully!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
return render_template('pages/tryout_form.html', tryout=tryout, org_teams=org_teams,
managers=managers, coaches=coaches, esport_games=ESPORT_GAMES)
@tryouts_bp.route('/<int:tryout_id>')
@login_required
def view_tryout(tryout_id):
"""View a specific tryout with all details. Permission via polymorphic dispatch."""
tryout = Tryout.query.get_or_404(tryout_id)
can_view = False
if isinstance(current_user, Admin):
can_view = True
elif isinstance(current_user, Manager):
can_view = tryout.created_by == current_user.id or tryout.manager_id == current_user.id
elif isinstance(current_user, Coach):
can_view = current_user.can_manage_this_tryout(tryout)
elif isinstance(current_user, Player):
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first() is not None
player_in_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
).first() is not None
can_view = is_registered or player_in_match
elif isinstance(current_user, Scout):
can_view = True
if not can_view:
flash('You do not have permission to view this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registrations = TryoutRegistration.query.filter_by(tryout_id=tryout_id).all()
registered_players = [User.query.get(r.player_id) for r in registrations if r.player_id]
evaluations = Evaluation.query.filter_by(tryout_id=tryout_id).all()
player_eval_status = {}
if current_user.can_evaluate():
for p in registered_players:
existing = Evaluation.query.filter_by(
tryout_id=tryout_id, player_id=p.id, evaluator_id=current_user.id,
).first()
player_eval_status[p.id] = existing is not None
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id,
).first() is not None
teams = Team.query.filter_by(tryout_id=tryout_id).all()
team_data = []
for team in teams:
members = TeamMember.query.filter_by(team_id=team.id).all()
team_data.append({
'team': team,
'members': [{'player': User.query.get(m.player_id), 'position': m.position}
for m in members],
})
can_edit = current_user.can_manage_this_tryout(tryout)
can_view_calendar = can_edit
if isinstance(current_user, Player):
player_in_match = MatchParticipant.query.join(Match).filter(
MatchParticipant.player_id == current_user.id,
Match.tryout_id == tryout_id,
).first() is not None
can_view_calendar = is_registered or player_in_match
all_players = None
if can_edit:
all_players = User.query.filter_by(role='player').order_by(User.username).all()
matches = Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
match_data = []
for match in matches:
all_participants = list(match.participants.all())
confirmed_count = sum(1 for p in all_participants if p.attendance_confirmed)
total_count = len(all_participants)
player_presence = []
for p in all_participants:
if p.player:
player_presence.append({
'participant_id': p.id, 'player_id': p.player_id,
'player_name': p.player.username,
'attendance_confirmed': p.attendance_confirmed,
})
if match.match_type == 'team_vs_team':
participants = {
'team1': match.team1.name if match.team1 else 'TBD',
'team2': match.team2.name if match.team2 else 'TBD',
'team1_players': [{'name': m.player.username, 'position': m.position}
for m in match.team1.members.all()] if match.team1 else [],
'team2_players': [{'name': m.player.username, 'position': m.position}
for m in match.team2.members.all()] if match.team2 else [],
}
elif match.match_type == 'player_vs_player':
team1_players = [{'name': p.player.username, 'position': p.position}
for p in match.participants.filter_by(team_side=1).all() if p.player]
team2_players = [{'name': p.player.username, 'position': p.position}
for p in match.participants.filter_by(team_side=2).all() if p.player]
participants = {
'team1': 'Team 1', 'team2': 'Team 2',
'team1_players': team1_players, 'team2_players': team2_players,
}
else:
participants = [p.player.username for p in match.participants.all()]
match_data.append({
'match': match, 'participants': participants,
'confirmed_count': confirmed_count, 'total_count': total_count,
'player_presence': player_presence,
})
return render_template('pages/view_tryout.html',
tryout=tryout, registered_players=registered_players,
evaluations=evaluations, player_eval_status=player_eval_status,
is_registered=is_registered, registrations=registrations,
team_data=team_data, can_edit=can_edit,
can_view_calendar=can_view_calendar, all_players=all_players,
matches=matches, match_data=match_data,
game_positions=GAME_POSITIONS, now=datetime.utcnow())
@tryouts_bp.route('/<int:tryout_id>/register', methods=['POST'])
@login_required
def register_for_tryout(tryout_id):
"""Register a player for a tryout. Only Players can self-register."""
tryout = Tryout.query.get_or_404(tryout_id)
if not isinstance(current_user, Player):
flash('Only players can register for tryouts.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.status not in ['upcoming', 'in_progress']:
flash('This tryout is not accepting registrations.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=current_user.id).first()
if existing:
flash('You are already registered for this tryout.', 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players:
flash('This tryout is full.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=current_user.id)
db.session.add(registration)
db.session.commit()
flash('Successfully registered for tryout!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/status', methods=['POST'])
@login_required
def update_status(tryout_id):
"""Update the status of a tryout."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
new_status = request.form.get('status')
if new_status in ['upcoming', 'in_progress', 'completed']:
tryout.status = new_status
db.session.commit()
flash(f'Tryout status updated to {new_status}.', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/registration/<int:player_id>/status', methods=['POST'])
@login_required
def update_registration_status(tryout_id, player_id):
"""Update a registration's attendance status."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first_or_404()
new_status = request.form.get('status')
if new_status in ['registered', 'attended', 'no_show']:
registration.status = new_status
db.session.commit()
flash('Registration status updated.', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/register_player', methods=['POST'])
@login_required
def register_player(tryout_id):
"""Manually register a player for a tryout (by managers/coaches)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player_id = request.form.get('player_id')
if not player_id:
flash('Please select a player.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
player = User.query.get_or_404(int(player_id))
if not isinstance(player, Player):
flash('Can only register players.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
existing = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player.id).first()
if existing:
flash(f'{player.username} is already registered for this tryout.', 'info')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
if tryout.max_players:
count = TryoutRegistration.query.filter_by(tryout_id=tryout_id).count()
if count >= tryout.max_players:
flash('This tryout is full.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
registration = TryoutRegistration(tryout_id=tryout_id, player_id=player.id)
db.session.add(registration)
db.session.commit()
flash(f'{player.username} registered for tryout!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/remove_player/<int:player_id>', methods=['POST'])
@login_required
def remove_player(tryout_id, player_id):
"""Remove a registered player from a tryout (cascades to teams/matches)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
player = User.query.get_or_404(player_id)
registration = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first()
if registration:
db.session.delete(registration)
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
if team_ids:
TeamMember.query.filter(
TeamMember.team_id.in_(team_ids),
TeamMember.player_id == player_id,
).delete(synchronize_session=False)
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids),
MatchParticipant.player_id == player_id,
).delete(synchronize_session=False)
db.session.commit()
flash(f'{player.username} removed from tryout.', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/create', methods=['POST'])
@login_required
def create_team(tryout_id):
"""Create a tryout-specific team."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
team_name = request.form.get('team_name')
if team_name:
team = Team(tryout_id=tryout_id, name=team_name, created_by=current_user.id)
db.session.add(team)
db.session.commit()
flash(f'Team "{team_name}" created!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/team/<int:team_id>/add', methods=['POST'])
@login_required
def add_to_team(tryout_id, team_id):
"""Add a player to a tryout team."""
team = Team.query.get_or_404(team_id)
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('Permission denied.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# The two ids arrive independently in the URL. Without this check, being
# allowed to manage tryout A was enough to modify a team belonging to
# tryout B, since only the tryout was authorised.
if team.tryout_id != tryout_id:
abort(404)
player_id = request.form.get('player_id', type=int)
if not player_id:
flash('Please select a player.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
# Only players registered for this tryout may be placed on its teams.
is_registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first() is not None
if not is_registered:
flash('That player is not registered for this tryout.', 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
position = request.form.get('position', '')
existing = TeamMember.query.filter_by(team_id=team_id, player_id=player_id).first()
if existing:
flash('Player is already on this team.', 'info')
else:
member = TeamMember(team_id=team_id, player_id=player_id, position=position)
db.session.add(member)
db.session.commit()
flash('Player added to team!', 'success')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
@tryouts_bp.route('/<int:tryout_id>/delete', methods=['POST'])
@login_required
def delete_tryout(tryout_id):
"""Delete a tryout and all associated data (matches, teams, registrations, evaluations)."""
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
flash('You do not have permission to delete this tryout.', 'danger')
return redirect(url_for('tryouts.list_tryouts'))
match_ids = [m.id for m in Match.query.filter_by(tryout_id=tryout_id).all()]
team_ids = [t.id for t in Team.query.filter_by(tryout_id=tryout_id).all()]
# Personal notes outlive the tryout: they are a coach's observations
# about a player, not tryout data. Only their context links are cleared.
# Missing this step made the deletion fail on the foreign keys below.
PersonalNote.query.filter_by(tryout_id=tryout_id).update(
{'tryout_id': None}, synchronize_session=False)
if match_ids:
PersonalNote.query.filter(PersonalNote.match_id.in_(match_ids)).update(
{'match_id': None}, synchronize_session=False)
if team_ids:
PersonalNote.query.filter(PersonalNote.team_id.in_(team_ids)).update(
{'team_id': None}, synchronize_session=False)
if match_ids:
MatchParticipant.query.filter(
MatchParticipant.match_id.in_(match_ids)
).delete(synchronize_session=False)
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
if team_ids:
TeamMember.query.filter(
TeamMember.team_id.in_(team_ids)
).delete(synchronize_session=False)
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
Evaluation.query.filter_by(tryout_id=tryout_id).delete()
db.session.delete(tryout)
db.session.commit()
flash('Tryout deleted successfully.', 'success')
return redirect(url_for('tryouts.list_tryouts'))