Plateforme de decisions collectives pour Duniter/G1. Backend FastAPI async + PostgreSQL (14 tables, 8 routers, 6 services, moteur de vote avec formule d'inertie WoT/Smith/TechComm). Frontend Nuxt 4 + Nuxt UI v3 + Pinia (9 pages, 5 stores). Infrastructure Docker + Woodpecker CI + Traefik. Documentation technique et utilisateur (15 fichiers). Seed : Licence G1, Engagement Forgeron v2.0.0, 4 protocoles de vote. 30 tests unitaires (formules, mode params, vote nuance) -- tous verts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
263 lines
8.5 KiB
Python
263 lines
8.5 KiB
Python
"""Documents router: CRUD for reference documents, items, and item versions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
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,
|
|
DocumentItemCreate,
|
|
DocumentItemOut,
|
|
DocumentOut,
|
|
DocumentUpdate,
|
|
ItemVersionCreate,
|
|
ItemVersionOut,
|
|
)
|
|
from app.services.auth_service import get_current_identity
|
|
|
|
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]
|
|
|
|
|
|
@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)
|