Claude commited on
Commit
bd09498
·
unverified ·
1 Parent(s): c48eec0

feat(sprint4-session-a): API FastAPI — structure de base + endpoints lecture et export

Browse files

Structure 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 ADDED
@@ -0,0 +1 @@
 
 
1
+ """API package."""
backend/app/api/v1/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """API v1 package."""
backend/app/api/v1/corpora.py ADDED
@@ -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()
backend/app/api/v1/export.py ADDED
@@ -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
+ )
backend/app/api/v1/pages.py ADDED
@@ -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
backend/app/api/v1/profiles.py ADDED
@@ -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()
backend/app/config.py ADDED
@@ -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()
backend/app/main.py ADDED
@@ -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)
backend/app/models/__init__.py ADDED
@@ -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"]
backend/app/models/corpus.py ADDED
@@ -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")
backend/app/models/database.py ADDED
@@ -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
backend/tests/conftest_api.py ADDED
@@ -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()
backend/tests/test_api_corpora.py ADDED
@@ -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"]
backend/tests/test_api_export.py ADDED
@@ -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
backend/tests/test_api_pages.py ADDED
@@ -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