Backend: - CRUD complet documents/items/versions (update, delete, accept, reject, reorder) - Service IPFS (upload/retrieve/pin via kubo HTTP API) - Service sanctuaire : pipeline SHA-256 + IPFS + on-chain (system.remark) - Verification integrite des entrees sanctuaire - Recherche par reference (document -> entrees sanctuaire) - Serialisation deterministe des documents pour archivage - 14 tests unitaires supplementaires (document service) Frontend: - 9 composants : StatusBadge, MarkdownRenderer, DiffView, ItemCard, ItemVersionDiff, DocumentList, SanctuaryEntry, IPFSLink, ChainAnchor - Page detail item avec historique des versions et diff - Page detail sanctuaire avec verification integrite - Modal de creation de document + proposition de version - Archivage document vers sanctuaire depuis la page detail Documentation: - API reference mise a jour (9 nouveaux endpoints) - Guides utilisateur documents et sanctuaire enrichis Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
330 lines
8.2 KiB
Python
330 lines
8.2 KiB
Python
"""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
|