- 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.
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""
|
|
Imago — Application principale FastAPI
|
|
"""
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from slowapi import _rate_limit_exceeded_handler
|
|
from slowapi.errors import RateLimitExceeded
|
|
|
|
from app.config import settings
|
|
from app.database import init_db
|
|
from app.logging_config import configure_logging
|
|
from app.routers import images_router, ai_router, auth_router, files_router
|
|
from app.middleware import limiter
|
|
from app.middleware.logging_middleware import LoggingMiddleware
|
|
from app.workers.redis_client import get_redis_pool, close_redis_pool
|
|
|
|
# Configure le logging structuré dès l'import
|
|
configure_logging(debug=settings.DEBUG)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from arq import create_pool
|
|
from arq.connections import RedisSettings
|
|
_arq_available = True
|
|
except ImportError:
|
|
_arq_available = False
|
|
|
|
try:
|
|
from prometheus_fastapi_instrumentator import Instrumentator
|
|
_prometheus_available = True
|
|
except ImportError:
|
|
_prometheus_available = False
|
|
|
|
|
|
def _arq_redis_settings() -> "RedisSettings":
|
|
"""Parse REDIS_URL en RedisSettings ARQ."""
|
|
url = settings.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(
|
|
host=host or "localhost",
|
|
port=port,
|
|
password=password,
|
|
database=database,
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Lifespan — initialisation au démarrage
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Création des répertoires de données
|
|
settings.upload_path
|
|
settings.thumbnails_path
|
|
|
|
# Initialisation de la base de données (création des tables)
|
|
await init_db()
|
|
|
|
active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL
|
|
|
|
logger.info("startup.db_initialized", extra={"upload_dir": settings.UPLOAD_DIR})
|
|
logger.info("startup.ai_config", extra={
|
|
"provider": settings.AI_PROVIDER,
|
|
"model": active_model,
|
|
"ocr_enabled": settings.OCR_ENABLED,
|
|
})
|
|
|
|
# Initialisation Redis + ARQ pool
|
|
try:
|
|
app.state.redis = await get_redis_pool()
|
|
logger.info("startup.redis_connected", extra={"url": settings.REDIS_URL})
|
|
except Exception as e:
|
|
app.state.redis = None
|
|
logger.warning("startup.redis_unavailable", extra={"error": str(e)})
|
|
|
|
if _arq_available:
|
|
try:
|
|
app.state.arq_pool = await create_pool(_arq_redis_settings())
|
|
logger.info("startup.arq_pool_created")
|
|
except Exception as e:
|
|
app.state.arq_pool = _FallbackArqPool()
|
|
logger.warning("startup.arq_fallback", extra={"error": str(e)})
|
|
else:
|
|
app.state.arq_pool = _FallbackArqPool()
|
|
logger.warning("startup.arq_not_installed")
|
|
|
|
yield
|
|
|
|
# Fermeture propre
|
|
if hasattr(app.state, "arq_pool") and hasattr(app.state.arq_pool, "close"):
|
|
await app.state.arq_pool.close()
|
|
await close_redis_pool()
|
|
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
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
app = FastAPI(
|
|
title=settings.APP_NAME,
|
|
version=settings.APP_VERSION,
|
|
description="""
|
|
## Imago
|
|
|
|
Backend de gestion d'images et fonctionnalités AI pour l'interface Shaarli.
|
|
|
|
### Fonctionnalités
|
|
- 📸 **Upload et stockage d'images** avec génération de thumbnails
|
|
- 🔍 **Extraction EXIF** automatique (appareil, GPS, paramètres de prise de vue)
|
|
- 📝 **OCR** — extraction de texte depuis les images (Tesseract)
|
|
- 🤖 **Vision AI** — description et classification par tags (Gemini)
|
|
- 🔗 **Résumé d'URL** — scraping + résumé AI de pages web
|
|
- ✅ **Rédaction de tâches** — génération structurée via AI
|
|
- 📋 **File de tâches ARQ** — pipeline persistant avec retry automatique
|
|
- 📊 **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,
|
|
docs_url="/docs",
|
|
redoc_url="/redoc",
|
|
)
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Middleware CORS
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.CORS_ORIGINS,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Middleware Logging HTTP
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
app.add_middleware(LoggingMiddleware)
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Rate Limiting (slowapi)
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
app.state.limiter = limiter
|
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Prometheus Metrics
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
if _prometheus_available:
|
|
Instrumentator(
|
|
should_group_status_codes=True,
|
|
should_ignore_untemplated=True,
|
|
excluded_handlers=["/health", "/health/detailed", "/metrics"],
|
|
).instrument(app).expose(app, endpoint="/metrics", tags=["Observabilité"])
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Fichiers statiques / URLs signées
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
if settings.STORAGE_BACKEND == "local":
|
|
app.include_router(files_router)
|
|
app.mount("/static/uploads", StaticFiles(directory=str(settings.upload_path)), name="uploads")
|
|
app.mount("/static/thumbnails", StaticFiles(directory=str(settings.thumbnails_path)), name="thumbnails")
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Routers
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
app.include_router(images_router)
|
|
app.include_router(ai_router)
|
|
app.include_router(auth_router)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Routes utilitaires
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/", tags=["Santé"])
|
|
async def root():
|
|
return {
|
|
"app": settings.APP_NAME,
|
|
"version": settings.APP_VERSION,
|
|
"status": "running",
|
|
"docs": "/docs",
|
|
}
|
|
|
|
|
|
@app.get("/health", tags=["Santé"])
|
|
async def health():
|
|
ai_configured = (
|
|
(settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or
|
|
(settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY))
|
|
)
|
|
active_model = settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL
|
|
|
|
return {
|
|
"status": "healthy",
|
|
"ai_enabled": settings.AI_ENABLED,
|
|
"ai_provider": settings.AI_PROVIDER,
|
|
"ai_configured": ai_configured,
|
|
"ocr_enabled": settings.OCR_ENABLED,
|
|
"model": active_model,
|
|
}
|
|
|
|
|
|
@app.get("/health/detailed", tags=["Santé"])
|
|
async def health_detailed(request: Request):
|
|
"""Endpoint de santé détaillé pour monitoring avancé."""
|
|
checks = {}
|
|
|
|
# DB check
|
|
try:
|
|
from app.database import AsyncSessionLocal
|
|
from sqlalchemy import text
|
|
async with AsyncSessionLocal() as session:
|
|
await session.execute(text("SELECT 1"))
|
|
checks["database"] = {"status": "ok"}
|
|
except Exception as e:
|
|
checks["database"] = {"status": "error", "error": str(e)}
|
|
|
|
# Redis check
|
|
redis = getattr(request.app.state, "redis", None)
|
|
if redis:
|
|
try:
|
|
await redis.ping()
|
|
checks["redis"] = {"status": "ok"}
|
|
except Exception as e:
|
|
checks["redis"] = {"status": "error", "error": str(e)}
|
|
else:
|
|
checks["redis"] = {"status": "not_configured"}
|
|
|
|
# ARQ check
|
|
arq_pool = getattr(request.app.state, "arq_pool", None)
|
|
if arq_pool and not isinstance(arq_pool, _FallbackArqPool):
|
|
checks["arq"] = {"status": "ok"}
|
|
else:
|
|
checks["arq"] = {"status": "fallback"}
|
|
|
|
# OCR check
|
|
checks["ocr"] = {"status": "enabled" if settings.OCR_ENABLED else "disabled"}
|
|
|
|
# Storage check
|
|
checks["storage"] = {
|
|
"backend": settings.STORAGE_BACKEND,
|
|
"status": "ok",
|
|
}
|
|
|
|
overall = "healthy" if all(
|
|
c.get("status") in ("ok", "enabled", "disabled", "not_configured", "fallback")
|
|
for c in checks.values()
|
|
) else "degraded"
|
|
|
|
return {
|
|
"status": overall,
|
|
"checks": checks,
|
|
"version": settings.APP_VERSION,
|
|
}
|