v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user