Imago/app/routers/images.py

497 lines
18 KiB
Python

"""
Router — Images : upload, lecture, suppression, retraitement
Sécurisé : authentification par API Key + isolation par client_id.
"""
import logging
import math
from typing import Optional
from fastapi import (
APIRouter, Depends, HTTPException, UploadFile, File,
Request, status, Query,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from app.database import get_db
from app.dependencies.auth import get_current_client, require_scope
from app.models.client import APIClient
from app.models.image import Image, ProcessingStatus
from app.schemas import (
UploadResponse, ImageDetail, ImageSummary,
StatusResponse, PaginatedImages, DeleteResponse,
TagsResponse, ReprocessResponse,
)
from app.services import storage
from app.middleware import limiter, get_upload_rate_limit
from app.workers.image_worker import QUEUE_STANDARD, QUEUE_PREMIUM
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/images", tags=["Images"])
# ─────────────────────────────────────────────────────────────
# UTILITAIRE : récupérer une image avec isolation client
# ─────────────────────────────────────────────────────────────
async def get_image_or_404(
image_id: int, client_id: str, db: AsyncSession
) -> Image:
"""
Récupère une image par ID en vérifiant qu'elle appartient au client.
Lève HTTP 404 si introuvable ou si elle n'appartient pas au client.
"""
result = await db.execute(
select(Image).where(
Image.id == image_id,
Image.client_id == client_id,
)
)
image = result.scalar_one_or_none()
if not image:
raise HTTPException(status_code=404, detail="Image introuvable")
return image
def _dynamic_upload_limit(key: str) -> str:
"""Retourne la limite dynamique basée sur le plan du client."""
# On parse le plan depuis la clé ou le state — fallback free
return get_upload_rate_limit("free")
# ─────────────────────────────────────────────────────────────
# UPLOAD
# ─────────────────────────────────────────────────────────────
@router.post(
"/upload",
response_model=UploadResponse,
status_code=status.HTTP_201_CREATED,
summary="Uploader une image",
description="Upload une image, lance automatiquement le pipeline AI (EXIF + OCR + Vision).",
dependencies=[Depends(require_scope("images:write"))],
)
@limiter.limit("500/hour")
async def upload_image(
request: Request,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
# Vérification quota avant upload
quota_mb = client.quota_storage_mb or 500
used_bytes = client.storage_used_bytes or 0
if used_bytes >= quota_mb * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Quota de stockage dépassé ({quota_mb} MB)",
)
# Sauvegarde fichier + thumbnail (isolé par client_id)
file_data = await storage.save_upload(file, client_id=client.id)
# Création de l'enregistrement BDD
image = Image(**file_data)
db.add(image)
# Mise à jour du quota
file_size = file_data.get("file_size", 0)
client.storage_used_bytes = (client.storage_used_bytes or 0) + file_size
await db.commit()
await db.refresh(image)
# Enqueue dans ARQ (persistant, avec retry)
arq_pool = request.app.state.arq_pool
await arq_pool.enqueue_job(
"process_image_task",
image.id,
str(client.id)
)
return UploadResponse(
id=image.id,
uuid=image.uuid,
original_name=image.original_name,
status=image.processing_status,
)
# ─────────────────────────────────────────────────────────────
# LISTE
# ─────────────────────────────────────────────────────────────
@router.get(
"",
response_model=PaginatedImages,
summary="Lister les images",
dependencies=[Depends(require_scope("images:read"))],
)
async def list_images(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
tag: Optional[str] = Query(None, description="Filtrer par tag AI"),
status_filter: Optional[ProcessingStatus] = Query(None, alias="status"),
search: Optional[str] = Query(None, description="Recherche dans description et OCR"),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
# Filtre d'isolation par client
query = select(Image).where(Image.client_id == client.id)
if status_filter:
query = query.where(Image.processing_status == status_filter)
if tag:
query = query.where(Image.ai_tags.contains([tag]))
if search:
query = query.where(
or_(
Image.ai_description.ilike(f"%{search}%"),
Image.ocr_text.ilike(f"%{search}%"),
Image.original_name.ilike(f"%{search}%"),
)
)
# Count total
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar_one()
# Pagination
offset = (page - 1) * page_size
query = query.order_by(Image.uploaded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
images = result.scalars().all()
# Quota info
used_mb = round((client.storage_used_bytes or 0) / (1024 * 1024), 2)
quota_mb = client.quota_storage_mb or 500
pct = round(used_mb / quota_mb * 100, 1) if quota_mb > 0 else 0.0
return PaginatedImages(
total=total,
page=page,
page_size=page_size,
pages=math.ceil(total / page_size) if total else 0,
storage_used_mb=used_mb,
storage_quota_mb=quota_mb,
quota_pct=pct,
items=[
ImageSummary(
id=img.id,
uuid=img.uuid,
original_name=img.original_name,
mime_type=img.mime_type,
file_size=img.file_size,
width=img.width,
height=img.height,
uploaded_at=img.uploaded_at,
processing_status=img.processing_status,
ai_tags=img.ai_tags,
ai_description=img.ai_description,
thumbnail_path=img.thumbnail_path,
)
for img in images
],
)
# ─────────────────────────────────────────────────────────────
# DÉTAIL COMPLET
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}",
response_model=ImageDetail,
summary="Détail complet d'une image",
description="Retourne toutes les données : fichier, EXIF, OCR et résultats AI.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_image(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return ImageDetail.from_orm_full(image)
# ─────────────────────────────────────────────────────────────
# STATUT DU PIPELINE
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/status",
response_model=StatusResponse,
summary="Statut du traitement AI",
description="Permet de poller l'avancement du pipeline (pending → processing → done/error).",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_status(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return StatusResponse(
id=image.id,
uuid=image.uuid,
status=image.processing_status,
error=image.processing_error,
started_at=image.processing_started_at,
done_at=image.processing_done_at,
)
# ─────────────────────────────────────────────────────────────
# DONNÉES EXIF
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/exif",
summary="Métadonnées EXIF de l'image",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_exif(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return {
"id": image.id,
"camera": {
"make": image.exif_make,
"model": image.exif_model,
"lens": image.exif_lens,
"iso": image.exif_iso,
"aperture": image.exif_aperture,
"shutter_speed": image.exif_shutter,
"focal_length": image.exif_focal,
"flash": image.exif_flash,
"orientation": image.exif_orientation,
"software": image.exif_software,
"taken_at": image.exif_taken_at,
},
"gps": {
"latitude": image.exif_gps_lat,
"longitude": image.exif_gps_lon,
"altitude": image.exif_altitude,
"has_gps": image.has_gps,
"maps_url": (
f"https://maps.google.com/?q={image.exif_gps_lat},{image.exif_gps_lon}"
if image.has_gps else None
),
},
"raw": image.exif_raw,
}
# ─────────────────────────────────────────────────────────────
# DONNÉES OCR
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/ocr",
summary="Texte extrait de l'image (OCR)",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_ocr(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return {
"id": image.id,
"has_text": image.ocr_has_text,
"text": image.ocr_text,
"language": image.ocr_language,
"confidence": image.ocr_confidence,
}
# ─────────────────────────────────────────────────────────────
# DONNÉES AI
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/ai",
summary="Résultats AI (description + tags)",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_ai(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return {
"id": image.id,
"description": image.ai_description,
"tags": image.ai_tags,
"confidence": image.ai_confidence,
"model_used": image.ai_model_used,
"processed_at": image.ai_processed_at,
"tokens": {
"prompt": image.ai_prompt_tokens,
"output": image.ai_output_tokens,
"total": (image.ai_prompt_tokens or 0) + (image.ai_output_tokens or 0),
},
}
# ─────────────────────────────────────────────────────────────
# TAGS — Vue globale (filtrée par client)
# ─────────────────────────────────────────────────────────────
@router.get(
"/tags/all",
response_model=TagsResponse,
summary="Tous les tags utilisés",
description="Liste dédupliquée de tous les tags AI générés sur les images du client.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_all_tags(
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
result = await db.execute(
select(Image.ai_tags).where(
Image.client_id == client.id,
Image.ai_tags.isnot(None),
)
)
all_tag_lists = result.scalars().all()
unique_tags = sorted(set(
tag
for tag_list in all_tag_lists
for tag in (tag_list or [])
))
return TagsResponse(tags=unique_tags, total=len(unique_tags))
# ─────────────────────────────────────────────────────────────
# RETRAITEMENT AI
# ─────────────────────────────────────────────────────────────
@router.post(
"/{image_id}/reprocess",
response_model=ReprocessResponse,
summary="Relancer le pipeline AI",
description="Reprocess une image existante (utile après changement de modèle AI).",
dependencies=[Depends(require_scope("images:write"))],
)
@limiter.limit("500/hour")
async def reprocess_image(
request: Request,
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
# Reset du statut
image.processing_status = ProcessingStatus.PENDING
image.processing_error = None
image.processing_started_at = None
image.processing_done_at = None
await db.commit()
arq_pool = request.app.state.arq_pool
await arq_pool.enqueue_job(
"process_image_task",
image_id,
str(client.id)
)
return ReprocessResponse(id=image_id)
# ─────────────────────────────────────────────────────────────
# SUPPRESSION
# ─────────────────────────────────────────────────────────────
@router.delete(
"/{image_id}",
response_model=DeleteResponse,
summary="Supprimer une image",
dependencies=[Depends(require_scope("images:delete"))],
)
async def delete_image(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
# Décrémentation du quota
file_size = image.file_size or 0
client.storage_used_bytes = max(0, (client.storage_used_bytes or 0) - file_size)
# Suppression des fichiers sur disque
storage.delete_files(image.file_path, image.thumbnail_path)
await db.delete(image)
await db.commit()
return DeleteResponse(deleted_id=image_id)
# ─────────────────────────────────────────────────────────────
# URLs SIGNÉES
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/download-url",
summary="URL signée de téléchargement",
description="Retourne une URL signée temporaire pour télécharger l'image originale.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_download_url(
image_id: int,
expires_in: int = Query(900, ge=60, le=86400, description="Durée de validité en secondes"),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
from app.services.storage_backend import get_storage_backend
image = await get_image_or_404(image_id, client.id, db)
backend = get_storage_backend()
url = await backend.get_signed_url(image.file_path, expires_in=expires_in)
return {"url": url, "expires_in": expires_in}
@router.get(
"/{image_id}/thumbnail-url",
summary="URL signée du thumbnail",
description="Retourne une URL signée temporaire pour le thumbnail de l'image.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_thumbnail_url(
image_id: int,
expires_in: int = Query(900, ge=60, le=86400, description="Durée de validité en secondes"),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
from app.services.storage_backend import get_storage_backend
image = await get_image_or_404(image_id, client.id, db)
if not image.thumbnail_path:
raise HTTPException(status_code=404, detail="Thumbnail non disponible")
backend = get_storage_backend()
url = await backend.get_signed_url(image.thumbnail_path, expires_in=expires_in)
return {"url": url, "expires_in": expires_in}