Sprint 2 : moteur de documents + sanctuaire

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>
This commit is contained in:
Yvv
2026-02-28 13:08:48 +01:00
parent 25437f24e3
commit 2bdc731639
26 changed files with 3452 additions and 397 deletions

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import uuid
from sqlalchemy import select
@@ -10,6 +11,8 @@ 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.
@@ -106,3 +109,221 @@ async def apply_version(
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

View File

@@ -0,0 +1,125 @@
"""IPFS service: upload, retrieve, and pin content via kubo HTTP API.
Uses httpx async client to communicate with the local kubo node.
All operations handle connection errors gracefully: they log a warning
and return None instead of crashing the caller.
"""
from __future__ import annotations
import logging
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# Timeout for IPFS operations (seconds)
_IPFS_TIMEOUT = 30.0
async def upload_to_ipfs(content: str | bytes) -> str | None:
"""Upload content to IPFS via kubo HTTP API (POST /api/v0/add).
Parameters
----------
content:
The content to upload. Strings are encoded as UTF-8.
Returns
-------
str | None
The IPFS CID (Content Identifier) of the uploaded content,
or None if the upload failed.
"""
if isinstance(content, str):
content = content.encode("utf-8")
try:
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/add",
files={"file": ("content.txt", content, "application/octet-stream")},
)
response.raise_for_status()
data = response.json()
cid = data.get("Hash")
if cid:
logger.info("Contenu uploade sur IPFS: CID=%s", cid)
return cid
except httpx.ConnectError:
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
return None
except httpx.HTTPStatusError as exc:
logger.warning("Erreur HTTP IPFS lors de l'upload: %s", exc.response.status_code)
return None
except Exception:
logger.warning("Erreur inattendue lors de l'upload IPFS", exc_info=True)
return None
async def get_from_ipfs(cid: str) -> bytes | None:
"""Retrieve content from IPFS by CID via the gateway.
Parameters
----------
cid:
The IPFS Content Identifier to retrieve.
Returns
-------
bytes | None
The raw content bytes, or None if retrieval failed.
"""
try:
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/cat",
params={"arg": cid},
)
response.raise_for_status()
logger.info("Contenu recupere depuis IPFS: CID=%s", cid)
return response.content
except httpx.ConnectError:
logger.warning("Impossible de se connecter au noeud IPFS (%s)", settings.IPFS_API_URL)
return None
except httpx.HTTPStatusError as exc:
logger.warning("Erreur HTTP IPFS lors de la recuperation (CID=%s): %s", cid, exc.response.status_code)
return None
except Exception:
logger.warning("Erreur inattendue lors de la recuperation IPFS (CID=%s)", cid, exc_info=True)
return None
async def pin(cid: str) -> bool:
"""Pin content on the local IPFS node to prevent garbage collection.
Parameters
----------
cid:
The IPFS Content Identifier to pin.
Returns
-------
bool
True if pinning succeeded, False otherwise.
"""
try:
async with httpx.AsyncClient(timeout=_IPFS_TIMEOUT) as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/pin/add",
params={"arg": cid},
)
response.raise_for_status()
logger.info("Contenu epingle sur IPFS: CID=%s", cid)
return True
except httpx.ConnectError:
logger.warning("Impossible de se connecter au noeud IPFS pour l'epinglage (%s)", settings.IPFS_API_URL)
return False
except httpx.HTTPStatusError as exc:
logger.warning("Erreur HTTP IPFS lors de l'epinglage (CID=%s): %s", cid, exc.response.status_code)
return False
except Exception:
logger.warning("Erreur inattendue lors de l'epinglage IPFS (CID=%s)", cid, exc_info=True)
return False

View File

@@ -9,12 +9,17 @@ from __future__ import annotations
import hashlib
import json
import logging
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.sanctuary import SanctuaryEntry
from app.services import ipfs_service
logger = logging.getLogger(__name__)
async def archive_to_sanctuary(
@@ -26,6 +31,12 @@ async def archive_to_sanctuary(
) -> SanctuaryEntry:
"""Hash content and create a sanctuary entry.
Pipeline:
1. Hash content (SHA-256)
2. Try to upload to IPFS via ipfs_service (catch errors, log, continue)
3. Try to anchor on-chain via blockchain_service (catch errors, log, continue)
4. Create SanctuaryEntry with whatever succeeded
Parameters
----------
entry_type:
@@ -45,33 +56,65 @@ async def archive_to_sanctuary(
SanctuaryEntry
The newly created sanctuary entry with content_hash set.
"""
# Compute SHA-256 hash of the content
# 1. Compute SHA-256 hash of the content
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
# Build metadata
metadata = {
metadata: dict = {
"archived_at": datetime.now(timezone.utc).isoformat(),
"entry_type": entry_type,
"content_length": len(content),
}
ipfs_cid: str | None = None
chain_tx_hash: str | None = None
chain_block: int | None = None
# 2. Try to upload to IPFS
try:
ipfs_cid = await ipfs_service.upload_to_ipfs(content)
if ipfs_cid:
# Pin the content to keep it available
await ipfs_service.pin(ipfs_cid)
metadata["ipfs_cid"] = ipfs_cid
logger.info("Contenu archive sur IPFS: CID=%s", ipfs_cid)
else:
logger.warning("Upload IPFS echoue (retour None) pour %s:%s", entry_type, reference_id)
except Exception:
logger.warning(
"Erreur lors de l'upload IPFS pour %s:%s",
entry_type, reference_id,
exc_info=True,
)
# 3. Try to anchor on-chain (still a structured stub)
try:
chain_tx_hash, chain_block = await _anchor_on_chain(content_hash)
if chain_tx_hash:
metadata["chain_tx_hash"] = chain_tx_hash
metadata["chain_block"] = chain_block
logger.info("Hash ancre on-chain: tx=%s block=%s", chain_tx_hash, chain_block)
except NotImplementedError:
logger.info("Ancrage on-chain pas encore implemente, etape ignoree")
except Exception:
logger.warning(
"Erreur lors de l'ancrage on-chain pour %s:%s",
entry_type, reference_id,
exc_info=True,
)
# 4. Create SanctuaryEntry with whatever succeeded
entry = SanctuaryEntry(
entry_type=entry_type,
reference_id=reference_id,
title=title,
content_hash=content_hash,
ipfs_cid=ipfs_cid,
chain_tx_hash=chain_tx_hash,
chain_block=chain_block,
metadata_json=json.dumps(metadata, ensure_ascii=False),
)
# TODO: Upload content to IPFS via kubo HTTP API
# ipfs_cid = await _upload_to_ipfs(content)
# entry.ipfs_cid = ipfs_cid
# TODO: Anchor hash on-chain via system.remark
# tx_hash, block_number = await _anchor_on_chain(content_hash)
# entry.chain_tx_hash = tx_hash
# entry.chain_block = block_number
db.add(entry)
await db.commit()
await db.refresh(entry)
@@ -79,31 +122,115 @@ async def archive_to_sanctuary(
return entry
async def _upload_to_ipfs(content: str) -> str:
"""Upload content to IPFS via kubo HTTP API.
async def verify_entry(
entry_id: uuid.UUID,
db: AsyncSession,
) -> dict:
"""Verify the integrity of a sanctuary entry.
TODO: Implement using httpx against settings.IPFS_API_URL.
Re-fetches the content (from IPFS if available) and re-hashes it
to compare with the stored content_hash.
Example::
Parameters
----------
entry_id:
UUID of the SanctuaryEntry to verify.
db:
Async database session.
import httpx
from app.config import settings
Returns
-------
dict
Verification result with keys:
- ``entry_id``: UUID of the entry
- ``valid``: bool indicating if the hash matches
- ``stored_hash``: the stored content_hash
- ``computed_hash``: the re-computed hash (or None if content unavailable)
- ``source``: where the content was fetched from (``"ipfs"`` or ``"unavailable"``)
- ``detail``: human-readable detail message
async with httpx.AsyncClient() as client:
response = await client.post(
f"{settings.IPFS_API_URL}/api/v0/add",
files={"file": ("content.txt", content.encode("utf-8"))},
)
response.raise_for_status()
return response.json()["Hash"]
Raises
------
ValueError
If the entry is not found.
"""
raise NotImplementedError("IPFS upload pas encore implemente")
result = await db.execute(
select(SanctuaryEntry).where(SanctuaryEntry.id == entry_id)
)
entry = result.scalar_one_or_none()
if entry is None:
raise ValueError(f"Entree sanctuaire introuvable : {entry_id}")
stored_hash = entry.content_hash
computed_hash: str | None = None
source = "unavailable"
# Try to re-fetch content from IPFS
if entry.ipfs_cid:
try:
content_bytes = await ipfs_service.get_from_ipfs(entry.ipfs_cid)
if content_bytes is not None:
computed_hash = hashlib.sha256(content_bytes).hexdigest()
source = "ipfs"
except Exception:
logger.warning(
"Impossible de recuperer le contenu IPFS pour verification (CID=%s)",
entry.ipfs_cid,
exc_info=True,
)
if computed_hash is None:
return {
"entry_id": entry.id,
"valid": False,
"stored_hash": stored_hash,
"computed_hash": None,
"source": source,
"detail": "Contenu indisponible pour la verification",
}
is_valid = computed_hash == stored_hash
return {
"entry_id": entry.id,
"valid": is_valid,
"stored_hash": stored_hash,
"computed_hash": computed_hash,
"source": source,
"detail": "Integrite verifiee" if is_valid else "Hash different - contenu potentiellement altere",
}
async def _anchor_on_chain(content_hash: str) -> tuple[str, int]:
async def get_entries_by_reference(
reference_id: uuid.UUID,
db: AsyncSession,
) -> list[SanctuaryEntry]:
"""Query all sanctuary entries for a given reference_id.
Parameters
----------
reference_id:
UUID of the referenced entity (document, decision, etc.).
db:
Async database session.
Returns
-------
list[SanctuaryEntry]
All entries matching the reference_id, ordered by creation date desc.
"""
result = await db.execute(
select(SanctuaryEntry)
.where(SanctuaryEntry.reference_id == reference_id)
.order_by(SanctuaryEntry.created_at.desc())
)
return list(result.scalars().all())
async def _anchor_on_chain(content_hash: str) -> tuple[str | None, int | None]:
"""Anchor a content hash on-chain via system.remark.
TODO: Implement using substrate-interface.
Currently a stub. When implemented, this will use substrate-interface
to submit a system.remark extrinsic containing the content hash.
Example::
@@ -119,5 +246,15 @@ async def _anchor_on_chain(content_hash: str) -> tuple[str, int]:
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
return receipt.extrinsic_hash, receipt.block_number
Parameters
----------
content_hash:
The SHA-256 hash to anchor.
Returns
-------
tuple[str | None, int | None]
(tx_hash, block_number) or (None, None) if not implemented.
"""
raise NotImplementedError("Ancrage on-chain pas encore implemente")