IIIF-Studio / backend /app /main.py
Claude
fix: comprehensive repo audit — 15 issues fixed
9097545 unverified
Raw
History Blame Contribute Delete
5.8 kB
"""
Application FastAPI — point d'entrée de IIIF Studio.
Tous les endpoints sont sous /api/v1/ (R10).
CORS ouvert pour le développement local (origins=["*"]).
La BDD SQLite est créée automatiquement au démarrage (lifespan).
"""
# 1. stdlib
import logging
from contextlib import asynccontextmanager
from pathlib import Path
# 2. third-party
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
# 3. local — on importe les modèles pour que Base.metadata les connaisse
import app.models # noqa: F401 (enregistrement des modèles SQLAlchemy)
from app.api.v1 import corpora, export, ingest, jobs, manuscripts, models_api, pages, profiles, search
from app.models.database import Base, engine
logger = logging.getLogger(__name__)
def _migrate_model_configs(connection) -> None:
"""Ajoute la colonne supports_vision si absente (migration BDD existantes)."""
from sqlalchemy import inspect, text
inspector = inspect(connection)
columns = {c["name"] for c in inspector.get_columns("model_configs")}
if "supports_vision" not in columns:
connection.execute(
text("ALTER TABLE model_configs ADD COLUMN supports_vision BOOLEAN NOT NULL DEFAULT 1")
)
logger.info("Migration : colonne supports_vision ajoutée à model_configs")
def _migrate_page_search(connection) -> None:
"""Ajoute la colonne normalized_text si absente (recherche SQL LIKE)."""
from sqlalchemy import inspect, text
inspector = inspect(connection)
if "page_search" not in inspector.get_table_names():
return
columns = {c["name"] for c in inspector.get_columns("page_search")}
if "normalized_text" not in columns:
connection.execute(
text("ALTER TABLE page_search ADD COLUMN normalized_text TEXT NOT NULL DEFAULT ''")
)
logger.info("Migration : colonne normalized_text ajoutée à page_search")
@asynccontextmanager
async def lifespan(application: FastAPI):
"""Crée les tables SQLite au démarrage, libère l'engine à l'arrêt."""
from app.config import settings
# ── Diagnostic des chemins au démarrage ──────────────────────────────────
# Ces logs apparaissent dans la console HuggingFace et permettent de
# diagnostiquer instantanément tout problème de chemin en production.
logger.info(
"Démarrage IIIF Studio — chemins configurés",
extra={
"profiles_dir": str(settings.profiles_dir),
"profiles_dir_ok": settings.profiles_dir.is_dir(),
"prompts_dir": str(settings.prompts_dir),
"prompts_dir_ok": settings.prompts_dir.is_dir(),
"data_dir": str(settings.data_dir),
"data_dir_ok": settings.data_dir.exists(),
},
)
if not settings.profiles_dir.is_dir():
logger.error(
"ERREUR CRITIQUE : profiles_dir introuvable au démarrage. "
"GET /api/v1/profiles retournera []. "
"Vérifier PROFILES_DIR ou la structure du container.",
extra={"profiles_dir": str(settings.profiles_dir)},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Migrations : create_all ne fait pas d'ALTER TABLE sur les tables existantes
await conn.run_sync(_migrate_model_configs)
await conn.run_sync(_migrate_page_search)
logger.info("Tables SQLite initialisées")
yield
await engine.dispose()
logger.info("Engine SQLite fermé")
app = FastAPI(
title="IIIF Studio",
description="Plateforme générique de génération d'éditions savantes augmentées",
version="0.1.0",
lifespan=lifespan,
)
# ── CORS (configurable via CORS_ORIGINS ; défaut : ["*"] pour le dev) ─────────
from app.config import settings as _settings
app.add_middleware(
CORSMiddleware,
allow_origins=_settings.cors_origins,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Routers (préfixe /api/v1/ — R10) ─────────────────────────────────────────
_V1_PREFIX = "/api/v1"
app.include_router(corpora.router, prefix=_V1_PREFIX)
app.include_router(manuscripts.router, prefix=_V1_PREFIX)
app.include_router(pages.router, prefix=_V1_PREFIX)
app.include_router(export.router, prefix=_V1_PREFIX)
app.include_router(profiles.router, prefix=_V1_PREFIX)
app.include_router(jobs.router, prefix=_V1_PREFIX)
app.include_router(ingest.router, prefix=_V1_PREFIX)
app.include_router(models_api.router, prefix=_V1_PREFIX)
app.include_router(search.router, prefix=_V1_PREFIX)
# ── Serving frontend SPA (production) ou redirect /docs (dev) ────────────────
_STATIC_DIR = Path("/app/static")
@app.get("/{full_path:path}", include_in_schema=False, response_model=None)
async def serve_frontend(full_path: str) -> FileResponse | RedirectResponse:
"""En production sert le frontend React (SPA). En dev redirige vers /docs."""
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail=f"Endpoint not found: /{full_path}")
if _STATIC_DIR.is_dir():
candidate = (_STATIC_DIR / full_path).resolve()
# Empêcher le path traversal : le fichier résolu doit être sous _STATIC_DIR
if candidate.is_file() and candidate.is_relative_to(_STATIC_DIR.resolve()):
return FileResponse(candidate)
index = _STATIC_DIR / "index.html"
if index.exists():
return FileResponse(index)
return RedirectResponse(url="/docs")