Files
decision/backend/app/routers/documents.py
Yvv 2bdc731639 Sprint 2 : moteur de documents + sanctuaire
Backend:
- CRUD complet documents/items/versions (update, delete, accept, reject, reorder)
- Service IPFS (upload/retrieve/pin via kubo HTTP API)
- Service sanctuaire : pipeline SHA-256 + IPFS + on-chain (system.remark)
- Verification integrite des entrees sanctuaire
- Recherche par reference (document -> entrees sanctuaire)
- Serialisation deterministe des documents pour archivage
- 14 tests unitaires supplementaires (document service)

Frontend:
- 9 composants : StatusBadge, MarkdownRenderer, DiffView, ItemCard,
  ItemVersionDiff, DocumentList, SanctuaryEntry, IPFSLink, ChainAnchor
- Page detail item avec historique des versions et diff
- Page detail sanctuaire avec verification integrite
- Modal de creation de document + proposition de version
- Archivage document vers sanctuaire depuis la page detail

Documentation:
- API reference mise a jour (9 nouveaux endpoints)
- Guides utilisateur documents et sanctuaire enrichis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:08:48 +01:00

468 lines
15 KiB
Python

"""Documents router: CRUD for reference documents, items, and item versions."""
from __future__ import annotations
import difflib
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.document import Document, DocumentItem, ItemVersion
from app.models.user import DuniterIdentity
from app.schemas.document import (
DocumentCreate,
DocumentFullOut,
DocumentItemCreate,
DocumentItemOut,
DocumentItemUpdate,
DocumentOut,
DocumentUpdate,
ItemReorderRequest,
ItemVersionCreate,
ItemVersionOut,
)
from app.services import document_service
from app.services.auth_service import get_current_identity
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_document_by_slug(db: AsyncSession, slug: str) -> Document:
"""Fetch a document by slug or raise 404."""
result = await db.execute(select(Document).where(Document.slug == slug))
doc = result.scalar_one_or_none()
if doc is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document introuvable")
return doc
async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID) -> DocumentItem:
"""Fetch a document item by ID within a document, or raise 404."""
result = await db.execute(
select(DocumentItem).where(
DocumentItem.id == item_id,
DocumentItem.document_id == document_id,
)
)
item = result.scalar_one_or_none()
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item introuvable")
return item
# ── Document routes ─────────────────────────────────────────────────────────
@router.get("/", response_model=list[DocumentOut])
async def list_documents(
db: AsyncSession = Depends(get_db),
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[DocumentOut]:
"""List all reference documents, with optional filters."""
stmt = select(Document)
if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None:
stmt = stmt.where(Document.status == status_filter)
stmt = stmt.order_by(Document.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
documents = result.scalars().all()
# Compute items_count for each document
out = []
for doc in documents:
count_result = await db.execute(
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
)
items_count = count_result.scalar() or 0
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = items_count
out.append(doc_out)
return out
@router.post("/", response_model=DocumentOut, status_code=status.HTTP_201_CREATED)
async def create_document(
payload: DocumentCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentOut:
"""Create a new reference document."""
# Check slug uniqueness
existing = await db.execute(select(Document).where(Document.slug == payload.slug))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Un document avec ce slug existe deja",
)
doc = Document(**payload.model_dump())
db.add(doc)
await db.commit()
await db.refresh(doc)
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = 0
return doc_out
@router.get("/{slug}", response_model=DocumentOut)
async def get_document(
slug: str,
db: AsyncSession = Depends(get_db),
) -> DocumentOut:
"""Get a single document by its slug."""
doc = await _get_document_by_slug(db, slug)
count_result = await db.execute(
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
)
items_count = count_result.scalar() or 0
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = items_count
return doc_out
@router.put("/{slug}", response_model=DocumentOut)
async def update_document(
slug: str,
payload: DocumentUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentOut:
"""Update a document's metadata (title, status, description, version)."""
doc = await _get_document_by_slug(db, slug)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(doc, field, value)
await db.commit()
await db.refresh(doc)
count_result = await db.execute(
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
)
items_count = count_result.scalar() or 0
doc_out = DocumentOut.model_validate(doc)
doc_out.items_count = items_count
return doc_out
# ── Document Item routes ────────────────────────────────────────────────────
@router.post("/{slug}/items", response_model=DocumentItemOut, status_code=status.HTTP_201_CREATED)
async def add_item(
slug: str,
payload: DocumentItemCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentItemOut:
"""Add a new item (clause, rule, etc.) to a document."""
doc = await _get_document_by_slug(db, slug)
# Determine sort_order: max existing + 1
max_order_result = await db.execute(
select(func.max(DocumentItem.sort_order)).where(DocumentItem.document_id == doc.id)
)
max_order = max_order_result.scalar() or 0
item = DocumentItem(
document_id=doc.id,
sort_order=max_order + 1,
**payload.model_dump(),
)
db.add(item)
await db.commit()
await db.refresh(item)
return DocumentItemOut.model_validate(item)
@router.get("/{slug}/items", response_model=list[DocumentItemOut])
async def list_items(
slug: str,
db: AsyncSession = Depends(get_db),
) -> list[DocumentItemOut]:
"""List all items in a document, ordered by sort_order."""
doc = await _get_document_by_slug(db, slug)
result = await db.execute(
select(DocumentItem)
.where(DocumentItem.document_id == doc.id)
.order_by(DocumentItem.sort_order)
)
items = result.scalars().all()
return [DocumentItemOut.model_validate(item) for item in items]
# NOTE: reorder must be declared BEFORE /{slug}/items/{item_id} routes
# to avoid "reorder" being parsed as a UUID path parameter.
@router.put("/{slug}/items/reorder", response_model=list[DocumentItemOut])
async def reorder_items(
slug: str,
payload: ItemReorderRequest,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> list[DocumentItemOut]:
"""Reorder items in a document by updating their sort_order values."""
doc = await _get_document_by_slug(db, slug)
items = await document_service.reorder_items(
doc.id,
[(entry.item_id, entry.sort_order) for entry in payload.items],
db,
)
return [DocumentItemOut.model_validate(item) for item in items]
@router.get("/{slug}/items/{item_id}", response_model=DocumentItemOut)
async def get_item(
slug: str,
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> DocumentItemOut:
"""Get a single item with its version history."""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
return DocumentItemOut.model_validate(item)
@router.post(
"/{slug}/items/{item_id}/versions",
response_model=ItemVersionOut,
status_code=status.HTTP_201_CREATED,
)
async def propose_version(
slug: str,
item_id: uuid.UUID,
payload: ItemVersionCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> ItemVersionOut:
"""Propose a new version of a document item.
Automatically computes a unified diff between the current text and the proposed text.
"""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
# Compute diff
diff_lines = difflib.unified_diff(
item.current_text.splitlines(keepends=True),
payload.proposed_text.splitlines(keepends=True),
fromfile="actuel",
tofile="propose",
)
diff_text = "".join(diff_lines) or None
version = ItemVersion(
item_id=item.id,
proposed_text=payload.proposed_text,
diff_text=diff_text,
rationale=payload.rationale,
proposed_by_id=identity.id,
)
db.add(version)
await db.commit()
await db.refresh(version)
return ItemVersionOut.model_validate(version)
# ── Item update & delete ───────────────────────────────────────────────────
@router.put("/{slug}/items/{item_id}", response_model=DocumentItemOut)
async def update_item(
slug: str,
item_id: uuid.UUID,
payload: DocumentItemUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentItemOut:
"""Update an item's text, title, position, or item_type."""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(item, field, value)
await db.commit()
await db.refresh(item)
return DocumentItemOut.model_validate(item)
@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(
slug: str,
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> None:
"""Delete a document item.
Refuses deletion if the item has any active votes (status 'voting').
"""
doc = await _get_document_by_slug(db, slug)
item = await _get_item(db, doc.id, item_id)
# Check for active votes on this item's versions
active_versions_result = await db.execute(
select(func.count()).select_from(ItemVersion).where(
ItemVersion.item_id == item.id,
ItemVersion.status == "voting",
)
)
active_count = active_versions_result.scalar() or 0
if active_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Impossible de supprimer un element avec des votes en cours",
)
await db.delete(item)
await db.commit()
# ── Version accept & reject ────────────────────────────────────────────────
@router.put(
"/{slug}/items/{item_id}/versions/{version_id}/accept",
response_model=DocumentItemOut,
)
async def accept_version(
slug: str,
item_id: uuid.UUID,
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DocumentItemOut:
"""Accept a proposed version and apply it to the document item.
Replaces the item's current_text with the version's proposed_text
and rejects all other pending/voting versions for this item.
"""
doc = await _get_document_by_slug(db, slug)
# Verify item belongs to document
await _get_item(db, doc.id, item_id)
try:
updated_item = await document_service.apply_version(item_id, version_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return DocumentItemOut.model_validate(updated_item)
@router.put(
"/{slug}/items/{item_id}/versions/{version_id}/reject",
response_model=ItemVersionOut,
)
async def reject_version(
slug: str,
item_id: uuid.UUID,
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> ItemVersionOut:
"""Reject a proposed version."""
doc = await _get_document_by_slug(db, slug)
# Verify item belongs to document
await _get_item(db, doc.id, item_id)
try:
version = await document_service.reject_version(item_id, version_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return ItemVersionOut.model_validate(version)
# ── Version listing ────────────────────────────────────────────────────────
@router.get(
"/{slug}/items/{item_id}/versions",
response_model=list[ItemVersionOut],
)
async def list_versions(
slug: str,
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
) -> list[ItemVersionOut]:
"""List all versions for a document item."""
doc = await _get_document_by_slug(db, slug)
await _get_item(db, doc.id, item_id)
result = await db.execute(
select(ItemVersion)
.where(ItemVersion.item_id == item_id)
.order_by(ItemVersion.created_at.desc())
)
versions = result.scalars().all()
return [ItemVersionOut.model_validate(v) for v in versions]
# ── Document full view ─────────────────────────────────────────────────────
@router.get("/{slug}/full", response_model=DocumentFullOut)
async def get_document_full(
slug: str,
db: AsyncSession = Depends(get_db),
) -> DocumentFullOut:
"""Get a document with all its items (not just count)."""
doc = await document_service.get_document_with_items(slug, db)
if doc is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document introuvable")
return DocumentFullOut.model_validate(doc)
# ── Document archive to sanctuary ──────────────────────────────────────────
@router.post("/{slug}/archive", status_code=status.HTTP_201_CREATED)
async def archive_document(
slug: str,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> dict:
"""Archive a document to the sanctuary (IPFS + on-chain hash).
Serializes the full document text and sends it through the sanctuary pipeline.
"""
entry = await document_service.archive_document(slug, db)
return {
"message": "Document archive avec succes",
"sanctuary_entry_id": str(entry.id),
"content_hash": entry.content_hash,
"ipfs_cid": entry.ipfs_cid,
"chain_tx_hash": entry.chain_tx_hash,
}