"""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, 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) 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, }