From 2bdc7316390f0f299beab310d34d6045a6d7c48e Mon Sep 17 00:00:00 2001
From: Yvv
Date: Sat, 28 Feb 2026 13:08:48 +0100
Subject: [PATCH] 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
---
backend/app/routers/documents.py | 207 +++++++-
backend/app/routers/sanctuary.py | 58 ++-
backend/app/schemas/document.py | 70 +++
backend/app/services/document_service.py | 221 +++++++++
backend/app/services/ipfs_service.py | 125 +++++
backend/app/services/sanctuary_service.py | 191 ++++++--
backend/app/tests/test_documents.py | 418 ++++++++++++++++
docs/content/dev/3.api-reference.md | 140 +++++-
docs/content/dev/4.database-schema.md | 36 +-
docs/content/user/3.documents.md | 102 +++-
docs/content/user/7.sanctuary.md | 82 +++-
frontend/app/components/common/DiffView.vue | 51 ++
.../components/common/MarkdownRenderer.vue | 67 +++
.../app/components/common/StatusBadge.vue | 61 +++
.../app/components/documents/DocumentList.vue | 88 ++++
.../app/components/documents/ItemCard.vue | 89 ++++
.../components/documents/ItemVersionDiff.vue | 95 ++++
.../app/components/sanctuary/ChainAnchor.vue | 33 ++
.../app/components/sanctuary/IPFSLink.vue | 38 ++
.../components/sanctuary/SanctuaryEntry.vue | 171 +++++++
frontend/app/pages/documents/[slug].vue | 134 ++----
.../pages/documents/[slug]/items/[itemId].vue | 328 +++++++++++++
frontend/app/pages/documents/index.vue | 252 ++++++----
frontend/app/pages/sanctuary/[id].vue | 445 ++++++++++++++++++
frontend/app/pages/sanctuary/index.vue | 213 +++------
frontend/app/stores/documents.ts | 134 +++++-
26 files changed, 3452 insertions(+), 397 deletions(-)
create mode 100644 backend/app/services/ipfs_service.py
create mode 100644 backend/app/tests/test_documents.py
create mode 100644 frontend/app/components/common/DiffView.vue
create mode 100644 frontend/app/components/common/MarkdownRenderer.vue
create mode 100644 frontend/app/components/common/StatusBadge.vue
create mode 100644 frontend/app/components/documents/DocumentList.vue
create mode 100644 frontend/app/components/documents/ItemCard.vue
create mode 100644 frontend/app/components/documents/ItemVersionDiff.vue
create mode 100644 frontend/app/components/sanctuary/ChainAnchor.vue
create mode 100644 frontend/app/components/sanctuary/IPFSLink.vue
create mode 100644 frontend/app/components/sanctuary/SanctuaryEntry.vue
create mode 100644 frontend/app/pages/documents/[slug]/items/[itemId].vue
create mode 100644 frontend/app/pages/sanctuary/[id].vue
diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py
index 5680732..c6d068c 100644
--- a/backend/app/routers/documents.py
+++ b/backend/app/routers/documents.py
@@ -3,27 +3,33 @@
from __future__ import annotations
import difflib
+import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.document import Document, DocumentItem, ItemVersion
from app.models.user import DuniterIdentity
from app.schemas.document import (
DocumentCreate,
+ DocumentFullOut,
DocumentItemCreate,
DocumentItemOut,
+ DocumentItemUpdate,
DocumentOut,
DocumentUpdate,
+ ItemReorderRequest,
ItemVersionCreate,
ItemVersionOut,
)
+from app.services import document_service
from app.services.auth_service import get_current_identity
+logger = logging.getLogger(__name__)
+
router = APIRouter()
@@ -208,6 +214,29 @@ async def list_items(
return [DocumentItemOut.model_validate(item) for item in items]
+# NOTE: reorder must be declared BEFORE /{slug}/items/{item_id} routes
+# to avoid "reorder" being parsed as a UUID path parameter.
+
+
+@router.put("/{slug}/items/reorder", response_model=list[DocumentItemOut])
+async def reorder_items(
+ slug: str,
+ payload: ItemReorderRequest,
+ db: AsyncSession = Depends(get_db),
+ identity: DuniterIdentity = Depends(get_current_identity),
+) -> list[DocumentItemOut]:
+ """Reorder items in a document by updating their sort_order values."""
+ doc = await _get_document_by_slug(db, slug)
+
+ items = await document_service.reorder_items(
+ doc.id,
+ [(entry.item_id, entry.sort_order) for entry in payload.items],
+ db,
+ )
+
+ return [DocumentItemOut.model_validate(item) for item in items]
+
+
@router.get("/{slug}/items/{item_id}", response_model=DocumentItemOut)
async def get_item(
slug: str,
@@ -260,3 +289,179 @@ async def propose_version(
await db.refresh(version)
return ItemVersionOut.model_validate(version)
+
+
+# ── Item update & delete ───────────────────────────────────────────────────
+
+
+@router.put("/{slug}/items/{item_id}", response_model=DocumentItemOut)
+async def update_item(
+ slug: str,
+ item_id: uuid.UUID,
+ payload: DocumentItemUpdate,
+ db: AsyncSession = Depends(get_db),
+ identity: DuniterIdentity = Depends(get_current_identity),
+) -> DocumentItemOut:
+ """Update an item's text, title, position, or item_type."""
+ doc = await _get_document_by_slug(db, slug)
+ item = await _get_item(db, doc.id, item_id)
+
+ update_data = payload.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(item, field, value)
+
+ await db.commit()
+ await db.refresh(item)
+
+ return DocumentItemOut.model_validate(item)
+
+
+@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_item(
+ slug: str,
+ item_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ identity: DuniterIdentity = Depends(get_current_identity),
+) -> None:
+ """Delete a document item.
+
+ Refuses deletion if the item has any active votes (status 'voting').
+ """
+ doc = await _get_document_by_slug(db, slug)
+ item = await _get_item(db, doc.id, item_id)
+
+ # Check for active votes on this item's versions
+ active_versions_result = await db.execute(
+ select(func.count()).select_from(ItemVersion).where(
+ ItemVersion.item_id == item.id,
+ ItemVersion.status == "voting",
+ )
+ )
+ active_count = active_versions_result.scalar() or 0
+ if active_count > 0:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="Impossible de supprimer un element avec des votes en cours",
+ )
+
+ await db.delete(item)
+ await db.commit()
+
+
+# ── Version accept & reject ────────────────────────────────────────────────
+
+
+@router.put(
+ "/{slug}/items/{item_id}/versions/{version_id}/accept",
+ response_model=DocumentItemOut,
+)
+async def accept_version(
+ slug: str,
+ item_id: uuid.UUID,
+ version_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ identity: DuniterIdentity = Depends(get_current_identity),
+) -> DocumentItemOut:
+ """Accept a proposed version and apply it to the document item.
+
+ Replaces the item's current_text with the version's proposed_text
+ and rejects all other pending/voting versions for this item.
+ """
+ doc = await _get_document_by_slug(db, slug)
+ # Verify item belongs to document
+ await _get_item(db, doc.id, item_id)
+
+ try:
+ updated_item = await document_service.apply_version(item_id, version_id, db)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
+
+ return DocumentItemOut.model_validate(updated_item)
+
+
+@router.put(
+ "/{slug}/items/{item_id}/versions/{version_id}/reject",
+ response_model=ItemVersionOut,
+)
+async def reject_version(
+ slug: str,
+ item_id: uuid.UUID,
+ version_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ identity: DuniterIdentity = Depends(get_current_identity),
+) -> ItemVersionOut:
+ """Reject a proposed version."""
+ doc = await _get_document_by_slug(db, slug)
+ # Verify item belongs to document
+ await _get_item(db, doc.id, item_id)
+
+ try:
+ version = await document_service.reject_version(item_id, version_id, db)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
+
+ return ItemVersionOut.model_validate(version)
+
+
+# ── Version listing ────────────────────────────────────────────────────────
+
+
+@router.get(
+ "/{slug}/items/{item_id}/versions",
+ response_model=list[ItemVersionOut],
+)
+async def list_versions(
+ slug: str,
+ item_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+) -> list[ItemVersionOut]:
+ """List all versions for a document item."""
+ doc = await _get_document_by_slug(db, slug)
+ await _get_item(db, doc.id, item_id)
+
+ result = await db.execute(
+ select(ItemVersion)
+ .where(ItemVersion.item_id == item_id)
+ .order_by(ItemVersion.created_at.desc())
+ )
+ versions = result.scalars().all()
+ return [ItemVersionOut.model_validate(v) for v in versions]
+
+
+# ── Document full view ─────────────────────────────────────────────────────
+
+
+@router.get("/{slug}/full", response_model=DocumentFullOut)
+async def get_document_full(
+ slug: str,
+ db: AsyncSession = Depends(get_db),
+) -> DocumentFullOut:
+ """Get a document with all its items (not just count)."""
+ doc = await document_service.get_document_with_items(slug, db)
+ if doc is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document introuvable")
+
+ return DocumentFullOut.model_validate(doc)
+
+
+# ── Document archive to sanctuary ──────────────────────────────────────────
+
+
+@router.post("/{slug}/archive", status_code=status.HTTP_201_CREATED)
+async def archive_document(
+ slug: str,
+ db: AsyncSession = Depends(get_db),
+ identity: DuniterIdentity = Depends(get_current_identity),
+) -> dict:
+ """Archive a document to the sanctuary (IPFS + on-chain hash).
+
+ Serializes the full document text and sends it through the sanctuary pipeline.
+ """
+ entry = await document_service.archive_document(slug, db)
+ return {
+ "message": "Document archive avec succes",
+ "sanctuary_entry_id": str(entry.id),
+ "content_hash": entry.content_hash,
+ "ipfs_cid": entry.ipfs_cid,
+ "chain_tx_hash": entry.chain_tx_hash,
+ }
diff --git a/backend/app/routers/sanctuary.py b/backend/app/routers/sanctuary.py
index a502eaa..90daad0 100644
--- a/backend/app/routers/sanctuary.py
+++ b/backend/app/routers/sanctuary.py
@@ -12,6 +12,7 @@ from app.database import get_db
from app.models.sanctuary import SanctuaryEntry
from app.models.user import DuniterIdentity
from app.schemas.sanctuary import SanctuaryEntryCreate, SanctuaryEntryOut
+from app.services import sanctuary_service
from app.services.auth_service import get_current_identity
router = APIRouter()
@@ -37,19 +38,6 @@ async def list_entries(
return [SanctuaryEntryOut.model_validate(e) for e in entries]
-@router.get("/{id}", response_model=SanctuaryEntryOut)
-async def get_entry(
- id: uuid.UUID,
- db: AsyncSession = Depends(get_db),
-) -> SanctuaryEntryOut:
- """Get a single sanctuary entry by ID."""
- result = await db.execute(select(SanctuaryEntry).where(SanctuaryEntry.id == id))
- entry = result.scalar_one_or_none()
- if entry is None:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entree sanctuaire introuvable")
- return SanctuaryEntryOut.model_validate(entry)
-
-
@router.post("/", response_model=SanctuaryEntryOut, status_code=status.HTTP_201_CREATED)
async def create_entry(
payload: SanctuaryEntryCreate,
@@ -71,3 +59,47 @@ async def create_entry(
await db.refresh(entry)
return SanctuaryEntryOut.model_validate(entry)
+
+
+@router.get("/by-reference/{reference_id}", response_model=list[SanctuaryEntryOut])
+async def get_entries_by_reference(
+ reference_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+) -> list[SanctuaryEntryOut]:
+ """Get all sanctuary entries for a given reference ID.
+
+ Useful for finding all sanctuary entries associated with a document,
+ decision, or vote result.
+ """
+ entries = await sanctuary_service.get_entries_by_reference(reference_id, db)
+ return [SanctuaryEntryOut.model_validate(e) for e in entries]
+
+
+@router.get("/{id}/verify")
+async def verify_entry(
+ id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+) -> dict:
+ """Verify integrity of a sanctuary entry.
+
+ Re-fetches the content (from IPFS if available), re-hashes it,
+ and compares with the stored content_hash.
+ """
+ try:
+ result = await sanctuary_service.verify_entry(id, db)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
+ return result
+
+
+@router.get("/{id}", response_model=SanctuaryEntryOut)
+async def get_entry(
+ id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+) -> SanctuaryEntryOut:
+ """Get a single sanctuary entry by ID."""
+ result = await db.execute(select(SanctuaryEntry).where(SanctuaryEntry.id == id))
+ entry = result.scalar_one_or_none()
+ if entry is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entree sanctuaire introuvable")
+ return SanctuaryEntryOut.model_validate(entry)
diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py
index 264dc2d..d04efcc 100644
--- a/backend/app/schemas/document.py
+++ b/backend/app/schemas/document.py
@@ -60,6 +60,15 @@ class DocumentItemCreate(BaseModel):
voting_protocol_id: UUID | None = None
+class DocumentItemUpdate(BaseModel):
+ """Partial update for a document item."""
+
+ title: str | None = Field(default=None, max_length=256)
+ current_text: str | None = Field(default=None, min_length=1)
+ position: str | None = Field(default=None, max_length=16)
+ item_type: str | None = Field(default=None, max_length=32)
+
+
class DocumentItemOut(BaseModel):
"""Full document item representation."""
@@ -77,6 +86,59 @@ class DocumentItemOut(BaseModel):
updated_at: datetime
+class DocumentItemFullOut(BaseModel):
+ """Document item with its full version history."""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: UUID
+ document_id: UUID
+ position: str
+ item_type: str
+ title: str | None = None
+ current_text: str
+ voting_protocol_id: UUID | None = None
+ sort_order: int
+ created_at: datetime
+ updated_at: datetime
+ versions: list[ItemVersionOut] = Field(default_factory=list)
+
+
+class DocumentFullOut(BaseModel):
+ """Document with full items list (not just count)."""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: UUID
+ slug: str
+ title: str
+ doc_type: str
+ version: str
+ status: str
+ description: str | None = None
+ ipfs_cid: str | None = None
+ chain_anchor: str | None = None
+ created_at: datetime
+ updated_at: datetime
+ items: list[DocumentItemOut] = Field(default_factory=list)
+
+
+# ── Item Reorder ─────────────────────────────────────────────────
+
+
+class ItemReorderEntry(BaseModel):
+ """A single item reorder entry."""
+
+ item_id: UUID
+ sort_order: int = Field(..., ge=0)
+
+
+class ItemReorderRequest(BaseModel):
+ """Payload for reordering items in a document."""
+
+ items: list[ItemReorderEntry]
+
+
# ── Item Version ─────────────────────────────────────────────────
@@ -101,3 +163,11 @@ class ItemVersionOut(BaseModel):
decision_id: UUID | None = None
proposed_by_id: UUID | None = None
created_at: datetime
+
+
+# ── Forward reference resolution ─────────────────────────────────
+# DocumentItemFullOut references ItemVersionOut which is defined after it.
+# With `from __future__ import annotations`, Pydantic needs explicit rebuild.
+
+DocumentItemFullOut.model_rebuild()
+DocumentFullOut.model_rebuild()
diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py
index 87e2dca..82ca43d 100644
--- a/backend/app/services/document_service.py
+++ b/backend/app/services/document_service.py
@@ -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
diff --git a/backend/app/services/ipfs_service.py b/backend/app/services/ipfs_service.py
new file mode 100644
index 0000000..b2f2b1b
--- /dev/null
+++ b/backend/app/services/ipfs_service.py
@@ -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
diff --git a/backend/app/services/sanctuary_service.py b/backend/app/services/sanctuary_service.py
index 7a76d98..c698879 100644
--- a/backend/app/services/sanctuary_service.py
+++ b/backend/app/services/sanctuary_service.py
@@ -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")
diff --git a/backend/app/tests/test_documents.py b/backend/app/tests/test_documents.py
new file mode 100644
index 0000000..ecf4973
--- /dev/null
+++ b/backend/app/tests/test_documents.py
@@ -0,0 +1,418 @@
+"""Tests for document service: apply_version, reject_version, and serialization.
+
+These are pure unit tests that mock the database layer to test
+the service logic in isolation.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import uuid
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for document service tests")
+
+from app.services.document_service import ( # noqa: E402
+ apply_version,
+ reject_version,
+ serialize_document_to_text,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers: mock objects that behave like SQLAlchemy models
+# ---------------------------------------------------------------------------
+
+
+def _make_item(
+ item_id: uuid.UUID | None = None,
+ document_id: uuid.UUID | None = None,
+ current_text: str = "Texte original",
+ position: str = "1",
+ item_type: str = "clause",
+ title: str | None = None,
+ sort_order: int = 0,
+) -> MagicMock:
+ """Create a mock DocumentItem."""
+ item = MagicMock()
+ item.id = item_id or uuid.uuid4()
+ item.document_id = document_id or uuid.uuid4()
+ item.current_text = current_text
+ item.position = position
+ item.item_type = item_type
+ item.title = title
+ item.sort_order = sort_order
+ item.created_at = datetime.now(timezone.utc)
+ item.updated_at = datetime.now(timezone.utc)
+ return item
+
+
+def _make_version(
+ version_id: uuid.UUID | None = None,
+ item_id: uuid.UUID | None = None,
+ proposed_text: str = "Texte propose",
+ status: str = "proposed",
+) -> MagicMock:
+ """Create a mock ItemVersion."""
+ version = MagicMock()
+ version.id = version_id or uuid.uuid4()
+ version.item_id = item_id or uuid.uuid4()
+ version.proposed_text = proposed_text
+ version.status = status
+ version.diff_text = None
+ version.rationale = None
+ version.decision_id = None
+ version.proposed_by_id = None
+ version.created_at = datetime.now(timezone.utc)
+ return version
+
+
+def _make_document(
+ doc_id: uuid.UUID | None = None,
+ slug: str = "test-doc",
+ title: str = "Document de test",
+ doc_type: str = "licence",
+ version: str = "1.0.0",
+ status: str = "active",
+ description: str | None = "Description de test",
+ items: list | None = None,
+) -> MagicMock:
+ """Create a mock Document."""
+ doc = MagicMock()
+ doc.id = doc_id or uuid.uuid4()
+ doc.slug = slug
+ doc.title = title
+ doc.doc_type = doc_type
+ doc.version = version
+ doc.status = status
+ doc.description = description
+ doc.ipfs_cid = None
+ doc.chain_anchor = None
+ doc.items = items or []
+ doc.created_at = datetime.now(timezone.utc)
+ doc.updated_at = datetime.now(timezone.utc)
+ return doc
+
+
+def _make_async_db(
+ item: MagicMock | None = None,
+ version: MagicMock | None = None,
+ other_versions: list[MagicMock] | None = None,
+) -> AsyncMock:
+ """Create a mock async database session.
+
+ The mock session's execute() returns appropriate results based on
+ the query being run. It supports multiple sequential calls:
+ 1st call -> item lookup
+ 2nd call -> version lookup
+ 3rd call (optional) -> other versions lookup (for apply_version)
+ """
+ db = AsyncMock()
+
+ call_results = []
+
+ # Item result
+ item_result = MagicMock()
+ item_result.scalar_one_or_none.return_value = item
+ call_results.append(item_result)
+
+ # Version result
+ version_result = MagicMock()
+ version_result.scalar_one_or_none.return_value = version
+ call_results.append(version_result)
+
+ # Other versions result (for apply_version)
+ if other_versions is not None:
+ other_result = MagicMock()
+ other_scalars = MagicMock()
+ other_scalars.__iter__ = MagicMock(return_value=iter(other_versions))
+ other_result.scalars.return_value = other_scalars
+ call_results.append(other_result)
+
+ db.execute = AsyncMock(side_effect=call_results)
+ db.commit = AsyncMock()
+ db.refresh = AsyncMock()
+
+ return db
+
+
+# ---------------------------------------------------------------------------
+# Tests: apply_version
+# ---------------------------------------------------------------------------
+
+
+class TestApplyVersion:
+ """Test document_service.apply_version."""
+
+ @pytest.mark.asyncio
+ async def test_apply_version_updates_text(self):
+ """Applying a version replaces item's current_text with proposed_text."""
+ item_id = uuid.uuid4()
+ version_id = uuid.uuid4()
+
+ item = _make_item(item_id=item_id, current_text="Ancien texte")
+ version = _make_version(
+ version_id=version_id,
+ item_id=item_id,
+ proposed_text="Nouveau texte",
+ )
+
+ db = _make_async_db(item=item, version=version, other_versions=[])
+
+ result = await apply_version(item_id, version_id, db)
+
+ assert result.current_text == "Nouveau texte"
+ assert version.status == "accepted"
+ db.commit.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_apply_version_rejects_other_pending(self):
+ """Applying a version rejects other pending/voting versions."""
+ item_id = uuid.uuid4()
+ version_id = uuid.uuid4()
+
+ item = _make_item(item_id=item_id)
+ version = _make_version(version_id=version_id, item_id=item_id)
+
+ other_v1 = _make_version(item_id=item_id, status="proposed")
+ other_v2 = _make_version(item_id=item_id, status="voting")
+
+ db = _make_async_db(
+ item=item,
+ version=version,
+ other_versions=[other_v1, other_v2],
+ )
+
+ await apply_version(item_id, version_id, db)
+
+ assert other_v1.status == "rejected"
+ assert other_v2.status == "rejected"
+
+ @pytest.mark.asyncio
+ async def test_apply_version_item_not_found(self):
+ """ValueError is raised when item does not exist."""
+ db = _make_async_db(item=None, version=None)
+
+ with pytest.raises(ValueError, match="Element de document introuvable"):
+ await apply_version(uuid.uuid4(), uuid.uuid4(), db)
+
+ @pytest.mark.asyncio
+ async def test_apply_version_version_not_found(self):
+ """ValueError is raised when version does not exist."""
+ item = _make_item()
+ db = _make_async_db(item=item, version=None)
+
+ with pytest.raises(ValueError, match="Version introuvable"):
+ await apply_version(item.id, uuid.uuid4(), db)
+
+ @pytest.mark.asyncio
+ async def test_apply_version_wrong_item(self):
+ """ValueError is raised when version belongs to a different item."""
+ item_id = uuid.uuid4()
+ other_item_id = uuid.uuid4()
+ version_id = uuid.uuid4()
+
+ item = _make_item(item_id=item_id)
+ version = _make_version(version_id=version_id, item_id=other_item_id)
+
+ db = _make_async_db(item=item, version=version)
+
+ with pytest.raises(ValueError, match="n'appartient pas"):
+ await apply_version(item_id, version_id, db)
+
+
+# ---------------------------------------------------------------------------
+# Tests: reject_version
+# ---------------------------------------------------------------------------
+
+
+class TestRejectVersion:
+ """Test document_service.reject_version."""
+
+ @pytest.mark.asyncio
+ async def test_reject_version_sets_status(self):
+ """Rejecting a version sets its status to 'rejected'."""
+ item_id = uuid.uuid4()
+ version_id = uuid.uuid4()
+
+ item = _make_item(item_id=item_id)
+ version = _make_version(
+ version_id=version_id,
+ item_id=item_id,
+ status="proposed",
+ )
+
+ db = _make_async_db(item=item, version=version)
+
+ result = await reject_version(item_id, version_id, db)
+
+ assert result.status == "rejected"
+ db.commit.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_reject_version_item_not_found(self):
+ """ValueError is raised when item does not exist."""
+ db = _make_async_db(item=None, version=None)
+
+ with pytest.raises(ValueError, match="Element de document introuvable"):
+ await reject_version(uuid.uuid4(), uuid.uuid4(), db)
+
+ @pytest.mark.asyncio
+ async def test_reject_version_version_not_found(self):
+ """ValueError is raised when version does not exist."""
+ item = _make_item()
+ db = _make_async_db(item=item, version=None)
+
+ with pytest.raises(ValueError, match="Version introuvable"):
+ await reject_version(item.id, uuid.uuid4(), db)
+
+ @pytest.mark.asyncio
+ async def test_reject_version_wrong_item(self):
+ """ValueError is raised when version belongs to a different item."""
+ item_id = uuid.uuid4()
+ other_item_id = uuid.uuid4()
+ version_id = uuid.uuid4()
+
+ item = _make_item(item_id=item_id)
+ version = _make_version(version_id=version_id, item_id=other_item_id)
+
+ db = _make_async_db(item=item, version=version)
+
+ with pytest.raises(ValueError, match="n'appartient pas"):
+ await reject_version(item_id, version_id, db)
+
+
+# ---------------------------------------------------------------------------
+# Tests: serialize_document_to_text
+# ---------------------------------------------------------------------------
+
+
+class TestSerializeDocumentToText:
+ """Test document serialization for archival."""
+
+ def test_basic_serialization(self):
+ """A document with items serializes to the expected text format."""
+ doc_id = uuid.uuid4()
+
+ item1 = _make_item(
+ document_id=doc_id,
+ position="1",
+ title="Preambule",
+ item_type="preamble",
+ current_text="Le texte du preambule.",
+ sort_order=0,
+ )
+ item2 = _make_item(
+ document_id=doc_id,
+ position="2",
+ title="Article premier",
+ item_type="clause",
+ current_text="Le texte de l'article premier.",
+ sort_order=1,
+ )
+ item3 = _make_item(
+ document_id=doc_id,
+ position="2.1",
+ title=None,
+ item_type="rule",
+ current_text="Sous-article sans titre.",
+ sort_order=2,
+ )
+
+ doc = _make_document(
+ doc_id=doc_id,
+ title="Licence G1",
+ version="2.0.0",
+ doc_type="licence",
+ status="active",
+ description="La licence monetaire de la G1",
+ items=[item1, item2, item3],
+ )
+
+ text = serialize_document_to_text(doc)
+
+ assert "# Licence G1" in text
+ assert "Version: 2.0.0" in text
+ assert "Type: licence" in text
+ assert "Statut: active" in text
+ assert "Description: La licence monetaire de la G1" in text
+
+ # Items
+ assert "## 1 - Preambule [preamble]" in text
+ assert "Le texte du preambule." in text
+ assert "## 2 - Article premier [clause]" in text
+ assert "Le texte de l'article premier." in text
+ assert "## 2.1 [rule]" in text
+ assert "Sous-article sans titre." in text
+
+ def test_serialization_ordering(self):
+ """Items are serialized in sort_order, not insertion order."""
+ doc_id = uuid.uuid4()
+
+ item_b = _make_item(
+ document_id=doc_id,
+ position="2",
+ title="Second",
+ current_text="Texte B",
+ sort_order=1,
+ )
+ item_a = _make_item(
+ document_id=doc_id,
+ position="1",
+ title="Premier",
+ current_text="Texte A",
+ sort_order=0,
+ )
+
+ # Insert in reverse order
+ doc = _make_document(doc_id=doc_id, items=[item_b, item_a])
+
+ text = serialize_document_to_text(doc)
+
+ # "Premier" should appear before "Second"
+ idx_a = text.index("Texte A")
+ idx_b = text.index("Texte B")
+ assert idx_a < idx_b, "Items should be ordered by sort_order"
+
+ def test_serialization_without_description(self):
+ """Document without description omits that line."""
+ doc = _make_document(description=None, items=[])
+ text = serialize_document_to_text(doc)
+
+ assert "Description:" not in text
+
+ def test_serialization_hash_is_deterministic(self):
+ """Same document content produces the same SHA-256 hash."""
+ doc_id = uuid.uuid4()
+ item = _make_item(
+ document_id=doc_id,
+ position="1",
+ title="Test",
+ current_text="Contenu identique",
+ sort_order=0,
+ )
+
+ doc1 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item])
+ doc2 = _make_document(doc_id=doc_id, title="Doc", version="1.0", items=[item])
+
+ text1 = serialize_document_to_text(doc1)
+ text2 = serialize_document_to_text(doc2)
+
+ hash1 = hashlib.sha256(text1.encode("utf-8")).hexdigest()
+ hash2 = hashlib.sha256(text2.encode("utf-8")).hexdigest()
+
+ assert hash1 == hash2
+
+ def test_empty_document(self):
+ """A document with no items serializes header only."""
+ doc = _make_document(items=[])
+ text = serialize_document_to_text(doc)
+
+ assert "# Document de test" in text
+ assert "Version: 1.0.0" in text
+ # Should end with just a newline after the header block
+ lines = text.strip().split("\n")
+ assert len(lines) >= 4 # title, version, type, status
diff --git a/docs/content/dev/3.api-reference.md b/docs/content/dev/3.api-reference.md
index ad1412f..b40ba76 100644
--- a/docs/content/dev/3.api-reference.md
+++ b/docs/content/dev/3.api-reference.md
@@ -27,7 +27,14 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
| POST | `/{slug}/items` | Ajouter un item au document | Oui |
| GET | `/{slug}/items` | Lister les items d'un document | Non |
| GET | `/{slug}/items/{item_id}` | Obtenir un item avec son historique | Non |
+| PUT | `/{slug}/items/{item_id}` | Mettre a jour un item (titre, texte, position, type) | Oui |
+| DELETE | `/{slug}/items/{item_id}` | Supprimer un item du document | Oui |
| POST | `/{slug}/items/{item_id}/versions` | Proposer une nouvelle version d'un item | Oui |
+| GET | `/{slug}/items/{item_id}/versions` | Lister les versions d'un item | Non |
+| PUT | `/{slug}/items/{item_id}/versions/{version_id}/accept` | Accepter une version proposee (applique le texte a l'item) | Oui |
+| PUT | `/{slug}/items/{item_id}/versions/{version_id}/reject` | Rejeter une version proposee | Oui |
+| PUT | `/{slug}/items/reorder` | Reordonner les items d'un document | Oui |
+| POST | `/{slug}/archive` | Archiver le document dans le sanctuaire (hash SHA-256 + IPFS + on-chain) | Oui |
## Decisions (`/api/v1/decisions`)
@@ -73,11 +80,13 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
## Sanctuaire (`/api/v1/sanctuary`)
-| Methode | Endpoint | Description | Auth |
-| ------- | --------- | ---------------------------------------------------------- | ---- |
-| GET | `/` | Lister les entrees du sanctuaire (filtre: entry_type) | Non |
-| GET | `/{id}` | Obtenir une entree du sanctuaire | Non |
-| POST | `/` | Creer une entree (hash SHA-256, CID IPFS, TX on-chain) | Oui |
+| Methode | Endpoint | Description | Auth |
+| ------- | --------------------------------- | ---------------------------------------------------------- | ---- |
+| GET | `/` | Lister les entrees du sanctuaire (filtre: entry_type) | Non |
+| GET | `/{id}` | Obtenir une entree du sanctuaire | Non |
+| POST | `/` | Creer une entree (hash SHA-256, CID IPFS, TX on-chain) | Oui |
+| GET | `/{id}/verify` | Verifier l'integrite d'une entree (recalcul SHA-256, verification IPFS et on-chain) | Non |
+| GET | `/by-reference/{reference_id}` | Obtenir les entrees liees a une entite source par son UUID | Non |
## WebSocket (`/api/v1/ws`)
@@ -91,6 +100,127 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
| ------- | -------------- | -------------------------- |
| GET | `/api/health` | Verification de sante (hors versionning) |
+## Details des endpoints Sprint 2
+
+### `PUT /api/v1/documents/{slug}/items/{item_id}` -- Mettre a jour un item
+
+Met a jour les champs d'un item existant (titre, texte courant, position, type). Seuls les champs fournis sont mis a jour (mise a jour partielle).
+
+**Corps de la requete** (tous les champs sont optionnels) :
+
+```json
+{
+ "title": "Nouveau titre",
+ "current_text": "Texte mis a jour...",
+ "position": "2.1",
+ "item_type": "rule"
+}
+```
+
+**Reponse** : `200 OK` avec l'item mis a jour (`DocumentItemOut`).
+
+---
+
+### `DELETE /api/v1/documents/{slug}/items/{item_id}` -- Supprimer un item
+
+Supprime un item d'un document. La suppression est en cascade : toutes les versions associees sont egalement supprimees.
+
+**Reponse** : `204 No Content`.
+
+---
+
+### `GET /api/v1/documents/{slug}/items/{item_id}/versions` -- Lister les versions d'un item
+
+Retourne l'historique complet des versions proposees pour un item, ordonne par date de creation decroissante.
+
+**Parametres de requete** : `skip`, `limit` (pagination standard).
+
+**Reponse** : `200 OK` avec une liste de `ItemVersionOut`.
+
+---
+
+### `PUT /api/v1/documents/{slug}/items/{item_id}/versions/{version_id}/accept` -- Accepter une version
+
+Accepte une version proposee. Le texte propose remplace le texte courant de l'item. Toutes les autres versions en statut `proposed` ou `voting` pour cet item sont automatiquement rejetees.
+
+**Reponse** : `200 OK` avec la version mise a jour (`ItemVersionOut`, statut `accepted`).
+
+---
+
+### `PUT /api/v1/documents/{slug}/items/{item_id}/versions/{version_id}/reject` -- Rejeter une version
+
+Rejette une version proposee. Le texte courant de l'item reste inchange.
+
+**Reponse** : `200 OK` avec la version mise a jour (`ItemVersionOut`, statut `rejected`).
+
+---
+
+### `PUT /api/v1/documents/{slug}/items/reorder` -- Reordonner les items
+
+Modifie l'ordre d'affichage des items dans un document en mettant a jour le champ `sort_order` de chaque item.
+
+**Corps de la requete** :
+
+```json
+{
+ "items": [
+ { "item_id": "uuid-1", "sort_order": 0 },
+ { "item_id": "uuid-2", "sort_order": 1 },
+ { "item_id": "uuid-3", "sort_order": 2 }
+ ]
+}
+```
+
+**Reponse** : `200 OK` avec la liste des items reordonnes.
+
+---
+
+### `POST /api/v1/documents/{slug}/archive` -- Archiver un document
+
+Archive le document complet dans le sanctuaire. Le processus :
+
+1. Le contenu integral du document (metadonnees + items) est serialise.
+2. Un hash SHA-256 est calcule sur le contenu.
+3. Le contenu est envoye sur IPFS (CID retourne).
+4. Le hash est ancre on-chain via `system.remark` sur Duniter V2.
+5. Une entree `sanctuary_entries` est creee avec les references.
+6. Le statut du document passe a `archived` et les champs `ipfs_cid` et `chain_anchor` sont mis a jour.
+
+**Reponse** : `200 OK` avec le document mis a jour incluant `ipfs_cid` et `chain_anchor`.
+
+---
+
+### `GET /api/v1/sanctuary/{id}/verify` -- Verifier l'integrite d'une entree
+
+Verifie l'integrite d'une entree du sanctuaire en effectuant trois controles :
+
+1. **Hash SHA-256** : recalcul du hash a partir du contenu source et comparaison avec `content_hash`.
+2. **IPFS** : verification que le CID IPFS pointe vers un contenu valide (si disponible).
+3. **On-chain** : verification que le hash est present dans le `system.remark` du bloc reference (si disponible).
+
+**Reponse** :
+
+```json
+{
+ "entry_id": "uuid",
+ "hash_valid": true,
+ "ipfs_valid": true,
+ "chain_valid": true,
+ "verified_at": "2026-02-28T12:00:00Z",
+ "details": "Tous les controles sont valides."
+}
+```
+
+---
+
+### `GET /api/v1/sanctuary/by-reference/{reference_id}` -- Entrees par reference
+
+Retourne toutes les entrees du sanctuaire liees a une entite source (document, decision ou session de vote) identifiee par son UUID.
+
+**Parametres de requete** : `skip`, `limit` (pagination standard).
+
+**Reponse** : `200 OK` avec une liste de `SanctuaryEntryOut`.
+
## Pagination
Les endpoints de liste acceptent les parametres `skip` (offset, defaut 0) et `limit` (max 200, defaut 50).
diff --git a/docs/content/dev/4.database-schema.md b/docs/content/dev/4.database-schema.md
index ca6835a..3977d0b 100644
--- a/docs/content/dev/4.database-schema.md
+++ b/docs/content/dev/4.database-schema.md
@@ -38,7 +38,7 @@ Sessions d'authentification (tokens).
### `documents`
-Documents de reference modulaires.
+Documents de reference modulaires. Le cycle de vie d'un document suit les statuts `draft` -> `active` -> `archived`. Lors de l'archivage (Sprint 2), les champs `ipfs_cid` et `chain_anchor` sont renseignes automatiquement par le service sanctuaire.
| Colonne | Type | Description |
| ------------ | ------------ | ----------------------------------------------------- |
@@ -49,38 +49,38 @@ Documents de reference modulaires.
| version | VARCHAR(32) | Version semantique (defaut "0.1.0") |
| status | VARCHAR(32) | Statut : draft, active, archived |
| description | TEXT | Description du document |
-| ipfs_cid | VARCHAR(128) | CID IPFS de la derniere version archivee |
-| chain_anchor | VARCHAR(128) | Hash de transaction on-chain |
+| ipfs_cid | VARCHAR(128) | CID IPFS de la derniere version archivee (renseigne lors de l'archivage) |
+| chain_anchor | VARCHAR(128) | Hash de transaction on-chain (renseigne lors de l'archivage) |
| created_at | TIMESTAMPTZ | Date de creation |
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
### `document_items`
-Items individuels composant un document (clauses, regles, verifications, etc.).
+Items individuels composant un document (clauses, regles, verifications, etc.). Chaque item peut etre modifie, supprime ou reordonne individuellement (Sprint 2). Le champ `current_text` est mis a jour automatiquement lorsqu'une version est acceptee.
| Colonne | Type | Description |
| ------------------- | ------------ | ------------------------------------------------- |
| id | UUID (PK) | Identifiant unique |
-| document_id | UUID (FK) | -> documents.id |
+| document_id | UUID (FK) | -> documents.id (cascade delete) |
| position | VARCHAR(16) | Numero de position ("1", "1.1", "3.2") |
| item_type | VARCHAR(32) | Type : clause, rule, verification, preamble, section |
| title | VARCHAR(256) | Titre de l'item |
-| current_text | TEXT | Texte courant de l'item |
+| current_text | TEXT | Texte courant de l'item (mis a jour lors de l'acceptation d'une version) |
| voting_protocol_id | UUID (FK) | -> voting_protocols.id (protocole specifique) |
-| sort_order | INTEGER | Ordre de tri |
+| sort_order | INTEGER | Ordre de tri (modifiable via endpoint reorder) |
| created_at | TIMESTAMPTZ | Date de creation |
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
### `item_versions`
-Historique des versions proposees pour chaque item.
+Historique des versions proposees pour chaque item. Lors de l'acceptation d'une version (Sprint 2), le `current_text` de l'item parent est remplace par le `proposed_text` de la version, et toutes les autres versions en attente (`proposed`, `voting`) sont automatiquement rejetees.
| Colonne | Type | Description |
| -------------- | ------------ | ------------------------------------------------------ |
| id | UUID (PK) | Identifiant unique |
-| item_id | UUID (FK) | -> document_items.id |
+| item_id | UUID (FK) | -> document_items.id (cascade delete) |
| proposed_text | TEXT | Texte propose |
-| diff_text | TEXT | Diff unifie entre texte courant et propose |
+| diff_text | TEXT | Diff unifie entre texte courant et propose (genere automatiquement) |
| rationale | TEXT | Justification de la modification |
| status | VARCHAR(32) | Statut : proposed, voting, accepted, rejected |
| decision_id | UUID (FK) | -> decisions.id (decision associee) |
@@ -240,16 +240,16 @@ Configurations de formules de seuil WoT.
### `sanctuary_entries`
-Entrees du sanctuaire (archivage immuable).
+Entrees du sanctuaire (archivage immuable). Le champ `reference_id` permet de retrouver toutes les entrees liees a un document, une decision ou une session de vote via l'endpoint `/by-reference/{reference_id}` (Sprint 2). L'endpoint `/verify` recalcule le hash et verifie la coherence IPFS/on-chain.
| Colonne | Type | Description |
| -------------- | ------------ | ------------------------------------------ |
| id | UUID (PK) | Identifiant unique |
| entry_type | VARCHAR(64) | Type : document, decision, vote_result |
-| reference_id | UUID | UUID de l'entite source |
+| reference_id | UUID | UUID de l'entite source (indexe pour recherche par reference) |
| title | VARCHAR(256) | Titre |
| content_hash | VARCHAR(128) | Hash SHA-256 du contenu |
-| ipfs_cid | VARCHAR(128) | CID IPFS |
+| ipfs_cid | VARCHAR(128) | CID IPFS (renseigne lors de l'upload) |
| chain_tx_hash | VARCHAR(128) | Hash de la transaction on-chain |
| chain_block | INTEGER | Numero de bloc de la transaction |
| metadata_json | TEXT | Metadonnees JSON supplementaires |
@@ -279,9 +279,10 @@ duniter_identities
documents
|-- 1:N --> document_items
+ |-- 1:N ..> sanctuary_entries (via reference_id, non FK)
document_items
- |-- 1:N --> item_versions
+ |-- 1:N --> item_versions (cascade delete)
|-- N:1 --> voting_protocols
item_versions
@@ -289,6 +290,7 @@ item_versions
decisions
|-- 1:N --> decision_steps
+ |-- 1:N ..> sanctuary_entries (via reference_id, non FK)
decision_steps
|-- N:1 --> vote_sessions
@@ -296,6 +298,7 @@ decision_steps
vote_sessions
|-- 1:N --> votes
|-- N:1 --> voting_protocols
+ |-- 1:N ..> sanctuary_entries (via reference_id, non FK)
mandates
|-- 1:N --> mandate_steps
@@ -309,4 +312,9 @@ voting_protocols
formula_configs
|-- 1:N --> voting_protocols
+
+sanctuary_entries
+ |-- reference_id ..> documents | decisions | vote_sessions (lien logique)
```
+
+> **Note Sprint 2** : Les liens `..>` (pointilles) representent des references logiques via le champ `reference_id` de `sanctuary_entries`. Ce ne sont pas des cles etrangeres PostgreSQL car `reference_id` peut pointer vers differentes tables selon le `entry_type`.
diff --git a/docs/content/user/3.documents.md b/docs/content/user/3.documents.md
index 0d14bea..8e3977f 100644
--- a/docs/content/user/3.documents.md
+++ b/docs/content/user/3.documents.md
@@ -25,7 +25,19 @@ Les documents de reference sont les textes fondateurs de la communaute Duniter/G
3. Cliquez sur le document pour voir la liste de ses items.
4. Chaque item affiche son texte courant, son type et sa position dans le document.
-## Proposer une modification
+### Voir un item en detail
+
+Pour consulter un item specifique avec tout son historique :
+
+1. Depuis la liste des items du document, cliquez sur l'item souhaite.
+2. La vue detaillee affiche :
+ - Le **texte courant** de l'item.
+ - Le **type** (clause, regle, verification, preambule, section) et la **position** hierarchique.
+ - Le **protocole de vote** specifique a cet item (s'il en a un).
+ - L'**historique des versions** proposees, avec pour chacune son statut (proposee, en vote, acceptee, rejetee).
+3. Pour chaque version, vous pouvez consulter le **diff** (differences entre le texte courant et le texte propose) ainsi que la **justification** de l'auteur.
+
+## Proposer une modification (version)
Tout membre authentifie peut proposer une modification a un item de document :
@@ -35,7 +47,39 @@ Tout membre authentifie peut proposer une modification a un item de document :
4. Ajoutez une **justification** expliquant pourquoi cette modification est necessaire.
5. Soumettez. Un diff automatique est genere entre le texte courant et votre proposition.
-La proposition passe ensuite par un processus de decision (examen, vote) avant d'etre acceptee ou rejetee.
+La proposition cree une nouvelle **version** de l'item. Cette version passe ensuite par un processus de decision (examen, vote) avant d'etre acceptee ou rejetee.
+
+::callout{type="info"}
+Plusieurs versions peuvent etre proposees simultanement pour un meme item. Lorsqu'une version est acceptee, toutes les autres versions en attente sont automatiquement rejetees.
+::
+
+## Examiner et accepter/rejeter une version
+
+Les membres habilites (selon le protocole de vote associe) peuvent examiner les versions proposees :
+
+### Consulter les versions en attente
+
+1. Ouvrez le document et selectionnez l'item concerne.
+2. Consultez la liste des **versions proposees** dans l'onglet historique.
+3. Chaque version affiche :
+ - Le **texte propose** et le **diff** par rapport au texte courant.
+ - La **justification** fournie par l'auteur.
+ - Le **statut** actuel (proposee, en vote, acceptee, rejetee).
+ - L'**identite** du proposant.
+
+### Accepter une version
+
+1. Selectionnez la version a accepter.
+2. Cliquez sur **Accepter cette version**.
+3. Le texte propose **remplace automatiquement** le texte courant de l'item.
+4. Toutes les autres versions en statut `proposee` ou `en vote` pour cet item sont **automatiquement rejetees**.
+
+### Rejeter une version
+
+1. Selectionnez la version a rejeter.
+2. Cliquez sur **Rejeter cette version**.
+3. Le texte courant de l'item **reste inchange**.
+4. La version est archivee avec le statut `rejetee`.
## Cycle de vie d'une proposition
@@ -44,13 +88,57 @@ Proposee --> En vote --> Acceptee --> Texte courant mis a jour
--> Rejetee --> Archivee
```
+## Cycle de vie d'un document
+
+Un document suit un cycle de vie en trois etapes :
+
+```
+Brouillon --> Actif --> Archive
+```
+
+### Brouillon (draft)
+
+Le document est en cours de redaction. Les items peuvent etre ajoutes, modifies, supprimes et reordonnes librement. Le document n'est pas encore soumis au vote permanent.
+
+### Actif (active)
+
+Le document est en vigueur et sous **vote permanent**. Tout membre authentifie peut proposer des modifications aux items via le systeme de versions. Les modifications sont soumises au processus de decision (qualification, examen, vote) avant d'etre appliquees.
+
+### Archive (archived)
+
+Le document a ete archive dans le **Sanctuaire**. Son contenu est fige et preservee de maniere immuable via :
+
+- Un hash SHA-256 pour garantir l'integrite.
+- Un stockage sur IPFS pour la distribution decentralisee.
+- Un ancrage on-chain via `system.remark` sur Duniter V2.
+
+Un document archive ne peut plus etre modifie. Pour le consulter, rendez-vous dans la section Sanctuaire.
+
+## Archiver un document dans le Sanctuaire
+
+Pour archiver un document actif (necessite une authentification) :
+
+1. Ouvrez le document actif a archiver.
+2. Cliquez sur **Archiver dans le Sanctuaire**.
+3. Le systeme effectue automatiquement :
+ - La serialisation complete du document (metadonnees + tous les items).
+ - Le calcul du hash SHA-256 du contenu.
+ - L'envoi du contenu sur IPFS.
+ - L'ancrage du hash on-chain.
+4. Le statut du document passe a **Archive**.
+5. Les champs **CID IPFS** et **ancrage on-chain** sont renseignes sur le document.
+
+::callout{type="warning"}
+L'archivage est une operation irreversible. Une fois archive, le document ne peut plus etre modifie.
+::
+
## Statuts des documents
-| Statut | Description |
-| -------- | ------------------------------------------------ |
-| Brouillon | En cours de redaction, non soumis au vote |
-| Actif | Document en vigueur, sous vote permanent |
-| Archive | Document archive, plus en vigueur |
+| Statut | Description |
+| --------- | ------------------------------------------------ |
+| Brouillon | En cours de redaction, non soumis au vote |
+| Actif | Document en vigueur, sous vote permanent |
+| Archive | Document archive dans le Sanctuaire, plus modifiable |
## Versionnage
diff --git a/docs/content/user/7.sanctuary.md b/docs/content/user/7.sanctuary.md
index c49512f..3061075 100644
--- a/docs/content/user/7.sanctuary.md
+++ b/docs/content/user/7.sanctuary.md
@@ -41,9 +41,37 @@ La gouvernance exige la transparence et la tracabilite. Le Sanctuaire garantit q
- Le numero de bloc
- La date d'archivage
+## Consulter les entrees par document de reference
+
+Pour retrouver toutes les entrees du Sanctuaire liees a un document, une decision ou une session de vote specifique :
+
+1. Depuis la fiche du document (ou de la decision), cliquez sur **Voir dans le Sanctuaire**.
+2. La liste affiche toutes les entrees archivees associees a cette entite source.
+3. Vous pouvez aussi acceder directement a cette vue via l'URL : `/sanctuaire/par-reference/{id}`.
+
+Cette fonctionnalite permet de retracer l'historique complet d'archivage d'un document au fil de ses modifications adoptees.
+
## Verification d'integrite
-Pour verifier qu'une entree du Sanctuaire est authentique :
+### Verification automatique
+
+Glibredecision propose une verification automatique d'integrite pour chaque entree du Sanctuaire :
+
+1. Ouvrez l'entree a verifier dans le Sanctuaire.
+2. Cliquez sur **Verifier l'integrite**.
+3. Le systeme effectue automatiquement trois controles :
+ - **Hash SHA-256** : le hash est recalcule a partir du contenu source et compare avec le hash enregistre.
+ - **IPFS** : le contenu est recupere via le CID IPFS et verifie (si le CID est renseigne).
+ - **On-chain** : le hash est recherche dans le `system.remark` du bloc reference sur la blockchain Duniter V2 (si le hash de transaction est renseigne).
+4. Le resultat affiche pour chaque controle un indicateur **valide** ou **invalide**.
+
+::callout{type="info"}
+Si les trois controles sont valides, le contenu est authentique et n'a pas ete modifie depuis son archivage.
+::
+
+### Verification manuelle
+
+Pour une verification independante de la plateforme :
1. Recuperez le contenu via IPFS en utilisant le CID affiche.
2. Calculez le hash SHA-256 du contenu telecharge.
@@ -52,6 +80,57 @@ Pour verifier qu'une entree du Sanctuaire est authentique :
Si les trois hash correspondent, le contenu est authentique et n'a pas ete modifie.
+## Acces au contenu via IPFS
+
+Chaque entree du Sanctuaire possede un **CID IPFS** (Content Identifier) qui permet d'acceder au contenu archive de maniere decentralisee.
+
+### Utiliser le lien IPFS gateway
+
+Le CID est affiche sous forme de lien cliquable pointant vers une passerelle IPFS publique :
+
+- **Passerelle publique** : `https://ipfs.io/ipfs/{CID}`
+- **Passerelle locale** (si vous executez un noeud kubo) : `http://localhost:8080/ipfs/{CID}`
+
+En cliquant sur le lien CID dans l'interface, le contenu archive s'ouvre directement dans votre navigateur.
+
+### Recuperer le contenu via la CLI IPFS
+
+Si vous avez un noeud IPFS local (kubo), vous pouvez recuperer le contenu directement :
+
+```bash
+ipfs cat {CID}
+```
+
+Ou le telecharger :
+
+```bash
+ipfs get {CID} -o document_archive.txt
+```
+
+## Comprendre les informations d'ancrage on-chain
+
+Chaque entree du Sanctuaire affiche des informations relatives a son ancrage sur la blockchain Duniter V2 :
+
+| Information | Description |
+| -------------------- | -------------------------------------------------------- |
+| Hash de transaction | Identifiant unique de la transaction `system.remark` contenant le hash du contenu |
+| Numero de bloc | Le bloc de la blockchain dans lequel la transaction a ete incluse |
+| Date d'archivage | Horodatage de la creation de l'entree dans le Sanctuaire |
+
+### Verifier sur un explorateur blockchain
+
+Pour verifier l'ancrage on-chain de maniere independante :
+
+1. Copiez le **hash de transaction** affiche sur l'entree du Sanctuaire.
+2. Ouvrez un explorateur de la blockchain Duniter V2 (par exemple Polkadot.js Apps connecte au reseau Duniter).
+3. Recherchez la transaction par son hash ou parcourez le **bloc** indique.
+4. Dans les extrinsics du bloc, reperer l'appel `system.remark` contenant le hash SHA-256 du contenu.
+5. Si le hash dans le remark correspond au hash SHA-256 affiche dans le Sanctuaire, l'ancrage est confirme.
+
+::callout{type="tip"}
+L'ancrage on-chain fournit une preuve horodatee et infalsifiable de l'existence du contenu a la date du bloc. Meme si la plateforme Glibredecision disparait, la preuve reste verifiable sur la blockchain.
+::
+
## Automatisation
L'archivage dans le Sanctuaire est declenche automatiquement lorsqu'un processus decisionnel est finalise :
@@ -59,3 +138,4 @@ L'archivage dans le Sanctuaire est declenche automatiquement lorsqu'un processus
- Quand une version d'item de document est **acceptee**, le nouveau texte est archive.
- Quand une session de vote est **cloturee**, le resultat detaille est archive.
- Quand une decision est **executee**, l'ensemble de la decision est archive.
+- Quand un document est **archive** via le bouton d'archivage, l'integralite du document est archivee dans le Sanctuaire.
diff --git a/frontend/app/components/common/DiffView.vue b/frontend/app/components/common/DiffView.vue
new file mode 100644
index 0000000..e8e93c5
--- /dev/null
+++ b/frontend/app/components/common/DiffView.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/frontend/app/components/common/MarkdownRenderer.vue b/frontend/app/components/common/MarkdownRenderer.vue
new file mode 100644
index 0000000..85e00aa
--- /dev/null
+++ b/frontend/app/components/common/MarkdownRenderer.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
diff --git a/frontend/app/components/common/StatusBadge.vue b/frontend/app/components/common/StatusBadge.vue
new file mode 100644
index 0000000..7a0f4ce
--- /dev/null
+++ b/frontend/app/components/common/StatusBadge.vue
@@ -0,0 +1,61 @@
+
+
+
+
+ {{ resolved.label }}
+
+
diff --git a/frontend/app/components/documents/DocumentList.vue b/frontend/app/components/documents/DocumentList.vue
new file mode 100644
index 0000000..40d6ded
--- /dev/null
+++ b/frontend/app/components/documents/DocumentList.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
Aucun document de reference pour le moment
+
+
+
+
+
+
+
+
+
+
+ {{ doc.title }}
+
+
+
+
+
+
+
+ {{ typeLabel(doc.doc_type) }}
+
+ v{{ doc.version }}
+
+
+
+
+ {{ doc.description }}
+
+
+
+
+
+
+ {{ doc.items_count }} items
+
+
{{ formatDate(doc.updated_at) }}
+
+
+
+
+
diff --git a/frontend/app/components/documents/ItemCard.vue b/frontend/app/components/documents/ItemCard.vue
new file mode 100644
index 0000000..9a8831d
--- /dev/null
+++ b/frontend/app/components/documents/ItemCard.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+ {{ item.position }}
+
+
+ {{ item.title }}
+
+
+ {{ itemTypeLabel(item.item_type) }}
+
+
+
+
+ Sous vote
+
+
+ Pas de vote
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/app/components/documents/ItemVersionDiff.vue b/frontend/app/components/documents/ItemVersionDiff.vue
new file mode 100644
index 0000000..722b562
--- /dev/null
+++ b/frontend/app/components/documents/ItemVersionDiff.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+ Propose par
+
+ {{ truncateAddress(version.proposed_by) }}
+
+
+
+
+ {{ formatDate(version.created_at) }}
+
+
+
+
+
+
Justification
+
{{ version.rationale }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/app/components/sanctuary/ChainAnchor.vue b/frontend/app/components/sanctuary/ChainAnchor.vue
new file mode 100644
index 0000000..aa0df0d
--- /dev/null
+++ b/frontend/app/components/sanctuary/ChainAnchor.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+ {{ truncatedHash }}
+
+
+
+ Bloc #{{ block.toLocaleString('fr-FR') }}
+
+
+
+
+
+ Non ancre
+
+
+
diff --git a/frontend/app/components/sanctuary/IPFSLink.vue b/frontend/app/components/sanctuary/IPFSLink.vue
new file mode 100644
index 0000000..ec4b30b
--- /dev/null
+++ b/frontend/app/components/sanctuary/IPFSLink.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ {{ truncatedCid }}
+
+
+
+
+
+ Non disponible
+
+
+
diff --git a/frontend/app/components/sanctuary/SanctuaryEntry.vue b/frontend/app/components/sanctuary/SanctuaryEntry.vue
new file mode 100644
index 0000000..51c0f28
--- /dev/null
+++ b/frontend/app/components/sanctuary/SanctuaryEntry.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ entry.title || 'Entree sans titre' }}
+
+
+ {{ formatDate(entry.created_at) }}
+
+
+
+
+ {{ typeLabel(entry.entry_type) }}
+
+
+
+
+
+
+
+
+
+ {{ truncateHash(entry.content_hash) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
+
+
+
+
+
+ Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
+
+
+
+
+
+
+
+
diff --git a/frontend/app/pages/documents/[slug].vue b/frontend/app/pages/documents/[slug].vue
index b4c95be..b3fca82 100644
--- a/frontend/app/pages/documents/[slug].vue
+++ b/frontend/app/pages/documents/[slug].vue
@@ -1,9 +1,14 @@
@@ -114,14 +105,24 @@ function formatDate(dateStr: string): string {
{{ typeLabel(documents.current.doc_type) }}
-
- {{ statusLabel(documents.current.status) }}
-
+
v{{ documents.current.version }}
+
+
+
+
+
@@ -153,14 +154,17 @@ function formatDate(dateStr: string): string {
Ancrage IPFS
-
-
- {{ documents.current.ipfs_cid.slice(0, 16) }}...
-
-
- Non ancre
-
-
+
+
+
+
+
+
+
+
@@ -177,52 +181,14 @@ function formatDate(dateStr: string): string {
-
-
-
-
-
-
- {{ item.position }}
-
-
- {{ itemTypeLabel(item.item_type) }}
-
-
- {{ item.title }}
-
-
-
-
- Sous vote
-
-
- Pas de vote
-
-
-
-
-
-
-
- {{ item.current_text }}
-
-
-
-
+ :item="item"
+ :document-slug="slug"
+ :show-actions="auth.isAuthenticated"
+ @propose="handlePropose"
+ />
diff --git a/frontend/app/pages/documents/[slug]/items/[itemId].vue b/frontend/app/pages/documents/[slug]/items/[itemId].vue
new file mode 100644
index 0000000..f37475f
--- /dev/null
+++ b/frontend/app/pages/documents/[slug]/items/[itemId].vue
@@ -0,0 +1,328 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ documents.error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentItem.position }}
+
+
+ {{ itemTypeLabel(currentItem.item_type) }}
+
+
+ Sous vote
+
+
+
+ {{ currentItem.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Texte en vigueur
+
+
+
+ Cree le {{ formatDate(currentItem.created_at) }}
+ Mis a jour le {{ formatDate(currentItem.updated_at) }}
+
+
+
+
+
+
+
+
+
{{ documents.error }}
+
+
+
+
+
+
+ Historique des versions ({{ documents.versions.length }})
+
+
+
+
+
+
+
+
+
+
+
+
+
Aucune version proposee pour cet item
+
+ Connectez-vous pour proposer une modification
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Proposer une modification
+
+
+ Modifiez le texte ci-dessous et fournissez une justification pour votre proposition.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/app/pages/documents/index.vue b/frontend/app/pages/documents/index.vue
index df9df36..8920bcb 100644
--- a/frontend/app/pages/documents/index.vue
+++ b/frontend/app/pages/documents/index.vue
@@ -1,5 +1,8 @@
@@ -92,6 +111,15 @@ function formatDate(dateStr: string): string {
Documents fondateurs de la communaute Duniter/G1 sous vote permanent
+
+
+
@@ -110,15 +138,8 @@ function formatDate(dateStr: string): string {
/>
-
-
-
-
-
-
-
-
+
@@ -127,65 +148,96 @@ function formatDate(dateStr: string): string {
-
-
-
-
-
-
Aucun document de reference pour le moment
-
-
-
+
+
-
-
-
-
-
-
-
- |
- {{ col.label }}
- |
-
-
-
-
- |
-
-
- {{ doc.title }}
-
- |
-
-
- {{ typeLabel(doc.doc_type) }}
-
- |
-
- v{{ doc.version }}
- |
-
-
- {{ statusLabel(doc.status) }}
-
- |
-
- {{ doc.items_count }}
- |
-
- {{ formatDate(doc.updated_at) }}
- |
-
-
-
+
+
+
+
+
+ Nouveau document de reference
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
diff --git a/frontend/app/pages/sanctuary/[id].vue b/frontend/app/pages/sanctuary/[id].vue
new file mode 100644
index 0000000..09beb35
--- /dev/null
+++ b/frontend/app/pages/sanctuary/[id].vue
@@ -0,0 +1,445 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ entry.title || 'Entree sans titre' }}
+
+
+
+
+ {{ typeLabel(entry.entry_type) }}
+
+
+ {{ formatDate(entry.created_at) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ verifyResult.match ? 'Integrite verifiee avec succes' : 'Verification echouee' }}
+
+
{{ verifyResult.message }}
+
+
+
+
+
+
+
+
+
+
+ {{ entry.content_hash }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ancrage On-chain
+
+
+
+
+
+
+
TX Hash
+
+ {{ entry.chain_tx_hash }}
+
+
+
+
Numero de bloc
+
+ #{{ entry.chain_block.toLocaleString('fr-FR') }}
+
+
+
+
+
+
+
+
+
+ En attente d'ancrage on-chain via system.remark
+
+
+
+
+
+
+
+
+
+
+
+
+
Statut de verification
+
+
+
+
+
+
+ Hash SHA-256
+
+
+ {{ entry.content_hash ? 'Calcule' : 'En attente' }}
+
+
+
+
+
+
+
+
+ IPFS
+
+
+ {{ entry.ipfs_cid ? 'Epingle' : 'En attente' }}
+
+
+
+
+
+
+
+
+ On-chain
+
+
+ {{ entry.chain_tx_hash ? 'Ancre' : 'En attente' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ formattedMetadata || entry.metadata_json }}
+
+
+
+
+
+
+
+
+
+
Reference
+
+
+
+
Identifiant
+
{{ entry.id }}
+
+
+
Reference (document/decision)
+
{{ entry.reference_id }}
+
+
+
+
+
+
+
diff --git a/frontend/app/pages/sanctuary/index.vue b/frontend/app/pages/sanctuary/index.vue
index a33a35b..3270fe0 100644
--- a/frontend/app/pages/sanctuary/index.vue
+++ b/frontend/app/pages/sanctuary/index.vue
@@ -1,20 +1,9 @@
@@ -108,10 +87,35 @@ function truncateHash(hash: string | null, length: number = 16): string {
/>
+
+
+
+
+
+
+
+ {{ verifyResult.match ? 'Integrite verifiee' : 'Verification echouee' }}
+
+
{{ verifyResult.message }}
+
+
+
+
+
+
-
+
@@ -138,104 +142,25 @@ function truncateHash(hash: string | null, length: number = 16): string {
-
+
-
-
-
-
-
-
-
-
- {{ entry.title || 'Entree sans titre' }}
-
-
- {{ formatDate(entry.created_at) }}
-
-
-
-
- {{ typeLabel(entry.entry_type) }}
-
-
-
-
-
-
-
-
-
- SHA-256
-
-
- {{ truncateHash(entry.content_hash) }}
-
-
-
-
-
-
-
- IPFS CID
-
-
-
- {{ truncateHash(entry.ipfs_cid) }}
-
-
-
- En attente d'epinglage
-
-
-
-
-
-
-
- On-chain
-
-
-
- {{ truncateHash(entry.chain_tx_hash) }}
-
-
- Bloc #{{ entry.chain_block.toLocaleString('fr-FR') }}
-
-
-
- En attente d'ancrage
-
-
-
-
-
-
-
-
-
- IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
-
-
-
-
-
- Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
-
-
+
+
+
+
+
+
+ Verification en cours...
-
+
diff --git a/frontend/app/stores/documents.ts b/frontend/app/stores/documents.ts
index 36d1187..9385ef4 100644
--- a/frontend/app/stores/documents.ts
+++ b/frontend/app/stores/documents.ts
@@ -32,6 +32,20 @@ export interface Document {
items_count: number
}
+export interface ItemVersion {
+ id: string
+ item_id: string
+ version_number: number
+ proposed_text: string
+ rationale: string | null
+ diff: string | null
+ status: string
+ proposed_by: string | null
+ reviewed_by: string | null
+ created_at: string
+ updated_at: string
+}
+
export interface DocumentCreate {
slug: string
title: string
@@ -40,10 +54,16 @@ export interface DocumentCreate {
version?: string
}
+export interface VersionProposal {
+ proposed_text: string
+ rationale?: string | null
+}
+
interface DocumentsState {
list: Document[]
current: Document | null
items: DocumentItem[]
+ versions: ItemVersion[]
loading: boolean
error: string | null
}
@@ -53,6 +73,7 @@ export const useDocumentsStore = defineStore('documents', {
list: [],
current: null,
items: [],
+ versions: [],
loading: false,
error: null,
}),
@@ -139,11 +160,122 @@ export const useDocumentsStore = defineStore('documents', {
},
/**
- * Clear the current document and items.
+ * Fetch all versions for a specific item within a document.
+ */
+ async fetchItemVersions(slug: string, itemId: string) {
+ this.loading = true
+ this.error = null
+
+ try {
+ const { $api } = useApi()
+ this.versions = await $api
(
+ `/documents/${slug}/items/${itemId}/versions`,
+ )
+ } catch (err: any) {
+ this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des versions'
+ } finally {
+ this.loading = false
+ }
+ },
+
+ /**
+ * Propose a new version for a document item.
+ */
+ async proposeVersion(slug: string, itemId: string, data: VersionProposal) {
+ this.error = null
+
+ try {
+ const { $api } = useApi()
+ const version = await $api(
+ `/documents/${slug}/items/${itemId}/versions`,
+ {
+ method: 'POST',
+ body: data,
+ },
+ )
+ this.versions.unshift(version)
+ return version
+ } catch (err: any) {
+ this.error = err?.data?.detail || err?.message || 'Erreur lors de la proposition'
+ throw err
+ }
+ },
+
+ /**
+ * Accept a proposed version.
+ */
+ async acceptVersion(slug: string, itemId: string, versionId: string) {
+ this.error = null
+
+ try {
+ const { $api } = useApi()
+ const updated = await $api(
+ `/documents/${slug}/items/${itemId}/versions/${versionId}/accept`,
+ { method: 'POST' },
+ )
+ const idx = this.versions.findIndex(v => v.id === versionId)
+ if (idx >= 0) this.versions[idx] = updated
+ return updated
+ } catch (err: any) {
+ this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'acceptation'
+ throw err
+ }
+ },
+
+ /**
+ * Reject a proposed version.
+ */
+ async rejectVersion(slug: string, itemId: string, versionId: string) {
+ this.error = null
+
+ try {
+ const { $api } = useApi()
+ const updated = await $api(
+ `/documents/${slug}/items/${itemId}/versions/${versionId}/reject`,
+ { method: 'POST' },
+ )
+ const idx = this.versions.findIndex(v => v.id === versionId)
+ if (idx >= 0) this.versions[idx] = updated
+ return updated
+ } catch (err: any) {
+ this.error = err?.data?.detail || err?.message || 'Erreur lors du rejet'
+ throw err
+ }
+ },
+
+ /**
+ * Archive a document into the Sanctuary.
+ */
+ async archiveDocument(slug: string) {
+ this.error = null
+
+ try {
+ const { $api } = useApi()
+ const doc = await $api(
+ `/documents/${slug}/archive`,
+ { method: 'POST' },
+ )
+ // Update current if viewing this document
+ if (this.current?.slug === slug) {
+ this.current = doc
+ }
+ // Update in list
+ const idx = this.list.findIndex(d => d.slug === slug)
+ if (idx >= 0) this.list[idx] = doc
+ return doc
+ } catch (err: any) {
+ this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'archivage'
+ throw err
+ }
+ },
+
+ /**
+ * Clear the current document, items and versions.
*/
clearCurrent() {
this.current = null
this.items = []
+ this.versions = []
},
},
})