Files
team-tryouts/app/storage.py
T
cedrick2711 38defc53a5
CI - Security, Lint & Tests / validate (push) Failing after 1m14s
debut changement vers google drive
2026-08-25 19:01:28 -04:00

219 lines
8.4 KiB
Python

"""Where the application's files live, and how the database refers to them.
Three roots: the project itself, the document store, and the log directory.
They are here together because they are the same defect three times over
(OBS-006) — `os.path.join(os.getcwd(), …)`, evaluated at import or upload
time, so every one of them moved with whatever directory the process was
started from. The document store was fixed first, in wave G, because it was
the one that also blocked OPS-011; the other two were left behind, which is
the repeated lesson of this project: a faulty pattern corrected in one layer
stays in the others.
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 io
import logging
import os
from app.google_drive import (
GoogleDriveStorageError,
delete_file as delete_google_drive_file,
download_file as download_google_drive_file,
upload_file as upload_google_drive_file,
)
#: 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'
#: Environment override for the log directory. Same reasoning: a release
#: directory that carries its own logs loses them at the next switch.
LOG_DIR_ENV = 'LOG_DIR'
#: Environment override for the backup directory.
BACKUP_DIR_ENV = 'BACKUP_DIR'
#: Sub-directory holding uploaded contracts, under the document root.
CONTRACTS_DIR = 'contrats signés'
#: Database marker for documents stored remotely. Existing rows continue to
#: hold relative or absolute filesystem paths, so switching storage does not
#: invalidate contracts already uploaded before the Google Drive move.
GOOGLE_DRIVE_PATH_PREFIX = 'gdrive://'
#: Storage backends deliberately stay explicit. Google Drive is the
#: production default; local disk exists only for legacy rows and isolated
#: test/development environments.
DOCUMENT_STORAGE_BACKEND_ENV = 'DOCUMENT_STORAGE_BACKEND'
def project_root():
"""Absolute path of the project, derived from this file's location.
The anchor every other root falls back to. Not `os.getcwd()`: the
process is started by Waitress under Pterodactyl, by pytest, by a task
scheduler and by a person in a shell, and only one of those four is
reliably in the project directory.
"""
package_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.dirname(package_dir)
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.
"""
return _rooted(DOCUMENTS_ROOT_ENV, 'documents')
def logs_root():
"""Absolute path of the log directory.
Was `os.path.join(os.getcwd(), 'logs')`. Starting the server from
another directory sent the logs somewhere new without a word, which is
the worst possible failure mode for the one file you go and read when
something else has gone wrong.
"""
return _rooted(LOG_DIR_ENV, 'logs')
def backups_root():
"""Absolute path of the backup directory."""
return _rooted(BACKUP_DIR_ENV, 'backups')
def _rooted(env_name, default_name):
"""The configured path, or `default_name` under the project root."""
configured = os.getenv(env_name)
if configured:
return os.path.abspath(configured)
return os.path.join(project_root(), default_name)
def document_storage_backend():
"""Return the configured backend for new document uploads."""
backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'google_drive').strip().lower()
if backend not in {'google_drive', 'local'}:
raise ValueError(
f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}'
)
return backend
def is_google_drive_path(stored_path):
"""Whether a database path references a Google Drive file."""
return bool(stored_path and stored_path.startswith(GOOGLE_DRIVE_PATH_PREFIX))
def _google_drive_id(stored_path):
file_id = stored_path.removeprefix(GOOGLE_DRIVE_PATH_PREFIX)
if not file_id:
raise GoogleDriveStorageError('The stored Google Drive file identifier is empty.')
return file_id
def store_uploaded_document(file_storage, relative_path):
"""Store an uploaded document and return its durable database reference.
Local storage retains the relative-path format used by existing rows. A
Google Drive upload returns an opaque Drive file identifier prefixed with
``gdrive://`` so it cannot be mistaken for a filesystem path.
"""
if document_storage_backend() == 'local':
absolute_path = document_path(relative_path)
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
file_storage.save(absolute_path)
return relative_path
file_id = upload_google_drive_file(
stream=file_storage.stream,
filename=os.path.basename(relative_path),
mimetype=file_storage.mimetype or 'application/pdf',
)
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
def open_document(stored_path):
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
if is_google_drive_path(stored_path):
return io.BytesIO(download_google_drive_file(_google_drive_id(stored_path)))
return document_path(stored_path)
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.
"""
logger = logging.getLogger(__name__)
removed = 0
for stored_path in stored_paths:
if not stored_path:
continue
try:
if is_google_drive_path(stored_path):
delete_google_drive_file(_google_drive_id(stored_path))
else:
os.remove(document_path(stored_path))
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', stored_path)
except (GoogleDriveStorageError, 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.',
stored_path,
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)