fix(data): supprimer un compte emporte ses contrats

DATA-012. delete_user supprimait les lignes Contract et laissait les PDF.
Des contrats nominatifs signes restaient donc sur le serveur apres la
suppression du compte, sans plus aucune reference en base : invisibles pour
l application, ingerables par elle, et toujours des donnees personnelles.

Les chemins sont lus **avant** que les lignes partent — apres, plus rien ne
dit ou sont les fichiers — et les fichiers sont retires **apres** le commit.
L ordre compte dans ce sens et pas dans l autre : un echec entre les deux
doit laisser un fichier sans ligne, ce qui est recuperable et correspond
exactement a l etat precedent, plutot qu une ligne sans fichier, qui est un
telechargement en 500 pour toujours.

Un fichier deja absent est journalise en info et ignore ; un fichier
impossible a retirer est journalise en erreur avec ce que ca implique — il
devient orphelin, donc plus rien dans l application ne proposera jamais de
le supprimer. Rien ici ne peut faire echouer la suppression du compte : le
compte est la partie que quelqu un a demandee.

Le nombre de fichiers retires part dans le journal d authentification, a
cote de account.deleted.

554 tests.
This commit is contained in:
GGThed
2026-08-11 15:34:33 -04:00
parent 70db8a7491
commit 709e8a5d51
3 changed files with 138 additions and 0 deletions
+17
View File
@@ -42,6 +42,7 @@ from app.routes.users._shared import (
update_user_gamertags,
)
from app.routes.users.blueprint import users_bp
from app.storage import discard_documents
from app.validators import CreateUserSchema, EditUserSchema
@@ -249,6 +250,15 @@ def delete_user(user_id):
db.or_(OneOnOneRequest.player_id == user_id, OneOnOneRequest.coach_id == user_id),
).delete(synchronize_session=False)
UserGamertag.query.filter_by(user_id=user_id).delete()
# Read the file paths before the rows go: afterwards there is nothing
# left to say where the PDFs are (DATA-012). The files themselves are
# removed after the commit, below.
contract_files = [
path
for contract in Contract.query.filter_by(player_id=user_id).all()
for path in (contract.file_path, contract.signed_file_path)
]
Contract.query.filter_by(player_id=user_id).delete()
TryoutRegistration.query.filter_by(player_id=user_id).delete()
TeamPlayer.query.filter_by(player_id=user_id).delete()
@@ -265,6 +275,12 @@ def delete_user(user_id):
deleted_username, deleted_role = user.username, user.role
db.session.delete(user)
db.session.commit()
# After the commit, deliberately. A failure here leaves a file with no
# row — recoverable, and exactly what happened before this existed —
# rather than a row with no file, which is a download that 500s for ever.
discarded = discard_documents(contract_files)
log_auth_event(
'account.deleted',
actor=current_user.username,
@@ -272,6 +288,7 @@ def delete_user(user_id):
target=deleted_username,
target_id=user_id,
role=deleted_role,
contract_files_removed=discarded,
)
flash(
_('User %(deleted_username)s has been removed.', deleted_username=deleted_username),