Files
Imago/app/services/storage.py
T

136 lines
4.8 KiB
Python

"""
Service de stockage — sauvegarde fichiers, génération thumbnails
Multi-tenant : les fichiers sont isolés par client_id.
"""
import asyncio
import uuid
import logging
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__)
ALLOWED_MIME_TYPES = {
"image/jpeg", "image/png", "image/gif",
"image/webp", "image/bmp", "image/tiff",
}
THUMBNAIL_SIZE = (320, 320)
def _generate_filename(original: str) -> tuple[str, str]:
"""Retourne (uuid_filename, extension)."""
suffix = Path(original).suffix.lower() or ".jpg"
uid = str(uuid.uuid4())
return f"{uid}{suffix}", uid
async def save_upload(file: UploadFile, client_id: str) -> dict:
"""
Valide, sauvegarde le fichier uploadé et génère un thumbnail.
Utilise le backend de stockage configuré (Local ou S3).
L'ordre est : validation → upload fichier → génération thumbnail → retourne les métadonnées.
L'appelant est responsable de l'insertion BDD et du rollback en cas d'échec.
"""
# ── Validation MIME ───────────────────────────────────────
if file.content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Type non supporté : {file.content_type}. "
f"Acceptés : {', '.join(ALLOWED_MIME_TYPES)}",
)
# ── Lecture du contenu ────────────────────────────────────
content = await file.read()
if len(content) > settings.max_upload_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Fichier trop volumineux. Max : {settings.MAX_UPLOAD_SIZE_MB} MB",
)
# ── Nommage ───────────────────────────────────────────────
filename, file_uuid = _generate_filename(file.filename or "image")
# 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}"
backend = get_storage_backend()
# ── Sauvegarde fichier original ───────────────────────────
await backend.save(content, rel_file_path, file.content_type)
# ── Dimensions + thumbnail ────────────────────────────────
width, height = None, None
thumb_saved = False
try:
with PILImage.open(io.BytesIO(content)) as img:
width, height = img.size
img.thumbnail(THUMBNAIL_SIZE, PILImage.LANCZOS)
# Convertit en RGB si nécessaire
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# 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:
logger.warning("thumbnail.generation_error", extra={
"file": rel_file_path,
"error": str(e),
})
return {
"uuid": file_uuid,
"original_name": file.filename,
"filename": filename,
"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,
"height": height,
"uploaded_at": datetime.now(timezone.utc),
"client_id": client_id,
}
async def delete_files(file_path: str, thumbnail_path: str | None = None) -> None:
"""
Supprime le fichier original et son thumbnail via le backend de stockage.
Entièrement asynchrone — les fichiers sont supprimés de façon déterministe.
"""
backend = get_storage_backend()
errors: list[str] = []
try:
await backend.delete(file_path)
except Exception as e:
errors.append(f"original: {e}")
if thumbnail_path:
try:
await backend.delete(thumbnail_path)
except Exception as e:
errors.append(f"thumbnail: {e}")
if errors:
logger.warning("storage.delete_partial", extra={
"file_path": file_path,
"errors": errors,
})