Files
Imago/app/main.py
T
bruno 2f2a5e9b4a
CI / Lint & Format (push) Failing after 28s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped
Add admin panel, WebSocket support, and API versioning
Introduce an admin portal (React + Nginx), WebSocket routing, and
API versioning middleware with `/api/v1/` prefix deprecation.
Add master API key authentication, new Prometheus metrics for AI
token consumption and active WebSockets, and extend S3 config
with a public endpoint URL. Update test paths and fixtures to
align with the new routing structure.
2026-06-22 11:25:22 -04:00

391 lines
16 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, ws_router, admin_router
from app.middleware import limiter
from app.middleware.logging_middleware import LoggingMiddleware
from app.middleware.versioning import APIVersioningMiddleware
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=f"{settings.APP_NAME} API",
version=settings.APP_VERSION,
description="""
## Imago API (v1)
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.
### 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 de Versionnement API
# ─────────────────────────────────────────────────────────────
app.add_middleware(APIVersioningMiddleware)
# ─────────────────────────────────────────────────────────────
# 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 Versionnés (/api/v1/)
# ─────────────────────────────────────────────────────────────
from fastapi import APIRouter
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(images_router)
api_v1_router.include_router(ai_router)
api_v1_router.include_router(auth_router)
# Mount the versioned router on the app
app.include_router(api_v1_router)
# ─────────────────────────────────────────────────────────────
# Routers Non-Versionnés (WebSockets, Fichiers, Santé, Metrics)
# ─────────────────────────────────────────────────────────────
app.include_router(ws_router)
app.include_router(admin_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 = {}
# 1. Backend check
checks["backend"] = {
"status": "ok",
"version": settings.APP_VERSION,
"uptime": "N/A" # Optionnel: ajouter un tracker d'uptime si nécessaire
}
# 2. Database 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)}
# 3. 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"}
# 4. Worker & Queue check (ARQ)
arq_pool = getattr(request.app.state, "arq_pool", None)
if arq_pool and not isinstance(arq_pool, _FallbackArqPool):
try:
# On essaye de voir le nombre de jobs en attente
queued_jobs = await arq_pool.zcard("arq:queue")
checks["queue"] = {
"status": "ok",
"pending_jobs": queued_jobs
}
# 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"] = {
"status": "ok" if worker_active else "error",
"active_workers": 1 if worker_active else 0,
"detail": "Worker opérationnel" if worker_active else "Aucun worker détecté"
}
except Exception as e:
checks["queue"] = {"status": "error", "error": str(e)}
checks["worker"] = {"status": "error", "error": str(e)}
else:
checks["queue"] = {"status": "fallback"}
checks["worker"] = {"status": "error", "detail": "ARQ non disponible"}
# 5. MinIO / Storage check
from app.services.storage_backend import get_storage_backend, S3Storage
storage = get_storage_backend()
try:
if isinstance(storage, S3Storage):
# On vérifie la connexion réelle à MinIO en listant les objets (ou head bucket)
# Pour faire simple/rapide on vérifie juste si on peut accéder au 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:
checks["minio"] = {
"status": "ok",
"backend": "local",
"path": settings.UPLOAD_DIR
}
except Exception as e:
checks["minio"] = {"status": "error", "error": str(e)}
# 6. OCR check (Tesseract)
checks["tesseract"] = {"status": "ok" if settings.OCR_ENABLED else "disabled"}
# 7. AI Provider check
ai_configured = (
(settings.AI_PROVIDER == "gemini" and bool(settings.GEMINI_API_KEY)) or
(settings.AI_PROVIDER == "openrouter" and bool(settings.OPENROUTER_API_KEY))
)
checks["ai"] = {
"status": "ok" if ai_configured else "warning",
"provider": settings.AI_PROVIDER,
"configured": ai_configured
}
overall = "healthy" if all(
c.get("status") in ("ok", "enabled", "disabled", "not_configured", "fallback")
for k, c in checks.items() if k != "ai" # On tolère AI warning
) else "degraded"
return {
"status": overall,
"checks": checks,
"version": settings.APP_VERSION,
}