127 lines
4.7 KiB
Python
127 lines
4.7 KiB
Python
"""
|
|
Service de stockage — sauvegarde fichiers, génération thumbnails
|
|
Multi-tenant : les fichiers sont isolés par client_id.
|
|
"""
|
|
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).
|
|
"""
|
|
# ── 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:
|
|
# 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
|
|
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("Erreur génération thumbnail : %s", 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,
|
|
}
|
|
|
|
|
|
def delete_files(file_path: str, thumbnail_path: str | None = None) -> None:
|
|
"""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
|