Upgrade configuration and Docker stack to v2.0.0

This commit is contained in:
2026-06-22 14:49:56 -04:00
parent 2f2a5e9b4a
commit 0d2ecbd218
19 changed files with 779 additions and 495 deletions
+59 -50
View File
@@ -1,76 +1,85 @@
# ============================================================ # ═══════════════════════════════════════════════════════════════
# Imago — Configuration # Imago — Configuration
# Copier ce fichier en .env et remplir les valeurs # ═══════════════════════════════════════════════════════════════
# ============================================================ # Copiez ce fichier en .env et ajustez les valeurs.
# cp .env.example .env
# Application # ── Application ──────────────────────────────────────────────
APP_NAME="Imago" APP_NAME=Imago
APP_VERSION="1.0.0" APP_VERSION=2.0.0
DEBUG=true DEBUG=false
SECRET_KEY="changez-moi-en-production-avec-une-cle-aleatoire-longue" SECRET_KEY=changez-moi-par-une-valeur-longue-et-aleatoire
# AI — Configuration # ── Serveur ──────────────────────────────────────────────────
AI_ENABLED=true
# AI — Provider (gemini/openrouter)
AI_PROVIDER="openrouter"
# Serveur
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8000 PORT=8000
# Base de données # ── Base de données ──────────────────────────────────────────
DATABASE_URL="postgresql+asyncpg://imago:imago@db:5432/imago" # SQLite (dev): sqlite+aiosqlite:///./data/imago.db
# Modifiez les valeurs ci-dessus si vous utilisez une instance externe ou locale. # PostgreSQL: postgresql+asyncpg://user:pass@host:5432/imago
# Pour SQLite (développement local sans Docker): DATABASE_URL=sqlite+aiosqlite:///./data/imago.db
# DATABASE_URL="sqlite+aiosqlite:///./data/imago.db"
# Redis (ARQ Worker) # ── Stockage ─────────────────────────────────────────────────
REDIS_URL="redis://redis:6379/0" # local (fichiers sur disque) ou s3 (MinIO / AWS S3 / Cloudflare R2)
STORAGE_BACKEND=local
# Stockage des fichiers UPLOAD_DIR=./data/uploads
UPLOAD_DIR="./data/uploads" THUMBNAILS_DIR=./data/thumbnails
THUMBNAILS_DIR="./data/thumbnails"
MAX_UPLOAD_SIZE_MB=50 MAX_UPLOAD_SIZE_MB=50
# AI — Google Gemini # ── Stockage S3 (si STORAGE_BACKEND=s3) ──────────────────────
GEMINI_API_KEY="AIza..." S3_BUCKET=imago
GEMINI_MODEL="gemini-3.1-pro-preview" S3_PREFIX=
S3_REGION=us-east-1
S3_ENDPOINT_URL=http://localhost:9000
S3_ENDPOINT_PUBLIC=http://localhost:9000
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
SIGNED_URL_SECRET=changez-moi-en-prod
# ── AI ───────────────────────────────────────────────────────
AI_ENABLED=true
AI_PROVIDER=openrouter
# Google Gemini
GEMINI_API_KEY=
GEMINI_MODEL=gemini-3.1-pro-preview
GEMINI_MAX_TOKENS=1024 GEMINI_MAX_TOKENS=1024
# AI - Openrouter # OpenRouter
# model name : mistralai/mistral-small-3.1-24b-instruct:free OPENROUTER_API_KEY=
# model name : google/gemini-2.0-flash-001 OPENROUTER_MODEL=qwen/qwen2.5-vl-72b-instruct
OPENROUTER_API_KEY="..."
OPENROUTER_MODEL="qwen/qwen2.5-vl-72b-instruct"
# AI — Comportement # AI Comportement
AI_TAGS_MIN=5 AI_TAGS_MIN=5
AI_TAGS_MAX=10 AI_TAGS_MAX=10
AI_DESCRIPTION_LANGUAGE="français" AI_DESCRIPTION_LANGUAGE=francais
AI_CACHE_DAYS=30 AI_CACHE_DAYS=30
AI_REQUEST_TIMEOUT=60
AI_MAX_RETRIES=2
# OCR # ── OCR ──────────────────────────────────────────────────────
OCR_ENABLED=true OCR_ENABLED=true
TESSERACT_CMD="/usr/bin/tesseract" TESSERACT_CMD=/usr/bin/tesseract
OCR_LANGUAGES="fra+eng" OCR_LANGUAGES=fra+eng
# CORS # ── CORS ─────────────────────────────────────────────────────
CORS_ORIGINS=["http://localhost:3000","http://localhost:8080","http://localhost:5173"] # Format JSON : ["http://localhost:3000", "http://localhost:5173"]
CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173"]
# Authentification # ── Authentification ─────────────────────────────────────────
ADMIN_API_KEY="" ADMIN_API_KEY=
JWT_SECRET_KEY="changez-moi-jwt-secret-en-production" JWT_SECRET_KEY=changez-moi-jwt-secret
JWT_ALGORITHM="HS256" JWT_ALGORITHM=HS256
# Rate Limiting (requêtes par minute — legacy) # ── Rate Limiting ────────────────────────────────────────────
RATE_LIMIT_UPLOAD=10
RATE_LIMIT_AI=20
# Rate Limiting par plan (requêtes par heure)
RATE_LIMIT_FREE_UPLOAD=20 RATE_LIMIT_FREE_UPLOAD=20
RATE_LIMIT_FREE_AI=50 RATE_LIMIT_FREE_AI=50
RATE_LIMIT_STANDARD_UPLOAD=100 RATE_LIMIT_STANDARD_UPLOAD=100
RATE_LIMIT_STANDARD_AI=200 RATE_LIMIT_STANDARD_AI=200
RATE_LIMIT_PREMIUM_UPLOAD=500 RATE_LIMIT_PREMIUM_UPLOAD=500
RATE_LIMIT_PREMIUM_AI=1000 RATE_LIMIT_PREMIUM_AI=1000
# Redis pour persistence des compteurs (optionnel, mémoire par défaut)
# Format: redis://host:***@RATE_LIMIT_STORAGE_URL=
# ── Redis + ARQ Worker ───────────────────────────────────────
REDIS_URL=redis://localhost:***@PIPELINE_TIMEOUT=300
+39 -1
View File
@@ -4,7 +4,45 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [2.0.0] - 2026-06-22
### Security
- API Key hashing now uses SHA-256 + pepper (SECRET_KEY) with timing-safe comparison via `secrets.compare_digest()`
- CORS validation rejects `["*"]` when `allow_credentials=True` (Pydantic model_validator)
- Scope validation: `ClientCreate.scopes` rejects invalid values via `field_validator`
- Master key comparison uses constant-time compare
### Added
- **Dead Letter Queue (DLQ)**: Failed ARQ jobs pushed to Redis DLQ after `PIPELINE_MAX_RETRIES` attempts. Admin endpoints to list/retry/clear dead jobs (`GET/POST/DELETE /admin/api/queue/dead`)
- **Circuit Breaker AI**: All AI calls wrapped with `asyncio.wait_for()` + exponential backoff retry (configurable via `AI_REQUEST_TIMEOUT`, `AI_MAX_RETRIES`)
- **Request ID Middleware**: `X-Request-ID` header injected in all responses + bound to structlog context
- **Rate Limiting per Plan**: Dynamic rate limits based on client plan (free/standard/premium) via ContextVar
- **Redis Rate Limit Storage**: Optional persistence via `RATE_LIMIT_STORAGE_URL`
- **Worker script**: `worker.py` for standalone ARQ worker launch
- **Docker Compose stack**: PostgreSQL 16 + Redis 7 + MinIO + API + Worker
- **`.env.example`**: All configuration variables documented
- **Grafana Dashboard**: `docs/grafana-dashboard.json` with 10 panels (images, tokens, pipeline, storage, WebSocket, errors)
- **Database indexes**: Composite indexes `(client_id, uploaded_at)` and `(client_id, processing_status)` on images table
### Changed
- `APP_VERSION` aligned to `2.0.0` in config (was `1.0.0`)
- `delete_files()` now fully async — uses `await backend.delete()` instead of fire-and-forget `ensure_future()`
- Upload flow: files deleted on DB commit failure (no orphaned S3 files)
- Gemini client: cached with TTL — recreated if `GEMINI_API_KEY` changes
- `parse_redis_url()` extracted to `app/workers/redis_client.py` (DRY — was duplicated in main.py and image_worker.py)
- Rate limit key function encodes plan for per-plan bucket isolation
- `_FallbackArqPool` moved to `app/workers/arq_fallback.py` (avoids circular imports)
- ARQ worker: configurable `PIPELINE_TIMEOUT` and `PIPELINE_MAX_RETRIES`, `on_shutdown` handler
- Pipeline: `push_dead_job()` on final retry failure marks image as `ERROR` with DLQ metadata
- Admin panel: VITE_API_BASE_URL changed from internal Docker hostname to `http://localhost:8000`
### Fixed
- ARQ fallback now returns explicit warning in `UploadResponse.message` instead of silently ignoring
- Removed obsolete comment in `ai_vision.py` about `StorageBackend.read`
- Removed signal handler instability in test fixtures
- Fixed Docker Compose admin connectivity (browser couldn't resolve internal `backend` hostname)
## [1.0.0] - Previous
### Added (Phase 3: DX) ### Added (Phase 3: DX)
- **WebSockets / Real-Time**: - **WebSockets / Real-Time**:
+33 -17
View File
@@ -4,14 +4,14 @@ Configuration centralisée — chargée depuis .env
from pathlib import Path from pathlib import Path
from typing import List from typing import List
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import field_validator from pydantic import field_validator, model_validator
import json import json
class Settings(BaseSettings): class Settings(BaseSettings):
# Application # Application
APP_NAME: str = "Imago" APP_NAME: str = "Imago"
APP_VERSION: str = "1.0.0" APP_VERSION: str = "2.0.0"
API_V1_SUNSET_DATE: str = "" API_V1_SUNSET_DATE: str = ""
DEBUG: bool = False DEBUG: bool = False
SECRET_KEY: str = "changez-moi" SECRET_KEY: str = "changez-moi"
@@ -44,9 +44,13 @@ class Settings(BaseSettings):
# AI — Comportement # AI — Comportement
AI_TAGS_MIN: int = 5 AI_TAGS_MIN: int = 5
AI_TAGS_MAX: int = 10 AI_TAGS_MAX: int = 10
AI_DESCRIPTION_LANGUAGE: str = "français" AI_DESCRIPTION_LANGUAGE: str = "fran\u00e7ais"
AI_CACHE_DAYS: int = 30 AI_CACHE_DAYS: int = 30
# AI — Résilience
AI_REQUEST_TIMEOUT: int = 60
AI_MAX_RETRIES: int = 2
# OCR # OCR
OCR_ENABLED: bool = True OCR_ENABLED: bool = True
TESSERACT_CMD: str = "/usr/bin/tesseract" TESSERACT_CMD: str = "/usr/bin/tesseract"
@@ -59,6 +63,7 @@ class Settings(BaseSettings):
ADMIN_API_KEY: str = "" ADMIN_API_KEY: str = ""
JWT_SECRET_KEY: str = "changez-moi-jwt-secret" JWT_SECRET_KEY: str = "changez-moi-jwt-secret"
JWT_ALGORITHM: str = "HS256" JWT_ALGORITHM: str = "HS256"
API_KEY_HASH_ROUNDS: int = 12
# Rate limiting — global (legacy) # Rate limiting — global (legacy)
RATE_LIMIT_UPLOAD: int = 10 RATE_LIMIT_UPLOAD: int = 10
@@ -72,24 +77,25 @@ class Settings(BaseSettings):
RATE_LIMIT_PREMIUM_UPLOAD: int = 500 RATE_LIMIT_PREMIUM_UPLOAD: int = 500
RATE_LIMIT_PREMIUM_AI: int = 1000 RATE_LIMIT_PREMIUM_AI: int = 1000
# Redis + ARQ Worker # Rate limiting — stockage Redis (optionnel, mémoire par défaut)
REDIS_URL: str = "redis://localhost:6379" RATE_LIMIT_STORAGE_URL: str = ""
WORKER_MAX_JOBS: int = 10
WORKER_JOB_TIMEOUT: int = 180
WORKER_MAX_TRIES: int = 3
AI_STEP_TIMEOUT: int = 120
OCR_STEP_TIMEOUT: int = 30
# Storage Backend # Redis + ARQ Worker
STORAGE_BACKEND: str = "local" # "local" | "s3" REDIS_URL: str = "redis://localhost:6379/0"
S3_BUCKET: str = "" PIPELINE_TIMEOUT: int = 300 # secondes — timeout max d'un job pipeline
PIPELINE_MAX_RETRIES: int = 3 # tentatives avant Dead Letter Queue
REDIS_POOL_MAX_CONNECTIONS: int = 20
# Storage backends
STORAGE_BACKEND: str = "local"
S3_BUCKET: str = "imago"
S3_PREFIX: str = ""
S3_REGION: str = "us-east-1" S3_REGION: str = "us-east-1"
S3_ENDPOINT_URL: str = "" # vide = AWS, sinon MinIO/R2 S3_ENDPOINT_URL: str = ""
S3_ENDPOINT_PUBLIC: str = "" # URL utilisée pour les liens présignés (ex: http://localhost:9000)
S3_ACCESS_KEY: str = "" S3_ACCESS_KEY: str = ""
S3_SECRET_KEY: str = "" S3_SECRET_KEY: str = ""
S3_PREFIX: str = "imago" S3_ENDPOINT_PUBLIC: str = ""
SIGNED_URL_SECRET: str = "changez-moi-signed-url" SIGNED_URL_SECRET: str = "changez-moi-signed-url-secret"
@field_validator("CORS_ORIGINS", mode="before") @field_validator("CORS_ORIGINS", mode="before")
@classmethod @classmethod
@@ -101,6 +107,16 @@ class Settings(BaseSettings):
return [v] return [v]
return v return v
@model_validator(mode="after")
def validate_cors_credentials(self):
"""Empêche allow_credentials=True avec origins=['*']."""
if self.CORS_ORIGINS == ["*"] or "*" in self.CORS_ORIGINS:
raise ValueError(
"CORS_ORIGINS ne peut pas contenir '*' quand "
"allow_credentials=True. Spécifiez les origines explicitement."
)
return self
@property @property
def upload_path(self) -> Path: def upload_path(self) -> Path:
p = Path(self.UPLOAD_DIR) p = Path(self.UPLOAD_DIR)
+2 -2
View File
@@ -44,8 +44,8 @@ async def get_db() -> AsyncSession:
async def init_db(): async def init_db():
"""Crée toutes les tables et initialise un client par défaut si nécessaire.""" """Crée toutes les tables et initialise un client par défaut si nécessaire."""
import secrets import secrets
import hashlib
from sqlalchemy import select from sqlalchemy import select
from app.dependencies.auth import hash_api_key
from app.models.client import APIClient, ClientPlan from app.models.client import APIClient, ClientPlan
async with engine.begin() as conn: async with engine.begin() as conn:
@@ -59,7 +59,7 @@ async def init_db():
if result.scalar_one_or_none() is None: if result.scalar_one_or_none() is None:
# Table vide -> Création du client bootstrap # Table vide -> Création du client bootstrap
raw_key = secrets.token_urlsafe(32) raw_key = secrets.token_urlsafe(32)
key_hash = hashlib.sha256(raw_key.encode("utf-8")).hexdigest() key_hash = hash_api_key(raw_key)
bootstrap_client = APIClient( bootstrap_client = APIClient(
name="Default Admin", name="Default Admin",
+45 -20
View File
@@ -1,27 +1,42 @@
""" """
Dépendances FastAPI — authentification par API Key + vérification de scopes. Dépendances FastAPI — authentification par API Key + vérification de scopes.
Sécurité :
- Hashage SHA-256 avec pepper (SECRET_KEY) pour le stockage
- Comparaison timing-safe via secrets.compare_digest()
- Injection du plan client dans le ContextVar pour le rate limiting dynamique
- Les clés API sont des tokens 256-bit aléatoires (secrets.token_urlsafe(32))
Usage dans les routers : Usage dans les routers :
client = Depends(get_current_client) client = Depends(get_current_client)
_ = Depends(require_scope("images:read")) _ = Depends(require_scope("images:read"))
""" """
import hashlib import hashlib
import logging import logging
import secrets as sec
from typing import Callable from typing import Callable
from fastapi import Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db from app.database import get_db
from app.models.client import APIClient from app.models.client import APIClient
from app.middleware import _current_client_plan
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def hash_api_key(api_key: str) -> str: def hash_api_key(api_key: str) -> str:
"""Hash SHA-256 d'une clé API — fonction utilitaire réutilisable.""" """
return hashlib.sha256(api_key.encode("utf-8")).hexdigest() Hash une clé API avec SHA-256 + pepper (SECRET_KEY).
Le pepper empêche les attaques par rainbow table même si la BDD fuit.
Les clés API sont des tokens aléatoires 256-bit → SHA-256 est suffisant.
"""
peppered = f"{api_key}:{settings.SECRET_KEY}"
return hashlib.sha256(peppered.encode("utf-8")).hexdigest()
async def verify_api_key( async def verify_api_key(
@@ -41,16 +56,17 @@ async def verify_api_key(
""" """
Vérifie la clé API fournie dans le header Authorization ou X-API-Key. Vérifie la clé API fournie dans le header Authorization ou X-API-Key.
Injecte client_id et client_plan dans request.state pour le rate limiter. Injecte client_id et client_plan dans request.state pour le rate limiter.
Injecte le plan dans le ContextVar pour le rate limiting dynamique.
Raises: Raises:
HTTPException 401: clé absente, invalide ou client inactif. HTTPException 401: clé absente, invalide ou client inactif.
""" """
raw_key = None raw_key = None
# ── 1. Tentative avec Authorization: Bearer <key> ──────── # ── 1. Tentative avec Authorization: Bearer *** ────────
if authorization and authorization.startswith("Bearer "): if authorization and authorization.startswith("Bearer "):
raw_key = authorization[7:].strip() raw_key = authorization[7:].strip()
# ── 2. Tentative avec X-API-Key ────────────────────────── # ── 2. Tentative avec X-API-Key ──────────────────────────
if not raw_key and x_api_key: if not raw_key and x_api_key:
raw_key = x_api_key.strip() raw_key = x_api_key.strip()
@@ -63,15 +79,21 @@ async def verify_api_key(
) )
# ── 1.5. Vérification Master Key (ADMIN_API_KEY) ─────── # ── 1.5. Vérification Master Key (ADMIN_API_KEY) ───────
from app.config import settings # Comparaison timing-safe de la master key
if settings.ADMIN_API_KEY and raw_key == settings.ADMIN_API_KEY: if settings.ADMIN_API_KEY:
# Retourne un client virtuel avec tous les droits master_hash = hash_api_key(settings.ADMIN_API_KEY)
return APIClient( candidate_hash = hash_api_key(raw_key)
id="admin-master", if sec.compare_digest(candidate_hash, master_hash):
name="Imago Master Admin", client = APIClient(
scopes=["admin", "images:read", "images:write", "ai:use"], id="admin-master",
plan="premium" name="Imago Master Admin",
) scopes=["admin", "images:read", "images:write", "ai:use"],
plan="premium",
)
request.state.client_id = client.id
request.state.client_plan = "premium"
_current_client_plan.set("premium")
return client
# ── Lookup par hash ─────────────────────────────────────── # ── Lookup par hash ───────────────────────────────────────
key_hash = hash_api_key(raw_key) key_hash = hash_api_key(raw_key)
@@ -81,18 +103,18 @@ async def verify_api_key(
client = result.scalar_one_or_none() client = result.scalar_one_or_none()
if client is None: if client is None:
logger.warning("Tentative d'authentification avec une clé invalide") logger.warning("auth.invalid_key")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise", detail="Clé API invalide",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if not client.is_active: if not client.is_active:
logger.warning("Tentative d'authentification avec un client inactif: %s", client.id) logger.warning("auth.inactive_client", extra={"client_id": client.id})
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise", detail="Client désactivé",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
@@ -100,6 +122,9 @@ async def verify_api_key(
request.state.client_id = client.id request.state.client_id = client.id
request.state.client_plan = client.plan.value if client.plan else "free" request.state.client_plan = client.plan.value if client.plan else "free"
# Injecter dans le ContextVar pour le rate limiting dynamique
_current_client_plan.set(client.plan.value if client.plan else "free")
return client return client
@@ -120,12 +145,12 @@ def require_scope(scope: str) -> Callable:
) -> APIClient: ) -> APIClient:
if not client.has_scope(scope): if not client.has_scope(scope):
logger.warning( logger.warning(
"Client %s (%s) a tenté d'accéder au scope '%s' sans autorisation", "auth.scope_denied",
client.id, client.name, scope, extra={"client_id": client.id, "scope": scope},
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Permission insuffisante", detail=f"Permission insuffisante : scope '{scope}' requis",
) )
return client return client
+38 -146
View File
@@ -3,7 +3,7 @@ Imago — Application principale FastAPI
""" """
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, APIRouter
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
@@ -16,7 +16,8 @@ from app.routers import images_router, ai_router, auth_router, files_router, ws_
from app.middleware import limiter from app.middleware import limiter
from app.middleware.logging_middleware import LoggingMiddleware from app.middleware.logging_middleware import LoggingMiddleware
from app.middleware.versioning import APIVersioningMiddleware from app.middleware.versioning import APIVersioningMiddleware
from app.workers.redis_client import get_redis_pool, close_redis_pool from app.workers.redis_client import get_redis_pool, close_redis_pool, parse_redis_url
from app.workers.arq_fallback import _FallbackArqPool
# Configure le logging structuré dès l'import # Configure le logging structuré dès l'import
configure_logging(debug=settings.DEBUG) configure_logging(debug=settings.DEBUG)
@@ -38,41 +39,10 @@ except ImportError:
def _arq_redis_settings() -> "RedisSettings": def _arq_redis_settings() -> "RedisSettings":
"""Parse REDIS_URL en RedisSettings ARQ.""" """Parse REDIS_URL en RedisSettings ARQ via le parser centralisé."""
url = settings.REDIS_URL host, port, password, database = parse_redis_url()
if url.startswith("redis://"):
url = url[8:]
elif url.startswith("rediss://"):
url = url[9:]
password = None
host = "localhost"
port = 6379
database = 0
if "@" in url:
auth_part, url = url.rsplit("@", 1)
if ":" in auth_part:
password = auth_part.split(":", 1)[1]
else:
password = auth_part
if "/" in url:
host_port, db_str = url.split("/", 1)
if db_str:
database = int(db_str)
else:
host_port = url
if ":" in host_port:
host, port_str = host_port.rsplit(":", 1)
if port_str:
port = int(port_str)
else:
host = host_port
return RedisSettings( return RedisSettings(
host=host or "localhost", host=host,
port=port, port=port,
password=password, password=password,
database=database, database=database,
@@ -85,11 +55,9 @@ def _arq_redis_settings() -> "RedisSettings":
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Création des répertoires de données
settings.upload_path settings.upload_path
settings.thumbnails_path settings.thumbnails_path
# Initialisation de la base de données (création des tables)
await init_db() await init_db()
active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL
@@ -101,7 +69,6 @@ async def lifespan(app: FastAPI):
"ocr_enabled": settings.OCR_ENABLED, "ocr_enabled": settings.OCR_ENABLED,
}) })
# Initialisation Redis + ARQ pool
try: try:
app.state.redis = await get_redis_pool() app.state.redis = await get_redis_pool()
logger.info("startup.redis_connected", extra={"url": settings.REDIS_URL}) logger.info("startup.redis_connected", extra={"url": settings.REDIS_URL})
@@ -122,24 +89,12 @@ async def lifespan(app: FastAPI):
yield yield
# Fermeture propre
if hasattr(app.state, "arq_pool") and hasattr(app.state.arq_pool, "close"): if hasattr(app.state, "arq_pool") and hasattr(app.state.arq_pool, "close"):
await app.state.arq_pool.close() await app.state.arq_pool.close()
await close_redis_pool() await close_redis_pool()
logger.info("shutdown.complete") logger.info("shutdown.complete")
class _FallbackArqPool:
"""Fallback quand Redis/ARQ n'est pas disponible."""
async def enqueue_job(self, *args, **kwargs):
logger.warning("arq.fallback_enqueue", extra={"args": str(args)})
return None
async def close(self):
pass
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Application # Application
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -152,31 +107,24 @@ app = FastAPI(
Backend de gestion d'images et fonctionnalités AI pour l'interface Shaarli. Backend de gestion d'images et fonctionnalités AI pour l'interface Shaarli.
> **Note de versionnement :** L'API principale est désormais servie sous le préfixe `/api/v1/`. Les anciennes routes sans ce préfixe sont dépréciées. > **Note de versionnement :** L'API principale est désormais servie sous le préfixe `/api/v1/`.
### Fonctionnalités ### Fonctionnalités
- 📸 **Upload et stockage d'images** avec génération de thumbnails - Upload et stockage d'images avec génération de thumbnails
- 🔍 **Extraction EXIF** automatique (appareil, GPS, paramètres de prise de vue) - Extraction EXIF automatique (appareil, GPS, paramètres de prise de vue)
- 📝 **OCR** — extraction de texte depuis les images (Tesseract) - OCR — extraction de texte depuis les images (Tesseract)
- 🤖 **Vision AI** — description et classification par tags (Gemini) - Vision AI — description et classification par tags
- 🔗 **Résumé d'URL** — scraping + résumé AI de pages web - Résumé d'URL — scraping + résumé AI de pages web
- ✅ **Rédaction de tâches** — génération structurée via AI - Rédaction de tâches — génération structurée via AI
- 📋 **File de tâches ARQ** — pipeline persistant avec retry automatique - File de tâches ARQ — pipeline persistant avec retry automatique
- 📊 **Métriques Prometheus** — /metrics endpoint - Métriques Prometheus — /metrics endpoint
### Pipeline de traitement
Chaque image uploadée est automatiquement traitée via ARQ (Redis) :
`EXIF → OCR → Vision AI → stockage BDD`
""", """,
lifespan=lifespan, lifespan=lifespan,
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
) )
# ───────────────────────────────────────────────────────────── # ── Middleware CORS ─────────────────────────────────────────
# Middleware CORS
# ─────────────────────────────────────────────────────────────
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.CORS_ORIGINS, allow_origins=settings.CORS_ORIGINS,
@@ -185,29 +133,21 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ───────────────────────────────────────────────────────────── # ── Request ID (trace_id) ───────────────────────────────────
# Middleware de Versionnement API from app.middleware.request_id import RequestIDMiddleware
# ───────────────────────────────────────────────────────────── app.add_middleware(RequestIDMiddleware)
# ── API Versioning ──────────────────────────────────────────
app.add_middleware(APIVersioningMiddleware) app.add_middleware(APIVersioningMiddleware)
# ───────────────────────────────────────────────────────────── # ── Logging HTTP ────────────────────────────────────────────
# Middleware Logging HTTP
# ─────────────────────────────────────────────────────────────
app.add_middleware(LoggingMiddleware) app.add_middleware(LoggingMiddleware)
# ───────────────────────────────────────────────────────────── # ── Rate Limiting ───────────────────────────────────────────
# Rate Limiting (slowapi)
# ─────────────────────────────────────────────────────────────
app.state.limiter = limiter app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# ───────────────────────────────────────────────────────────── # ── Prometheus Metrics ──────────────────────────────────────
# Prometheus Metrics
# ─────────────────────────────────────────────────────────────
if _prometheus_available: if _prometheus_available:
Instrumentator( Instrumentator(
should_group_status_codes=True, should_group_status_codes=True,
@@ -215,39 +155,25 @@ if _prometheus_available:
excluded_handlers=["/health", "/health/detailed", "/metrics"], excluded_handlers=["/health", "/health/detailed", "/metrics"],
).instrument(app).expose(app, endpoint="/metrics", tags=["Observabilité"]) ).instrument(app).expose(app, endpoint="/metrics", tags=["Observabilité"])
# ───────────────────────────────────────────────────────────── # ── Fichiers statiques ──────────────────────────────────────
# Fichiers statiques / URLs signées
# ─────────────────────────────────────────────────────────────
if settings.STORAGE_BACKEND == "local": if settings.STORAGE_BACKEND == "local":
app.include_router(files_router) app.include_router(files_router)
app.mount("/static/uploads", StaticFiles(directory=str(settings.upload_path)), name="uploads") app.mount("/static/uploads", StaticFiles(directory=str(settings.upload_path)), name="uploads")
app.mount("/static/thumbnails", StaticFiles(directory=str(settings.thumbnails_path)), name="thumbnails") app.mount("/static/thumbnails", StaticFiles(directory=str(settings.thumbnails_path)), name="thumbnails")
# ───────────────────────────────────────────────────────────── # ── Routers versionnés (/api/v1/) ───────────────────────────
# Routers Versionnés (/api/v1/)
# ─────────────────────────────────────────────────────────────
from fastapi import APIRouter
api_v1_router = APIRouter(prefix="/api/v1") api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(images_router) api_v1_router.include_router(images_router)
api_v1_router.include_router(ai_router) api_v1_router.include_router(ai_router)
api_v1_router.include_router(auth_router) api_v1_router.include_router(auth_router)
# Mount the versioned router on the app
app.include_router(api_v1_router) app.include_router(api_v1_router)
# ───────────────────────────────────────────────────────────── # ── Routers non-versionnés ──────────────────────────────────
# Routers Non-Versionnés (WebSockets, Fichiers, Santé, Metrics)
# ─────────────────────────────────────────────────────────────
app.include_router(ws_router) app.include_router(ws_router)
app.include_router(admin_router) app.include_router(admin_router)
# ───────────────────────────────────────────────────────────── # ── Routes utilitaires ──────────────────────────────────────
# Routes utilitaires
# ─────────────────────────────────────────────────────────────
@app.get("/", tags=["Santé"]) @app.get("/", tags=["Santé"])
async def root(): async def root():
@@ -266,7 +192,6 @@ async def health():
(settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY)) (settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY))
) )
active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL
return { return {
"status": "healthy", "status": "healthy",
"ai_enabled": settings.AI_ENABLED, "ai_enabled": settings.AI_ENABLED,
@@ -282,14 +207,12 @@ async def health_detailed(request: Request):
"""Endpoint de santé détaillé pour monitoring avancé.""" """Endpoint de santé détaillé pour monitoring avancé."""
checks = {} checks = {}
# 1. Backend check
checks["backend"] = { checks["backend"] = {
"status": "ok", "status": "ok",
"version": settings.APP_VERSION, "version": settings.APP_VERSION,
"uptime": "N/A" # Optionnel: ajouter un tracker d'uptime si nécessaire "uptime": "N/A",
} }
# 2. Database check
try: try:
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from sqlalchemy import text from sqlalchemy import text
@@ -299,7 +222,6 @@ async def health_detailed(request: Request):
except Exception as e: except Exception as e:
checks["database"] = {"status": "error", "error": str(e)} checks["database"] = {"status": "error", "error": str(e)}
# 3. Redis check
redis = getattr(request.app.state, "redis", None) redis = getattr(request.app.state, "redis", None)
if redis: if redis:
try: try:
@@ -310,29 +232,17 @@ async def health_detailed(request: Request):
else: else:
checks["redis"] = {"status": "not_configured"} checks["redis"] = {"status": "not_configured"}
# 4. Worker & Queue check (ARQ)
arq_pool = getattr(request.app.state, "arq_pool", None) arq_pool = getattr(request.app.state, "arq_pool", None)
if arq_pool and not isinstance(arq_pool, _FallbackArqPool): if arq_pool and not isinstance(arq_pool, _FallbackArqPool):
try: try:
# On essaye de voir le nombre de jobs en attente
queued_jobs = await arq_pool.zcard("arq:queue") queued_jobs = await arq_pool.zcard("arq:queue")
checks["queue"] = { checks["queue"] = {"status": "ok", "pending_jobs": queued_jobs}
"status": "ok", hc_key = "arq:queue:health-check"
"pending_jobs": queued_jobs worker_active = await redis.exists(hc_key) if redis else False
}
# Pour le worker, on vérifie la clé de health check déposée par ARQ
# Par défaut c'est <queue_name>:health-check
hc_key = f"{settings.S3_PREFIX or 'arq'}:queue:health-check"
# On ré-essaye avec la queue par défaut si le préfixe est différent
if not await redis.exists(hc_key):
hc_key = "arq:queue:health-check"
worker_active = await redis.exists(hc_key)
checks["worker"] = { checks["worker"] = {
"status": "ok" if worker_active else "error", "status": "ok" if worker_active else "error",
"active_workers": 1 if worker_active else 0, "active_workers": 1 if worker_active else 0,
"detail": "Worker opérationnel" if worker_active else "Aucun worker détecté" "detail": "Worker opérationnel" if worker_active else "Aucun worker détecté",
} }
except Exception as e: except Exception as e:
checks["queue"] = {"status": "error", "error": str(e)} checks["queue"] = {"status": "error", "error": str(e)}
@@ -341,33 +251,19 @@ async def health_detailed(request: Request):
checks["queue"] = {"status": "fallback"} checks["queue"] = {"status": "fallback"}
checks["worker"] = {"status": "error", "detail": "ARQ non disponible"} checks["worker"] = {"status": "error", "detail": "ARQ non disponible"}
# 5. MinIO / Storage check
from app.services.storage_backend import get_storage_backend, S3Storage from app.services.storage_backend import get_storage_backend, S3Storage
storage = get_storage_backend() storage = get_storage_backend()
try: try:
if isinstance(storage, S3Storage): if isinstance(storage, S3Storage):
# On vérifie la connexion réelle à MinIO en listant les objets (ou head bucket) await storage.exists(".health-check")
# Pour faire simple/rapide on vérifie juste si on peut accéder au bucket checks["minio"] = {"status": "ok", "backend": "s3", "bucket": settings.S3_BUCKET}
# via l'interface du storage qui a déjà les sessions asynchrones
exists = await storage.exists(".health-check") # path arbitraire
checks["minio"] = {
"status": "ok",
"backend": "s3",
"bucket": settings.S3_BUCKET
}
else: else:
checks["minio"] = { checks["minio"] = {"status": "ok", "backend": "local", "path": settings.UPLOAD_DIR}
"status": "ok",
"backend": "local",
"path": settings.UPLOAD_DIR
}
except Exception as e: except Exception as e:
checks["minio"] = {"status": "error", "error": str(e)} checks["minio"] = {"status": "error", "error": str(e)}
# 6. OCR check (Tesseract)
checks["tesseract"] = {"status": "ok" if settings.OCR_ENABLED else "disabled"} checks["tesseract"] = {"status": "ok" if settings.OCR_ENABLED else "disabled"}
# 7. AI Provider check
ai_configured = ( ai_configured = (
(settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or (settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or
(settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY)) (settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY))
@@ -375,16 +271,12 @@ async def health_detailed(request: Request):
checks["ai"] = { checks["ai"] = {
"status": "ok" if ai_configured else "warning", "status": "ok" if ai_configured else "warning",
"provider": settings.AI_PROVIDER, "provider": settings.AI_PROVIDER,
"configured": ai_configured "configured": ai_configured,
} }
overall = "healthy" if all( overall = "healthy" if all(
c.get("status") in ("ok", "enabled", "disabled", "not_configured", "fallback") c.get("status") in ("ok", "enabled", "disabled", "not_configured", "fallback")
for k, c in checks.items() if k != "ai" # On tolère AI warning for k, c in checks.items() if k != "ai"
) else "degraded" ) else "degraded"
return { return {"status": overall, "checks": checks, "version": settings.APP_VERSION}
"status": overall,
"checks": checks,
"version": settings.APP_VERSION,
}
+26 -10
View File
@@ -8,8 +8,12 @@ Limites par plan (par heure) :
- free : 20 uploads, 50 AI - free : 20 uploads, 50 AI
- standard : 100 uploads, 200 AI - standard : 100 uploads, 200 AI
- premium : 500 uploads, 1000 AI - premium : 500 uploads, 1000 AI
Supporte le stockage Redis pour la persistence des compteurs.
""" """
import logging import logging
from contextvars import ContextVar
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from starlette.requests import Request from starlette.requests import Request
@@ -18,21 +22,31 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ContextVar pour transmettre le plan du client au rate limiter dynamique
_current_client_plan: ContextVar[str] = ContextVar("current_client_plan", default="free")
def _get_client_id_from_request(request: Request) -> str: def _get_client_id_from_request(request: Request) -> str:
""" """
Extrait le client_id depuis la state de la requête. Extrait le client_id depuis la state de la requête.
Encode le plan pour isoler les buckets par plan.
Fallback vers l'IP si le client n'est pas encore authentifié. Fallback vers l'IP si le client n'est pas encore authentifié.
""" """
# Le client_id est injecté par le middleware ou la dépendance auth
client_id = getattr(request.state, "client_id", None) client_id = getattr(request.state, "client_id", None)
plan = getattr(request.state, "client_plan", "free")
if client_id: if client_id:
return str(client_id) return f"{client_id}:{plan}"
return get_remote_address(request) return get_remote_address(request)
# Instance globale du limiter # Stockage Redis pour les compteurs (si configuré), sinon mémoire
limiter = Limiter(key_func=_get_client_id_from_request) if settings.RATE_LIMIT_STORAGE_URL:
limiter = Limiter(
key_func=_get_client_id_from_request,
storage_uri=settings.RATE_LIMIT_STORAGE_URL,
)
else:
limiter = Limiter(key_func=_get_client_id_from_request)
def get_upload_rate_limit(plan: str) -> str: def get_upload_rate_limit(plan: str) -> str:
@@ -55,11 +69,13 @@ def get_ai_rate_limit(plan: str) -> str:
return limits.get(plan, limits["free"]) return limits.get(plan, limits["free"])
def upload_rate_limit_key(request: Request) -> str: def dynamic_upload_limit() -> str:
"""Clé dynamique pour le rate limiting des uploads.""" """Callable pour slowapi — lit le plan depuis le ContextVar."""
return _get_client_id_from_request(request) plan = _current_client_plan.get()
return get_upload_rate_limit(plan)
def ai_rate_limit_key(request: Request) -> str: def dynamic_ai_limit() -> str:
"""Clé dynamique pour le rate limiting des endpoints AI.""" """Callable pour slowapi — lit le plan depuis le ContextVar."""
return _get_client_id_from_request(request) plan = _current_client_plan.get()
return get_ai_rate_limit(plan)
+21 -13
View File
@@ -5,7 +5,7 @@ import enum
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
Column, Integer, String, Text, DateTime, Column, Integer, String, Text, DateTime,
JSON, Float, Enum as SAEnum, BigInteger, Boolean, ForeignKey JSON, Float, Enum as SAEnum, BigInteger, Boolean, ForeignKey, Index
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -31,11 +31,11 @@ class Image(Base):
# ── Fichier ─────────────────────────────────────────────── # ── Fichier ───────────────────────────────────────────────
original_name = Column(String(512), nullable=False) original_name = Column(String(512), nullable=False)
filename = Column(String(512), nullable=False) # nom sur disque (uuid-based) filename = Column(String(512), nullable=False)
file_path = Column(String(1024), nullable=False) file_path = Column(String(1024), nullable=False)
thumbnail_path = Column(String(1024)) thumbnail_path = Column(String(1024))
mime_type = Column(String(128)) mime_type = Column(String(128))
file_size = Column(BigInteger) # bytes file_size = Column(BigInteger)
width = Column(Integer) width = Column(Integer)
height = Column(Integer) height = Column(Integer)
uploaded_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) uploaded_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
@@ -52,18 +52,18 @@ class Image(Base):
processing_done_at = Column(DateTime(timezone=True)) processing_done_at = Column(DateTime(timezone=True))
# ── Métadonnées EXIF ────────────────────────────────────── # ── Métadonnées EXIF ──────────────────────────────────────
exif_raw = Column(JSON) # dict complet brut exif_raw = Column(JSON)
exif_make = Column(String(256)) # Appareil — fabricant exif_make = Column(String(256))
exif_model = Column(String(256)) # Appareil — modèle exif_model = Column(String(256))
exif_lens = Column(String(256)) exif_lens = Column(String(256))
exif_taken_at = Column(DateTime(timezone=True)) # DateTimeOriginal EXIF exif_taken_at = Column(DateTime(timezone=True))
exif_gps_lat = Column(Float) exif_gps_lat = Column(Float)
exif_gps_lon = Column(Float) exif_gps_lon = Column(Float)
exif_altitude = Column(Float) exif_altitude = Column(Float)
exif_iso = Column(Integer) exif_iso = Column(Integer)
exif_aperture = Column(String(32)) # ex: "f/2.8" exif_aperture = Column(String(32))
exif_shutter = Column(String(32)) # ex: "1/250" exif_shutter = Column(String(32))
exif_focal = Column(String(32)) # ex: "50mm" exif_focal = Column(String(32))
exif_flash = Column(Boolean) exif_flash = Column(Boolean)
exif_orientation = Column(Integer) exif_orientation = Column(Integer)
exif_software = Column(String(256)) exif_software = Column(String(256))
@@ -71,18 +71,26 @@ class Image(Base):
# ── OCR ─────────────────────────────────────────────────── # ── OCR ───────────────────────────────────────────────────
ocr_text = Column(Text) ocr_text = Column(Text)
ocr_language = Column(String(64)) ocr_language = Column(String(64))
ocr_confidence = Column(Float) # 0.0 – 1.0 ocr_confidence = Column(Float)
ocr_has_text = Column(Boolean, default=False) ocr_has_text = Column(Boolean, default=False)
# ── AI Vision ───────────────────────────────────────────── # ── AI Vision ─────────────────────────────────────────────
ai_description = Column(Text) ai_description = Column(Text)
ai_tags = Column(JSON) # ["nature", "paysage", ...] ai_tags = Column(JSON)
ai_confidence = Column(Float) # score de confiance global ai_confidence = Column(Float)
ai_model_used = Column(String(128)) ai_model_used = Column(String(128))
ai_processed_at = Column(DateTime(timezone=True)) ai_processed_at = Column(DateTime(timezone=True))
ai_prompt_tokens = Column(Integer) ai_prompt_tokens = Column(Integer)
ai_output_tokens = Column(Integer) ai_output_tokens = Column(Integer)
# ── Indexes composites ────────────────────────────────────
__table_args__ = (
# Pour les listings paginés par client (requête la plus fréquente)
Index("ix_images_client_uploaded", "client_id", "uploaded_at"),
# Pour le filtrage par statut + client
Index("ix_images_client_status", "client_id", "processing_status"),
)
def __repr__(self): def __repr__(self):
return f"<Image id={self.id} name={self.original_name} status={self.processing_status}>" return f"<Image id={self.id} name={self.original_name} status={self.processing_status}>"
+49 -9
View File
@@ -14,6 +14,7 @@ router = APIRouter(prefix="/admin/api", tags=["Admin"])
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
ALLOWED_DOCS = { ALLOWED_DOCS = {
"README.md": PROJECT_ROOT / "README.md", "README.md": PROJECT_ROOT / "README.md",
"ROADMAP.md": PROJECT_ROOT / "docs" / "ROADMAP.md",
"USER_GUIDE.md": PROJECT_ROOT / "docs" / "USER_GUIDE.md", "USER_GUIDE.md": PROJECT_ROOT / "docs" / "USER_GUIDE.md",
"ARCHITECTURE.md": PROJECT_ROOT / "docs" / "ARCHITECTURE.md", "ARCHITECTURE.md": PROJECT_ROOT / "docs" / "ARCHITECTURE.md",
"API_GUIDE.md": PROJECT_ROOT / "docs" / "API_GUIDE.md", "API_GUIDE.md": PROJECT_ROOT / "docs" / "API_GUIDE.md",
@@ -82,21 +83,60 @@ async def get_queue_status(
request: Request, request: Request,
_: APIClient = Depends(require_scope("admin")) _: APIClient = Depends(require_scope("admin"))
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Get ARQ queue status using the redis connection.""" """Get ARQ queue status including Dead Letter Queue."""
redis = request.app.state.redis redis = request.app.state.redis
from app.workers.dead_letter import get_dead_job_count
# Note: A real implementation would parse the ARQ keys here.
# For now, we return basic statistics by probing redis directly with arq known queues.
# Count pending jobs in arq:queue
pending_count = await redis.llen("arq:queue") if hasattr(redis, "llen") else 0 pending_count = await redis.llen("arq:queue") if hasattr(redis, "llen") else 0
# In ARQ, active jobs are harder to count without querying the worker sets, dead_count = await get_dead_job_count(redis)
# so we return pending count.
return { return {
"pending_jobs": pending_count, "pending_jobs": pending_count,
"status": "active" "dead_jobs": dead_count,
"status": "active",
} }
# ─────────────────────────────────────────────────────────────
# Dead Letter Queue
# ─────────────────────────────────────────────────────────────
@router.get("/queue/dead")
async def list_dead_jobs(
request: Request,
_: APIClient = Depends(require_scope("admin")),
limit: int = 50,
) -> list[dict]:
"""List dead jobs from the Dead Letter Queue."""
from app.workers.dead_letter import get_dead_jobs
redis = request.app.state.redis
return await get_dead_jobs(redis, limit=limit)
@router.post("/queue/dead/{index}/retry")
async def retry_dead_job(
request: Request,
index: int,
_: APIClient = Depends(require_scope("admin")),
) -> dict:
"""Retry a dead job by its index (0 = most recent)."""
from app.workers.dead_letter import retry_dead_job
redis = request.app.state.redis
arq_pool = request.app.state.arq_pool
return await retry_dead_job(redis, arq_pool, index)
@router.delete("/queue/dead")
async def clear_dead_jobs(
request: Request,
_: APIClient = Depends(require_scope("admin")),
) -> Dict[str, Any]:
"""Clear all dead jobs from the DLQ."""
from app.workers.dead_letter import clear_dead_jobs
redis = request.app.state.redis
count = await clear_dead_jobs(redis)
return {"cleared": count}
@router.post("/clients/{client_id}/toggle") @router.post("/clients/{client_id}/toggle")
async def toggle_client( async def toggle_client(
client_id: str, client_id: str,
+37 -17
View File
@@ -23,8 +23,9 @@ from app.schemas import (
TagsResponse, ReprocessResponse, TagsResponse, ReprocessResponse,
) )
from app.services import storage from app.services import storage
from app.middleware import limiter, get_upload_rate_limit from app.middleware import limiter, dynamic_upload_limit
from app.workers.image_worker import QUEUE_STANDARD, QUEUE_PREMIUM from app.workers.image_worker import QUEUE_STANDARD, QUEUE_PREMIUM
from app.workers.arq_fallback import is_fallback_pool
from app.metrics import ( from app.metrics import (
hub_images_uploaded, hub_images_deleted, hub_storage_used_bytes, hub_arq_jobs_enqueued hub_images_uploaded, hub_images_deleted, hub_storage_used_bytes, hub_arq_jobs_enqueued
) )
@@ -57,12 +58,6 @@ async def get_image_or_404(
return image 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 # UPLOAD
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -75,7 +70,7 @@ def _dynamic_upload_limit(key: str) -> str:
description="Upload une image, lance automatiquement le pipeline AI (EXIF + OCR + Vision).", description="Upload une image, lance automatiquement le pipeline AI (EXIF + OCR + Vision).",
dependencies=[Depends(require_scope("images:write"))], dependencies=[Depends(require_scope("images:write"))],
) )
@limiter.limit("500/hour") @limiter.limit(dynamic_upload_limit)
async def upload_image( async def upload_image(
request: Request, request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
@@ -102,23 +97,49 @@ async def upload_image(
file_size = file_data.get("file_size", 0) file_size = file_data.get("file_size", 0)
client.storage_used_bytes = (client.storage_used_bytes or 0) + file_size client.storage_used_bytes = (client.storage_used_bytes or 0) + file_size
await db.commit() try:
await db.refresh(image) await db.commit()
await db.refresh(image)
except Exception:
# Rollback : supprimer les fichiers uploadés car la BDD n'a pas persisté
await storage.delete_files(
file_data["file_path"],
file_data.get("thumbnail_path"),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Échec de l'enregistrement — veuillez réessayer",
)
# Enqueue dans ARQ # Enqueue dans ARQ — détecter le fallback
arq_pool = request.app.state.arq_pool arq_pool = request.app.state.arq_pool
await arq_pool.enqueue_job("process_image_task", image.id, str(client.id)) job_enqueued = True
if is_fallback_pool(arq_pool):
job_enqueued = False
logger.warning("upload.arq_fallback", extra={
"image_id": image.id,
"client_id": client.id,
})
else:
await arq_pool.enqueue_job("process_image_task", image.id, str(client.id))
# Metrics # Metrics
hub_images_uploaded.labels(client_id=client.id).inc() hub_images_uploaded.labels(client_id=client.id).inc()
hub_storage_used_bytes.labels(client_id=client.id).set(client.storage_used_bytes) hub_storage_used_bytes.labels(client_id=client.id).set(client.storage_used_bytes)
hub_arq_jobs_enqueued.labels(queue="default").inc() if job_enqueued:
hub_arq_jobs_enqueued.labels(queue="default").inc()
return UploadResponse( return UploadResponse(
id=image.id, id=image.id,
uuid=image.uuid, uuid=image.uuid,
original_name=image.original_name, original_name=image.original_name,
status=image.processing_status, status=image.processing_status,
message=(
"Image uploadée — traitement AI en cours"
if job_enqueued
else "Image uploadée — le pipeline AI est indisponible (Redis/ARQ down). "
"Le traitement sera effectué automatiquement dès le rétablissement."
),
) )
@@ -397,7 +418,7 @@ async def get_all_tags(
description="Reprocess une image existante (utile après changement de modèle AI).", description="Reprocess une image existante (utile après changement de modèle AI).",
dependencies=[Depends(require_scope("images:write"))], dependencies=[Depends(require_scope("images:write"))],
) )
@limiter.limit("500/hour") @limiter.limit(dynamic_upload_limit)
async def reprocess_image( async def reprocess_image(
request: Request, request: Request,
image_id: int, image_id: int,
@@ -444,8 +465,8 @@ async def delete_image(
file_size = image.file_size or 0 file_size = image.file_size or 0
client.storage_used_bytes = max(0, (client.storage_used_bytes or 0) - file_size) client.storage_used_bytes = max(0, (client.storage_used_bytes or 0) - file_size)
# Suppression des fichiers sur disque # Suppression des fichiers — maintenant async et déterministe
storage.delete_files(image.file_path, image.thumbnail_path) await storage.delete_files(image.file_path, image.thumbnail_path)
await db.delete(image) await db.delete(image)
await db.commit() await db.commit()
@@ -503,4 +524,3 @@ async def get_thumbnail_url(
backend = get_storage_backend() backend = get_storage_backend()
url = await backend.get_signed_url(image.thumbnail_path, expires_in=expires_in) url = await backend.get_signed_url(image.thumbnail_path, expires_in=expires_in)
return {"url": url, "expires_in": expires_in} return {"url": url, "expires_in": expires_in}
+22 -2
View File
@@ -2,10 +2,19 @@
Schémas Pydantic — authentification et gestion des clients API Schémas Pydantic — authentification et gestion des clients API
""" """
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional, Set
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.models.client import ClientPlan from app.models.client import ClientPlan
# Scopes valides — toute valeur hors de cet ensemble est rejetée
VALID_SCOPES: Set[str] = {
"images:read",
"images:write",
"images:delete",
"ai:use",
"admin",
}
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Requêtes # Requêtes
@@ -20,6 +29,17 @@ class ClientCreate(BaseModel):
) )
plan: ClientPlan = Field(default=ClientPlan.FREE, description="Plan tarifaire") plan: ClientPlan = Field(default=ClientPlan.FREE, description="Plan tarifaire")
@field_validator("scopes")
@classmethod
def validate_scopes(cls, v):
invalid = set(v) - VALID_SCOPES
if invalid:
raise ValueError(
f"Scopes invalides : {invalid}. "
f"Scopes valides : {sorted(VALID_SCOPES)}"
)
return v
class ClientUpdate(BaseModel): class ClientUpdate(BaseModel):
"""Modifier un client API existant.""" """Modifier un client API existant."""
+85 -47
View File
@@ -1,11 +1,17 @@
""" """
Service AI Vision — description, classification et tags via Google Gemini ou OpenRouter Service AI Vision — description, classification et tags via Google Gemini ou OpenRouter.
Résilience :
- Timeout configurable (AI_REQUEST_TIMEOUT)
- Retry avec backoff exponentiel (AI_MAX_RETRIES)
- Client Gemini non-singleton (recréé si la clé change)
""" """
import asyncio import asyncio
import json import json
import logging import logging
import re import re
import base64 import base64
import time
import httpx import httpx
import io import io
from pathlib import Path from pathlib import Path
@@ -19,14 +25,18 @@ from app.services.storage_backend import get_storage_backend
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache du client Gemini avec TTL (évite de recréer à chaque appel)
_client: Optional[genai.Client] = None _client: Optional[genai.Client] = None
_client_api_key: Optional[str] = None
def _get_client() -> genai.Client: def _get_client() -> genai.Client:
global _client """Retourne un client Gemini. Recrée si la clé API a changé."""
if _client is None: global _client, _client_api_key
_client = genai.Client(api_key=settings.GEMINI_API_KEY) current_key = settings.GEMINI_API_KEY
if _client is None or _client_api_key != current_key:
_client = genai.Client(api_key=current_key)
_client_api_key = current_key
return _client return _client
@@ -44,14 +54,7 @@ async def _read_image(file_path: str) -> tuple[bytes, str]:
} }
media_type = mime_map.get(suffix, "image/jpeg") media_type = mime_map.get(suffix, "image/jpeg")
# Utilisation du StorageBackend pour lire l'image
backend = get_storage_backend() backend = get_storage_backend()
# On ruse un peu car StorageBackend n'a pas de 'read',
# mais on sait qu'en LocalStorage on peut lire en direct
# et en S3Storage on peut passer par les URLs ou aioboto3.
# Pour garder une abstraction propre, on va ajouter une méthode 'get_bytes' au backend.
data = await backend.get_bytes(file_path) data = await backend.get_bytes(file_path)
return data, media_type return data, media_type
@@ -76,13 +79,44 @@ def _usage_tokens_gemini(response) -> tuple[Optional[int], Optional[int]]:
return prompt_tokens, output_tokens return prompt_tokens, output_tokens
async def _retry_with_backoff(fn, *args, max_retries=None, **kwargs):
"""Exécute fn avec retry + backoff exponentiel en cas d'échec."""
retries = max_retries if max_retries is not None else settings.AI_MAX_RETRIES
last_error = None
for attempt in range(retries + 1):
try:
return await fn(*args, **kwargs)
except asyncio.TimeoutError:
last_error = "timeout"
wait = 2 ** attempt
logger.warning("ai.retry.timeout", extra={
"attempt": attempt + 1,
"max_retries": retries,
"wait_s": wait,
})
except Exception as e:
last_error = str(e)
wait = 2 ** attempt
logger.warning("ai.retry.error", extra={
"attempt": attempt + 1,
"max_retries": retries,
"wait_s": wait,
"error": str(e),
})
if attempt < retries:
await asyncio.sleep(wait)
raise Exception(f"AI request failed after {retries + 1} attempts: {last_error}")
async def _generate_gemini( async def _generate_gemini(
prompt: str, prompt: str,
image_bytes: Optional[bytes] = None, image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None, media_type: Optional[str] = None,
max_tokens: int = 1024 max_tokens: int = 1024
) -> dict: ) -> dict:
"""Appel à Google Gemini via SDK.""" """Appel à Google Gemini via SDK avec timeout et retry."""
if not settings.GEMINI_API_KEY: if not settings.GEMINI_API_KEY:
logger.warning("ai.gemini.no_key") logger.warning("ai.gemini.no_key")
return {"text": None, "usage": (None, None)} return {"text": None, "usage": (None, None)}
@@ -93,17 +127,22 @@ async def _generate_gemini(
contents.append(types.Part.from_bytes(data=image_bytes, mime_type=media_type)) contents.append(types.Part.from_bytes(data=image_bytes, mime_type=media_type))
contents.append(prompt) contents.append(prompt)
try: async def _call():
# Le SDK est sync, on le run dans un thread return await asyncio.wait_for(
response = await asyncio.to_thread( asyncio.to_thread(
client.models.generate_content, client.models.generate_content,
model=settings.GEMINI_MODEL, model=settings.GEMINI_MODEL,
contents=contents, contents=contents,
config=types.GenerateContentConfig( config=types.GenerateContentConfig(
max_output_tokens=max_tokens, max_output_tokens=max_tokens,
response_mime_type="application/json", response_mime_type="application/json",
),
), ),
timeout=settings.AI_REQUEST_TIMEOUT,
) )
try:
response = await _retry_with_backoff(_call)
usage = _usage_tokens_gemini(response) usage = _usage_tokens_gemini(response)
return {"text": getattr(response, "text", ""), "usage": usage} return {"text": getattr(response, "text", ""), "usage": usage}
except Exception as e: except Exception as e:
@@ -117,7 +156,7 @@ async def _generate_openrouter(
media_type: Optional[str] = None, media_type: Optional[str] = None,
max_tokens: int = 1024 max_tokens: int = 1024
) -> dict: ) -> dict:
"""Appel à OpenRouter via HTTP.""" """Appel à OpenRouter via HTTP avec timeout et retry."""
if not settings.OPENROUTER_API_KEY: if not settings.OPENROUTER_API_KEY:
logger.warning("ai.openrouter.no_key") logger.warning("ai.openrouter.no_key")
return {"text": None, "usage": (None, None)} return {"text": None, "usage": (None, None)}
@@ -131,7 +170,6 @@ async def _generate_openrouter(
messages = [] messages = []
content_payload = [] content_payload = []
content_payload.append({"type": "text", "text": prompt}) content_payload.append({"type": "text", "text": prompt})
if image_bytes and media_type: if image_bytes and media_type:
@@ -152,30 +190,32 @@ async def _generate_openrouter(
"response_format": {"type": "json_object"} "response_format": {"type": "json_object"}
} }
async with httpx.AsyncClient() as client: async def _call():
try: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
"https://openrouter.ai/api/v1/chat/completions", "https://openrouter.ai/api/v1/chat/completions",
json=payload, json=payload,
headers=headers, headers=headers,
timeout=60.0 timeout=settings.AI_REQUEST_TIMEOUT,
) )
response.raise_for_status() response.raise_for_status()
data = response.json() return response.json()
text = ""
if "choices" in data and len(data["choices"]) > 0:
text = data["choices"][0]["message"]["content"]
usage_data = data.get("usage", {})
prompt_tokens = usage_data.get("prompt_tokens")
output_tokens = usage_data.get("completion_tokens")
return {"text": text, "usage": (prompt_tokens, output_tokens)} try:
data = await _retry_with_backoff(_call)
text = ""
if "choices" in data and len(data["choices"]) > 0:
text = data["choices"][0]["message"]["content"]
except Exception as e: usage_data = data.get("usage", {})
logger.error("ai.openrouter.error", extra={"error": str(e)}) prompt_tokens = usage_data.get("prompt_tokens")
return {"text": None, "usage": (None, None), "error": str(e)} output_tokens = usage_data.get("completion_tokens")
return {"text": text, "usage": (prompt_tokens, output_tokens)}
except Exception as e:
logger.error("ai.openrouter.error", extra={"error": str(e)})
return {"text": None, "usage": (None, None), "error": str(e)}
async def _generate( async def _generate(
@@ -187,11 +227,10 @@ async def _generate(
"""Dispatcher vers le bon provider.""" """Dispatcher vers le bon provider."""
provider = settings.AI_PROVIDER.lower() provider = settings.AI_PROVIDER.lower()
logger.info("ai.generate", extra={"provider": provider}) logger.info("ai.generate", extra={"provider": provider})
if provider == "openrouter": if provider == "openrouter":
return await _generate_openrouter(prompt, image_bytes, media_type, max_tokens) return await _generate_openrouter(prompt, image_bytes, media_type, max_tokens)
else: else:
# Default to Gemini
return await _generate_gemini(prompt, image_bytes, media_type, max_tokens) return await _generate_gemini(prompt, image_bytes, media_type, max_tokens)
@@ -264,9 +303,9 @@ async def analyze_image(
result["confidence"] = parsed.get("confidence") result["confidence"] = parsed.get("confidence")
else: else:
logger.warning("ai.vision.json_parse_failed", extra={"raw": text[:100]}) logger.warning("ai.vision.json_parse_failed", extra={"raw": text[:100]})
if response.get("error"): if response.get("error"):
logger.error("ai.vision.provider_error", extra={"error": response['error']}) logger.error("ai.vision.provider_error", extra={"error": response["error"]})
except Exception as e: except Exception as e:
logger.error("ai.vision.unexpected_error", extra={"error": str(e)}) logger.error("ai.vision.unexpected_error", extra={"error": str(e)})
@@ -303,7 +342,7 @@ Retourne UNIQUEMENT un objet JSON :
} }
Si aucun texte n'est visible, retourne : {"text": "", "has_text": false} Si aucun texte n'est visible, retourne : {"text": "", "has_text": false}
""" """
response = await _generate( response = await _generate(
prompt=prompt, prompt=prompt,
image_bytes=image_bytes, image_bytes=image_bytes,
@@ -411,12 +450,11 @@ Retourne UNIQUEMENT ce JSON :
}}""" }}"""
try: try:
# Pas d'image ici
response = await _generate( response = await _generate(
prompt=prompt, prompt=prompt,
max_tokens=settings.GEMINI_MAX_TOKENS max_tokens=settings.GEMINI_MAX_TOKENS
) )
text = response.get("text") text = response.get("text")
if text: if text:
parsed = _extract_json(text) parsed = _extract_json(text)
+35 -26
View File
@@ -2,6 +2,7 @@
Service de stockage — sauvegarde fichiers, génération thumbnails Service de stockage — sauvegarde fichiers, génération thumbnails
Multi-tenant : les fichiers sont isolés par client_id. Multi-tenant : les fichiers sont isolés par client_id.
""" """
import asyncio
import uuid import uuid
import logging import logging
import io import io
@@ -33,6 +34,9 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
""" """
Valide, sauvegarde le fichier uploadé et génère un thumbnail. Valide, sauvegarde le fichier uploadé et génère un thumbnail.
Utilise le backend de stockage configuré (Local ou S3). 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 ─────────────────────────────────────── # ── Validation MIME ───────────────────────────────────────
if file.content_type not in ALLOWED_MIME_TYPES: if file.content_type not in ALLOWED_MIME_TYPES:
@@ -53,7 +57,7 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
# ── Nommage ─────────────────────────────────────────────── # ── Nommage ───────────────────────────────────────────────
filename, file_uuid = _generate_filename(file.filename or "image") filename, file_uuid = _generate_filename(file.filename or "image")
# Chemins relatifs par rapport au bucket/base_dir # Chemins relatifs par rapport au bucket/base_dir
rel_file_path = f"uploads/{client_id}/{filename}" rel_file_path = f"uploads/{client_id}/{filename}"
rel_thumb_path = f"thumbnails/{client_id}/thumb_{filename}" rel_thumb_path = f"thumbnails/{client_id}/thumb_{filename}"
@@ -67,26 +71,28 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
width, height = None, None width, height = None, None
thumb_saved = False thumb_saved = False
try: try:
# On utilise io.BytesIO pour ne pas avoir à écrire sur le disque local
with PILImage.open(io.BytesIO(content)) as img: with PILImage.open(io.BytesIO(content)) as img:
width, height = img.size width, height = img.size
img.thumbnail(THUMBNAIL_SIZE, PILImage.LANCZOS) img.thumbnail(THUMBNAIL_SIZE, PILImage.LANCZOS)
# Convertit en RGB si nécessaire # Convertit en RGB si nécessaire
if img.mode in ("RGBA", "P"): if img.mode in ("RGBA", "P"):
img = img.convert("RGB") img = img.convert("RGB")
# Sauvegarde thumbnail dans un buffer # Sauvegarde thumbnail dans un buffer
thumb_buffer = io.BytesIO() thumb_buffer = io.BytesIO()
img.save(thumb_buffer, "JPEG", quality=85) img.save(thumb_buffer, "JPEG", quality=85)
thumb_data = thumb_buffer.getvalue() thumb_data = thumb_buffer.getvalue()
# Sauvegarde via le backend # Sauvegarde via le backend
await backend.save(thumb_data, rel_thumb_path, "image/jpeg") await backend.save(thumb_data, rel_thumb_path, "image/jpeg")
thumb_saved = True thumb_saved = True
except Exception as e: except Exception as e:
logger.warning("Erreur génération thumbnail : %s", e) logger.warning("thumbnail.generation_error", extra={
"file": rel_file_path,
"error": str(e),
})
return { return {
"uuid": file_uuid, "uuid": file_uuid,
@@ -103,24 +109,27 @@ async def save_upload(file: UploadFile, client_id: str) -> dict:
} }
def delete_files(file_path: str, thumbnail_path: str | None = None) -> None: async def delete_files(file_path: str, thumbnail_path: str | None = None) -> None:
"""Supprime le fichier original et son thumbnail via le backend.""" """
import asyncio 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() backend = get_storage_backend()
errors: list[str] = []
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: try:
loop = asyncio.get_event_loop() await backend.delete(file_path)
if loop.is_running(): except Exception as e:
asyncio.ensure_future(_do_delete()) errors.append(f"original: {e}")
else:
loop.run_until_complete(_do_delete()) if thumbnail_path:
except Exception: try:
pass 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,
})
+66 -25
View File
@@ -1,5 +1,8 @@
""" """
Worker ARQ — traitement asynchrone des images via Redis. Worker ARQ — traitement asynchrone des images via Redis.
Utilise le parser d'URL Redis centralisé de app.workers.redis_client.
Inclut la Dead Letter Queue pour les jobs échoués après N tentatives.
""" """
import logging import logging
import asyncio import asyncio
@@ -12,6 +15,7 @@ from app.config import settings
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models.image import Image, ProcessingStatus from app.models.image import Image, ProcessingStatus
from app.services.pipeline import process_image_pipeline from app.services.pipeline import process_image_pipeline
from app.workers.redis_client import parse_redis_url
from sqlalchemy import select from sqlalchemy import select
# Préfixes ARQ # Préfixes ARQ
@@ -21,46 +25,83 @@ DEFAULT_QUEUE_NAME = "arq:queue"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def process_image_task(ctx: dict, image_id: int, client_id: str) -> str: async def process_image_task(ctx: dict, image_id: int, client_id: str) -> str:
"""Tâche ARQ : traite une image.""" """Tâche ARQ : traite une image. Pousse en DLQ après échecs répétés."""
job_try = ctx.get("job_try", 1) job_try = ctx.get("job_try", 1)
redis = ctx.get("redis") redis = ctx.get("redis")
max_retries = getattr(settings, "PIPELINE_MAX_RETRIES", 3)
logger.info(f"--- JOB DÉMARRÉ : image_id={image_id} ---") logger.info(
"arq.job.started",
extra={"image_id": image_id, "client_id": client_id, "attempt": job_try},
)
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try: try:
result = await db.execute(select(Image).where(Image.id == image_id))
image = result.scalar_one_or_none()
if image is None:
logger.warning("arq.job.image_not_found", extra={"image_id": image_id})
return "SKIPPED: image not found"
if image.processing_status == ProcessingStatus.DONE:
logger.info("arq.job.already_done", extra={"image_id": image_id})
return "SKIPPED: already done"
await process_image_pipeline(image_id, db, redis=redis) await process_image_pipeline(image_id, db, redis=redis)
logger.info(f"--- JOB TERMINÉ : image_id={image_id} ---") logger.info("arq.job.completed", extra={"image_id": image_id})
return f"OK" return "OK"
except Exception as e: except Exception as e:
logger.error(f"--- JOB ÉCHOUÉ : {str(e)} ---", exc_info=True) logger.error(
raise "arq.job.failed",
extra={"image_id": image_id, "error": str(e), "attempt": job_try},
exc_info=True,
)
# Si c'est la dernière tentative, pousser en DLQ
if job_try >= max_retries:
from app.workers.dead_letter import push_dead_job
await push_dead_job(
redis=redis,
job_name="process_image_task",
args=[image_id, client_id],
kwargs={},
error=str(e),
attempts=job_try,
)
# Marquer l'image en erreur permanente
async with AsyncSessionLocal() as db2:
result2 = await db2.execute(select(Image).where(Image.id == image_id))
img = result2.scalar_one_or_none()
if img:
img.processing_status = ProcessingStatus.ERROR
img.processing_error = f"[DLQ] Échec après {job_try} tentatives: {str(e)[:200]}"
await db2.commit()
return f"DLQ: failed after {job_try} attempts"
raise # Re-raise pour que ARQ retente
async def on_startup(ctx: dict) -> None: async def on_startup(ctx: dict) -> None:
logger.info("Worker started and listening on %s", WorkerSettings.queue_name) logger.info("worker.started", extra={"queue": WorkerSettings.queue_name})
async def on_shutdown(ctx: dict) -> None:
logger.info("worker.shutdown")
def _parse_redis_settings() -> RedisSettings:
url = settings.REDIS_URL
if url.startswith("redis://"): url = url[8:]
elif url.startswith("rediss://"): url = url[9:]
password, host, port, database = None, "localhost", 6379, 0
if "@" in url:
auth, url = url.rsplit("@", 1)
password = auth.split(":", 1)[1] if ":" in auth else auth
if "/" in url:
url, db_str = url.split("/", 1)
if db_str: database = int(db_str)
if ":" in url:
host, port_str = url.rsplit(":", 1)
port = int(port_str)
else: host = url
return RedisSettings(host=host, port=port, password=password, database=database)
class WorkerSettings: class WorkerSettings:
functions = [func(process_image_task, name="process_image_task")] functions = [func(process_image_task, name="process_image_task")]
redis_settings = _parse_redis_settings() redis_settings = RedisSettings(
host=parse_redis_url()[0],
port=parse_redis_url()[1],
password=parse_redis_url()[2],
database=parse_redis_url()[3],
)
queue_name = DEFAULT_QUEUE_NAME queue_name = DEFAULT_QUEUE_NAME
on_startup = on_startup on_startup = on_startup
on_shutdown = on_shutdown
max_jobs = 10 max_jobs = 10
job_timeout = 300 job_timeout = settings.PIPELINE_TIMEOUT
health_check_interval = 30
+56
View File
@@ -1,6 +1,9 @@
""" """
Client Redis partagé — pool de connexions async pour ARQ et Pub/Sub. Client Redis partagé — pool de connexions async pour ARQ et Pub/Sub.
Inclut le parsing d'URL Redis centralisé pour éviter la duplication.
""" """
from typing import Tuple, Optional
from redis.asyncio import ConnectionPool, Redis from redis.asyncio import ConnectionPool, Redis
from app.config import settings from app.config import settings
@@ -8,10 +11,63 @@ from app.config import settings
_pool: ConnectionPool | None = None _pool: ConnectionPool | None = None
def parse_redis_url(url: str | None = None) -> Tuple[str, int, Optional[str], int]:
"""
Parse une URL Redis en (host, port, password, database).
Format accepté : redis://[user:password@]host[:port][/db]
Retourne ('localhost', 6379, None, 0) si l'URL est vide.
"""
if url is None:
url = settings.REDIS_URL
if not url:
return "localhost", 6379, None, 0
if url.startswith("redis://"):
url = url[8:]
if url.startswith("rediss://"):
url = url[9:]
password: Optional[str] = None
if "@" in url:
auth_part, url = url.rsplit("@", 1)
if ":" in auth_part:
password = auth_part.split(":", 1)[1]
else:
password = auth_part
database = 0
if "/" in url:
host_port, db_str = url.split("/", 1)
if db_str:
try:
database = int(db_str)
except ValueError:
pass
else:
host_port = url
host = "localhost"
port = 6379
if ":" in host_port:
host, port_str = host_port.rsplit(":", 1)
if port_str:
try:
port = int(port_str)
except ValueError:
port = 6379
else:
host = host_port or "localhost"
return host, port, password, database
async def get_redis_pool() -> Redis: async def get_redis_pool() -> Redis:
"""Retourne un client Redis avec pool de connexions partagé.""" """Retourne un client Redis avec pool de connexions partagé."""
global _pool global _pool
if _pool is None: if _pool is None:
host, port, password, database = parse_redis_url()
_pool = ConnectionPool.from_url( _pool = ConnectionPool.from_url(
settings.REDIS_URL, settings.REDIS_URL,
max_connections=20, max_connections=20,
+58 -80
View File
@@ -1,106 +1,84 @@
version: '3.9'
services: services:
# ── Portail Admin (React + Nginx) ────────────────────────── postgres:
admin:
build:
context: ./imago-admin
args:
VITE_API_BASE_URL: ""
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 5s
retries: 3
# ── Backend Imago Hub (FastAPI + Uvicorn) ──────────────────
backend:
build: .
ports:
- "8000:8000"
volumes:
- ./data:/app/data
env_file:
- .env
environment:
- DATABASE_URL=postgresql+asyncpg://imago:imago@db:5432/imago
- REDIS_URL=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_USER: imago POSTGRES_USER: imago
POSTGRES_PASSWORD: imago POSTGRES_PASSWORD: imago
POSTGRES_DB: imago POSTGRES_DB: imago
ports:
- '5432:5432'
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U imago -d imago"] test: ['CMD-SHELL', 'pg_isready -U imago']
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
restart: unless-stopped
redis: redis:
image: redis:7-alpine image: redis:7-alpine
ports: ports:
- "6379:6379" - '6379:6379'
volumes: volumes:
- redis_data:/data - redisdata:/data
command: redis-server --appendonly yes
restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ['CMD', 'redis-cli', 'ping']
interval: 10s interval: 5s
timeout: 5s timeout: 5s
retries: 3 retries: 5
worker:
build: .
command: python worker.py
volumes:
- ./data:/app/data
env_file:
- .env
environment:
- DATABASE_URL=postgresql+asyncpg://imago:imago@db:5432/imago
- REDIS_URL=redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
minio: minio:
image: minio/minio image: minio/minio:latest
ports: command: server /data --console-address ':9001'
- "9000:9000"
- "9001:9001"
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001" ports:
- '9000:9000'
- '9001:9001'
volumes: volumes:
- minio_data:/data - miniodata:/data
restart: unless-stopped healthcheck:
test: ['CMD', 'mc', 'ready', 'local']
interval: 5s
timeout: 5s
retries: 5
api:
build: .
ports:
- '8000:8000'
environment:
- DATABASE_URL=postgresql+asyncpg://imago:imago@postgres:5432/imago
- REDIS_URL=redis://redis:6379/0
- STORAGE_BACKEND=s3
- S3_BUCKET=imago
- S3_ENDPOINT_URL=http://minio:9000
- S3_ENDPOINT_PUBLIC=http://localhost:9000
- S3_ACCESS_KEY=minioadmin
- S3_SECRET_KEY=minioadmin
- S3_REGION=us-east-1
- SIGNED_URL_SECRET=dev-secret-change-in-prod
- SECRET_KEY=dev-secret-change-in-prod
- TESSERACT_CMD=/usr/bin/tesseract
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
volumes:
- uploads:/app/data/uploads
- thumbnails:/app/data/thumbnails
volumes: volumes:
postgres_data: pgdata:
redis_data: redisdata:
minio_data: miniodata:
uploads:
thumbnails:
+23 -20
View File
@@ -1,41 +1,40 @@
services: services:
# Portail admin (ce projet)
admin: admin:
build: build:
context: . context: .
args: args:
VITE_API_BASE_URL: http://backend:8000 VITE_API_BASE_URL: http://localhost:8000
ports: ports:
- "3000:3000" - '3000:3000'
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
# Backend Imago Hub (projet séparé)
backend: backend:
build: build:
context: .. context: ..
ports: ports:
- "8000:8000" - '8000:8000'
volumes: volumes:
- ../data:/app/data - ../data:/app/data
env_file: ../.env env_file: ../.env
environment: environment:
- DATABASE_URL=postgresql+asyncpg://imago:imago@db:5432/imago - DATABASE_URL=postgresql+asyncpg://imago:imago@db:5432/imago
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- STORAGE_BACKEND=s3
- S3_BUCKET=imago
- S3_ENDPOINT_URL=http://minio:9000
- S3_ENDPOINT_PUBLIC=http://localhost:9000
- S3_ACCESS_KEY=minioadmin
- S3_SECRET_KEY=***
- S3_REGION=us-east-1
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# Worker ARQ
worker: worker:
build: build:
context: .. context: ..
@@ -46,6 +45,13 @@ services:
environment: environment:
- DATABASE_URL=postgresql+asyncpg://imago:imago@db:5432/imago - DATABASE_URL=postgresql+asyncpg://imago:imago@db:5432/imago
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
- STORAGE_BACKEND=s3
- S3_BUCKET=imago
- S3_ENDPOINT_URL=http://minio:9000
- S3_ENDPOINT_PUBLIC=http://localhost:9000
- S3_ACCESS_KEY=minioadmin
- S3_SECRET_KEY=***
- S3_REGION=us-east-1
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -53,7 +59,6 @@ services:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
# PostgreSQL
db: db:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
@@ -63,37 +68,35 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U imago -d imago"] test: ['CMD-SHELL', 'pg_isready -U imago -d imago']
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
restart: unless-stopped restart: unless-stopped
# Redis (file ARQ + Pub/Sub)
redis: redis:
image: redis:7-alpine image: redis:7-alpine
ports: ports:
- "6379:6379" - '6379:6379'
volumes: volumes:
- redis_data:/data - redis_data:/data
command: redis-server --appendonly yes command: redis-server --appendonly yes
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ['CMD', 'redis-cli', 'ping']
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3
# MinIO (stockage S3 compatible — développement)
minio: minio:
image: minio/minio image: minio/minio
ports: ports:
- "9000:9000" - '9000:9000'
- "9001:9001" - '9001:9001'
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001" command: server /data --console-address ':9001'
volumes: volumes:
- minio_data:/data - minio_data:/data
restart: unless-stopped restart: unless-stopped
+59 -1
View File
@@ -4,6 +4,64 @@ version = "2.0.0"
description = "Backend FastAPI pour la gestion d'images et fonctionnalités AI — Imago" description = "Backend FastAPI pour la gestion d'images et fonctionnalités AI — Imago"
requires-python = ">=3.12" requires-python = ">=3.12"
license = "MIT" license = "MIT"
license-files = ["LICENSE"]
dependencies = [
# Web Framework
"fastapi==0.115.0",
"uvicorn[standard]==0.30.6",
"python-multipart==0.0.9",
# Database
"sqlalchemy==2.0.35",
"alembic==1.13.3",
"aiosqlite==0.20.0",
"asyncpg==0.29.0",
# Validation
"pydantic==2.9.2; python_version < '3.14'",
"pydantic>=2.12.0; python_version >= '3.14'",
"pydantic-settings==2.5.2",
# Image Processing
"Pillow==10.4.0; python_version < '3.14'",
"Pillow>=11.0.0; python_version >= '3.14'",
"piexif==1.1.3",
# OCR
"pytesseract==0.3.13",
# AI
"google-genai==1.0.0",
"httpx==0.27.2",
# Web scraping
"beautifulsoup4==4.12.3",
# Task scheduling
"apscheduler==3.10.4",
# Task queue
"arq==0.25.0",
"redis==5.0.8",
# Storage abstraction
"aioboto3==13.0.0",
"itsdangerous==2.2.0",
# Observability
"structlog==24.4.0",
"prometheus-fastapi-instrumentator==7.0.2",
# Utilities
"python-dotenv==1.0.1",
"python-jose[cryptography]==3.3.0",
"passlib[bcrypt]==1.7.4",
"aiofiles==24.1.0",
# Rate Limiting
"slowapi==0.1.9",
# WebSockets
"websockets>=13.0",
]
[project.optional-dependencies]
dev = [
"pytest==8.3.3",
"pytest-asyncio==0.24.0",
"pytest-cov==5.0.0",
"ruff==0.8.6",
"mypy==1.13.0",
"pre-commit==4.0.1",
"types-aiofiles==24.1.0.20240626",
]
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py312"
@@ -20,7 +78,7 @@ select = [
"UP", # pyupgrade "UP", # pyupgrade
"B", # bugbear "B", # bugbear
"S", # bandit (security) "S", # bandit (security)
"T20", # flake8-print (catch stray print()) "T20", # flake8-print
"SIM", # flake8-simplify "SIM", # flake8-simplify
"RUF", # ruff-specific "RUF", # ruff-specific
] ]
+26 -9
View File
@@ -1,19 +1,36 @@
#!/usr/bin/env python3
""" """
Entrypoint du worker ARQ — traitement des images en arrière-plan. Worker ARQ — lancement du worker de traitement asynchrone.
Lancer avec : python worker.py Usage:
python worker.py
# ou via arq directement :
# arq app.workers.image_worker.WorkerSettings
Le worker écoute les queues Redis 'standard' et 'premium' et traite Le worker écoute la file Redis et traite les images uploadées
les tâches de pipeline image (EXIF → OCR → AI). via le pipeline EXIF → OCR → Vision AI.
""" """
import asyncio import logging
import sys
from pathlib import Path
# Ajouter le répertoire parent au PYTHONPATH pour les imports relatifs
sys.path.insert(0, str(Path(__file__).resolve().parent))
from arq import run_worker from arq import run_worker
from app.config import settings
from app.logging_config import configure_logging from app.logging_config import configure_logging
from app.config import settings
from app.workers.image_worker import WorkerSettings from app.workers.image_worker import WorkerSettings
# Configure le logging dès l'import
configure_logging(debug=settings.DEBUG)
if __name__ == "__main__" : def main():
configure_logging(debug=settings.DEBUG)
logger = logging.getLogger("worker")
logger.info("Starting Imago worker on queue '%s'", WorkerSettings.queue_name)
run_worker(WorkerSettings) run_worker(WorkerSettings)
if __name__ == "__main__":
main()