"""Document service: retrieval and version management.""" from __future__ import annotations import logging import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.document import Document, DocumentItem, ItemVersion logger = logging.getLogger(__name__) async def get_document_with_items(slug: str, db: AsyncSession) -> Document | None: """Load a document with all its items and their versions, eagerly. Parameters ---------- slug: Unique slug of the document. db: Async database session. Returns ------- Document | None The document with items and versions loaded, or None if not found. """ result = await db.execute( select(Document) .options( selectinload(Document.items).selectinload(DocumentItem.versions) ) .where(Document.slug == slug) ) return result.scalar_one_or_none() async def apply_version( item_id: uuid.UUID, version_id: uuid.UUID, db: AsyncSession, ) -> DocumentItem: """Apply an accepted version to a document item. This replaces the item's current_text with the version's proposed_text and marks the version as 'accepted'. Parameters ---------- item_id: UUID of the DocumentItem to update. version_id: UUID of the ItemVersion to apply. db: Async database session. Returns ------- DocumentItem The updated document item. Raises ------ ValueError If the item or version is not found, or the version does not belong to the item. """ # Load item item_result = await db.execute( select(DocumentItem).where(DocumentItem.id == item_id) ) item = item_result.scalar_one_or_none() if item is None: raise ValueError(f"Element de document introuvable : {item_id}") # Load version version_result = await db.execute( select(ItemVersion).where(ItemVersion.id == version_id) ) version = version_result.scalar_one_or_none() if version is None: raise ValueError(f"Version introuvable : {version_id}") if version.item_id != item.id: raise ValueError( f"La version {version_id} n'appartient pas a l'element {item_id}" ) # Apply the version item.current_text = version.proposed_text version.status = "accepted" # Mark all other pending/voting versions for this item as rejected other_versions_result = await db.execute( select(ItemVersion).where( ItemVersion.item_id == item_id, ItemVersion.id != version_id, ItemVersion.status.in_(["proposed", "voting"]), ) ) for other_version in other_versions_result.scalars(): other_version.status = "rejected" await db.commit() await db.refresh(item) return item async def reject_version( item_id: uuid.UUID, version_id: uuid.UUID, db: AsyncSession, ) -> ItemVersion: """Mark a version as rejected. Parameters ---------- item_id: UUID of the DocumentItem the version belongs to. version_id: UUID of the ItemVersion to reject. db: Async database session. Returns ------- ItemVersion The rejected version. Raises ------ ValueError If the item or version is not found, or the version does not belong to the item. """ # Load item to verify existence item_result = await db.execute( select(DocumentItem).where(DocumentItem.id == item_id) ) item = item_result.scalar_one_or_none() if item is None: raise ValueError(f"Element de document introuvable : {item_id}") # Load version version_result = await db.execute( select(ItemVersion).where(ItemVersion.id == version_id) ) version = version_result.scalar_one_or_none() if version is None: raise ValueError(f"Version introuvable : {version_id}") if version.item_id != item.id: raise ValueError( f"La version {version_id} n'appartient pas a l'element {item_id}" ) version.status = "rejected" await db.commit() await db.refresh(version) return version async def get_item_with_versions( item_id: uuid.UUID, db: AsyncSession, ) -> DocumentItem | None: """Eager-load a document item with all its versions. Parameters ---------- item_id: UUID of the DocumentItem. db: Async database session. Returns ------- DocumentItem | None The item with versions loaded, or None if not found. """ result = await db.execute( select(DocumentItem) .options(selectinload(DocumentItem.versions)) .where(DocumentItem.id == item_id) ) return result.scalar_one_or_none() async def reorder_items( document_id: uuid.UUID, items_order: list[tuple[uuid.UUID, int]], db: AsyncSession, ) -> list[DocumentItem]: """Update sort_order for multiple items in a document. Parameters ---------- document_id: UUID of the document. items_order: List of (item_id, sort_order) tuples. db: Async database session. Returns ------- list[DocumentItem] The updated items, ordered by sort_order. Raises ------ ValueError If any item is not found or does not belong to the document. """ for item_id, sort_order in items_order: 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 ValueError( f"Element {item_id} introuvable dans le document {document_id}" ) item.sort_order = sort_order await db.commit() # Return all items in new order result = await db.execute( select(DocumentItem) .where(DocumentItem.document_id == document_id) .order_by(DocumentItem.sort_order) ) return list(result.scalars().all()) def serialize_document_to_text(doc: Document) -> str: """Serialize a document and its items to a plain-text representation. The items must be eagerly loaded on the document before calling this. Parameters ---------- doc: Document with items loaded. Returns ------- str Plain-text serialization suitable for hashing and archival. """ lines: list[str] = [] lines.append(f"# {doc.title}") lines.append(f"Version: {doc.version}") lines.append(f"Type: {doc.doc_type}") lines.append(f"Statut: {doc.status}") if doc.description: lines.append(f"Description: {doc.description}") lines.append("") # Sort items by sort_order sorted_items = sorted(doc.items, key=lambda i: i.sort_order) for item in sorted_items: header = f"## {item.position}" if item.title: header += f" - {item.title}" header += f" [{item.item_type}]" lines.append(header) lines.append(item.current_text) lines.append("") return "\n".join(lines) async def archive_document(slug: str, db: AsyncSession): """Serialize a document to text and archive it to the sanctuary. Parameters ---------- slug: Slug of the document to archive. db: Async database session. Returns ------- SanctuaryEntry The newly created sanctuary entry. Raises ------ ValueError If the document is not found. """ from app.services import sanctuary_service doc = await get_document_with_items(slug, db) if doc is None: raise ValueError(f"Document introuvable : {slug}") content = serialize_document_to_text(doc) entry = await sanctuary_service.archive_to_sanctuary( entry_type="document", reference_id=doc.id, content=content, title=f"{doc.title} v{doc.version}", db=db, ) # Update document with sanctuary references if entry.ipfs_cid: doc.ipfs_cid = entry.ipfs_cid if entry.chain_tx_hash: doc.chain_anchor = entry.chain_tx_hash await db.commit() await db.refresh(doc) return entry