IIIF-Studio / backend /app /api /v1 /corpora.py
Claude
perf: Sprint 2a — caching, parallel pipeline, async I/O, tests
5d9b495 unverified
Raw
History Blame Contribute Delete
5 kB
"""
Endpoints CRUD pour les corpus (R10 — préfixe /api/v1/).
GET /api/v1/corpora
POST /api/v1/corpora
GET /api/v1/corpora/{corpus_id}
DELETE /api/v1/corpora/{corpus_id}
GET /api/v1/corpora/{corpus_id}/manuscripts
Règle : toute logique métier est dans les services, jamais dans les routers.
"""
# 1. stdlib
import uuid
from datetime import datetime, timezone
# 2. third-party
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
# 3. local
from app.models.corpus import CorpusModel, ManuscriptModel
from app.models.database import get_db
router = APIRouter(prefix="/corpora", tags=["corpora"])
# ── Schémas de requête / réponse ─────────────────────────────────────────────
class CorpusCreate(BaseModel):
slug: str = Field(..., pattern=r"^[a-z0-9][a-z0-9_-]{0,63}$")
title: str = Field(..., min_length=1, max_length=256)
profile_id: str = Field(..., pattern=r"^[a-z0-9][a-z0-9_-]*$")
class CorpusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
slug: str
title: str
profile_id: str
created_at: datetime
updated_at: datetime
class ManuscriptResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
corpus_id: str
title: str
shelfmark: str | None
date_label: str | None
total_pages: int
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("", response_model=list[CorpusResponse])
async def list_corpora(
db: AsyncSession = Depends(get_db),
skip: int = Query(0, ge=0, description="Nombre d'éléments à sauter"),
limit: int = Query(100, ge=1, le=1000, description="Nombre maximum d'éléments"),
) -> list[CorpusModel]:
"""Retourne les corpus enregistrés (paginé)."""
result = await db.execute(select(CorpusModel).offset(skip).limit(limit))
return list(result.scalars().all())
@router.post("", response_model=CorpusResponse, status_code=201)
async def create_corpus(
body: CorpusCreate, db: AsyncSession = Depends(get_db)
) -> CorpusModel:
"""Crée un nouveau corpus. Le slug doit être unique."""
# Vérifier que le profil existe dans profiles_dir
from app.config import settings as _settings
profile_path = _settings.profiles_dir / f"{body.profile_id}.json"
if not profile_path.is_file():
raise HTTPException(
status_code=422,
detail=f"Profil «{body.profile_id}» introuvable dans {_settings.profiles_dir}",
)
# Vérifier unicité du slug
existing = await db.execute(
select(CorpusModel).where(CorpusModel.slug == body.slug)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail=f"Slug «{body.slug}» déjà utilisé")
now = datetime.now(timezone.utc)
corpus = CorpusModel(
id=str(uuid.uuid4()),
slug=body.slug,
title=body.title,
profile_id=body.profile_id,
created_at=now,
updated_at=now,
)
db.add(corpus)
await db.commit()
await db.refresh(corpus)
return corpus
@router.get("/{corpus_id}", response_model=CorpusResponse)
async def get_corpus(corpus_id: str, db: AsyncSession = Depends(get_db)) -> CorpusModel:
"""Retourne un corpus par son id."""
corpus = await db.get(CorpusModel, corpus_id)
if corpus is None:
raise HTTPException(status_code=404, detail="Corpus introuvable")
return corpus
@router.delete("/{corpus_id}", status_code=204)
async def delete_corpus(corpus_id: str, db: AsyncSession = Depends(get_db)) -> None:
"""Supprime un corpus (cascade sur les manuscrits et pages associés)."""
corpus = await db.get(CorpusModel, corpus_id)
if corpus is None:
raise HTTPException(status_code=404, detail="Corpus introuvable")
await db.delete(corpus)
await db.commit()
@router.get("/{corpus_id}/manuscripts", response_model=list[ManuscriptResponse])
async def list_manuscripts(
corpus_id: str,
db: AsyncSession = Depends(get_db),
skip: int = Query(0, ge=0, description="Nombre d'éléments à sauter"),
limit: int = Query(100, ge=1, le=1000, description="Nombre maximum d'éléments"),
) -> list[ManuscriptModel]:
"""Retourne les manuscrits d'un corpus (paginé)."""
corpus = await db.get(CorpusModel, corpus_id)
if corpus is None:
raise HTTPException(status_code=404, detail="Corpus introuvable")
result = await db.execute(
select(ManuscriptModel)
.where(ManuscriptModel.corpus_id == corpus_id)
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())