""" 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, }