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>
This commit is contained in:
@@ -3,27 +3,33 @@
|
||||
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 sqlalchemy.orm import selectinload
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -208,6 +214,29 @@ async def list_items(
|
||||
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,
|
||||
@@ -260,3 +289,179 @@ async def propose_version(
|
||||
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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user