Files
team-tryouts/app/storage.py
T
GGThed 709e8a5d51 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.
2026-08-11 15:34:33 -04:00

102 lines
3.9 KiB
Python

"""Where uploaded documents live, and how the database refers to them.
Contracts were stored at `os.path.join(os.getcwd(), 'documents', …)`,
evaluated at upload time, and the resulting absolute path was written into
`Contract.file_path`. The storage root therefore moved with whatever
directory the process happened to be started from. Two consequences:
- one latent: start the server from elsewhere and new contracts land in a
new tree while the old ones become unreadable — with the database still
saying they are there, so the failure surfaces as a 500 on download
rather than as anything a person could act on;
- one blocking: it rules out a release-directory deployment (OPS-011)
outright. Every stored path would point inside a release that is about
to be replaced, so the first switch would take every contract ever
uploaded with it.
New rows keep a path *relative* to the document root. Old rows keep their
absolute path and are returned untouched, so this change needs no data
migration and can ship before Alembic does (DB-002).
"""
import os
#: Environment override for the document root. What a release-directory
#: deployment sets, to a path outside the releases — alongside them, not
#: inside whichever one is current.
DOCUMENTS_ROOT_ENV = 'DOCUMENTS_ROOT'
#: Sub-directory holding uploaded contracts, under the document root.
CONTRACTS_DIR = 'contrats signés'
def documents_root():
"""Absolute path of the document store.
Falls back to `documents/` beside this package — the project root
wherever it is installed, rather than wherever the process was launched.
"""
configured = os.getenv(DOCUMENTS_ROOT_ENV)
if configured:
return os.path.abspath(configured)
package_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(os.path.dirname(package_dir), 'documents')
def discard_documents(stored_paths):
"""Remove these documents from disk. Returns how many went (DATA-012).
`delete_user` removed the Contract rows and left the PDFs. Signed,
named contracts therefore stayed on the server after the account was
deleted, with nothing in the database pointing at them — invisible to
the application, unmanageable through it, and still personal data.
Call this **after** the commit that removed the rows, never before: a
failure between the two should leave a file with no row (recoverable,
and what the previous behaviour produced anyway) rather than a row with
no file (a download that 500s for ever).
A path that cannot be removed is logged and skipped. Nothing here should
be able to abort the deletion of an account.
"""
import logging
logger = logging.getLogger(__name__)
removed = 0
for stored_path in stored_paths:
if not stored_path:
continue
target = document_path(stored_path)
try:
os.remove(target)
removed += 1
except FileNotFoundError:
# Already gone. Two contracts sharing a stem, or a previous
# attempt: not a problem, and not worth an error line.
logger.info('Document already absent: %s', target)
except OSError as exc:
logger.error(
'Could not remove %s (%s). It is now an orphan: no database row '
'refers to it, so nothing in the application will ever offer to '
'delete it again.',
target,
exc,
)
return removed
def document_path(stored_path):
"""Absolute path of a document, from what the database holds.
Args:
stored_path: The value of Contract.file_path or signed_file_path.
Relative for rows written since this module existed, absolute
for the ones written before.
Returns:
str: An absolute path.
"""
if os.path.isabs(stored_path):
return stored_path
return os.path.join(documents_root(), stored_path)