v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
"""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, Response, 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.dependencies.org import get_active_org_id
|
||||
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),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
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 org_id is not None:
|
||||
stmt = stmt.where(Document.organization_id == org_id)
|
||||
else:
|
||||
stmt = stmt.where(Document.organization_id.is_(None))
|
||||
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),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> 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(), organization_id=org_id)
|
||||
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, response_class=Response, response_model=None)
|
||||
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