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