Mise à jour de la configuration de la base de données pour utiliser PostgreSQL et ajout de Redis dans le fichier .env. Modifications du Dockerfile pour installer les dépendances nécessaires. Amélioration des services de stockage pour supporter les opérations asynchrones avec S3 et le stockage local. Refactorisation des pipelines d'images pour une meilleure gestion des tâches asynchrones. Ajout de la gestion des clés API dans l'authentification. Mise à jour de la documentation et des exemples d'utilisation.
CI / Lint & Format (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled

This commit is contained in:
2026-02-24 16:19:18 -05:00
parent cc99fea20a
commit d68deb9c74
20 changed files with 235 additions and 285 deletions
+47 -44
View File
@@ -4,12 +4,13 @@ Multi-tenant : les fichiers sont isolés par client_id.
"""
import uuid
import logging
import aiofiles
import io
from pathlib import Path
from datetime import datetime, timezone
from PIL import Image as PILImage
from fastapi import UploadFile, HTTPException, status
from app.config import settings
from app.services.storage_backend import get_storage_backend
logger = logging.getLogger(__name__)
@@ -28,25 +29,10 @@ def _generate_filename(original: str) -> tuple[str, str]:
return f"{uid}{suffix}", uid
def _get_client_upload_path(client_id: str) -> Path:
"""Retourne le répertoire d'upload pour un client donné."""
p = settings.upload_path / client_id
p.mkdir(parents=True, exist_ok=True)
return p
def _get_client_thumbnails_path(client_id: str) -> Path:
"""Retourne le répertoire de thumbnails pour un client donné."""
p = settings.thumbnails_path / client_id
p.mkdir(parents=True, exist_ok=True)
return p
async def save_upload(file: UploadFile, client_id: str) -> dict:
"""
Valide, sauvegarde le fichier uploadé et génère un thumbnail.
Les fichiers sont stockés dans uploads/{client_id}/ pour l'isolation.
Retourne un dict avec toutes les métadonnées fichier.
Utilise le backend de stockage configuré (Local ou S3).
"""
# ── Validation MIME ───────────────────────────────────────
if file.content_type not in ALLOWED_MIME_TYPES:
@@ -65,40 +51,49 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
detail=f"Fichier trop volumineux. Max : {settings.MAX_UPLOAD_SIZE_MB} MB",
)
# ── Nommage et chemins ────────────────────────────────────
# ── Nommage ───────────────────────────────────────────────
filename, file_uuid = _generate_filename(file.filename or "image")
upload_dir = _get_client_upload_path(client_id)
thumb_dir = _get_client_thumbnails_path(client_id)
# Chemins relatifs par rapport au bucket/base_dir
rel_file_path = f"uploads/{client_id}/{filename}"
rel_thumb_path = f"thumbnails/{client_id}/thumb_{filename}"
file_path = upload_dir / filename
thumb_filename = f"thumb_{filename}"
thumb_path = thumb_dir / thumb_filename
backend = get_storage_backend()
# ── Sauvegarde fichier original ───────────────────────────
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
await backend.save(content, rel_file_path, file.content_type)
# ── Dimensions + thumbnail ────────────────────────────────
width, height = None, None
thumb_saved = False
try:
with PILImage.open(file_path) as img:
# On utilise io.BytesIO pour ne pas avoir à écrire sur le disque local
with PILImage.open(io.BytesIO(content)) as img:
width, height = img.size
img.thumbnail(THUMBNAIL_SIZE, PILImage.LANCZOS)
# Convertit en RGB si nécessaire (ex: PNG RGBA)
# Convertit en RGB si nécessaire
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(thumb_path, "JPEG", quality=85)
# Sauvegarde thumbnail dans un buffer
thumb_buffer = io.BytesIO()
img.save(thumb_buffer, "JPEG", quality=85)
thumb_data = thumb_buffer.getvalue()
# Sauvegarde via le backend
await backend.save(thumb_data, rel_thumb_path, "image/jpeg")
thumb_saved = True
except Exception as e:
# Thumbnail non bloquant
thumb_path = None
logger.warning("Erreur génération thumbnail : %s", e)
return {
"uuid": file_uuid,
"original_name": file.filename,
"filename": filename,
"file_path": str(file_path),
"thumbnail_path": str(thumb_path) if thumb_path else None,
"file_path": rel_file_path,
"thumbnail_path": rel_thumb_path if thumb_saved else None,
"mime_type": file.content_type,
"file_size": len(content),
"width": width,
@@ -109,15 +104,23 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
def delete_files(file_path: str, thumbnail_path: str | None = None) -> None:
"""Supprime le fichier original et son thumbnail du disque."""
for path_str in [file_path, thumbnail_path]:
if path_str:
p = Path(path_str)
if p.exists():
p.unlink()
def get_image_url(filename: str, client_id: str, thumb: bool = False) -> str:
"""Construit l'URL publique d'une image."""
prefix = "thumbnails" if thumb else "uploads"
return f"/static/{prefix}/{client_id}/{filename}"
"""Supprime le fichier original et son thumbnail via le backend."""
import asyncio
backend = get_storage_backend()
async def _do_delete():
await backend.delete(file_path)
if thumbnail_path:
await backend.delete(thumbnail_path)
# Note: delete_files est synchrone dans les routers existants,
# mais le backend est async. C'est un risque.
# TODO: Refactorer delete_image pour être full async.
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(_do_delete())
else:
loop.run_until_complete(_do_delete())
except Exception:
pass