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