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]>
This commit is contained in:
@@ -16,7 +16,13 @@ class Match(BaseMatch):
|
||||
tryout = db.relationship('Tryout', backref='matches')
|
||||
team1 = db.relationship('Team', foreign_keys=[team1_id], backref='matches_as_team1')
|
||||
team2 = db.relationship('Team', foreign_keys=[team2_id], backref='matches_as_team2')
|
||||
participants = db.relationship('MatchParticipant', backref='match', lazy='dynamic')
|
||||
# delete-orphan: without it, SQLAlchemy tries to detach participants by
|
||||
# setting match_id to NULL, which the NOT NULL column refuses — so
|
||||
# deleting any match that had participants raised IntegrityError.
|
||||
# TeamMatch.participants already declared this; Match did not.
|
||||
participants = db.relationship(
|
||||
'MatchParticipant', backref='match', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def get_participating_players(self):
|
||||
return [p.player_id for p in self.participants.all()]
|
||||
@@ -10,7 +10,7 @@ from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, Match, MatchParticipant, Team, TeamMember,
|
||||
TryoutRegistration, PlayerDisponibility,
|
||||
OneOnOneRequest,
|
||||
OneOnOneRequest, PersonalNote,
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
from app.discord_bot import send_schedule_notification
|
||||
@@ -507,6 +507,14 @@ def delete_match(match_id):
|
||||
if tryout.is_ended:
|
||||
flash('This tryout has ended. Matches can no longer be deleted.', 'danger')
|
||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout.id))
|
||||
|
||||
# Notes outlive the match they were taken during: a coach's observation
|
||||
# keeps its value, and deleting it here would destroy unrelated content.
|
||||
# Only the context link is dropped. Participants go through the
|
||||
# relationship's delete-orphan cascade.
|
||||
PersonalNote.query.filter_by(match_id=match_id).update(
|
||||
{'match_id': None}, synchronize_session=False)
|
||||
|
||||
db.session.delete(match)
|
||||
db.session.commit()
|
||||
flash('Match deleted successfully.', 'success')
|
||||
|
||||
+22
-6
@@ -9,6 +9,7 @@ from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player,
|
||||
OrgTeam, User, PersonalNote, TeamNote, Tryout, TeamPlayer,
|
||||
TeamMatch, Contract, OneOnOneRequest,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
@@ -209,13 +210,28 @@ def delete_team(team_id):
|
||||
team = OrgTeam.query.get_or_404(team_id)
|
||||
name = team.name
|
||||
|
||||
tryouts = Tryout.query.filter_by(target_org_team_id=team_id).all()
|
||||
for t in tryouts:
|
||||
t.target_org_team_id = None
|
||||
db.session.commit()
|
||||
# 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.
|
||||
|
||||
TeamPlayer.query.filter_by(org_team_id=team_id).delete()
|
||||
db.session.commit()
|
||||
# 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()
|
||||
|
||||
+15
-9
@@ -10,7 +10,7 @@ from app.extensions import db
|
||||
from app.models import (
|
||||
Admin, Manager, Coach, Player, Scout,
|
||||
User, Tryout, TryoutRegistration, Evaluation, Team, TeamMember,
|
||||
OrgTeam, Match, MatchParticipant,
|
||||
OrgTeam, Match, MatchParticipant, PersonalNote,
|
||||
ESPORT_GAMES, GAME_POSITIONS,
|
||||
)
|
||||
from datetime import datetime
|
||||
@@ -505,28 +505,34 @@ def delete_tryout(tryout_id):
|
||||
flash('You do not have permission to delete this tryout.', 'danger')
|
||||
return redirect(url_for('tryouts.list_tryouts'))
|
||||
|
||||
# Delete match participants for all matches in this tryout
|
||||
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)
|
||||
# Delete matches
|
||||
Match.query.filter(Match.id.in_(match_ids)).delete(synchronize_session=False)
|
||||
|
||||
# Delete team members for all teams in this tryout
|
||||
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)
|
||||
).delete(synchronize_session=False)
|
||||
# Delete teams
|
||||
Team.query.filter(Team.id.in_(team_ids)).delete(synchronize_session=False)
|
||||
|
||||
# Delete registrations
|
||||
TryoutRegistration.query.filter_by(tryout_id=tryout_id).delete()
|
||||
|
||||
# Delete evaluations
|
||||
Evaluation.query.filter_by(tryout_id=tryout_id).delete()
|
||||
|
||||
db.session.delete(tryout)
|
||||
|
||||
Reference in New Issue
Block a user