Sprint 1 : scaffolding complet de Glibredecision
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>
This commit is contained in:
262
backend/app/routers/documents.py
Normal file
262
backend/app/routers/documents.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user