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:
Yvv
2026-10-21 02:04:42 +02:00
co-authored by Claude Opus 5.5
parent 4b1732916d
commit 67ddee28b2
275 changed files with 8979 additions and 0 deletions
@@ -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