Spaces:
Build error
feat(sprint4-session-a): API FastAPI — structure de base + endpoints lecture et export
Browse filesStructure créée :
- app/config.py : Settings Pydantic lues depuis l'environnement (base_url,
data_dir, database_url, clés IA R06) — sans dépendance externe
- app/models/database.py : engine SQLAlchemy async (aiosqlite), Base
déclarative, get_db() dépendance FastAPI
- app/models/corpus.py : CorpusModel, ManuscriptModel, PageModel
(SQLAlchemy 2.0 Mapped, cascade delete-orphan)
- app/main.py : FastAPI + CORS ouvert + lifespan (create_all SQLite)
+ inclusion des 4 routers sous /api/v1/
Routers (R10 — tous sous /api/v1/) :
- api/v1/corpora.py : GET /corpora, POST /corpora (slug unique → 409),
GET /corpora/{id}, DELETE /corpora/{id}
- api/v1/pages.py : GET /pages/{id}, GET /pages/{id}/master-json (R02),
GET /pages/{id}/layers (couches détectées depuis master.json)
- api/v1/export.py : GET /manuscripts/{id}/iiif-manifest,
GET /manuscripts/{id}/mets, GET /pages/{id}/alto,
GET /manuscripts/{id}/export.zip (manifest + mets + alto par page)
- api/v1/profiles.py : GET /profiles, GET /profiles/{id}
Tests (63 nouveaux) :
- test_api_corpora.py : CRUD complet, 409, 422 Pydantic auto, isolation
- test_api_pages.py : 200/404, master-json via Path mock, couches
- test_api_export.py : IIIF, METS, ALTO, ZIP (contenu vérifié)
- conftest_api.py : fixtures db_session (SQLite in-memory) + async_client
Total : 396 tests passent, 3 skippés (intégration réseau).
https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg
- backend/app/api/__init__.py +1 -0
- backend/app/api/v1/__init__.py +1 -0
- backend/app/api/v1/corpora.py +99 -0
- backend/app/api/v1/export.py +218 -0
- backend/app/api/v1/pages.py +203 -0
- backend/app/api/v1/profiles.py +63 -0
- backend/app/config.py +53 -0
- backend/app/main.py +57 -0
- backend/app/models/__init__.py +7 -0
- backend/app/models/corpus.py +77 -0
- backend/app/models/database.py +46 -0
- backend/tests/conftest_api.py +56 -0
- backend/tests/test_api_corpora.py +208 -0
- backend/tests/test_api_export.py +385 -0
- backend/tests/test_api_pages.py +337 -0
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API package."""
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API v1 package."""
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Endpoints CRUD pour les corpus (R10 — préfixe /api/v1/).
|
| 3 |
+
|
| 4 |
+
GET /api/v1/corpora
|
| 5 |
+
POST /api/v1/corpora
|
| 6 |
+
GET /api/v1/corpora/{corpus_id}
|
| 7 |
+
DELETE /api/v1/corpora/{corpus_id}
|
| 8 |
+
|
| 9 |
+
Règle : toute logique métier est dans les services, jamais dans les routers.
|
| 10 |
+
"""
|
| 11 |
+
# 1. stdlib
|
| 12 |
+
import uuid
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
|
| 15 |
+
# 2. third-party
|
| 16 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 17 |
+
from pydantic import BaseModel, ConfigDict
|
| 18 |
+
from sqlalchemy import select
|
| 19 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 20 |
+
|
| 21 |
+
# 3. local
|
| 22 |
+
from app.models.corpus import CorpusModel
|
| 23 |
+
from app.models.database import get_db
|
| 24 |
+
|
| 25 |
+
router = APIRouter(prefix="/corpora", tags=["corpora"])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── Schémas de requête / réponse ─────────────────────────────────────────────
|
| 29 |
+
|
| 30 |
+
class CorpusCreate(BaseModel):
|
| 31 |
+
slug: str
|
| 32 |
+
title: str
|
| 33 |
+
profile_id: str
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class CorpusResponse(BaseModel):
|
| 37 |
+
model_config = ConfigDict(from_attributes=True)
|
| 38 |
+
|
| 39 |
+
id: str
|
| 40 |
+
slug: str
|
| 41 |
+
title: str
|
| 42 |
+
profile_id: str
|
| 43 |
+
created_at: datetime
|
| 44 |
+
updated_at: datetime
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ── Endpoints ────────────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
@router.get("", response_model=list[CorpusResponse])
|
| 50 |
+
async def list_corpora(db: AsyncSession = Depends(get_db)) -> list[CorpusModel]:
|
| 51 |
+
"""Retourne tous les corpus enregistrés."""
|
| 52 |
+
result = await db.execute(select(CorpusModel))
|
| 53 |
+
return list(result.scalars().all())
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.post("", response_model=CorpusResponse, status_code=201)
|
| 57 |
+
async def create_corpus(
|
| 58 |
+
body: CorpusCreate, db: AsyncSession = Depends(get_db)
|
| 59 |
+
) -> CorpusModel:
|
| 60 |
+
"""Crée un nouveau corpus. Le slug doit être unique."""
|
| 61 |
+
# Vérifier unicité du slug
|
| 62 |
+
existing = await db.execute(
|
| 63 |
+
select(CorpusModel).where(CorpusModel.slug == body.slug)
|
| 64 |
+
)
|
| 65 |
+
if existing.scalar_one_or_none() is not None:
|
| 66 |
+
raise HTTPException(status_code=409, detail=f"Slug «{body.slug}» déjà utilisé")
|
| 67 |
+
|
| 68 |
+
now = datetime.now(timezone.utc)
|
| 69 |
+
corpus = CorpusModel(
|
| 70 |
+
id=str(uuid.uuid4()),
|
| 71 |
+
slug=body.slug,
|
| 72 |
+
title=body.title,
|
| 73 |
+
profile_id=body.profile_id,
|
| 74 |
+
created_at=now,
|
| 75 |
+
updated_at=now,
|
| 76 |
+
)
|
| 77 |
+
db.add(corpus)
|
| 78 |
+
await db.commit()
|
| 79 |
+
await db.refresh(corpus)
|
| 80 |
+
return corpus
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@router.get("/{corpus_id}", response_model=CorpusResponse)
|
| 84 |
+
async def get_corpus(corpus_id: str, db: AsyncSession = Depends(get_db)) -> CorpusModel:
|
| 85 |
+
"""Retourne un corpus par son id."""
|
| 86 |
+
corpus = await db.get(CorpusModel, corpus_id)
|
| 87 |
+
if corpus is None:
|
| 88 |
+
raise HTTPException(status_code=404, detail="Corpus introuvable")
|
| 89 |
+
return corpus
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.delete("/{corpus_id}", status_code=204)
|
| 93 |
+
async def delete_corpus(corpus_id: str, db: AsyncSession = Depends(get_db)) -> None:
|
| 94 |
+
"""Supprime un corpus (cascade sur les manuscrits et pages associés)."""
|
| 95 |
+
corpus = await db.get(CorpusModel, corpus_id)
|
| 96 |
+
if corpus is None:
|
| 97 |
+
raise HTTPException(status_code=404, detail="Corpus introuvable")
|
| 98 |
+
await db.delete(corpus)
|
| 99 |
+
await db.commit()
|
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Endpoints d'export documentaire (R10 — préfixe /api/v1/).
|
| 3 |
+
|
| 4 |
+
GET /api/v1/manuscripts/{manuscript_id}/iiif-manifest → JSON
|
| 5 |
+
GET /api/v1/manuscripts/{manuscript_id}/mets → XML
|
| 6 |
+
GET /api/v1/pages/{page_id}/alto → XML
|
| 7 |
+
GET /api/v1/manuscripts/{manuscript_id}/export.zip → ZIP
|
| 8 |
+
|
| 9 |
+
Règle (R02) : toutes les sorties sont générées depuis les PageMasters
|
| 10 |
+
(master.json), jamais depuis les réponses brutes de l'IA.
|
| 11 |
+
"""
|
| 12 |
+
# 1. stdlib
|
| 13 |
+
import io
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import zipfile
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
# 2. third-party
|
| 20 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 21 |
+
from fastapi.responses import Response, StreamingResponse
|
| 22 |
+
from sqlalchemy import select
|
| 23 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 24 |
+
|
| 25 |
+
# 3. local
|
| 26 |
+
from app import config as _config_module
|
| 27 |
+
from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
|
| 28 |
+
from app.models.database import get_db
|
| 29 |
+
from app.schemas.page_master import PageMaster
|
| 30 |
+
from app.services.export.alto import generate_alto
|
| 31 |
+
from app.services.export.iiif import generate_manifest
|
| 32 |
+
from app.services.export.mets import generate_mets
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
router = APIRouter(tags=["export"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
async def _load_manuscript_with_masters(
|
| 42 |
+
manuscript_id: str,
|
| 43 |
+
db: AsyncSession,
|
| 44 |
+
) -> tuple[ManuscriptModel, CorpusModel, list[PageMaster]]:
|
| 45 |
+
"""Charge un manuscrit, son corpus et tous ses PageMasters.
|
| 46 |
+
|
| 47 |
+
Raises:
|
| 48 |
+
HTTPException 404: si le manuscrit ou son corpus est introuvable.
|
| 49 |
+
HTTPException 404: si aucun master.json n'est disponible.
|
| 50 |
+
"""
|
| 51 |
+
manuscript = await db.get(ManuscriptModel, manuscript_id)
|
| 52 |
+
if manuscript is None:
|
| 53 |
+
raise HTTPException(status_code=404, detail="Manuscrit introuvable")
|
| 54 |
+
|
| 55 |
+
corpus = await db.get(CorpusModel, manuscript.corpus_id)
|
| 56 |
+
if corpus is None:
|
| 57 |
+
raise HTTPException(status_code=404, detail="Corpus introuvable")
|
| 58 |
+
|
| 59 |
+
# Pages dans l'ordre de séquence
|
| 60 |
+
result = await db.execute(
|
| 61 |
+
select(PageModel)
|
| 62 |
+
.where(PageModel.manuscript_id == manuscript_id)
|
| 63 |
+
.order_by(PageModel.sequence)
|
| 64 |
+
)
|
| 65 |
+
pages = list(result.scalars().all())
|
| 66 |
+
|
| 67 |
+
masters: list[PageMaster] = []
|
| 68 |
+
for page in pages:
|
| 69 |
+
master = _read_master_json(corpus.slug, page.id)
|
| 70 |
+
if master is not None:
|
| 71 |
+
masters.append(master)
|
| 72 |
+
|
| 73 |
+
if not masters:
|
| 74 |
+
raise HTTPException(
|
| 75 |
+
status_code=404,
|
| 76 |
+
detail="Aucun master.json disponible pour ce manuscrit",
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
return manuscript, corpus, masters
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _read_master_json(corpus_slug: str, page_id: str) -> PageMaster | None:
|
| 83 |
+
"""Lit le master.json d'une page depuis data/. Retourne None si absent."""
|
| 84 |
+
path = (
|
| 85 |
+
_config_module.settings.data_dir
|
| 86 |
+
/ "corpora"
|
| 87 |
+
/ corpus_slug
|
| 88 |
+
/ "pages"
|
| 89 |
+
/ page_id
|
| 90 |
+
/ "master.json"
|
| 91 |
+
)
|
| 92 |
+
if not path.exists():
|
| 93 |
+
return None
|
| 94 |
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
| 95 |
+
return PageMaster.model_validate(raw)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _build_manuscript_meta(
|
| 99 |
+
manuscript: ManuscriptModel, corpus: CorpusModel
|
| 100 |
+
) -> dict:
|
| 101 |
+
return {
|
| 102 |
+
"manuscript_id": manuscript.id,
|
| 103 |
+
"label": manuscript.title,
|
| 104 |
+
"corpus_slug": corpus.slug,
|
| 105 |
+
"shelfmark": manuscript.shelfmark,
|
| 106 |
+
"date_label": manuscript.date_label,
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
| 111 |
+
|
| 112 |
+
@router.get("/manuscripts/{manuscript_id}/iiif-manifest")
|
| 113 |
+
async def get_iiif_manifest(
|
| 114 |
+
manuscript_id: str, db: AsyncSession = Depends(get_db)
|
| 115 |
+
) -> dict:
|
| 116 |
+
"""Génère et retourne le Manifest IIIF 3.0 du manuscrit (R02)."""
|
| 117 |
+
manuscript, corpus, masters = await _load_manuscript_with_masters(
|
| 118 |
+
manuscript_id, db
|
| 119 |
+
)
|
| 120 |
+
meta = _build_manuscript_meta(manuscript, corpus)
|
| 121 |
+
manifest = generate_manifest(
|
| 122 |
+
masters, meta, _config_module.settings.base_url
|
| 123 |
+
)
|
| 124 |
+
logger.info(
|
| 125 |
+
"Manifest IIIF servi",
|
| 126 |
+
extra={"manuscript_id": manuscript_id, "pages": len(masters)},
|
| 127 |
+
)
|
| 128 |
+
return manifest
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@router.get("/manuscripts/{manuscript_id}/mets")
|
| 132 |
+
async def get_mets(
|
| 133 |
+
manuscript_id: str, db: AsyncSession = Depends(get_db)
|
| 134 |
+
) -> Response:
|
| 135 |
+
"""Génère et retourne le METS XML du manuscrit (R02)."""
|
| 136 |
+
manuscript, corpus, masters = await _load_manuscript_with_masters(
|
| 137 |
+
manuscript_id, db
|
| 138 |
+
)
|
| 139 |
+
meta = _build_manuscript_meta(manuscript, corpus)
|
| 140 |
+
mets_xml = generate_mets(masters, meta)
|
| 141 |
+
return Response(
|
| 142 |
+
content=mets_xml,
|
| 143 |
+
media_type="application/xml; charset=utf-8",
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@router.get("/pages/{page_id}/alto")
|
| 148 |
+
async def get_alto(page_id: str, db: AsyncSession = Depends(get_db)) -> Response:
|
| 149 |
+
"""Génère et retourne l'ALTO XML d'une page (R02)."""
|
| 150 |
+
page = await db.get(PageModel, page_id)
|
| 151 |
+
if page is None:
|
| 152 |
+
raise HTTPException(status_code=404, detail="Page introuvable")
|
| 153 |
+
|
| 154 |
+
manuscript = await db.get(ManuscriptModel, page.manuscript_id)
|
| 155 |
+
corpus = await db.get(CorpusModel, manuscript.corpus_id)
|
| 156 |
+
|
| 157 |
+
master = _read_master_json(corpus.slug, page_id)
|
| 158 |
+
if master is None:
|
| 159 |
+
raise HTTPException(
|
| 160 |
+
status_code=404,
|
| 161 |
+
detail="master.json introuvable — la page n'a pas encore été analysée",
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
alto_xml = generate_alto(master)
|
| 165 |
+
return Response(
|
| 166 |
+
content=alto_xml,
|
| 167 |
+
media_type="application/xml; charset=utf-8",
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@router.get("/manuscripts/{manuscript_id}/export.zip")
|
| 172 |
+
async def get_export_zip(
|
| 173 |
+
manuscript_id: str, db: AsyncSession = Depends(get_db)
|
| 174 |
+
) -> StreamingResponse:
|
| 175 |
+
"""Génère et retourne un ZIP contenant manifest.json + mets.xml + alto par page.
|
| 176 |
+
|
| 177 |
+
Structure du ZIP :
|
| 178 |
+
manifest.json
|
| 179 |
+
mets.xml
|
| 180 |
+
alto/{page_id}.xml
|
| 181 |
+
"""
|
| 182 |
+
manuscript, corpus, masters = await _load_manuscript_with_masters(
|
| 183 |
+
manuscript_id, db
|
| 184 |
+
)
|
| 185 |
+
meta = _build_manuscript_meta(manuscript, corpus)
|
| 186 |
+
|
| 187 |
+
buf = io.BytesIO()
|
| 188 |
+
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
| 189 |
+
# manifest.json
|
| 190 |
+
manifest = generate_manifest(
|
| 191 |
+
masters, meta, _config_module.settings.base_url
|
| 192 |
+
)
|
| 193 |
+
zf.writestr(
|
| 194 |
+
"manifest.json",
|
| 195 |
+
json.dumps(manifest, ensure_ascii=False, indent=2),
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# mets.xml
|
| 199 |
+
mets_xml = generate_mets(masters, meta)
|
| 200 |
+
zf.writestr("mets.xml", mets_xml)
|
| 201 |
+
|
| 202 |
+
# alto/{page_id}.xml
|
| 203 |
+
for master in masters:
|
| 204 |
+
alto_xml = generate_alto(master)
|
| 205 |
+
zf.writestr(f"alto/{master.page_id}.xml", alto_xml)
|
| 206 |
+
|
| 207 |
+
buf.seek(0)
|
| 208 |
+
|
| 209 |
+
filename = f"{manuscript_id}.zip"
|
| 210 |
+
logger.info(
|
| 211 |
+
"Export ZIP généré",
|
| 212 |
+
extra={"manuscript_id": manuscript_id, "pages": len(masters)},
|
| 213 |
+
)
|
| 214 |
+
return StreamingResponse(
|
| 215 |
+
buf,
|
| 216 |
+
media_type="application/zip",
|
| 217 |
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
| 218 |
+
)
|
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Endpoints lecture des pages et de leur master.json (R10 — préfixe /api/v1/).
|
| 3 |
+
|
| 4 |
+
GET /api/v1/pages/{page_id}
|
| 5 |
+
GET /api/v1/pages/{page_id}/master-json
|
| 6 |
+
GET /api/v1/pages/{page_id}/layers
|
| 7 |
+
|
| 8 |
+
Règle (R02) : le master.json est la source canonique. Ces endpoints le lisent
|
| 9 |
+
depuis data/ — ils ne reconstruisent jamais une réponse depuis d'autres sources.
|
| 10 |
+
"""
|
| 11 |
+
# 1. stdlib
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
# 2. third-party
|
| 17 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 18 |
+
from pydantic import BaseModel, ConfigDict
|
| 19 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 20 |
+
|
| 21 |
+
# 3. local
|
| 22 |
+
from app import config as _config_module
|
| 23 |
+
from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
|
| 24 |
+
from app.models.database import get_db
|
| 25 |
+
from app.schemas.annotation import LayerStatus
|
| 26 |
+
from app.schemas.corpus_profile import LayerType
|
| 27 |
+
from app.schemas.page_master import PageMaster
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
router = APIRouter(prefix="/pages", tags=["pages"])
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── Schémas de réponse ────────────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
class PageResponse(BaseModel):
|
| 36 |
+
model_config = ConfigDict(from_attributes=True)
|
| 37 |
+
|
| 38 |
+
id: str
|
| 39 |
+
manuscript_id: str
|
| 40 |
+
folio_label: str
|
| 41 |
+
sequence: int
|
| 42 |
+
image_master_path: str | None
|
| 43 |
+
processing_status: str
|
| 44 |
+
confidence_summary: float | None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class LayerInfo(BaseModel):
|
| 48 |
+
layer_type: str
|
| 49 |
+
status: str
|
| 50 |
+
has_content: bool
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 54 |
+
|
| 55 |
+
async def _load_master(
|
| 56 |
+
page: PageModel,
|
| 57 |
+
db: AsyncSession,
|
| 58 |
+
) -> PageMaster | None:
|
| 59 |
+
"""Lit et valide le master.json d'une page depuis data/.
|
| 60 |
+
|
| 61 |
+
Retourne None si le fichier n'existe pas.
|
| 62 |
+
"""
|
| 63 |
+
manuscript = await db.get(ManuscriptModel, page.manuscript_id)
|
| 64 |
+
if manuscript is None:
|
| 65 |
+
return None
|
| 66 |
+
corpus = await db.get(CorpusModel, manuscript.corpus_id)
|
| 67 |
+
if corpus is None:
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
master_path = (
|
| 71 |
+
_config_module.settings.data_dir
|
| 72 |
+
/ "corpora"
|
| 73 |
+
/ corpus.slug
|
| 74 |
+
/ "pages"
|
| 75 |
+
/ page.id
|
| 76 |
+
/ "master.json"
|
| 77 |
+
)
|
| 78 |
+
if not master_path.exists():
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
raw = json.loads(master_path.read_text(encoding="utf-8"))
|
| 82 |
+
return PageMaster.model_validate(raw)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
| 86 |
+
|
| 87 |
+
@router.get("/{page_id}", response_model=PageResponse)
|
| 88 |
+
async def get_page(page_id: str, db: AsyncSession = Depends(get_db)) -> PageModel:
|
| 89 |
+
"""Retourne les métadonnées BDD d'une page."""
|
| 90 |
+
page = await db.get(PageModel, page_id)
|
| 91 |
+
if page is None:
|
| 92 |
+
raise HTTPException(status_code=404, detail="Page introuvable")
|
| 93 |
+
return page
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.get("/{page_id}/master-json")
|
| 97 |
+
async def get_master_json(
|
| 98 |
+
page_id: str, db: AsyncSession = Depends(get_db)
|
| 99 |
+
) -> dict:
|
| 100 |
+
"""Retourne le PageMaster validé par Pydantic (source canonique R02)."""
|
| 101 |
+
page = await db.get(PageModel, page_id)
|
| 102 |
+
if page is None:
|
| 103 |
+
raise HTTPException(status_code=404, detail="Page introuvable")
|
| 104 |
+
|
| 105 |
+
master = await _load_master(page, db)
|
| 106 |
+
if master is None:
|
| 107 |
+
raise HTTPException(
|
| 108 |
+
status_code=404,
|
| 109 |
+
detail="master.json introuvable — la page n'a pas encore été analysée",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return master.model_dump(mode="json")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.get("/{page_id}/layers", response_model=list[LayerInfo])
|
| 116 |
+
async def get_page_layers(
|
| 117 |
+
page_id: str, db: AsyncSession = Depends(get_db)
|
| 118 |
+
) -> list[LayerInfo]:
|
| 119 |
+
"""Liste les couches disponibles dans le master.json de la page.
|
| 120 |
+
|
| 121 |
+
Une couche est présente si le champ correspondant est non-null dans le JSON.
|
| 122 |
+
"""
|
| 123 |
+
page = await db.get(PageModel, page_id)
|
| 124 |
+
if page is None:
|
| 125 |
+
raise HTTPException(status_code=404, detail="Page introuvable")
|
| 126 |
+
|
| 127 |
+
master = await _load_master(page, db)
|
| 128 |
+
if master is None:
|
| 129 |
+
raise HTTPException(
|
| 130 |
+
status_code=404,
|
| 131 |
+
detail="master.json introuvable — la page n'a pas encore été analysée",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
layers: list[LayerInfo] = []
|
| 135 |
+
|
| 136 |
+
# Couches toujours présentes après analyse primaire
|
| 137 |
+
layers.append(
|
| 138 |
+
LayerInfo(
|
| 139 |
+
layer_type=LayerType.IMAGE.value,
|
| 140 |
+
status=LayerStatus.DONE.value,
|
| 141 |
+
has_content=bool(master.image),
|
| 142 |
+
)
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
if master.ocr is not None:
|
| 146 |
+
layers.append(
|
| 147 |
+
LayerInfo(
|
| 148 |
+
layer_type=LayerType.OCR_DIPLOMATIC.value,
|
| 149 |
+
status=LayerStatus.DONE.value,
|
| 150 |
+
has_content=bool(master.ocr.diplomatic_text),
|
| 151 |
+
)
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
if master.translation is not None:
|
| 155 |
+
if master.translation.fr:
|
| 156 |
+
layers.append(
|
| 157 |
+
LayerInfo(
|
| 158 |
+
layer_type=LayerType.TRANSLATION_FR.value,
|
| 159 |
+
status=LayerStatus.DONE.value,
|
| 160 |
+
has_content=True,
|
| 161 |
+
)
|
| 162 |
+
)
|
| 163 |
+
if master.translation.en:
|
| 164 |
+
layers.append(
|
| 165 |
+
LayerInfo(
|
| 166 |
+
layer_type=LayerType.TRANSLATION_EN.value,
|
| 167 |
+
status=LayerStatus.DONE.value,
|
| 168 |
+
has_content=True,
|
| 169 |
+
)
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
if master.summary is not None:
|
| 173 |
+
layers.append(
|
| 174 |
+
LayerInfo(
|
| 175 |
+
layer_type=LayerType.SUMMARY.value,
|
| 176 |
+
status=LayerStatus.DONE.value,
|
| 177 |
+
has_content=bool(master.summary),
|
| 178 |
+
)
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
if master.commentary is not None:
|
| 182 |
+
if master.commentary.scholarly:
|
| 183 |
+
layers.append(
|
| 184 |
+
LayerInfo(
|
| 185 |
+
layer_type=LayerType.SCHOLARLY_COMMENTARY.value,
|
| 186 |
+
status=LayerStatus.DONE.value,
|
| 187 |
+
has_content=True,
|
| 188 |
+
)
|
| 189 |
+
)
|
| 190 |
+
if master.commentary.public:
|
| 191 |
+
layers.append(
|
| 192 |
+
LayerInfo(
|
| 193 |
+
layer_type=LayerType.PUBLIC_COMMENTARY.value,
|
| 194 |
+
status=LayerStatus.DONE.value,
|
| 195 |
+
has_content=True,
|
| 196 |
+
)
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
logger.info(
|
| 200 |
+
"Couches listées",
|
| 201 |
+
extra={"page_id": page_id, "count": len(layers)},
|
| 202 |
+
)
|
| 203 |
+
return layers
|
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Endpoints de lecture des profils de corpus (R10 — préfixe /api/v1/).
|
| 3 |
+
|
| 4 |
+
GET /api/v1/profiles
|
| 5 |
+
GET /api/v1/profiles/{profile_id}
|
| 6 |
+
|
| 7 |
+
Les profils sont des fichiers JSON dans profiles/ (racine du dépôt).
|
| 8 |
+
Ils sont validés par CorpusProfile avant d'être retournés.
|
| 9 |
+
"""
|
| 10 |
+
# 1. stdlib
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
# 2. third-party
|
| 16 |
+
from fastapi import APIRouter, HTTPException
|
| 17 |
+
from pydantic import ValidationError
|
| 18 |
+
|
| 19 |
+
# 3. local
|
| 20 |
+
from app.schemas.corpus_profile import CorpusProfile
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# Répertoire des profils — relatif à la racine du dépôt
|
| 25 |
+
# (identique au chemin utilisé dans test_profiles.py)
|
| 26 |
+
_PROFILES_DIR = Path(__file__).parent.parent.parent.parent.parent / "profiles"
|
| 27 |
+
|
| 28 |
+
router = APIRouter(prefix="/profiles", tags=["profiles"])
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _load_profile(path: Path) -> CorpusProfile | None:
|
| 32 |
+
"""Charge et valide un fichier de profil JSON. Retourne None si invalide."""
|
| 33 |
+
try:
|
| 34 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 35 |
+
return CorpusProfile.model_validate(data)
|
| 36 |
+
except (json.JSONDecodeError, ValidationError) as exc:
|
| 37 |
+
logger.warning("Profil invalide ignoré", extra={"path": str(path), "error": str(exc)})
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("", response_model=list[dict])
|
| 42 |
+
async def list_profiles() -> list[dict]:
|
| 43 |
+
"""Retourne tous les profils valides du dossier profiles/."""
|
| 44 |
+
if not _PROFILES_DIR.is_dir():
|
| 45 |
+
return []
|
| 46 |
+
profiles = []
|
| 47 |
+
for path in sorted(_PROFILES_DIR.glob("*.json")):
|
| 48 |
+
profile = _load_profile(path)
|
| 49 |
+
if profile is not None:
|
| 50 |
+
profiles.append(profile.model_dump())
|
| 51 |
+
return profiles
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.get("/{profile_id}", response_model=dict)
|
| 55 |
+
async def get_profile(profile_id: str) -> dict:
|
| 56 |
+
"""Retourne un profil par son id (nom du fichier sans extension)."""
|
| 57 |
+
path = _PROFILES_DIR / f"{profile_id}.json"
|
| 58 |
+
if not path.exists():
|
| 59 |
+
raise HTTPException(status_code=404, detail="Profil introuvable")
|
| 60 |
+
profile = _load_profile(path)
|
| 61 |
+
if profile is None:
|
| 62 |
+
raise HTTPException(status_code=422, detail="Profil invalide")
|
| 63 |
+
return profile.model_dump()
|
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration globale de la plateforme, chargée depuis les variables d'environnement.
|
| 3 |
+
|
| 4 |
+
Équivalent fonctionnel de pydantic-settings sans dépendance externe :
|
| 5 |
+
- les valeurs sont lues depuis os.environ au moment de l'instanciation
|
| 6 |
+
- l'objet `settings` est importé partout dans l'application
|
| 7 |
+
- dans les tests : monkeypatch.setattr(config, "settings", ...) pour surcharger
|
| 8 |
+
"""
|
| 9 |
+
# 1. stdlib
|
| 10 |
+
import os
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# 2. third-party
|
| 14 |
+
from pydantic import BaseModel, ConfigDict
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Settings(BaseModel):
|
| 18 |
+
"""Paramètres d'application lus depuis les variables d'environnement.
|
| 19 |
+
|
| 20 |
+
Toutes les clés API sont optionnelles (None si non configurées).
|
| 21 |
+
Elles ne sont jamais loguées ni exportées (R06).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
model_config = ConfigDict(frozen=False)
|
| 25 |
+
|
| 26 |
+
# ── Serveur ──────────────────────────────────────────────────────────────
|
| 27 |
+
base_url: str = "http://localhost:8000"
|
| 28 |
+
data_dir: Path = Path("data")
|
| 29 |
+
|
| 30 |
+
# ── Base de données ───────────────────────────────────────────────────────
|
| 31 |
+
database_url: str = "sqlite+aiosqlite:///./scriptorium.db"
|
| 32 |
+
|
| 33 |
+
# ── Fournisseurs IA (R06 — clés depuis l'environnement uniquement) ────────
|
| 34 |
+
google_ai_studio_api_key: str | None = None
|
| 35 |
+
vertex_api_key: str | None = None
|
| 36 |
+
vertex_service_account_json: str | None = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _load_settings() -> Settings:
|
| 40 |
+
"""Lit les variables d'environnement et construit l'objet Settings."""
|
| 41 |
+
return Settings(
|
| 42 |
+
base_url=os.getenv("BASE_URL", "http://localhost:8000"),
|
| 43 |
+
data_dir=Path(os.getenv("DATA_DIR", "data")),
|
| 44 |
+
database_url=os.getenv(
|
| 45 |
+
"DATABASE_URL", "sqlite+aiosqlite:///./scriptorium.db"
|
| 46 |
+
),
|
| 47 |
+
google_ai_studio_api_key=os.getenv("GOOGLE_AI_STUDIO_API_KEY"),
|
| 48 |
+
vertex_api_key=os.getenv("VERTEX_API_KEY"),
|
| 49 |
+
vertex_service_account_json=os.getenv("VERTEX_SERVICE_ACCOUNT_JSON"),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
settings: Settings = _load_settings()
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Application FastAPI — point d'entrée de Scriptorium AI.
|
| 3 |
+
|
| 4 |
+
Tous les endpoints sont sous /api/v1/ (R10).
|
| 5 |
+
CORS ouvert pour le développement local (origins=["*"]).
|
| 6 |
+
La BDD SQLite est créée automatiquement au démarrage (lifespan).
|
| 7 |
+
"""
|
| 8 |
+
# 1. stdlib
|
| 9 |
+
import logging
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
|
| 12 |
+
# 2. third-party
|
| 13 |
+
from fastapi import FastAPI
|
| 14 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 15 |
+
|
| 16 |
+
# 3. local — on importe les modèles pour que Base.metadata les connaisse
|
| 17 |
+
import app.models # noqa: F401 (enregistrement des modèles SQLAlchemy)
|
| 18 |
+
from app.api.v1 import corpora, export, pages, profiles
|
| 19 |
+
from app.models.database import Base, engine
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@asynccontextmanager
|
| 25 |
+
async def lifespan(application: FastAPI):
|
| 26 |
+
"""Crée les tables SQLite au démarrage, libère l'engine à l'arrêt."""
|
| 27 |
+
async with engine.begin() as conn:
|
| 28 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 29 |
+
logger.info("Tables SQLite initialisées")
|
| 30 |
+
yield
|
| 31 |
+
await engine.dispose()
|
| 32 |
+
logger.info("Engine SQLite fermé")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
app = FastAPI(
|
| 36 |
+
title="Scriptorium AI",
|
| 37 |
+
description="Plateforme générique de génération d'éditions savantes augmentées",
|
| 38 |
+
version="0.1.0",
|
| 39 |
+
lifespan=lifespan,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# ── CORS (dev : tous les origines autorisés) ──────────────────────────────────
|
| 43 |
+
app.add_middleware(
|
| 44 |
+
CORSMiddleware,
|
| 45 |
+
allow_origins=["*"],
|
| 46 |
+
allow_credentials=True,
|
| 47 |
+
allow_methods=["*"],
|
| 48 |
+
allow_headers=["*"],
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# ── Routers (préfixe /api/v1/ — R10) ─────────────────────────────────────────
|
| 52 |
+
_V1_PREFIX = "/api/v1"
|
| 53 |
+
|
| 54 |
+
app.include_router(corpora.router, prefix=_V1_PREFIX)
|
| 55 |
+
app.include_router(pages.router, prefix=_V1_PREFIX)
|
| 56 |
+
app.include_router(export.router, prefix=_V1_PREFIX)
|
| 57 |
+
app.include_router(profiles.router, prefix=_V1_PREFIX)
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modèles SQLAlchemy — importés ici pour que Base.metadata les connaisse
|
| 3 |
+
au moment de la création des tables (Base.metadata.create_all).
|
| 4 |
+
"""
|
| 5 |
+
from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
|
| 6 |
+
|
| 7 |
+
__all__ = ["CorpusModel", "ManuscriptModel", "PageModel"]
|
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modèles SQLAlchemy 2.0 — tables Corpus, Manuscript, Page.
|
| 3 |
+
|
| 4 |
+
Ces modèles représentent la couche de persistance (BDD).
|
| 5 |
+
Ils NE se substituent PAS aux schémas Pydantic (source canonique des types).
|
| 6 |
+
"""
|
| 7 |
+
# 1. stdlib
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
|
| 10 |
+
# 2. third-party
|
| 11 |
+
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
|
| 12 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 13 |
+
|
| 14 |
+
# 3. local
|
| 15 |
+
from app.models.database import Base
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class CorpusModel(Base):
|
| 19 |
+
"""Un corpus regroupe un ou plusieurs manuscrits sous un même profil."""
|
| 20 |
+
|
| 21 |
+
__tablename__ = "corpora"
|
| 22 |
+
|
| 23 |
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
| 24 |
+
slug: Mapped[str] = mapped_column(String, unique=True, nullable=False, index=True)
|
| 25 |
+
title: Mapped[str] = mapped_column(String, nullable=False)
|
| 26 |
+
profile_id: Mapped[str] = mapped_column(String, nullable=False)
|
| 27 |
+
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
| 28 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
| 29 |
+
|
| 30 |
+
manuscripts: Mapped[list["ManuscriptModel"]] = relationship(
|
| 31 |
+
back_populates="corpus", cascade="all, delete-orphan"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ManuscriptModel(Base):
|
| 36 |
+
"""Un manuscrit appartient à un corpus et contient des pages."""
|
| 37 |
+
|
| 38 |
+
__tablename__ = "manuscripts"
|
| 39 |
+
|
| 40 |
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
| 41 |
+
corpus_id: Mapped[str] = mapped_column(
|
| 42 |
+
String, ForeignKey("corpora.id"), nullable=False, index=True
|
| 43 |
+
)
|
| 44 |
+
shelfmark: Mapped[str | None] = mapped_column(String, nullable=True)
|
| 45 |
+
title: Mapped[str] = mapped_column(String, nullable=False)
|
| 46 |
+
date_label: Mapped[str | None] = mapped_column(String, nullable=True)
|
| 47 |
+
total_pages: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
| 48 |
+
|
| 49 |
+
corpus: Mapped["CorpusModel"] = relationship(back_populates="manuscripts")
|
| 50 |
+
pages: Mapped[list["PageModel"]] = relationship(
|
| 51 |
+
back_populates="manuscript", cascade="all, delete-orphan"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class PageModel(Base):
|
| 56 |
+
"""Une page appartient à un manuscrit.
|
| 57 |
+
|
| 58 |
+
processing_status suit le cycle de vie défini en CLAUDE.md §10 :
|
| 59 |
+
CREATED → INGESTING → INGESTED → PREPARED → ANALYZED → LAYERED
|
| 60 |
+
→ EXPORTED → VALIDATED → ERROR
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
__tablename__ = "pages"
|
| 64 |
+
|
| 65 |
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
| 66 |
+
manuscript_id: Mapped[str] = mapped_column(
|
| 67 |
+
String, ForeignKey("manuscripts.id"), nullable=False, index=True
|
| 68 |
+
)
|
| 69 |
+
folio_label: Mapped[str] = mapped_column(String, nullable=False)
|
| 70 |
+
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 71 |
+
image_master_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 72 |
+
processing_status: Mapped[str] = mapped_column(
|
| 73 |
+
String, nullable=False, default="CREATED"
|
| 74 |
+
)
|
| 75 |
+
confidence_summary: Mapped[float | None] = mapped_column(Float, nullable=True)
|
| 76 |
+
|
| 77 |
+
manuscript: Mapped["ManuscriptModel"] = relationship(back_populates="pages")
|
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Engine SQLAlchemy async (aiosqlite) et session factory.
|
| 3 |
+
|
| 4 |
+
Utilisation dans les endpoints FastAPI :
|
| 5 |
+
async def my_endpoint(db: AsyncSession = Depends(get_db)):
|
| 6 |
+
...
|
| 7 |
+
|
| 8 |
+
Les tables sont créées au démarrage de l'application (voir main.py lifespan).
|
| 9 |
+
"""
|
| 10 |
+
# 1. stdlib
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
# 2. third-party
|
| 14 |
+
from sqlalchemy.ext.asyncio import (
|
| 15 |
+
AsyncSession,
|
| 16 |
+
async_sessionmaker,
|
| 17 |
+
create_async_engine,
|
| 18 |
+
)
|
| 19 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 20 |
+
|
| 21 |
+
# 3. local
|
| 22 |
+
from app.config import settings
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
engine = create_async_engine(
|
| 27 |
+
settings.database_url,
|
| 28 |
+
echo=False,
|
| 29 |
+
connect_args={"check_same_thread": False},
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
async_session_factory = async_sessionmaker(
|
| 33 |
+
engine,
|
| 34 |
+
expire_on_commit=False,
|
| 35 |
+
class_=AsyncSession,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class Base(DeclarativeBase):
|
| 40 |
+
pass
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def get_db():
|
| 44 |
+
"""Dépendance FastAPI — injecte une AsyncSession par requête."""
|
| 45 |
+
async with async_session_factory() as session:
|
| 46 |
+
yield session
|
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fixtures partagées pour les tests d'API FastAPI (Sprint 4 — Session A).
|
| 3 |
+
|
| 4 |
+
Usage dans les fichiers de test :
|
| 5 |
+
from tests.conftest_api import async_client, db_session # noqa: F401
|
| 6 |
+
|
| 7 |
+
Stratégie :
|
| 8 |
+
- BDD SQLite en mémoire (":memory:") — isolation totale entre tests
|
| 9 |
+
- Dependency override de `get_db` pour injecter la session de test
|
| 10 |
+
- `httpx.AsyncClient` + `ASGITransport` pour simuler des requêtes HTTP
|
| 11 |
+
"""
|
| 12 |
+
# 1. stdlib
|
| 13 |
+
import pytest
|
| 14 |
+
import pytest_asyncio
|
| 15 |
+
|
| 16 |
+
# 2. third-party
|
| 17 |
+
from httpx import ASGITransport, AsyncClient
|
| 18 |
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 19 |
+
|
| 20 |
+
# 3. local
|
| 21 |
+
import app.models # noqa: F401 — enregistrement des modèles dans Base.metadata
|
| 22 |
+
from app.models.database import Base, get_db
|
| 23 |
+
|
| 24 |
+
_TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@pytest_asyncio.fixture
|
| 28 |
+
async def db_session():
|
| 29 |
+
"""Session AsyncSession sur une BDD SQLite en mémoire."""
|
| 30 |
+
test_engine = create_async_engine(_TEST_DB_URL, echo=False)
|
| 31 |
+
async with test_engine.begin() as conn:
|
| 32 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 33 |
+
|
| 34 |
+
session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
|
| 35 |
+
async with session_factory() as session:
|
| 36 |
+
yield session
|
| 37 |
+
|
| 38 |
+
async with test_engine.begin() as conn:
|
| 39 |
+
await conn.run_sync(Base.metadata.drop_all)
|
| 40 |
+
await test_engine.dispose()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@pytest_asyncio.fixture
|
| 44 |
+
async def async_client(db_session: AsyncSession):
|
| 45 |
+
"""AsyncClient HTTP connecté à l'app FastAPI, avec BDD de test injectée."""
|
| 46 |
+
from app.main import app
|
| 47 |
+
|
| 48 |
+
async def _override_get_db():
|
| 49 |
+
yield db_session
|
| 50 |
+
|
| 51 |
+
app.dependency_overrides[get_db] = _override_get_db
|
| 52 |
+
async with AsyncClient(
|
| 53 |
+
transport=ASGITransport(app=app), base_url="http://test"
|
| 54 |
+
) as client:
|
| 55 |
+
yield client
|
| 56 |
+
app.dependency_overrides.clear()
|
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests des endpoints CRUD /api/v1/corpora (Sprint 4 — Session A).
|
| 3 |
+
|
| 4 |
+
Vérifie :
|
| 5 |
+
- GET /api/v1/corpora → liste vide puis liste peuplée
|
| 6 |
+
- POST /api/v1/corpora → 201 + corps de réponse complet
|
| 7 |
+
- POST /api/v1/corpora (slug dupliqué) → 409
|
| 8 |
+
- GET /api/v1/corpora/{id} → 200 ou 404
|
| 9 |
+
- DELETE /api/v1/corpora/{id} → 204 ou 404
|
| 10 |
+
- Champs manquants dans le POST body → 422 automatique Pydantic
|
| 11 |
+
"""
|
| 12 |
+
# 1. stdlib
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
# 3. local
|
| 16 |
+
from tests.conftest_api import async_client, db_session # noqa: F401
|
| 17 |
+
|
| 18 |
+
_CORPUS_PAYLOAD = {
|
| 19 |
+
"slug": "beatus-lat8878",
|
| 20 |
+
"title": "Beatus de Saint-Sever",
|
| 21 |
+
"profile_id": "medieval-illuminated",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# GET /api/v1/corpora
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
@pytest.mark.asyncio
|
| 30 |
+
async def test_list_corpora_empty(async_client):
|
| 31 |
+
response = await async_client.get("/api/v1/corpora")
|
| 32 |
+
assert response.status_code == 200
|
| 33 |
+
assert response.json() == []
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.mark.asyncio
|
| 37 |
+
async def test_list_corpora_after_create(async_client):
|
| 38 |
+
await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 39 |
+
response = await async_client.get("/api/v1/corpora")
|
| 40 |
+
assert response.status_code == 200
|
| 41 |
+
corpora = response.json()
|
| 42 |
+
assert len(corpora) == 1
|
| 43 |
+
assert corpora[0]["slug"] == "beatus-lat8878"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@pytest.mark.asyncio
|
| 47 |
+
async def test_list_corpora_multiple(async_client):
|
| 48 |
+
await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 49 |
+
await async_client.post(
|
| 50 |
+
"/api/v1/corpora",
|
| 51 |
+
json={"slug": "grandes-chroniques", "title": "Grandes Chroniques", "profile_id": "medieval-textual"},
|
| 52 |
+
)
|
| 53 |
+
response = await async_client.get("/api/v1/corpora")
|
| 54 |
+
assert len(response.json()) == 2
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
# POST /api/v1/corpora
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
|
| 61 |
+
@pytest.mark.asyncio
|
| 62 |
+
async def test_create_corpus_status_201(async_client):
|
| 63 |
+
response = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 64 |
+
assert response.status_code == 201
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@pytest.mark.asyncio
|
| 68 |
+
async def test_create_corpus_response_fields(async_client):
|
| 69 |
+
response = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 70 |
+
data = response.json()
|
| 71 |
+
assert "id" in data
|
| 72 |
+
assert data["slug"] == "beatus-lat8878"
|
| 73 |
+
assert data["title"] == "Beatus de Saint-Sever"
|
| 74 |
+
assert data["profile_id"] == "medieval-illuminated"
|
| 75 |
+
assert "created_at" in data
|
| 76 |
+
assert "updated_at" in data
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@pytest.mark.asyncio
|
| 80 |
+
async def test_create_corpus_id_is_uuid(async_client):
|
| 81 |
+
response = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 82 |
+
corpus_id = response.json()["id"]
|
| 83 |
+
# UUID v4 — 36 caractères avec tirets
|
| 84 |
+
assert len(corpus_id) == 36
|
| 85 |
+
assert corpus_id.count("-") == 4
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@pytest.mark.asyncio
|
| 89 |
+
async def test_create_corpus_duplicate_slug_409(async_client):
|
| 90 |
+
await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 91 |
+
response = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 92 |
+
assert response.status_code == 409
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@pytest.mark.asyncio
|
| 96 |
+
async def test_create_corpus_missing_slug_422(async_client):
|
| 97 |
+
response = await async_client.post(
|
| 98 |
+
"/api/v1/corpora", json={"title": "X", "profile_id": "medieval-illuminated"}
|
| 99 |
+
)
|
| 100 |
+
assert response.status_code == 422
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@pytest.mark.asyncio
|
| 104 |
+
async def test_create_corpus_missing_title_422(async_client):
|
| 105 |
+
response = await async_client.post(
|
| 106 |
+
"/api/v1/corpora", json={"slug": "x", "profile_id": "medieval-illuminated"}
|
| 107 |
+
)
|
| 108 |
+
assert response.status_code == 422
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@pytest.mark.asyncio
|
| 112 |
+
async def test_create_corpus_missing_profile_id_422(async_client):
|
| 113 |
+
response = await async_client.post(
|
| 114 |
+
"/api/v1/corpora", json={"slug": "x", "title": "X"}
|
| 115 |
+
)
|
| 116 |
+
assert response.status_code == 422
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
# GET /api/v1/corpora/{id}
|
| 121 |
+
# ---------------------------------------------------------------------------
|
| 122 |
+
|
| 123 |
+
@pytest.mark.asyncio
|
| 124 |
+
async def test_get_corpus_ok(async_client):
|
| 125 |
+
create_resp = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 126 |
+
corpus_id = create_resp.json()["id"]
|
| 127 |
+
|
| 128 |
+
response = await async_client.get(f"/api/v1/corpora/{corpus_id}")
|
| 129 |
+
assert response.status_code == 200
|
| 130 |
+
assert response.json()["id"] == corpus_id
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@pytest.mark.asyncio
|
| 134 |
+
async def test_get_corpus_fields(async_client):
|
| 135 |
+
create_resp = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 136 |
+
corpus_id = create_resp.json()["id"]
|
| 137 |
+
data = (await async_client.get(f"/api/v1/corpora/{corpus_id}")).json()
|
| 138 |
+
|
| 139 |
+
assert data["slug"] == "beatus-lat8878"
|
| 140 |
+
assert data["title"] == "Beatus de Saint-Sever"
|
| 141 |
+
assert data["profile_id"] == "medieval-illuminated"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@pytest.mark.asyncio
|
| 145 |
+
async def test_get_corpus_not_found(async_client):
|
| 146 |
+
response = await async_client.get("/api/v1/corpora/nonexistent-id")
|
| 147 |
+
assert response.status_code == 404
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@pytest.mark.asyncio
|
| 151 |
+
async def test_get_corpus_not_found_detail(async_client):
|
| 152 |
+
response = await async_client.get("/api/v1/corpora/unknown")
|
| 153 |
+
assert "introuvable" in response.json()["detail"].lower()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
# DELETE /api/v1/corpora/{id}
|
| 158 |
+
# ---------------------------------------------------------------------------
|
| 159 |
+
|
| 160 |
+
@pytest.mark.asyncio
|
| 161 |
+
async def test_delete_corpus_204(async_client):
|
| 162 |
+
create_resp = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 163 |
+
corpus_id = create_resp.json()["id"]
|
| 164 |
+
|
| 165 |
+
response = await async_client.delete(f"/api/v1/corpora/{corpus_id}")
|
| 166 |
+
assert response.status_code == 204
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@pytest.mark.asyncio
|
| 170 |
+
async def test_delete_corpus_disappears_from_list(async_client):
|
| 171 |
+
create_resp = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 172 |
+
corpus_id = create_resp.json()["id"]
|
| 173 |
+
|
| 174 |
+
await async_client.delete(f"/api/v1/corpora/{corpus_id}")
|
| 175 |
+
list_resp = await async_client.get("/api/v1/corpora")
|
| 176 |
+
assert list_resp.json() == []
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@pytest.mark.asyncio
|
| 180 |
+
async def test_delete_corpus_makes_get_404(async_client):
|
| 181 |
+
create_resp = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 182 |
+
corpus_id = create_resp.json()["id"]
|
| 183 |
+
|
| 184 |
+
await async_client.delete(f"/api/v1/corpora/{corpus_id}")
|
| 185 |
+
get_resp = await async_client.get(f"/api/v1/corpora/{corpus_id}")
|
| 186 |
+
assert get_resp.status_code == 404
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@pytest.mark.asyncio
|
| 190 |
+
async def test_delete_corpus_not_found(async_client):
|
| 191 |
+
response = await async_client.delete("/api/v1/corpora/nonexistent")
|
| 192 |
+
assert response.status_code == 404
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@pytest.mark.asyncio
|
| 196 |
+
async def test_delete_only_target_corpus(async_client):
|
| 197 |
+
"""Supprimer un corpus ne supprime pas les autres."""
|
| 198 |
+
r1 = await async_client.post("/api/v1/corpora", json=_CORPUS_PAYLOAD)
|
| 199 |
+
r2 = await async_client.post(
|
| 200 |
+
"/api/v1/corpora",
|
| 201 |
+
json={"slug": "other", "title": "Other", "profile_id": "medieval-textual"},
|
| 202 |
+
)
|
| 203 |
+
await async_client.delete(f"/api/v1/corpora/{r1.json()['id']}")
|
| 204 |
+
|
| 205 |
+
list_resp = await async_client.get("/api/v1/corpora")
|
| 206 |
+
remaining = list_resp.json()
|
| 207 |
+
assert len(remaining) == 1
|
| 208 |
+
assert remaining[0]["id"] == r2.json()["id"]
|
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests des endpoints d'export /api/v1/ (Sprint 4 — Session A).
|
| 3 |
+
|
| 4 |
+
Stratégie :
|
| 5 |
+
- BDD en mémoire avec corpus / manuscrit / pages créés directement
|
| 6 |
+
- master.json mockés via monkeypatch sur Path.exists + Path.read_text
|
| 7 |
+
- Vérifie : 200, 404, type de contenu, structure JSON/XML/ZIP
|
| 8 |
+
|
| 9 |
+
Tests :
|
| 10 |
+
- GET /api/v1/manuscripts/{id}/iiif-manifest → JSON IIIF 3.0 ou 404
|
| 11 |
+
- GET /api/v1/manuscripts/{id}/mets → XML ou 404
|
| 12 |
+
- GET /api/v1/pages/{id}/alto → XML ou 404
|
| 13 |
+
- GET /api/v1/manuscripts/{id}/export.zip → ZIP ou 404
|
| 14 |
+
"""
|
| 15 |
+
# 1. stdlib
|
| 16 |
+
import io
|
| 17 |
+
import json
|
| 18 |
+
import uuid
|
| 19 |
+
import zipfile
|
| 20 |
+
from datetime import datetime, timezone
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
# 2. third-party
|
| 24 |
+
import pytest
|
| 25 |
+
|
| 26 |
+
# 3. local
|
| 27 |
+
from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
|
| 28 |
+
from tests.conftest_api import async_client, db_session # noqa: F401
|
| 29 |
+
|
| 30 |
+
_NOW = datetime.now(timezone.utc)
|
| 31 |
+
_IIIF_CONTEXT = "http://iiif.io/api/presentation/3/context.json"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
# Helpers
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
async def _populate_db(db_session, n_pages: int = 2):
|
| 39 |
+
"""Crée corpus + manuscrit + n pages dans la BDD de test."""
|
| 40 |
+
corpus = CorpusModel(
|
| 41 |
+
id=str(uuid.uuid4()),
|
| 42 |
+
slug="test-ms",
|
| 43 |
+
title="Test Corpus",
|
| 44 |
+
profile_id="medieval-illuminated",
|
| 45 |
+
created_at=_NOW,
|
| 46 |
+
updated_at=_NOW,
|
| 47 |
+
)
|
| 48 |
+
db_session.add(corpus)
|
| 49 |
+
|
| 50 |
+
ms = ManuscriptModel(
|
| 51 |
+
id=str(uuid.uuid4()),
|
| 52 |
+
corpus_id=corpus.id,
|
| 53 |
+
title="Test Manuscript",
|
| 54 |
+
shelfmark="Latin 0001",
|
| 55 |
+
date_label="XIIe siècle",
|
| 56 |
+
total_pages=n_pages,
|
| 57 |
+
)
|
| 58 |
+
db_session.add(ms)
|
| 59 |
+
|
| 60 |
+
pages = []
|
| 61 |
+
for i in range(1, n_pages + 1):
|
| 62 |
+
p = PageModel(
|
| 63 |
+
id=f"test-ms-f{i:03d}r",
|
| 64 |
+
manuscript_id=ms.id,
|
| 65 |
+
folio_label=f"f{i:03d}r",
|
| 66 |
+
sequence=i,
|
| 67 |
+
image_master_path=f"/data/master/f{i:03d}r.tif",
|
| 68 |
+
processing_status="ANALYZED",
|
| 69 |
+
)
|
| 70 |
+
db_session.add(p)
|
| 71 |
+
pages.append(p)
|
| 72 |
+
|
| 73 |
+
await db_session.commit()
|
| 74 |
+
return corpus, ms, pages
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _make_master_json(page_id: str, folio_label: str, sequence: int) -> str:
|
| 78 |
+
return json.dumps({
|
| 79 |
+
"schema_version": "1.0",
|
| 80 |
+
"page_id": page_id,
|
| 81 |
+
"corpus_profile": "medieval-illuminated",
|
| 82 |
+
"manuscript_id": "test-ms",
|
| 83 |
+
"folio_label": folio_label,
|
| 84 |
+
"sequence": sequence,
|
| 85 |
+
"image": {
|
| 86 |
+
"original_url": f"https://example.com/{page_id}.jpg",
|
| 87 |
+
"derivative_web": f"/data/deriv/{page_id}.jpg",
|
| 88 |
+
"thumbnail": f"/data/thumb/{page_id}.jpg",
|
| 89 |
+
"width": 1500,
|
| 90 |
+
"height": 2000,
|
| 91 |
+
},
|
| 92 |
+
"layout": {"regions": []},
|
| 93 |
+
"ocr": {
|
| 94 |
+
"diplomatic_text": "Text content",
|
| 95 |
+
"blocks": [],
|
| 96 |
+
"lines": [],
|
| 97 |
+
"language": "la",
|
| 98 |
+
"confidence": 0.9,
|
| 99 |
+
"uncertain_segments": [],
|
| 100 |
+
},
|
| 101 |
+
"editorial": {
|
| 102 |
+
"status": "machine_draft",
|
| 103 |
+
"validated": False,
|
| 104 |
+
"validated_by": None,
|
| 105 |
+
"version": 1,
|
| 106 |
+
"notes": [],
|
| 107 |
+
},
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _mock_master_files(monkeypatch, pages):
|
| 112 |
+
"""Patche Path.exists / Path.read_text pour simuler les master.json."""
|
| 113 |
+
master_data = {
|
| 114 |
+
p.id: _make_master_json(p.id, p.folio_label, p.sequence)
|
| 115 |
+
for p in pages
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
def fake_exists(self: Path) -> bool:
|
| 119 |
+
return any(p_id in str(self) for p_id in master_data)
|
| 120 |
+
|
| 121 |
+
def fake_read_text(self: Path, **kwargs) -> str:
|
| 122 |
+
for p_id, data in master_data.items():
|
| 123 |
+
if p_id in str(self):
|
| 124 |
+
return data
|
| 125 |
+
raise FileNotFoundError(str(self))
|
| 126 |
+
|
| 127 |
+
monkeypatch.setattr(Path, "exists", fake_exists)
|
| 128 |
+
monkeypatch.setattr(Path, "read_text", fake_read_text)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
# GET /api/v1/manuscripts/{id}/iiif-manifest
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
|
| 135 |
+
@pytest.mark.asyncio
|
| 136 |
+
async def test_iiif_manifest_not_found(async_client):
|
| 137 |
+
response = await async_client.get("/api/v1/manuscripts/unknown/iiif-manifest")
|
| 138 |
+
assert response.status_code == 404
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@pytest.mark.asyncio
|
| 142 |
+
async def test_iiif_manifest_no_master_files(async_client, db_session, monkeypatch):
|
| 143 |
+
_, ms, _ = await _populate_db(db_session, n_pages=1)
|
| 144 |
+
monkeypatch.setattr(Path, "exists", lambda self: False)
|
| 145 |
+
|
| 146 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/iiif-manifest")
|
| 147 |
+
assert response.status_code == 404
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@pytest.mark.asyncio
|
| 151 |
+
async def test_iiif_manifest_ok(async_client, db_session, monkeypatch):
|
| 152 |
+
_, ms, pages = await _populate_db(db_session, n_pages=2)
|
| 153 |
+
_mock_master_files(monkeypatch, pages)
|
| 154 |
+
|
| 155 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/iiif-manifest")
|
| 156 |
+
assert response.status_code == 200
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@pytest.mark.asyncio
|
| 160 |
+
async def test_iiif_manifest_context(async_client, db_session, monkeypatch):
|
| 161 |
+
_, ms, pages = await _populate_db(db_session, n_pages=2)
|
| 162 |
+
_mock_master_files(monkeypatch, pages)
|
| 163 |
+
|
| 164 |
+
data = (await async_client.get(f"/api/v1/manuscripts/{ms.id}/iiif-manifest")).json()
|
| 165 |
+
assert data["@context"] == _IIIF_CONTEXT
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@pytest.mark.asyncio
|
| 169 |
+
async def test_iiif_manifest_type(async_client, db_session, monkeypatch):
|
| 170 |
+
_, ms, pages = await _populate_db(db_session, n_pages=2)
|
| 171 |
+
_mock_master_files(monkeypatch, pages)
|
| 172 |
+
|
| 173 |
+
data = (await async_client.get(f"/api/v1/manuscripts/{ms.id}/iiif-manifest")).json()
|
| 174 |
+
assert data["type"] == "Manifest"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@pytest.mark.asyncio
|
| 178 |
+
async def test_iiif_manifest_canvas_count(async_client, db_session, monkeypatch):
|
| 179 |
+
_, ms, pages = await _populate_db(db_session, n_pages=2)
|
| 180 |
+
_mock_master_files(monkeypatch, pages)
|
| 181 |
+
|
| 182 |
+
data = (await async_client.get(f"/api/v1/manuscripts/{ms.id}/iiif-manifest")).json()
|
| 183 |
+
assert len(data["items"]) == 2
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@pytest.mark.asyncio
|
| 187 |
+
async def test_iiif_manifest_id_contains_base_url(async_client, db_session, monkeypatch):
|
| 188 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 189 |
+
_mock_master_files(monkeypatch, pages)
|
| 190 |
+
|
| 191 |
+
data = (await async_client.get(f"/api/v1/manuscripts/{ms.id}/iiif-manifest")).json()
|
| 192 |
+
assert data["id"].startswith("http")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ---------------------------------------------------------------------------
|
| 196 |
+
# GET /api/v1/manuscripts/{id}/mets
|
| 197 |
+
# ---------------------------------------------------------------------------
|
| 198 |
+
|
| 199 |
+
@pytest.mark.asyncio
|
| 200 |
+
async def test_mets_not_found(async_client):
|
| 201 |
+
response = await async_client.get("/api/v1/manuscripts/unknown/mets")
|
| 202 |
+
assert response.status_code == 404
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@pytest.mark.asyncio
|
| 206 |
+
async def test_mets_ok(async_client, db_session, monkeypatch):
|
| 207 |
+
_, ms, pages = await _populate_db(db_session, n_pages=2)
|
| 208 |
+
_mock_master_files(monkeypatch, pages)
|
| 209 |
+
|
| 210 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/mets")
|
| 211 |
+
assert response.status_code == 200
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@pytest.mark.asyncio
|
| 215 |
+
async def test_mets_content_type_xml(async_client, db_session, monkeypatch):
|
| 216 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 217 |
+
_mock_master_files(monkeypatch, pages)
|
| 218 |
+
|
| 219 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/mets")
|
| 220 |
+
assert "xml" in response.headers["content-type"]
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@pytest.mark.asyncio
|
| 224 |
+
async def test_mets_body_starts_with_xml(async_client, db_session, monkeypatch):
|
| 225 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 226 |
+
_mock_master_files(monkeypatch, pages)
|
| 227 |
+
|
| 228 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/mets")
|
| 229 |
+
assert response.text.lstrip().startswith("<?xml")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@pytest.mark.asyncio
|
| 233 |
+
async def test_mets_contains_mets_root(async_client, db_session, monkeypatch):
|
| 234 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 235 |
+
_mock_master_files(monkeypatch, pages)
|
| 236 |
+
|
| 237 |
+
text = (await async_client.get(f"/api/v1/manuscripts/{ms.id}/mets")).text
|
| 238 |
+
assert "mets" in text.lower()
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ---------------------------------------------------------------------------
|
| 242 |
+
# GET /api/v1/pages/{id}/alto
|
| 243 |
+
# ---------------------------------------------------------------------------
|
| 244 |
+
|
| 245 |
+
@pytest.mark.asyncio
|
| 246 |
+
async def test_alto_not_found(async_client):
|
| 247 |
+
response = await async_client.get("/api/v1/pages/unknown-page/alto")
|
| 248 |
+
assert response.status_code == 404
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
@pytest.mark.asyncio
|
| 252 |
+
async def test_alto_no_master_file(async_client, db_session, monkeypatch):
|
| 253 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 254 |
+
monkeypatch.setattr(Path, "exists", lambda self: False)
|
| 255 |
+
|
| 256 |
+
response = await async_client.get(f"/api/v1/pages/{pages[0].id}/alto")
|
| 257 |
+
assert response.status_code == 404
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@pytest.mark.asyncio
|
| 261 |
+
async def test_alto_ok(async_client, db_session, monkeypatch):
|
| 262 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 263 |
+
_mock_master_files(monkeypatch, pages)
|
| 264 |
+
|
| 265 |
+
response = await async_client.get(f"/api/v1/pages/{pages[0].id}/alto")
|
| 266 |
+
assert response.status_code == 200
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
@pytest.mark.asyncio
|
| 270 |
+
async def test_alto_content_type_xml(async_client, db_session, monkeypatch):
|
| 271 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 272 |
+
_mock_master_files(monkeypatch, pages)
|
| 273 |
+
|
| 274 |
+
response = await async_client.get(f"/api/v1/pages/{pages[0].id}/alto")
|
| 275 |
+
assert "xml" in response.headers["content-type"]
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
@pytest.mark.asyncio
|
| 279 |
+
async def test_alto_body_is_xml(async_client, db_session, monkeypatch):
|
| 280 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 281 |
+
_mock_master_files(monkeypatch, pages)
|
| 282 |
+
|
| 283 |
+
response = await async_client.get(f"/api/v1/pages/{pages[0].id}/alto")
|
| 284 |
+
assert response.text.lstrip().startswith("<?xml")
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@pytest.mark.asyncio
|
| 288 |
+
async def test_alto_contains_alto_element(async_client, db_session, monkeypatch):
|
| 289 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 290 |
+
_mock_master_files(monkeypatch, pages)
|
| 291 |
+
|
| 292 |
+
text = (await async_client.get(f"/api/v1/pages/{pages[0].id}/alto")).text
|
| 293 |
+
assert "alto" in text.lower() or "ALTO" in text
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# ---------------------------------------------------------------------------
|
| 297 |
+
# GET /api/v1/manuscripts/{id}/export.zip
|
| 298 |
+
# ---------------------------------------------------------------------------
|
| 299 |
+
|
| 300 |
+
@pytest.mark.asyncio
|
| 301 |
+
async def test_export_zip_not_found(async_client):
|
| 302 |
+
response = await async_client.get("/api/v1/manuscripts/unknown/export.zip")
|
| 303 |
+
assert response.status_code == 404
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
@pytest.mark.asyncio
|
| 307 |
+
async def test_export_zip_no_master_files(async_client, db_session, monkeypatch):
|
| 308 |
+
_, ms, _ = await _populate_db(db_session, n_pages=1)
|
| 309 |
+
monkeypatch.setattr(Path, "exists", lambda self: False)
|
| 310 |
+
|
| 311 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/export.zip")
|
| 312 |
+
assert response.status_code == 404
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@pytest.mark.asyncio
|
| 316 |
+
async def test_export_zip_ok(async_client, db_session, monkeypatch):
|
| 317 |
+
_, ms, pages = await _populate_db(db_session, n_pages=2)
|
| 318 |
+
_mock_master_files(monkeypatch, pages)
|
| 319 |
+
|
| 320 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/export.zip")
|
| 321 |
+
assert response.status_code == 200
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@pytest.mark.asyncio
|
| 325 |
+
async def test_export_zip_content_type(async_client, db_session, monkeypatch):
|
| 326 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 327 |
+
_mock_master_files(monkeypatch, pages)
|
| 328 |
+
|
| 329 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/export.zip")
|
| 330 |
+
assert "zip" in response.headers["content-type"]
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
@pytest.mark.asyncio
|
| 334 |
+
async def test_export_zip_contains_manifest(async_client, db_session, monkeypatch):
|
| 335 |
+
_, ms, pages = await _populate_db(db_session, n_pages=2)
|
| 336 |
+
_mock_master_files(monkeypatch, pages)
|
| 337 |
+
|
| 338 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/export.zip")
|
| 339 |
+
with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
|
| 340 |
+
assert "manifest.json" in zf.namelist()
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
@pytest.mark.asyncio
|
| 344 |
+
async def test_export_zip_contains_mets(async_client, db_session, monkeypatch):
|
| 345 |
+
_, ms, pages = await _populate_db(db_session, n_pages=2)
|
| 346 |
+
_mock_master_files(monkeypatch, pages)
|
| 347 |
+
|
| 348 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/export.zip")
|
| 349 |
+
with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
|
| 350 |
+
assert "mets.xml" in zf.namelist()
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
@pytest.mark.asyncio
|
| 354 |
+
async def test_export_zip_contains_alto_per_page(async_client, db_session, monkeypatch):
|
| 355 |
+
_, ms, pages = await _populate_db(db_session, n_pages=2)
|
| 356 |
+
_mock_master_files(monkeypatch, pages)
|
| 357 |
+
|
| 358 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/export.zip")
|
| 359 |
+
with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
|
| 360 |
+
names = zf.namelist()
|
| 361 |
+
alto_files = [n for n in names if n.startswith("alto/")]
|
| 362 |
+
assert len(alto_files) == 2
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
@pytest.mark.asyncio
|
| 366 |
+
async def test_export_zip_manifest_is_valid_json(async_client, db_session, monkeypatch):
|
| 367 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 368 |
+
_mock_master_files(monkeypatch, pages)
|
| 369 |
+
|
| 370 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/export.zip")
|
| 371 |
+
with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
|
| 372 |
+
manifest_data = json.loads(zf.read("manifest.json").decode("utf-8"))
|
| 373 |
+
assert manifest_data["@context"] == _IIIF_CONTEXT
|
| 374 |
+
assert manifest_data["type"] == "Manifest"
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
@pytest.mark.asyncio
|
| 378 |
+
async def test_export_zip_content_disposition(async_client, db_session, monkeypatch):
|
| 379 |
+
_, ms, pages = await _populate_db(db_session, n_pages=1)
|
| 380 |
+
_mock_master_files(monkeypatch, pages)
|
| 381 |
+
|
| 382 |
+
response = await async_client.get(f"/api/v1/manuscripts/{ms.id}/export.zip")
|
| 383 |
+
cd = response.headers.get("content-disposition", "")
|
| 384 |
+
assert "attachment" in cd
|
| 385 |
+
assert ".zip" in cd
|
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests des endpoints /api/v1/pages/{id} (Sprint 4 — Session A).
|
| 3 |
+
|
| 4 |
+
Stratégie :
|
| 5 |
+
- Données BDD créées directement via la session SQLAlchemy (pas via l'API)
|
| 6 |
+
- master.json mockés via monkeypatch sur les méthodes de Path
|
| 7 |
+
- Vérifie : 200, 404, structure du master.json, liste des couches
|
| 8 |
+
|
| 9 |
+
Tests :
|
| 10 |
+
- GET /api/v1/pages/{id} → 200 ou 404
|
| 11 |
+
- GET /api/v1/pages/{id}/master-json → 200 (mock), 404 (pas de fichier)
|
| 12 |
+
- GET /api/v1/pages/{id}/layers → liste des couches disponibles
|
| 13 |
+
"""
|
| 14 |
+
# 1. stdlib
|
| 15 |
+
import json
|
| 16 |
+
import uuid
|
| 17 |
+
from datetime import datetime, timezone
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from unittest.mock import MagicMock, patch
|
| 20 |
+
|
| 21 |
+
# 2. third-party
|
| 22 |
+
import pytest
|
| 23 |
+
|
| 24 |
+
# 3. local
|
| 25 |
+
from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
|
| 26 |
+
from tests.conftest_api import async_client, db_session # noqa: F401
|
| 27 |
+
|
| 28 |
+
_NOW = datetime.now(timezone.utc)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Helpers — création de données de test en BDD
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
async def _create_corpus(db_session, slug="test-corpus"):
|
| 36 |
+
corpus = CorpusModel(
|
| 37 |
+
id=str(uuid.uuid4()),
|
| 38 |
+
slug=slug,
|
| 39 |
+
title="Test Corpus",
|
| 40 |
+
profile_id="medieval-illuminated",
|
| 41 |
+
created_at=_NOW,
|
| 42 |
+
updated_at=_NOW,
|
| 43 |
+
)
|
| 44 |
+
db_session.add(corpus)
|
| 45 |
+
await db_session.commit()
|
| 46 |
+
await db_session.refresh(corpus)
|
| 47 |
+
return corpus
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def _create_manuscript(db_session, corpus_id, ms_id=None):
|
| 51 |
+
ms = ManuscriptModel(
|
| 52 |
+
id=ms_id or str(uuid.uuid4()),
|
| 53 |
+
corpus_id=corpus_id,
|
| 54 |
+
title="Test Manuscript",
|
| 55 |
+
shelfmark="Latin 8878",
|
| 56 |
+
date_label="XIe siècle",
|
| 57 |
+
total_pages=1,
|
| 58 |
+
)
|
| 59 |
+
db_session.add(ms)
|
| 60 |
+
await db_session.commit()
|
| 61 |
+
await db_session.refresh(ms)
|
| 62 |
+
return ms
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def _create_page(db_session, manuscript_id, page_id=None):
|
| 66 |
+
page = PageModel(
|
| 67 |
+
id=page_id or str(uuid.uuid4()),
|
| 68 |
+
manuscript_id=manuscript_id,
|
| 69 |
+
folio_label="f001r",
|
| 70 |
+
sequence=1,
|
| 71 |
+
image_master_path="/data/master/f001r.tif",
|
| 72 |
+
processing_status="ANALYZED",
|
| 73 |
+
confidence_summary=0.87,
|
| 74 |
+
)
|
| 75 |
+
db_session.add(page)
|
| 76 |
+
await db_session.commit()
|
| 77 |
+
await db_session.refresh(page)
|
| 78 |
+
return page
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _make_master_json(page_id: str, corpus_profile: str = "medieval-illuminated") -> str:
|
| 82 |
+
data = {
|
| 83 |
+
"schema_version": "1.0",
|
| 84 |
+
"page_id": page_id,
|
| 85 |
+
"corpus_profile": corpus_profile,
|
| 86 |
+
"manuscript_id": "ms-test",
|
| 87 |
+
"folio_label": "f001r",
|
| 88 |
+
"sequence": 1,
|
| 89 |
+
"image": {
|
| 90 |
+
"original_url": "https://example.com/f001r.jpg",
|
| 91 |
+
"derivative_web": "/data/deriv/f001r.jpg",
|
| 92 |
+
"thumbnail": "/data/thumb/f001r.jpg",
|
| 93 |
+
"width": 1500,
|
| 94 |
+
"height": 2000,
|
| 95 |
+
},
|
| 96 |
+
"layout": {"regions": []},
|
| 97 |
+
"ocr": {
|
| 98 |
+
"diplomatic_text": "Incipit liber primus",
|
| 99 |
+
"blocks": [],
|
| 100 |
+
"lines": [],
|
| 101 |
+
"language": "la",
|
| 102 |
+
"confidence": 0.87,
|
| 103 |
+
"uncertain_segments": [],
|
| 104 |
+
},
|
| 105 |
+
"translation": {"fr": "Ici commence le premier livre", "en": ""},
|
| 106 |
+
"summary": {"short": "Prologue", "detailed": "Le prologue du commentaire"},
|
| 107 |
+
"commentary": {
|
| 108 |
+
"public": "Ce folio ouvre l'œuvre",
|
| 109 |
+
"scholarly": "Analyse paléographique détaillée",
|
| 110 |
+
"claims": [],
|
| 111 |
+
},
|
| 112 |
+
"editorial": {
|
| 113 |
+
"status": "machine_draft",
|
| 114 |
+
"validated": False,
|
| 115 |
+
"validated_by": None,
|
| 116 |
+
"version": 1,
|
| 117 |
+
"notes": [],
|
| 118 |
+
},
|
| 119 |
+
}
|
| 120 |
+
return json.dumps(data)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
# GET /api/v1/pages/{id}
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
|
| 127 |
+
@pytest.mark.asyncio
|
| 128 |
+
async def test_get_page_not_found(async_client):
|
| 129 |
+
response = await async_client.get("/api/v1/pages/nonexistent-page")
|
| 130 |
+
assert response.status_code == 404
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@pytest.mark.asyncio
|
| 134 |
+
async def test_get_page_not_found_detail(async_client):
|
| 135 |
+
response = await async_client.get("/api/v1/pages/unknown")
|
| 136 |
+
assert "introuvable" in response.json()["detail"].lower()
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@pytest.mark.asyncio
|
| 140 |
+
async def test_get_page_ok(async_client, db_session):
|
| 141 |
+
corpus = await _create_corpus(db_session)
|
| 142 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 143 |
+
page = await _create_page(db_session, ms.id)
|
| 144 |
+
|
| 145 |
+
response = await async_client.get(f"/api/v1/pages/{page.id}")
|
| 146 |
+
assert response.status_code == 200
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@pytest.mark.asyncio
|
| 150 |
+
async def test_get_page_fields(async_client, db_session):
|
| 151 |
+
corpus = await _create_corpus(db_session)
|
| 152 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 153 |
+
page = await _create_page(db_session, ms.id)
|
| 154 |
+
|
| 155 |
+
data = (await async_client.get(f"/api/v1/pages/{page.id}")).json()
|
| 156 |
+
assert data["id"] == page.id
|
| 157 |
+
assert data["manuscript_id"] == ms.id
|
| 158 |
+
assert data["folio_label"] == "f001r"
|
| 159 |
+
assert data["sequence"] == 1
|
| 160 |
+
assert data["processing_status"] == "ANALYZED"
|
| 161 |
+
assert data["confidence_summary"] == pytest.approx(0.87)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
# GET /api/v1/pages/{id}/master-json
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
|
| 168 |
+
@pytest.mark.asyncio
|
| 169 |
+
async def test_master_json_page_not_found(async_client):
|
| 170 |
+
response = await async_client.get("/api/v1/pages/unknown/master-json")
|
| 171 |
+
assert response.status_code == 404
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@pytest.mark.asyncio
|
| 175 |
+
async def test_master_json_file_not_found(async_client, db_session, monkeypatch):
|
| 176 |
+
corpus = await _create_corpus(db_session)
|
| 177 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 178 |
+
page = await _create_page(db_session, ms.id)
|
| 179 |
+
|
| 180 |
+
# Simule l'absence du fichier
|
| 181 |
+
monkeypatch.setattr(Path, "exists", lambda self: False)
|
| 182 |
+
|
| 183 |
+
response = await async_client.get(f"/api/v1/pages/{page.id}/master-json")
|
| 184 |
+
assert response.status_code == 404
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@pytest.mark.asyncio
|
| 188 |
+
async def test_master_json_ok(async_client, db_session, monkeypatch):
|
| 189 |
+
corpus = await _create_corpus(db_session)
|
| 190 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 191 |
+
page = await _create_page(db_session, ms.id)
|
| 192 |
+
|
| 193 |
+
master_data = _make_master_json(page.id)
|
| 194 |
+
|
| 195 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 196 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: master_data)
|
| 197 |
+
|
| 198 |
+
response = await async_client.get(f"/api/v1/pages/{page.id}/master-json")
|
| 199 |
+
assert response.status_code == 200
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@pytest.mark.asyncio
|
| 203 |
+
async def test_master_json_contains_page_id(async_client, db_session, monkeypatch):
|
| 204 |
+
corpus = await _create_corpus(db_session)
|
| 205 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 206 |
+
page = await _create_page(db_session, ms.id)
|
| 207 |
+
|
| 208 |
+
master_data = _make_master_json(page.id)
|
| 209 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 210 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: master_data)
|
| 211 |
+
|
| 212 |
+
data = (await async_client.get(f"/api/v1/pages/{page.id}/master-json")).json()
|
| 213 |
+
assert data["page_id"] == page.id
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@pytest.mark.asyncio
|
| 217 |
+
async def test_master_json_schema_version(async_client, db_session, monkeypatch):
|
| 218 |
+
corpus = await _create_corpus(db_session)
|
| 219 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 220 |
+
page = await _create_page(db_session, ms.id)
|
| 221 |
+
|
| 222 |
+
master_data = _make_master_json(page.id)
|
| 223 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 224 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: master_data)
|
| 225 |
+
|
| 226 |
+
data = (await async_client.get(f"/api/v1/pages/{page.id}/master-json")).json()
|
| 227 |
+
assert data["schema_version"] == "1.0"
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
@pytest.mark.asyncio
|
| 231 |
+
async def test_master_json_ocr_present(async_client, db_session, monkeypatch):
|
| 232 |
+
corpus = await _create_corpus(db_session)
|
| 233 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 234 |
+
page = await _create_page(db_session, ms.id)
|
| 235 |
+
|
| 236 |
+
master_data = _make_master_json(page.id)
|
| 237 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 238 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: master_data)
|
| 239 |
+
|
| 240 |
+
data = (await async_client.get(f"/api/v1/pages/{page.id}/master-json")).json()
|
| 241 |
+
assert data["ocr"]["diplomatic_text"] == "Incipit liber primus"
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ---------------------------------------------------------------------------
|
| 245 |
+
# GET /api/v1/pages/{id}/layers
|
| 246 |
+
# ---------------------------------------------------------------------------
|
| 247 |
+
|
| 248 |
+
@pytest.mark.asyncio
|
| 249 |
+
async def test_layers_page_not_found(async_client):
|
| 250 |
+
response = await async_client.get("/api/v1/pages/unknown/layers")
|
| 251 |
+
assert response.status_code == 404
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@pytest.mark.asyncio
|
| 255 |
+
async def test_layers_file_not_found(async_client, db_session, monkeypatch):
|
| 256 |
+
corpus = await _create_corpus(db_session)
|
| 257 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 258 |
+
page = await _create_page(db_session, ms.id)
|
| 259 |
+
|
| 260 |
+
monkeypatch.setattr(Path, "exists", lambda self: False)
|
| 261 |
+
|
| 262 |
+
response = await async_client.get(f"/api/v1/pages/{page.id}/layers")
|
| 263 |
+
assert response.status_code == 404
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
@pytest.mark.asyncio
|
| 267 |
+
async def test_layers_ok_is_list(async_client, db_session, monkeypatch):
|
| 268 |
+
corpus = await _create_corpus(db_session)
|
| 269 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 270 |
+
page = await _create_page(db_session, ms.id)
|
| 271 |
+
|
| 272 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 273 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master_json(page.id))
|
| 274 |
+
|
| 275 |
+
response = await async_client.get(f"/api/v1/pages/{page.id}/layers")
|
| 276 |
+
assert response.status_code == 200
|
| 277 |
+
layers = response.json()
|
| 278 |
+
assert isinstance(layers, list)
|
| 279 |
+
assert len(layers) > 0
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@pytest.mark.asyncio
|
| 283 |
+
async def test_layers_contains_image(async_client, db_session, monkeypatch):
|
| 284 |
+
corpus = await _create_corpus(db_session)
|
| 285 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 286 |
+
page = await _create_page(db_session, ms.id)
|
| 287 |
+
|
| 288 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 289 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master_json(page.id))
|
| 290 |
+
|
| 291 |
+
layers = (await async_client.get(f"/api/v1/pages/{page.id}/layers")).json()
|
| 292 |
+
types = [l["layer_type"] for l in layers]
|
| 293 |
+
assert "image" in types
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@pytest.mark.asyncio
|
| 297 |
+
async def test_layers_contains_ocr(async_client, db_session, monkeypatch):
|
| 298 |
+
corpus = await _create_corpus(db_session)
|
| 299 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 300 |
+
page = await _create_page(db_session, ms.id)
|
| 301 |
+
|
| 302 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 303 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master_json(page.id))
|
| 304 |
+
|
| 305 |
+
layers = (await async_client.get(f"/api/v1/pages/{page.id}/layers")).json()
|
| 306 |
+
types = [l["layer_type"] for l in layers]
|
| 307 |
+
assert "ocr_diplomatic" in types
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
@pytest.mark.asyncio
|
| 311 |
+
async def test_layers_contains_translation(async_client, db_session, monkeypatch):
|
| 312 |
+
corpus = await _create_corpus(db_session)
|
| 313 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 314 |
+
page = await _create_page(db_session, ms.id)
|
| 315 |
+
|
| 316 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 317 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master_json(page.id))
|
| 318 |
+
|
| 319 |
+
layers = (await async_client.get(f"/api/v1/pages/{page.id}/layers")).json()
|
| 320 |
+
types = [l["layer_type"] for l in layers]
|
| 321 |
+
assert "translation_fr" in types
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@pytest.mark.asyncio
|
| 325 |
+
async def test_layers_have_status(async_client, db_session, monkeypatch):
|
| 326 |
+
corpus = await _create_corpus(db_session)
|
| 327 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 328 |
+
page = await _create_page(db_session, ms.id)
|
| 329 |
+
|
| 330 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 331 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master_json(page.id))
|
| 332 |
+
|
| 333 |
+
layers = (await async_client.get(f"/api/v1/pages/{page.id}/layers")).json()
|
| 334 |
+
for layer in layers:
|
| 335 |
+
assert "layer_type" in layer
|
| 336 |
+
assert "status" in layer
|
| 337 |
+
assert "has_content" in layer
|