"""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)