Add comprehensive test suite for image processing and related services
- Implement tests for database generator to ensure proper session handling. - Create tests for EXIF extraction and conversion functions. - Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients. - Develop tests for OCR functionality, including language detection and text extraction. - Introduce tests for the image processing pipeline, covering success and failure scenarios. - Validate rate limiting functionality and ensure independent counters for different clients. - Implement scraper tests to verify HTML content fetching and error handling. - Add unit tests for various services, including storage and filename generation. - Establish worker entry point for ARQ to handle background image processing tasks.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from app.routers.images import router as images_router
|
||||
from app.routers.ai import router as ai_router
|
||||
from app.routers.auth import router as auth_router
|
||||
from app.routers.files import router as files_router
|
||||
|
||||
__all__ = ["images_router", "ai_router", "auth_router", "files_router"]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Router — AI : résumé d'URL, rédaction de tâches
|
||||
Sécurisé : authentification par API Key + scope ai:use + rate limiting.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.dependencies.auth import get_current_client, require_scope
|
||||
from app.models.client import APIClient
|
||||
from app.schemas import (
|
||||
SummarizeRequest, SummarizeResponse,
|
||||
DraftTaskRequest, DraftTaskResponse,
|
||||
)
|
||||
from app.services.scraper import fetch_page_content
|
||||
from app.services.ai_vision import summarize_url, draft_task
|
||||
from app.config import settings
|
||||
from app.middleware import limiter
|
||||
|
||||
|
||||
router = APIRouter(prefix="/ai", tags=["Intelligence Artificielle"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/summarize",
|
||||
response_model=SummarizeResponse,
|
||||
summary="Résumé AI d'une URL",
|
||||
description=(
|
||||
"Scrappe le contenu d'une URL et génère un résumé structuré + tags via AI. "
|
||||
"Utile pour enrichir les bookmarks Shaarli."
|
||||
),
|
||||
dependencies=[Depends(require_scope("ai:use"))],
|
||||
)
|
||||
@limiter.limit("1000/hour")
|
||||
async def summarize_link(
|
||||
request: Request,
|
||||
body: SummarizeRequest,
|
||||
client: APIClient = Depends(get_current_client),
|
||||
):
|
||||
if not settings.AI_ENABLED:
|
||||
raise HTTPException(status_code=503, detail="AI désactivée")
|
||||
|
||||
# Scraping
|
||||
page = await fetch_page_content(body.url)
|
||||
if page.get("error"):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Impossible de récupérer la page : {page['error']}",
|
||||
)
|
||||
|
||||
content = " ".join(filter(None, [
|
||||
page.get("title"),
|
||||
page.get("description"),
|
||||
page.get("text"),
|
||||
]))
|
||||
|
||||
if not content.strip():
|
||||
raise HTTPException(status_code=422, detail="Aucun contenu texte trouvé sur cette page")
|
||||
|
||||
# Résumé AI
|
||||
result = await summarize_url(
|
||||
url=body.url,
|
||||
content=content,
|
||||
language=body.language,
|
||||
)
|
||||
|
||||
return SummarizeResponse(
|
||||
url=body.url,
|
||||
title=page.get("title"),
|
||||
summary=result["summary"],
|
||||
tags=result["tags"],
|
||||
model=result["model"],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/draft-task",
|
||||
response_model=DraftTaskResponse,
|
||||
summary="Rédaction AI d'une tâche",
|
||||
description=(
|
||||
"Génère une tâche structurée (titre, description, étapes, priorité) "
|
||||
"à partir d'une description libre."
|
||||
),
|
||||
dependencies=[Depends(require_scope("ai:use"))],
|
||||
)
|
||||
@limiter.limit("1000/hour")
|
||||
async def generate_task(
|
||||
request: Request,
|
||||
body: DraftTaskRequest,
|
||||
client: APIClient = Depends(get_current_client),
|
||||
):
|
||||
if not settings.AI_ENABLED:
|
||||
raise HTTPException(status_code=503, detail="AI désactivée")
|
||||
|
||||
result = await draft_task(
|
||||
description=body.description,
|
||||
context=body.context,
|
||||
language=body.language,
|
||||
)
|
||||
|
||||
if not result.get("title"):
|
||||
raise HTTPException(status_code=500, detail="Échec de la génération de la tâche")
|
||||
|
||||
return DraftTaskResponse(**result)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Router — Auth : gestion des clients API (CRUD + rotation de clé)
|
||||
"""
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_client, hash_api_key, require_scope
|
||||
from app.models.client import APIClient
|
||||
from app.schemas.auth import (
|
||||
ClientCreate,
|
||||
ClientCreateResponse,
|
||||
ClientResponse,
|
||||
ClientUpdate,
|
||||
KeyRotateResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentification"])
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# CRÉER UN CLIENT
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/clients",
|
||||
response_model=ClientCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Créer un nouveau client API",
|
||||
description=(
|
||||
"Crée un client et retourne la clé API **en clair une seule fois**. "
|
||||
"Stockez-la immédiatement — elle ne sera plus jamais affichée."
|
||||
),
|
||||
dependencies=[Depends(require_scope("admin"))],
|
||||
)
|
||||
async def create_client(
|
||||
body: ClientCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ClientCreateResponse:
|
||||
# Génération de la clé API
|
||||
raw_key = secrets.token_urlsafe(32)
|
||||
key_hash = hash_api_key(raw_key)
|
||||
|
||||
client = APIClient(
|
||||
name=body.name,
|
||||
api_key_hash=key_hash,
|
||||
scopes=body.scopes,
|
||||
plan=body.plan,
|
||||
)
|
||||
db.add(client)
|
||||
await db.flush()
|
||||
await db.refresh(client)
|
||||
|
||||
logger.info("Client créé : %s (%s)", client.name, client.id)
|
||||
|
||||
return ClientCreateResponse(
|
||||
id=client.id,
|
||||
name=client.name,
|
||||
scopes=client.scopes,
|
||||
plan=client.plan,
|
||||
is_active=client.is_active,
|
||||
created_at=client.created_at,
|
||||
updated_at=client.updated_at,
|
||||
api_key=raw_key,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# LISTER LES CLIENTS
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/clients",
|
||||
response_model=list[ClientResponse],
|
||||
summary="Lister tous les clients API",
|
||||
dependencies=[Depends(require_scope("admin"))],
|
||||
)
|
||||
async def list_clients(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ClientResponse]:
|
||||
result = await db.execute(select(APIClient).order_by(APIClient.created_at.desc()))
|
||||
clients = result.scalars().all()
|
||||
return [ClientResponse.model_validate(c) for c in clients]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# DÉTAIL D'UN CLIENT
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/clients/{client_id}",
|
||||
response_model=ClientResponse,
|
||||
summary="Détail d'un client API",
|
||||
dependencies=[Depends(require_scope("admin"))],
|
||||
)
|
||||
async def get_client(
|
||||
client_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ClientResponse:
|
||||
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client introuvable")
|
||||
return ClientResponse.model_validate(client)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# MODIFIER UN CLIENT
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.patch(
|
||||
"/clients/{client_id}",
|
||||
response_model=ClientResponse,
|
||||
summary="Modifier un client API",
|
||||
dependencies=[Depends(require_scope("admin"))],
|
||||
)
|
||||
async def update_client(
|
||||
client_id: str,
|
||||
body: ClientUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ClientResponse:
|
||||
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client introuvable")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(client, field, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(client)
|
||||
|
||||
logger.info("Client mis à jour : %s (%s)", client.name, client.id)
|
||||
return ClientResponse.model_validate(client)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# ROTATION DE CLÉ
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/clients/{client_id}/rotate-key",
|
||||
response_model=KeyRotateResponse,
|
||||
summary="Régénérer la clé API d'un client",
|
||||
description="Invalide l'ancienne clé et en génère une nouvelle.",
|
||||
dependencies=[Depends(require_scope("admin"))],
|
||||
)
|
||||
async def rotate_key(
|
||||
client_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KeyRotateResponse:
|
||||
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client introuvable")
|
||||
|
||||
raw_key = secrets.token_urlsafe(32)
|
||||
client.api_key_hash = hash_api_key(raw_key)
|
||||
await db.flush()
|
||||
|
||||
logger.info("Clé API rotée pour client : %s (%s)", client.name, client.id)
|
||||
|
||||
return KeyRotateResponse(id=client.id, api_key=raw_key)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# DÉSACTIVER UN CLIENT (soft delete)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete(
|
||||
"/clients/{client_id}",
|
||||
response_model=ClientResponse,
|
||||
summary="Désactiver un client API",
|
||||
description="Soft delete — marque le client comme inactif sans supprimer les données.",
|
||||
dependencies=[Depends(require_scope("admin"))],
|
||||
)
|
||||
async def delete_client(
|
||||
client_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ClientResponse:
|
||||
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client introuvable")
|
||||
|
||||
client.is_active = False
|
||||
await db.flush()
|
||||
await db.refresh(client)
|
||||
|
||||
logger.info("Client désactivé : %s (%s)", client.name, client.id)
|
||||
return ClientResponse.model_validate(client)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Router — Files : sert les fichiers locaux via URLs signées HMAC.
|
||||
|
||||
Monté uniquement quand STORAGE_BACKEND == "local".
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.services.storage_backend import get_storage_backend, LocalStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/files", tags=["Fichiers"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/signed/{token}",
|
||||
summary="Télécharger un fichier via URL signée",
|
||||
description="Valide le token HMAC et retourne le fichier correspondant.",
|
||||
)
|
||||
async def serve_signed_file(token: str):
|
||||
"""Sert un fichier local via un token HMAC signé."""
|
||||
backend = get_storage_backend()
|
||||
|
||||
if not isinstance(backend, LocalStorage):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Endpoint non disponible avec le backend de stockage actuel",
|
||||
)
|
||||
|
||||
# Valider le token
|
||||
path = backend.validate_token(token)
|
||||
if path is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Token invalide ou expiré",
|
||||
)
|
||||
|
||||
# Vérifier que le fichier existe
|
||||
abs_path = backend.get_absolute_path(path)
|
||||
if not abs_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail="Le fichier n'existe plus",
|
||||
)
|
||||
|
||||
# Détecter le content type
|
||||
suffix = abs_path.suffix.lower()
|
||||
mime_map = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
}
|
||||
media_type = mime_map.get(suffix, "application/octet-stream")
|
||||
|
||||
return FileResponse(
|
||||
path=str(abs_path),
|
||||
media_type=media_type,
|
||||
filename=abs_path.name,
|
||||
)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
queue_name = "premium" if client.plan and client.plan.value == "premium" else "standard"
|
||||
await arq_pool.enqueue_job(
|
||||
"process_image_task",
|
||||
image.id,
|
||||
str(client.id),
|
||||
_queue_name=queue_name,
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
# Enqueue dans ARQ
|
||||
arq_pool = request.app.state.arq_pool
|
||||
queue_name = "premium" if client.plan and client.plan.value == "premium" else "standard"
|
||||
await arq_pool.enqueue_job(
|
||||
"process_image_task",
|
||||
image_id,
|
||||
str(client.id),
|
||||
_queue_name=queue_name,
|
||||
)
|
||||
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user