- Systeme de themes adaptatifs : Peps (light chaud), Zen (light calme), Chagrine (dark violet), Grave (dark ambre) avec CSS custom properties - Dashboard d'accueil orienté onboarding avec cartes-portes et teaser boite a outils - SectionLayout reutilisable : liste + sidebar toolbox + status pills cliquables (En prepa / En vote / En vigueur / Clos) - ToolboxVignette : vignettes Contexte / Tutos / Choisir / Demarrer - Seed : Acte engagement certification + forgeron, Runtime Upgrade (decision on-chain), 3 modalites de vote (majoritaire, quadratique, permanent) - Backend adapte SQLite (Uuid portable, 204 fix, pool conditionnel) - Correction noms composants (pathPrefix: false), pinia/nuxt ^0.11 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
468 lines
15 KiB
Python
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, 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.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, 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,
|
|
}
|