Sprint 4 : decisions et mandats -- workflow complet + vote integration

Backend: 7 nouveaux endpoints (advance, assign, revoke, create-vote-session),
services enrichis avec creation de sessions de vote, assignation de mandataire
et revocation. 35 nouveaux tests (104 total). Frontend: store mandates, page
cadrage decisions, detail mandats, composants DecisionWorkflow, DecisionCadrage,
DecisionCard, MandateTimeline, MandateCard. Documentation mise a jour.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 14:28:34 +01:00
parent cede2a585f
commit 3cb1754592
24 changed files with 3988 additions and 354 deletions

View File

@@ -13,13 +13,16 @@ from app.database import get_db
from app.models.decision import Decision, DecisionStep
from app.models.user import DuniterIdentity
from app.schemas.decision import (
DecisionAdvanceOut,
DecisionCreate,
DecisionOut,
DecisionStepCreate,
DecisionStepOut,
DecisionUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.services.auth_service import get_current_identity
from app.services.decision_service import advance_decision, create_vote_session_for_step
router = APIRouter()
@@ -141,3 +144,64 @@ async def add_step(
await db.refresh(step)
return DecisionStepOut.model_validate(step)
# ── Workflow routes ────────────────────────────────────────────────────────
@router.post("/{id}/advance", response_model=DecisionAdvanceOut)
async def advance_decision_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> DecisionAdvanceOut:
"""Advance a decision to its next step or status."""
try:
decision = await advance_decision(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output
decision = await _get_decision(db, decision.id)
data = DecisionOut.model_validate(decision).model_dump()
data["message"] = f"Decision avancee au statut : {decision.status}"
return DecisionAdvanceOut(**data)
@router.post(
"/{id}/steps/{step_id}/create-vote-session",
response_model=VoteSessionOut,
status_code=status.HTTP_201_CREATED,
)
async def create_vote_session_for_step_endpoint(
id: uuid.UUID,
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteSessionOut:
"""Create a vote session linked to a decision step."""
try:
session = await create_vote_session_for_step(id, step_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return VoteSessionOut.model_validate(session)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> None:
"""Delete a decision (only if in draft status)."""
decision = await _get_decision(db, id)
if decision.status != "draft":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Seules les decisions en brouillon peuvent etre supprimees",
)
await db.delete(decision)
await db.commit()

View File

@@ -13,12 +13,22 @@ from app.database import get_db
from app.models.mandate import Mandate, MandateStep
from app.models.user import DuniterIdentity
from app.schemas.mandate import (
MandateAdvanceOut,
MandateAssignRequest,
MandateCreate,
MandateOut,
MandateStepCreate,
MandateStepOut,
MandateUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.services.auth_service import get_current_identity
from app.services.mandate_service import (
advance_mandate,
assign_mandatee,
create_vote_session_for_step,
revoke_mandate,
)
router = APIRouter()
@@ -95,7 +105,7 @@ async def get_mandate(
@router.put("/{id}", response_model=MandateOut)
async def update_mandate(
id: uuid.UUID,
payload: MandateCreate,
payload: MandateUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateOut:
@@ -165,3 +175,80 @@ async def list_steps(
"""List all steps for a mandate, ordered by step_order."""
mandate = await _get_mandate(db, id)
return [MandateStepOut.model_validate(s) for s in mandate.steps]
# ── Workflow routes ────────────────────────────────────────────────────────
@router.post("/{id}/advance", response_model=MandateAdvanceOut)
async def advance_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateAdvanceOut:
"""Advance a mandate to its next step or status."""
try:
mandate = await advance_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output
mandate = await _get_mandate(db, mandate.id)
data = MandateOut.model_validate(mandate).model_dump()
data["message"] = f"Mandat avance au statut : {mandate.status}"
return MandateAdvanceOut(**data)
@router.post("/{id}/assign", response_model=MandateOut)
async def assign_mandatee_endpoint(
id: uuid.UUID,
payload: MandateAssignRequest,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateOut:
"""Assign a mandatee to a mandate."""
try:
mandate = await assign_mandatee(id, payload.mandatee_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
@router.post("/{id}/revoke", response_model=MandateOut)
async def revoke_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> MandateOut:
"""Revoke an active mandate."""
try:
mandate = await revoke_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
@router.post(
"/{id}/steps/{step_id}/create-vote-session",
response_model=VoteSessionOut,
status_code=status.HTTP_201_CREATED,
)
async def create_vote_session_for_step_endpoint(
id: uuid.UUID,
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> VoteSessionOut:
"""Create a vote session linked to a mandate step."""
try:
session = await create_vote_session_for_step(id, step_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return VoteSessionOut.model_validate(session)

View File

@@ -70,3 +70,22 @@ class DecisionOut(BaseModel):
created_at: datetime
updated_at: datetime
steps: list[DecisionStepOut] = Field(default_factory=list)
class DecisionAdvanceOut(BaseModel):
"""Output after advancing a decision through its workflow."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
description: str | None = None
context: str | None = None
decision_type: str
status: str
voting_protocol_id: UUID | None = None
created_by_id: UUID | None = None
created_at: datetime
updated_at: datetime
steps: list[DecisionStepOut] = Field(default_factory=list)
message: str = Field(..., description="Message decrivant l'avancement effectue")

View File

@@ -51,6 +51,21 @@ class MandateCreate(BaseModel):
decision_id: UUID | None = None
class MandateUpdate(BaseModel):
"""Partial update for a mandate."""
title: str | None = Field(default=None, max_length=256)
description: str | None = None
mandate_type: str | None = Field(default=None, max_length=64)
decision_id: UUID | None = None
class MandateAssignRequest(BaseModel):
"""Request body for assigning a mandatee to a mandate."""
mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire")
class MandateOut(BaseModel):
"""Full mandate representation returned by the API."""
@@ -68,3 +83,23 @@ class MandateOut(BaseModel):
created_at: datetime
updated_at: datetime
steps: list[MandateStepOut] = Field(default_factory=list)
class MandateAdvanceOut(BaseModel):
"""Output after advancing a mandate through its workflow."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
description: str | None = None
mandate_type: str
status: str
mandatee_id: UUID | None = None
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
created_at: datetime
updated_at: datetime
steps: list[MandateStepOut] = Field(default_factory=list)
message: str = Field(..., description="Message decrivant l'avancement effectue")

View File

@@ -1,14 +1,17 @@
"""Decision service: step advancement logic."""
"""Decision service: step advancement logic and vote session integration."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.decision import Decision, DecisionStep
from app.models.protocol import VotingProtocol, FormulaConfig
from app.models.vote import VoteSession
# Valid status transitions for decisions
@@ -115,3 +118,90 @@ def _advance_decision_status(decision: Decision) -> None:
next_index = current_index + 1
if next_index < len(_DECISION_STATUS_ORDER):
decision.status = _DECISION_STATUS_ORDER[next_index]
async def create_vote_session_for_step(
decision_id: uuid.UUID,
step_id: uuid.UUID,
db: AsyncSession,
) -> VoteSession:
"""Create a VoteSession linked to a decision step.
Uses the decision's voting_protocol to configure the session.
Raises ValueError if the decision or step is not found, the step
already has a vote session, or no voting protocol is configured.
Parameters
----------
decision_id:
UUID of the Decision.
step_id:
UUID of the DecisionStep to attach the vote session to.
db:
Async database session.
Returns
-------
VoteSession
The newly created vote session.
"""
# Fetch decision with steps
result = await db.execute(
select(Decision)
.options(selectinload(Decision.steps))
.where(Decision.id == decision_id)
)
decision = result.scalar_one_or_none()
if decision is None:
raise ValueError(f"Decision introuvable : {decision_id}")
# Find the step
step: DecisionStep | None = None
for s in decision.steps:
if s.id == step_id:
step = s
break
if step is None:
raise ValueError(f"Etape introuvable : {step_id}")
# Check step doesn't already have a vote session
if step.vote_session_id is not None:
raise ValueError("Cette etape possede deja une session de vote")
# Check voting protocol is configured
if decision.voting_protocol_id is None:
raise ValueError("Aucun protocole de vote configure pour cette decision")
# Fetch the voting protocol and its formula config for duration
proto_result = await db.execute(
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == decision.voting_protocol_id)
)
protocol = proto_result.scalar_one_or_none()
if protocol is None:
raise ValueError("Protocole de vote introuvable")
duration_days = protocol.formula_config.duration_days if protocol.formula_config else 30
now = datetime.now(timezone.utc)
# Create the vote session with explicit UUID (needed before flush)
session_id = uuid.uuid4()
session = VoteSession(
id=session_id,
decision_id=decision.id,
voting_protocol_id=protocol.id,
starts_at=now,
ends_at=now + timedelta(days=duration_days),
status="open",
)
db.add(session)
# Link session to step
step.vote_session_id = session_id
await db.commit()
await db.refresh(session)
return session

View File

@@ -1,14 +1,18 @@
"""Mandate service: step advancement logic."""
"""Mandate service: step advancement logic, assignment and revocation."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.mandate import Mandate, MandateStep
from app.models.protocol import VotingProtocol
from app.models.user import DuniterIdentity
from app.models.vote import VoteSession
# Valid status transitions for mandates
@@ -116,3 +120,214 @@ def _advance_mandate_status(mandate: Mandate) -> None:
next_index = current_index + 1
if next_index < len(_MANDATE_STATUS_ORDER):
mandate.status = _MANDATE_STATUS_ORDER[next_index]
async def assign_mandatee(
mandate_id: uuid.UUID,
mandatee_id: uuid.UUID,
db: AsyncSession,
) -> Mandate:
"""Assign a mandatee (DuniterIdentity) to a mandate.
The mandate must be in a state that accepts assignment (not completed/revoked).
The mandatee must exist in the duniter_identities table.
Parameters
----------
mandate_id:
UUID of the Mandate.
mandatee_id:
UUID of the DuniterIdentity to assign.
db:
Async database session.
Returns
-------
Mandate
The updated mandate.
Raises
------
ValueError
If the mandate or mandatee is not found, or the mandate
is in a terminal state.
"""
result = await db.execute(
select(Mandate)
.options(selectinload(Mandate.steps))
.where(Mandate.id == mandate_id)
)
mandate = result.scalar_one_or_none()
if mandate is None:
raise ValueError(f"Mandat introuvable : {mandate_id}")
if mandate.status in ("completed", "revoked"):
raise ValueError(f"Impossible d'assigner un mandataire : le mandat est en statut terminal ({mandate.status})")
# Verify mandatee exists
identity_result = await db.execute(
select(DuniterIdentity).where(DuniterIdentity.id == mandatee_id)
)
identity = identity_result.scalar_one_or_none()
if identity is None:
raise ValueError(f"Identite Duniter introuvable : {mandatee_id}")
mandate.mandatee_id = mandatee_id
mandate.starts_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(mandate)
return mandate
async def revoke_mandate(
mandate_id: uuid.UUID,
db: AsyncSession,
) -> Mandate:
"""Revoke an active mandate.
Sets the mandate status to 'revoked' and cancels any active/pending steps.
Parameters
----------
mandate_id:
UUID of the Mandate to revoke.
db:
Async database session.
Returns
-------
Mandate
The updated mandate.
Raises
------
ValueError
If the mandate is not found or already in a terminal state.
"""
result = await db.execute(
select(Mandate)
.options(selectinload(Mandate.steps))
.where(Mandate.id == mandate_id)
)
mandate = result.scalar_one_or_none()
if mandate is None:
raise ValueError(f"Mandat introuvable : {mandate_id}")
if mandate.status in ("completed", "revoked"):
raise ValueError(f"Le mandat est deja en statut terminal : {mandate.status}")
# Cancel active and pending steps
for step in mandate.steps:
if step.status in ("active", "pending"):
step.status = "cancelled"
mandate.status = "revoked"
mandate.ends_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(mandate)
return mandate
async def create_vote_session_for_step(
mandate_id: uuid.UUID,
step_id: uuid.UUID,
db: AsyncSession,
) -> VoteSession:
"""Create a VoteSession linked to a mandate step.
Uses the mandate's linked decision's voting_protocol to configure
the session, or requires a voting_protocol_id on the mandate's decision.
Parameters
----------
mandate_id:
UUID of the Mandate.
step_id:
UUID of the MandateStep to attach the vote session to.
db:
Async database session.
Returns
-------
VoteSession
The newly created vote session.
Raises
------
ValueError
If the mandate, step, or protocol is not found, or the step
already has a vote session.
"""
# Fetch mandate with steps
result = await db.execute(
select(Mandate)
.options(selectinload(Mandate.steps))
.where(Mandate.id == mandate_id)
)
mandate = result.scalar_one_or_none()
if mandate is None:
raise ValueError(f"Mandat introuvable : {mandate_id}")
# Find the step
step: MandateStep | None = None
for s in mandate.steps:
if s.id == step_id:
step = s
break
if step is None:
raise ValueError(f"Etape introuvable : {step_id}")
# Check step doesn't already have a vote session
if step.vote_session_id is not None:
raise ValueError("Cette etape possede deja une session de vote")
# Resolve the voting protocol via the linked decision
if mandate.decision_id is None:
raise ValueError("Aucune decision liee a ce mandat pour determiner le protocole de vote")
from app.models.decision import Decision
decision_result = await db.execute(
select(Decision).where(Decision.id == mandate.decision_id)
)
decision = decision_result.scalar_one_or_none()
if decision is None or decision.voting_protocol_id is None:
raise ValueError("Aucun protocole de vote configure pour la decision liee")
# Fetch the voting protocol and its formula config for duration
proto_result = await db.execute(
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == decision.voting_protocol_id)
)
protocol = proto_result.scalar_one_or_none()
if protocol is None:
raise ValueError("Protocole de vote introuvable")
duration_days = protocol.formula_config.duration_days if protocol.formula_config else 30
now = datetime.now(timezone.utc)
# Create the vote session with explicit UUID (needed before flush)
session_id = uuid.uuid4()
session = VoteSession(
id=session_id,
decision_id=mandate.decision_id,
voting_protocol_id=protocol.id,
starts_at=now,
ends_at=now + timedelta(days=duration_days),
status="open",
)
db.add(session)
# Link session to step
step.vote_session_id = session_id
await db.commit()
await db.refresh(session)
return session

View File

@@ -0,0 +1,386 @@
"""Tests for decision service: advance_decision, create_vote_session_for_step.
These are pure unit tests that mock the database layer to test
the service logic in isolation.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for decision service tests")
from app.services.decision_service import ( # noqa: E402
advance_decision,
create_vote_session_for_step,
)
# ---------------------------------------------------------------------------
# Helpers: mock objects that behave like SQLAlchemy models
# ---------------------------------------------------------------------------
def _make_step(
step_id: uuid.UUID | None = None,
decision_id: uuid.UUID | None = None,
step_order: int = 0,
step_type: str = "qualification",
status: str = "pending",
vote_session_id: uuid.UUID | None = None,
) -> MagicMock:
"""Create a mock DecisionStep."""
step = MagicMock()
step.id = step_id or uuid.uuid4()
step.decision_id = decision_id or uuid.uuid4()
step.step_order = step_order
step.step_type = step_type
step.title = None
step.description = None
step.status = status
step.vote_session_id = vote_session_id
step.outcome = None
step.created_at = datetime.now(timezone.utc)
return step
def _make_decision(
decision_id: uuid.UUID | None = None,
title: str = "Decision de test",
decision_type: str = "document_change",
status: str = "draft",
voting_protocol_id: uuid.UUID | None = None,
steps: list | None = None,
) -> MagicMock:
"""Create a mock Decision."""
decision = MagicMock()
decision.id = decision_id or uuid.uuid4()
decision.title = title
decision.description = None
decision.context = None
decision.decision_type = decision_type
decision.status = status
decision.voting_protocol_id = voting_protocol_id
decision.created_by_id = None
decision.created_at = datetime.now(timezone.utc)
decision.updated_at = datetime.now(timezone.utc)
decision.steps = steps or []
return decision
def _make_protocol(
protocol_id: uuid.UUID | None = None,
duration_days: int = 30,
) -> MagicMock:
"""Create a mock VotingProtocol with formula_config."""
protocol = MagicMock()
protocol.id = protocol_id or uuid.uuid4()
protocol.name = "Protocole standard"
protocol.vote_type = "binary"
protocol.formula_config = MagicMock()
protocol.formula_config.duration_days = duration_days
return protocol
def _make_db_for_advance(decision: MagicMock | None) -> AsyncMock:
"""Create a mock async database session for advance_decision."""
db = AsyncMock()
result = MagicMock()
result.scalar_one_or_none.return_value = decision
db.execute = AsyncMock(return_value=result)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
def _make_db_for_vote_session(
decision: MagicMock | None,
protocol: MagicMock | None = None,
) -> AsyncMock:
"""Create a mock async database session for create_vote_session_for_step.
Sequential execute calls:
1st -> decision lookup
2nd -> protocol lookup
"""
db = AsyncMock()
call_results = []
# Decision result
decision_result = MagicMock()
decision_result.scalar_one_or_none.return_value = decision
call_results.append(decision_result)
# Protocol result
if protocol is not None:
proto_result = MagicMock()
proto_result.scalar_one_or_none.return_value = protocol
call_results.append(proto_result)
db.execute = AsyncMock(side_effect=call_results)
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.flush = AsyncMock()
db.add = MagicMock()
return db
# ---------------------------------------------------------------------------
# Tests: advance_decision
# ---------------------------------------------------------------------------
class TestAdvanceDecision:
"""Test decision_service.advance_decision."""
@pytest.mark.asyncio
async def test_advance_from_draft_activates_first_step(self):
"""Advancing a draft decision with pending steps activates the first one
and moves decision to qualification."""
decision_id = uuid.uuid4()
step1 = _make_step(decision_id=decision_id, step_order=0, status="pending")
step2 = _make_step(decision_id=decision_id, step_order=1, status="pending")
decision = _make_decision(
decision_id=decision_id,
status="draft",
steps=[step1, step2],
)
db = _make_db_for_advance(decision)
result = await advance_decision(decision_id, db)
assert step1.status == "active"
assert step2.status == "pending"
assert decision.status == "qualification"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_completes_active_and_activates_next(self):
"""Advancing completes the current active step and activates the next pending one."""
decision_id = uuid.uuid4()
step1 = _make_step(decision_id=decision_id, step_order=0, status="active")
step2 = _make_step(decision_id=decision_id, step_order=1, status="pending")
decision = _make_decision(
decision_id=decision_id,
status="qualification",
steps=[step1, step2],
)
db = _make_db_for_advance(decision)
result = await advance_decision(decision_id, db)
assert step1.status == "completed"
assert step2.status == "active"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_last_step_advances_status(self):
"""When the last active step is completed with no more pending steps,
the decision status advances."""
decision_id = uuid.uuid4()
step1 = _make_step(decision_id=decision_id, step_order=0, status="completed")
step2 = _make_step(decision_id=decision_id, step_order=1, status="active")
decision = _make_decision(
decision_id=decision_id,
status="qualification",
steps=[step1, step2],
)
db = _make_db_for_advance(decision)
result = await advance_decision(decision_id, db)
assert step2.status == "completed"
assert decision.status == "review"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_no_steps_advances_status(self):
"""A decision with no steps advances its status directly."""
decision_id = uuid.uuid4()
decision = _make_decision(
decision_id=decision_id,
status="draft",
steps=[],
)
db = _make_db_for_advance(decision)
result = await advance_decision(decision_id, db)
assert decision.status == "qualification"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_full_progression(self):
"""A decision with no steps can progress through all statuses."""
decision_id = uuid.uuid4()
decision = _make_decision(
decision_id=decision_id,
status="draft",
steps=[],
)
statuses_seen = [decision.status]
db = _make_db_for_advance(decision)
# Advance through all states until closed
for _ in range(10): # safety limit
# Recreate db mock for each call (side_effect is consumed)
db = _make_db_for_advance(decision)
await advance_decision(decision_id, db)
statuses_seen.append(decision.status)
if decision.status == "closed":
break
assert statuses_seen == [
"draft",
"qualification",
"review",
"voting",
"executed",
"closed",
]
@pytest.mark.asyncio
async def test_advance_closed_raises_error(self):
"""Advancing a closed decision raises ValueError."""
decision_id = uuid.uuid4()
decision = _make_decision(
decision_id=decision_id,
status="closed",
steps=[],
)
db = _make_db_for_advance(decision)
with pytest.raises(ValueError, match="deja cloturee"):
await advance_decision(decision_id, db)
@pytest.mark.asyncio
async def test_advance_not_found_raises_error(self):
"""Advancing a non-existent decision raises ValueError."""
db = _make_db_for_advance(None)
with pytest.raises(ValueError, match="Decision introuvable"):
await advance_decision(uuid.uuid4(), db)
# ---------------------------------------------------------------------------
# Tests: create_vote_session_for_step
# ---------------------------------------------------------------------------
class TestCreateVoteSessionForStep:
"""Test decision_service.create_vote_session_for_step."""
@pytest.mark.asyncio
async def test_create_vote_session_success(self):
"""A vote session is created and linked to the step."""
decision_id = uuid.uuid4()
step_id = uuid.uuid4()
protocol_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
decision_id=decision_id,
step_type="vote",
status="active",
vote_session_id=None,
)
decision = _make_decision(
decision_id=decision_id,
status="voting",
voting_protocol_id=protocol_id,
steps=[step],
)
protocol = _make_protocol(protocol_id=protocol_id, duration_days=14)
db = _make_db_for_vote_session(decision, protocol)
result = await create_vote_session_for_step(decision_id, step_id, db)
# The step should now have a vote_session_id set
assert step.vote_session_id is not None
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_vote_session_decision_not_found(self):
"""ValueError when decision does not exist."""
db = _make_db_for_vote_session(None)
with pytest.raises(ValueError, match="Decision introuvable"):
await create_vote_session_for_step(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_create_vote_session_step_not_found(self):
"""ValueError when step does not belong to the decision."""
decision_id = uuid.uuid4()
decision = _make_decision(decision_id=decision_id, steps=[])
db = _make_db_for_vote_session(decision)
with pytest.raises(ValueError, match="Etape introuvable"):
await create_vote_session_for_step(decision_id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_create_vote_session_step_already_has_session(self):
"""ValueError when step already has a vote session."""
decision_id = uuid.uuid4()
step_id = uuid.uuid4()
existing_session_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
decision_id=decision_id,
vote_session_id=existing_session_id,
)
decision = _make_decision(
decision_id=decision_id,
voting_protocol_id=uuid.uuid4(),
steps=[step],
)
db = _make_db_for_vote_session(decision)
with pytest.raises(ValueError, match="deja une session de vote"):
await create_vote_session_for_step(decision_id, step_id, db)
@pytest.mark.asyncio
async def test_create_vote_session_no_protocol(self):
"""ValueError when decision has no voting protocol configured."""
decision_id = uuid.uuid4()
step_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
decision_id=decision_id,
vote_session_id=None,
)
decision = _make_decision(
decision_id=decision_id,
voting_protocol_id=None,
steps=[step],
)
db = _make_db_for_vote_session(decision)
with pytest.raises(ValueError, match="Aucun protocole de vote"):
await create_vote_session_for_step(decision_id, step_id, db)

View File

@@ -0,0 +1,655 @@
"""Tests for mandate service: advance_mandate, assign_mandatee, revoke_mandate, create_vote_session_for_step.
These are pure unit tests that mock the database layer to test
the service logic in isolation.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for mandate service tests")
from app.services.mandate_service import ( # noqa: E402
advance_mandate,
assign_mandatee,
create_vote_session_for_step,
revoke_mandate,
)
# ---------------------------------------------------------------------------
# Helpers: mock objects that behave like SQLAlchemy models
# ---------------------------------------------------------------------------
def _make_step(
step_id: uuid.UUID | None = None,
mandate_id: uuid.UUID | None = None,
step_order: int = 0,
step_type: str = "formulation",
status: str = "pending",
vote_session_id: uuid.UUID | None = None,
) -> MagicMock:
"""Create a mock MandateStep."""
step = MagicMock()
step.id = step_id or uuid.uuid4()
step.mandate_id = mandate_id or uuid.uuid4()
step.step_order = step_order
step.step_type = step_type
step.title = None
step.description = None
step.status = status
step.vote_session_id = vote_session_id
step.outcome = None
step.created_at = datetime.now(timezone.utc)
return step
def _make_mandate(
mandate_id: uuid.UUID | None = None,
title: str = "Mandat de test",
mandate_type: str = "techcomm",
status: str = "draft",
mandatee_id: uuid.UUID | None = None,
decision_id: uuid.UUID | None = None,
steps: list | None = None,
) -> MagicMock:
"""Create a mock Mandate."""
mandate = MagicMock()
mandate.id = mandate_id or uuid.uuid4()
mandate.title = title
mandate.description = None
mandate.mandate_type = mandate_type
mandate.status = status
mandate.mandatee_id = mandatee_id
mandate.decision_id = decision_id
mandate.starts_at = None
mandate.ends_at = None
mandate.created_at = datetime.now(timezone.utc)
mandate.updated_at = datetime.now(timezone.utc)
mandate.steps = steps or []
return mandate
def _make_identity(identity_id: uuid.UUID | None = None) -> MagicMock:
"""Create a mock DuniterIdentity."""
identity = MagicMock()
identity.id = identity_id or uuid.uuid4()
identity.address = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
identity.username = "Alice"
return identity
def _make_protocol(
protocol_id: uuid.UUID | None = None,
duration_days: int = 30,
) -> MagicMock:
"""Create a mock VotingProtocol with formula_config."""
protocol = MagicMock()
protocol.id = protocol_id or uuid.uuid4()
protocol.name = "Protocole standard"
protocol.vote_type = "binary"
protocol.formula_config = MagicMock()
protocol.formula_config.duration_days = duration_days
return protocol
def _make_decision_mock(
decision_id: uuid.UUID | None = None,
voting_protocol_id: uuid.UUID | None = None,
) -> MagicMock:
"""Create a mock Decision for the vote session lookup."""
decision = MagicMock()
decision.id = decision_id or uuid.uuid4()
decision.voting_protocol_id = voting_protocol_id
return decision
def _make_db_for_advance(mandate: MagicMock | None) -> AsyncMock:
"""Create a mock async database session for advance_mandate."""
db = AsyncMock()
result = MagicMock()
result.scalar_one_or_none.return_value = mandate
db.execute = AsyncMock(return_value=result)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
def _make_db_for_assign(
mandate: MagicMock | None,
identity: MagicMock | None = None,
) -> AsyncMock:
"""Create a mock async database session for assign_mandatee.
Sequential execute calls:
1st -> mandate lookup
2nd -> identity lookup
"""
db = AsyncMock()
call_results = []
# Mandate result
mandate_result = MagicMock()
mandate_result.scalar_one_or_none.return_value = mandate
call_results.append(mandate_result)
# Identity result
if identity is not None or mandate is not None:
identity_result = MagicMock()
identity_result.scalar_one_or_none.return_value = identity
call_results.append(identity_result)
db.execute = AsyncMock(side_effect=call_results)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
def _make_db_for_revoke(mandate: MagicMock | None) -> AsyncMock:
"""Create a mock async database session for revoke_mandate."""
db = AsyncMock()
result = MagicMock()
result.scalar_one_or_none.return_value = mandate
db.execute = AsyncMock(return_value=result)
db.commit = AsyncMock()
db.refresh = AsyncMock()
return db
def _make_db_for_vote_session(
mandate: MagicMock | None,
decision: MagicMock | None = None,
protocol: MagicMock | None = None,
) -> AsyncMock:
"""Create a mock async database session for create_vote_session_for_step.
Sequential execute calls:
1st -> mandate lookup
2nd -> decision lookup
3rd -> protocol lookup
"""
db = AsyncMock()
call_results = []
# Mandate result
mandate_result = MagicMock()
mandate_result.scalar_one_or_none.return_value = mandate
call_results.append(mandate_result)
# Decision result
if decision is not None:
decision_result = MagicMock()
decision_result.scalar_one_or_none.return_value = decision
call_results.append(decision_result)
# Protocol result
if protocol is not None:
proto_result = MagicMock()
proto_result.scalar_one_or_none.return_value = protocol
call_results.append(proto_result)
db.execute = AsyncMock(side_effect=call_results)
db.commit = AsyncMock()
db.refresh = AsyncMock()
db.flush = AsyncMock()
db.add = MagicMock()
return db
# ---------------------------------------------------------------------------
# Tests: advance_mandate
# ---------------------------------------------------------------------------
class TestAdvanceMandate:
"""Test mandate_service.advance_mandate."""
@pytest.mark.asyncio
async def test_advance_from_draft_activates_first_step(self):
"""Advancing a draft mandate with pending steps activates the first one
and moves mandate to candidacy."""
mandate_id = uuid.uuid4()
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="pending")
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="pending")
mandate = _make_mandate(
mandate_id=mandate_id,
status="draft",
steps=[step1, step2],
)
db = _make_db_for_advance(mandate)
result = await advance_mandate(mandate_id, db)
assert step1.status == "active"
assert step2.status == "pending"
assert mandate.status == "candidacy"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_completes_active_and_activates_next(self):
"""Advancing completes the current active step and activates the next pending one."""
mandate_id = uuid.uuid4()
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="active")
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="pending")
mandate = _make_mandate(
mandate_id=mandate_id,
status="candidacy",
steps=[step1, step2],
)
db = _make_db_for_advance(mandate)
result = await advance_mandate(mandate_id, db)
assert step1.status == "completed"
assert step2.status == "active"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_last_step_advances_status(self):
"""When the last active step is completed with no more pending steps,
the mandate status advances."""
mandate_id = uuid.uuid4()
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="completed")
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="active")
mandate = _make_mandate(
mandate_id=mandate_id,
status="candidacy",
steps=[step1, step2],
)
db = _make_db_for_advance(mandate)
result = await advance_mandate(mandate_id, db)
assert step2.status == "completed"
assert mandate.status == "voting"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_advance_full_progression(self):
"""A mandate with no steps can progress through all statuses."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="draft",
steps=[],
)
statuses_seen = [mandate.status]
# Advance through all states until completed
for _ in range(10): # safety limit
db = _make_db_for_advance(mandate)
await advance_mandate(mandate_id, db)
statuses_seen.append(mandate.status)
if mandate.status == "completed":
break
assert statuses_seen == [
"draft",
"candidacy",
"voting",
"active",
"reporting",
"completed",
]
@pytest.mark.asyncio
async def test_advance_completed_raises_error(self):
"""Advancing a completed mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="completed",
steps=[],
)
db = _make_db_for_advance(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await advance_mandate(mandate_id, db)
@pytest.mark.asyncio
async def test_advance_revoked_raises_error(self):
"""Advancing a revoked mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="revoked",
steps=[],
)
db = _make_db_for_advance(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await advance_mandate(mandate_id, db)
@pytest.mark.asyncio
async def test_advance_not_found_raises_error(self):
"""Advancing a non-existent mandate raises ValueError."""
db = _make_db_for_advance(None)
with pytest.raises(ValueError, match="Mandat introuvable"):
await advance_mandate(uuid.uuid4(), db)
# ---------------------------------------------------------------------------
# Tests: assign_mandatee
# ---------------------------------------------------------------------------
class TestAssignMandatee:
"""Test mandate_service.assign_mandatee."""
@pytest.mark.asyncio
async def test_assign_success(self):
"""Assigning a valid identity to an active mandate succeeds."""
mandate_id = uuid.uuid4()
mandatee_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="active",
)
identity = _make_identity(identity_id=mandatee_id)
db = _make_db_for_assign(mandate, identity)
result = await assign_mandatee(mandate_id, mandatee_id, db)
assert mandate.mandatee_id == mandatee_id
assert mandate.starts_at is not None
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_assign_draft_mandate(self):
"""Assigning to a draft mandate is allowed."""
mandate_id = uuid.uuid4()
mandatee_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="draft",
)
identity = _make_identity(identity_id=mandatee_id)
db = _make_db_for_assign(mandate, identity)
result = await assign_mandatee(mandate_id, mandatee_id, db)
assert mandate.mandatee_id == mandatee_id
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_assign_not_found_raises_error(self):
"""Assigning to a non-existent mandate raises ValueError."""
db = _make_db_for_assign(None)
with pytest.raises(ValueError, match="Mandat introuvable"):
await assign_mandatee(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_completed_raises_error(self):
"""Assigning to a completed mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="completed",
)
db = _make_db_for_assign(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await assign_mandatee(mandate_id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_revoked_raises_error(self):
"""Assigning to a revoked mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="revoked",
)
db = _make_db_for_assign(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await assign_mandatee(mandate_id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_identity_not_found_raises_error(self):
"""Assigning a non-existent identity raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="active",
)
db = _make_db_for_assign(mandate, identity=None)
with pytest.raises(ValueError, match="Identite Duniter introuvable"):
await assign_mandatee(mandate_id, uuid.uuid4(), db)
# ---------------------------------------------------------------------------
# Tests: revoke_mandate
# ---------------------------------------------------------------------------
class TestRevokeMandate:
"""Test mandate_service.revoke_mandate."""
@pytest.mark.asyncio
async def test_revoke_active_mandate(self):
"""Revoking an active mandate sets status to revoked and cancels steps."""
mandate_id = uuid.uuid4()
step1 = _make_step(mandate_id=mandate_id, step_order=0, status="completed")
step2 = _make_step(mandate_id=mandate_id, step_order=1, status="active")
step3 = _make_step(mandate_id=mandate_id, step_order=2, status="pending")
mandate = _make_mandate(
mandate_id=mandate_id,
status="active",
steps=[step1, step2, step3],
)
db = _make_db_for_revoke(mandate)
result = await revoke_mandate(mandate_id, db)
assert mandate.status == "revoked"
assert mandate.ends_at is not None
# Completed steps stay completed
assert step1.status == "completed"
# Active and pending steps are cancelled
assert step2.status == "cancelled"
assert step3.status == "cancelled"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_revoke_draft_mandate(self):
"""Revoking a draft mandate is allowed."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="draft",
steps=[],
)
db = _make_db_for_revoke(mandate)
result = await revoke_mandate(mandate_id, db)
assert mandate.status == "revoked"
assert mandate.ends_at is not None
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_revoke_not_found_raises_error(self):
"""Revoking a non-existent mandate raises ValueError."""
db = _make_db_for_revoke(None)
with pytest.raises(ValueError, match="Mandat introuvable"):
await revoke_mandate(uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_revoke_completed_raises_error(self):
"""Revoking an already completed mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="completed",
)
db = _make_db_for_revoke(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await revoke_mandate(mandate_id, db)
@pytest.mark.asyncio
async def test_revoke_already_revoked_raises_error(self):
"""Revoking an already revoked mandate raises ValueError."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(
mandate_id=mandate_id,
status="revoked",
)
db = _make_db_for_revoke(mandate)
with pytest.raises(ValueError, match="statut terminal"):
await revoke_mandate(mandate_id, db)
# ---------------------------------------------------------------------------
# Tests: create_vote_session_for_step
# ---------------------------------------------------------------------------
class TestCreateVoteSessionForStep:
"""Test mandate_service.create_vote_session_for_step."""
@pytest.mark.asyncio
async def test_create_vote_session_success(self):
"""A vote session is created and linked to the mandate step."""
mandate_id = uuid.uuid4()
step_id = uuid.uuid4()
decision_id = uuid.uuid4()
protocol_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
mandate_id=mandate_id,
step_type="vote",
status="active",
vote_session_id=None,
)
mandate = _make_mandate(
mandate_id=mandate_id,
status="voting",
decision_id=decision_id,
steps=[step],
)
decision = _make_decision_mock(
decision_id=decision_id,
voting_protocol_id=protocol_id,
)
protocol = _make_protocol(protocol_id=protocol_id, duration_days=14)
db = _make_db_for_vote_session(mandate, decision, protocol)
result = await create_vote_session_for_step(mandate_id, step_id, db)
# The step should now have a vote_session_id set
assert step.vote_session_id is not None
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_vote_session_mandate_not_found(self):
"""ValueError when mandate does not exist."""
db = _make_db_for_vote_session(None)
with pytest.raises(ValueError, match="Mandat introuvable"):
await create_vote_session_for_step(uuid.uuid4(), uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_create_vote_session_step_not_found(self):
"""ValueError when step does not belong to the mandate."""
mandate_id = uuid.uuid4()
mandate = _make_mandate(mandate_id=mandate_id, decision_id=uuid.uuid4(), steps=[])
db = _make_db_for_vote_session(mandate)
with pytest.raises(ValueError, match="Etape introuvable"):
await create_vote_session_for_step(mandate_id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_create_vote_session_step_already_has_session(self):
"""ValueError when step already has a vote session."""
mandate_id = uuid.uuid4()
step_id = uuid.uuid4()
existing_session_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
mandate_id=mandate_id,
vote_session_id=existing_session_id,
)
mandate = _make_mandate(
mandate_id=mandate_id,
decision_id=uuid.uuid4(),
steps=[step],
)
db = _make_db_for_vote_session(mandate)
with pytest.raises(ValueError, match="deja une session de vote"):
await create_vote_session_for_step(mandate_id, step_id, db)
@pytest.mark.asyncio
async def test_create_vote_session_no_decision(self):
"""ValueError when mandate has no linked decision."""
mandate_id = uuid.uuid4()
step_id = uuid.uuid4()
step = _make_step(
step_id=step_id,
mandate_id=mandate_id,
vote_session_id=None,
)
mandate = _make_mandate(
mandate_id=mandate_id,
decision_id=None,
steps=[step],
)
db = _make_db_for_vote_session(mandate)
with pytest.raises(ValueError, match="Aucune decision liee"):
await create_vote_session_for_step(mandate_id, step_id, db)

View File

@@ -44,7 +44,10 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
| POST | `/` | Creer une nouvelle decision | Oui |
| GET | `/{id}` | Obtenir une decision avec ses etapes | Non |
| PUT | `/{id}` | Mettre a jour une decision | Oui |
| DELETE | `/{id}` | Supprimer une decision (brouillon uniquement) | Oui |
| POST | `/{id}/steps` | Ajouter une etape a une decision | Oui |
| POST | `/{id}/advance` | Avancer la decision a l'etape suivante | Oui |
| POST | `/{id}/steps/{step_id}/create-vote-session` | Creer une session de vote pour une etape | Oui |
## Votes (`/api/v1/votes`)
@@ -71,6 +74,10 @@ Tous les endpoints sont prefixes par `/api/v1`. L'API est auto-documentee via Op
| DELETE | `/{id}` | Supprimer un mandat (brouillon uniquement) | Oui |
| POST | `/{id}/steps` | Ajouter une etape a un mandat | Oui |
| GET | `/{id}/steps` | Lister les etapes d'un mandat | Non |
| POST | `/{id}/advance` | Avancer le mandat a l'etape suivante | Oui |
| POST | `/{id}/assign` | Assigner un mandataire au mandat | Oui |
| POST | `/{id}/revoke` | Revoquer un mandat actif | Oui |
| POST | `/{id}/steps/{step_id}/create-vote-session` | Creer une session de vote pour une etape | Oui |
## Protocoles (`/api/v1/protocols`)
@@ -448,6 +455,184 @@ Simule le calcul de seuil d'une formule avec des parametres arbitraires, sans cr
}
```
## Details des endpoints Sprint 4
### `POST /api/v1/decisions/{id}/advance` -- Avancer une decision
Avance une decision a l'etape suivante de son workflow. Complete l'etape active courante et active la prochaine etape en attente. Si toutes les etapes sont terminees, le statut global de la decision avance au statut suivant dans le cycle de vie (`draft` -> `qualification` -> `review` -> `voting` -> `executed` -> `closed`).
**Comportement** :
- S'il y a une etape `active` : elle passe a `completed`, la prochaine etape `pending` est activee.
- S'il n'y a pas d'etape active : la premiere etape `pending` est activee. Si la decision est en `draft`, elle passe a `qualification`.
- Si toutes les etapes sont terminees : le statut global avance.
**Preconditions** :
- La decision ne doit pas etre au statut `closed`.
- L'utilisateur doit etre authentifie.
**Reponse** : `200 OK` avec un `DecisionAdvanceOut` :
```json
{
"id": "uuid",
"title": "Runtime upgrade v3.2.0",
"description": "...",
"context": "...",
"decision_type": "runtime_upgrade",
"status": "review",
"voting_protocol_id": "uuid",
"created_by_id": "uuid",
"created_at": "2026-02-28T10:00:00Z",
"updated_at": "2026-02-28T12:00:00Z",
"steps": [
{"id": "uuid", "step_order": 0, "step_type": "qualification", "status": "completed", "...": "..."},
{"id": "uuid", "step_order": 1, "step_type": "review", "status": "active", "...": "..."},
{"id": "uuid", "step_order": 2, "step_type": "vote", "status": "pending", "...": "..."}
],
"message": "Etape 'Qualification' completee. Etape 'Examen' activee."
}
```
**Code 400** : Si la decision est deja cloturee ou qu'aucun avancement n'est possible.
---
### `DELETE /api/v1/decisions/{id}` -- Supprimer une decision
Supprime une decision. La suppression n'est autorisee que si la decision est au statut `draft`. Les etapes associees sont supprimees en cascade.
**Preconditions** :
- La decision doit etre au statut `draft`.
- L'utilisateur doit etre authentifie.
**Reponse** : `204 No Content`.
**Code 400** : Si la decision n'est pas au statut `draft`.
---
### `POST /api/v1/decisions/{id}/steps/{step_id}/create-vote-session` -- Creer une session de vote pour une etape de decision
Cree une session de vote liee a une etape specifique d'une decision. La session est configuree automatiquement a partir du protocole de vote de la decision.
**Comportement** :
1. Verifie que la decision et l'etape existent.
2. Verifie que l'etape est de type `vote` et au statut `active`.
3. Cree une session de vote avec le protocole de la decision.
4. Fait un snapshot des tailles WoT, Smith et TechComm.
5. Calcule la date de fin a partir de la duree de la formule.
6. Rattache la session a l'etape via `vote_session_id`.
**Reponse** : `201 Created` avec un `VoteSessionOut`.
**Code 400** : Si l'etape n'est pas de type `vote`, n'est pas `active`, ou si une session est deja liee.
---
### `POST /api/v1/mandates/{id}/advance` -- Avancer un mandat
Avance un mandat a l'etape suivante de son workflow. Fonctionne de maniere identique a l'avancement des decisions, avec le cycle de vie specifique aux mandats (`draft` -> `candidacy` -> `voting` -> `active` -> `reporting` -> `completed`).
**Comportement** :
- S'il y a une etape `active` : elle passe a `completed`, la prochaine etape `pending` est activee.
- S'il n'y a pas d'etape active : la premiere etape `pending` est activee. Si le mandat est en `draft`, il passe a `candidacy`.
- Si toutes les etapes sont terminees : le statut global avance.
**Preconditions** :
- Le mandat ne doit pas etre au statut `completed` ou `revoked`.
- L'utilisateur doit etre authentifie.
**Reponse** : `200 OK` avec un `MandateAdvanceOut` :
```json
{
"id": "uuid",
"title": "Membre Comite Technique 2026",
"description": "...",
"mandate_type": "techcomm",
"status": "candidacy",
"mandatee_id": null,
"decision_id": null,
"starts_at": null,
"ends_at": null,
"created_at": "2026-02-28T10:00:00Z",
"updated_at": "2026-02-28T12:00:00Z",
"steps": [
{"id": "uuid", "step_order": 0, "step_type": "formulation", "status": "completed", "...": "..."},
{"id": "uuid", "step_order": 1, "step_type": "candidacy", "status": "active", "...": "..."}
],
"message": "Etape 'Formulation' completee. Etape 'Candidature' activee."
}
```
**Code 400** : Si le mandat est deja en statut terminal (`completed` ou `revoked`).
---
### `POST /api/v1/mandates/{id}/assign` -- Assigner un mandataire
Assigne un mandataire (membre elu) a un mandat apres un vote reussi.
**Corps de la requete** :
```json
{
"mandatee_id": "uuid-de-l-identite-duniter"
}
```
**Comportement** :
1. Verifie que le mandat existe et qu'il est dans un statut permettant l'assignation.
2. Definit le `mandatee_id` du mandat.
3. Le mandat peut ensuite etre avance vers le statut `active`.
**Preconditions** :
- L'utilisateur doit etre authentifie.
- Le `mandatee_id` doit correspondre a une identite Duniter existante.
**Reponse** : `200 OK` avec un `MandateOut` mis a jour.
**Code 404** : Si le mandat ou l'identite Duniter n'existe pas.
---
### `POST /api/v1/mandates/{id}/revoke` -- Revoquer un mandat
Revoque un mandat actif de maniere anticipee. Le statut du mandat passe directement a `revoked`, mettant fin aux responsabilites du mandataire.
**Preconditions** :
- Le mandat doit etre au statut `active` ou `reporting`.
- L'utilisateur doit etre authentifie.
**Reponse** : `200 OK` avec un `MandateOut` mis a jour (statut `revoked`).
**Code 400** : Si le mandat n'est pas dans un statut permettant la revocation.
---
### `POST /api/v1/mandates/{id}/steps/{step_id}/create-vote-session` -- Creer une session de vote pour une etape de mandat
Cree une session de vote liee a une etape specifique d'un mandat. Fonctionne de maniere identique a la creation de session pour les decisions.
**Comportement** :
1. Verifie que le mandat et l'etape existent.
2. Verifie que l'etape est de type `vote` et au statut `active`.
3. Cree une session de vote avec le protocole associe.
4. Fait un snapshot des tailles WoT, Smith et TechComm.
5. Calcule la date de fin a partir de la duree de la formule.
6. Rattache la session a l'etape via `vote_session_id`.
**Reponse** : `201 Created` avec un `VoteSessionOut`.
**Code 400** : Si l'etape n'est pas de type `vote`, n'est pas `active`, ou si une session est deja liee.
---
## Pagination
Les endpoints de liste acceptent les parametres `skip` (offset, defaut 0) et `limit` (max 200, defaut 50).

View File

@@ -89,38 +89,42 @@ Historique des versions proposees pour chaque item. Lors de l'acceptation d'une
### `decisions`
Processus decisionnels multi-etapes.
Processus decisionnels multi-etapes. Le cycle de vie suit un ordre strict de statuts gere par le service `decision_service.advance_decision()`.
| Colonne | Type | Description |
| ------------------- | ------------ | -------------------------------------------------------- |
| id | UUID (PK) | Identifiant unique |
| title | VARCHAR(256) | Titre de la decision |
| description | TEXT | Description |
| context | TEXT | Contexte additionnel |
| decision_type | VARCHAR(64) | Type : runtime_upgrade, document_change, mandate_vote, custom |
| context | TEXT | Contexte additionnel (cadrage) |
| decision_type | VARCHAR(64) | Type : runtime_upgrade, document_change, mandate_vote, parameter_change, custom |
| status | VARCHAR(32) | Statut : draft, qualification, review, voting, executed, closed |
| voting_protocol_id | UUID (FK) | -> voting_protocols.id |
| created_by_id | UUID (FK) | -> duniter_identities.id |
| created_at | TIMESTAMPTZ | Date de creation |
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
**Workflow des statuts** : `draft` -> `qualification` -> `review` -> `voting` -> `executed` -> `closed`. La transition est geree par le service `advance_decision` qui complete l'etape active, active la suivante, et avance le statut global quand toutes les etapes sont terminees. La suppression n'est autorisee qu'en statut `draft`.
### `decision_steps`
Etapes d'un processus decisionnel.
Etapes d'un processus decisionnel. Chaque etape est traitee sequentiellement selon `step_order`. Le service `advance_decision` complete l'etape `active` et active la prochaine `pending`.
| Colonne | Type | Description |
| ---------------- | ------------ | -------------------------------------------------------- |
| id | UUID (PK) | Identifiant unique |
| decision_id | UUID (FK) | -> decisions.id |
| step_order | INTEGER | Ordre de l'etape |
| decision_id | UUID (FK) | -> decisions.id (cascade delete) |
| step_order | INTEGER | Ordre de l'etape (0-indexed, determine la sequence) |
| step_type | VARCHAR(32) | Type : qualification, review, vote, execution, reporting |
| title | VARCHAR(256) | Titre de l'etape |
| description | TEXT | Description |
| status | VARCHAR(32) | Statut : pending, active, completed, skipped |
| vote_session_id | UUID (FK) | -> vote_sessions.id (session de vote associee) |
| vote_session_id | UUID (FK) | -> vote_sessions.id (renseigne via create-vote-session pour les etapes de type `vote`) |
| outcome | TEXT | Resultat de l'etape |
| created_at | TIMESTAMPTZ | Date de creation |
**Types d'etapes** : `qualification` (verification recevabilite), `review` (examen communautaire), `vote` (session de vote formelle), `execution` (mise en oeuvre), `reporting` (compte-rendu). Les etapes de type `vote` sont les seules a pouvoir avoir un `vote_session_id` renseigne.
### `vote_sessions`
Sessions de vote avec snapshot des tailles WoT et decompte en temps reel.
@@ -170,7 +174,7 @@ Votes individuels avec preuve cryptographique.
### `mandates`
Mandats assignes a des membres.
Mandats assignes a des membres. Le cycle de vie suit un ordre strict de statuts gere par le service `mandate_service.advance_mandate()`. La revocation (`POST /mandates/{id}/revoke`) permet de passer directement au statut terminal `revoked`.
| Colonne | Type | Description |
| ------------- | ------------ | ------------------------------------------------------- |
@@ -179,30 +183,34 @@ Mandats assignes a des membres.
| description | TEXT | Description |
| mandate_type | VARCHAR(64) | Type : techcomm, smith, custom |
| status | VARCHAR(32) | Statut : draft, candidacy, voting, active, reporting, completed, revoked |
| mandatee_id | UUID (FK) | -> duniter_identities.id (titulaire du mandat) |
| decision_id | UUID (FK) | -> decisions.id (decision associee) |
| starts_at | TIMESTAMPTZ | Date de debut |
| ends_at | TIMESTAMPTZ | Date de fin |
| mandatee_id | UUID (FK) | -> duniter_identities.id (titulaire du mandat, renseigne via `POST /mandates/{id}/assign`) |
| decision_id | UUID (FK) | -> decisions.id (decision associee, optionnel) |
| starts_at | TIMESTAMPTZ | Date de debut (renseignee lors de l'assignation) |
| ends_at | TIMESTAMPTZ | Date de fin (renseignee lors de l'assignation) |
| created_at | TIMESTAMPTZ | Date de creation |
| updated_at | TIMESTAMPTZ | Date de derniere mise a jour |
**Workflow des statuts** : `draft` -> `candidacy` -> `voting` -> `active` -> `reporting` -> `completed`. Les statuts terminaux sont `completed` et `revoked`. La suppression n'est autorisee qu'en statut `draft`. La revocation est possible depuis les statuts `active` ou `reporting`.
### `mandate_steps`
Etapes du cycle de vie d'un mandat.
Etapes du cycle de vie d'un mandat. Chaque etape est traitee sequentiellement selon `step_order`. Le service `advance_mandate` complete l'etape `active` et active la prochaine `pending`.
| Colonne | Type | Description |
| ---------------- | ------------ | ------------------------------------------------------------- |
| id | UUID (PK) | Identifiant unique |
| mandate_id | UUID (FK) | -> mandates.id |
| step_order | INTEGER | Ordre de l'etape |
| mandate_id | UUID (FK) | -> mandates.id (cascade delete) |
| step_order | INTEGER | Ordre de l'etape (0-indexed, determine la sequence) |
| step_type | VARCHAR(32) | Type : formulation, candidacy, vote, assignment, reporting, completion, revocation |
| title | VARCHAR(256) | Titre de l'etape |
| description | TEXT | Description |
| status | VARCHAR(32) | Statut : pending, active, completed, skipped |
| vote_session_id | UUID (FK) | -> vote_sessions.id (session de vote associee) |
| vote_session_id | UUID (FK) | -> vote_sessions.id (renseigne via create-vote-session pour les etapes de type `vote`) |
| outcome | TEXT | Resultat de l'etape |
| created_at | TIMESTAMPTZ | Date de creation |
**Types d'etapes** : `formulation` (definition du mandat), `candidacy` (depot des candidatures), `vote` (election par vote WoT), `assignment` (attribution au candidat elu), `reporting` (compte-rendu), `completion` (fin normale), `revocation` (fin anticipee). Les etapes de type `vote` sont les seules a pouvoir avoir un `vote_session_id` renseigne.
### `voting_protocols`
Protocoles de vote reutilisables.

View File

@@ -7,55 +7,136 @@ description: Guide des processus decisionnels sur Glibredecision
## Principe
Une decision est un processus structure qui conduit a un choix collectif. Chaque decision suit un ensemble d'etapes definies, de la qualification a l'execution.
Une decision est un processus structure qui conduit a un choix collectif. Chaque decision suit un ensemble d'etapes definies, de la qualification a l'execution. Le systeme garantit la tracabilite de chaque etape et l'integration avec le systeme de vote WoT.
## Types de decisions
| Type | Description |
| ------------------ | ------------------------------------------------------ |
| Document change | Modification d'un item de document de reference |
| Runtime upgrade | Mise a jour du runtime de la blockchain Duniter |
| Mandate vote | Vote pour l'attribution d'un mandat |
| Custom | Decision personnalisee |
| Type | Description | Cas d'usage |
| ------------------ | ------------------------------------------------------ | -------------------------------------------------------- |
| `runtime_upgrade` | Mise a jour du runtime de la blockchain Duniter | Upgrade du reseau, changement de parametres consensus |
| `document_change` | Modification d'un item de document de reference | Amendement de la Licence G1, modification d'un engagement |
| `mandate_vote` | Vote pour l'attribution d'un mandat | Election d'un membre du Comite Technique |
| `parameter_change` | Modification de parametres systeme | Changement de duree de vote, seuil de majorite |
| `custom` | Decision personnalisee | Tout autre type de decision collective |
## Etapes d'une decision
## Cycle de vie d'une decision
Une decision progresse a travers les etapes suivantes :
| Etape | Description |
| --------------- | ---------------------------------------------------------------- |
| Qualification | Verification que la proposition est recevable |
| Examen (review) | Periode d'examen et de discussion par la communaute |
| Vote | Session de vote formelle avec seuil de validation |
| Execution | Mise en oeuvre de la decision adoptee |
| Rapport | Compte-rendu de l'execution et archivage des resultats |
Certaines etapes peuvent etre sautees selon le type de decision.
## Cycle de vie
Une decision progresse a travers six statuts successifs :
```
Brouillon --> Qualification --> Examen --> Vote --> Executee --> Cloturee
--> Rejetee
|
+--> Rejetee (si le vote echoue)
```
## Suivre une decision
| Statut | Description |
| ----------------- | ---------------------------------------------------------------- |
| `draft` | Brouillon initial. La decision est en cours de redaction et peut etre modifiee librement ou supprimee. |
| `qualification` | Phase de verification. On s'assure que la proposition est recevable (forme, competence, pertinence). |
| `review` | Periode d'examen et de discussion par la communaute. Les membres peuvent commenter et suggerer des amendements. |
| `voting` | Session de vote formelle. Le seuil de validation WoT s'applique. Une session de vote est creee et liee a l'etape correspondante. |
| `executed` | La decision a ete adoptee et est en cours de mise en oeuvre. |
| `closed` | La decision est cloturee. Le resultat est archive dans le sanctuaire. |
1. Rendez-vous dans la section **Decisions**.
2. Filtrez par type ou statut pour trouver la decision qui vous interesse.
3. La page de detail affiche toutes les etapes avec leur statut.
4. Si une etape de vote est active, vous pouvez voter directement depuis la page de decision.
Si le vote echoue (seuil non atteint), la decision passe en statut "rejetee" et ne peut plus avancer.
## Etapes d'une decision (decision_steps)
Chaque decision est composee d'etapes ordonnees qui definissent le workflow concret. Les types d'etapes disponibles sont :
| Type d'etape | Description |
| --------------- | ---------------------------------------------------------------- |
| `qualification` | Verification que la proposition est recevable |
| `review` | Periode d'examen et de discussion par la communaute |
| `vote` | Session de vote formelle avec seuil de validation WoT |
| `execution` | Mise en oeuvre de la decision adoptee |
| `reporting` | Compte-rendu de l'execution et archivage des resultats |
Chaque etape possede un statut individuel :
| Statut | Description |
| ----------- | ---------------------------------------------------------- |
| `pending` | L'etape n'a pas encore commence |
| `active` | L'etape est en cours |
| `completed` | L'etape est terminee avec succes |
| `skipped` | L'etape a ete ignoree (non applicable pour ce type de decision) |
## Comment le workflow avance
Le workflow d'une decision avance via l'action **"Avancer"** (endpoint `POST /decisions/{id}/advance`). A chaque appel :
1. **S'il y a une etape active** : elle est marquee `completed`, et la prochaine etape `pending` est activee.
2. **S'il n'y a pas d'etape active** : la premiere etape `pending` est activee. Si la decision est en `draft`, elle passe automatiquement en `qualification`.
3. **Si toutes les etapes sont terminees** : le statut global de la decision avance au statut suivant dans le cycle de vie.
Ce mecanisme garantit que les etapes sont parcourues dans l'ordre defini (`step_order`) et qu'aucune etape ne peut etre sautee involontairement.
## Creation d'une session de vote
Quand une etape de type `vote` est active, il faut creer une session de vote liee a cette etape. Cela se fait via l'action **"Creer une session de vote"** (endpoint `POST /decisions/{id}/steps/{step_id}/create-vote-session`).
La session de vote :
- Est automatiquement liee au protocole de vote de la decision
- Prend un snapshot des tailles WoT, Smith et TechComm au moment de la creation
- Calcule la date de fin en fonction de la duree definie dans la formule du protocole
- Est rattachee a l'etape via le champ `vote_session_id`
Une fois la session de vote cloturee (manuellement ou par expiration), l'etape peut etre completee et le workflow peut avancer.
## Creer une decision
Les membres authentifies peuvent creer une decision :
1. Cliquez sur **Nouvelle decision**.
2. Renseignez le titre, la description, le contexte et le type.
3. Selectionnez un **protocole de vote** qui definit les parametres de la formule de seuil.
4. Ajoutez les etapes necessaires.
5. Soumettez. La decision passe en statut "brouillon" jusqu'a ce que la premiere etape soit lancee.
2. Renseignez le **titre** : un intitule clair et concis de la proposition.
3. Renseignez la **description** : le detail de ce qui est propose.
4. Renseignez le **contexte** : pourquoi cette decision est necessaire (cadrage).
5. Choisissez le **type de decision** : `runtime_upgrade`, `document_change`, `mandate_vote`, `parameter_change` ou `custom`.
6. Selectionnez un **protocole de vote** qui definit les parametres de la formule de seuil WoT (duree, majorite, gradient, criteres Smith/TechComm).
7. **Ajoutez les etapes** necessaires dans l'ordre souhaite (qualification, examen, vote, execution, reporting).
8. Soumettez. La decision est creee en statut `draft`.
9. Utilisez l'action **"Avancer"** pour lancer la premiere etape et demarrer le processus.
## Supprimer une decision
Les decisions en statut `draft` peuvent etre supprimees via l'action **"Supprimer"** (endpoint `DELETE /decisions/{id}`). Une fois le processus demarre (statut `qualification` ou au-dela), la decision reste dans le systeme pour tracabilite.
## Suivre une decision
1. Rendez-vous dans la section **Decisions**.
2. Filtrez par type ou statut pour trouver la decision qui vous interesse.
3. La page de detail affiche toutes les etapes avec leur statut (en attente, active, terminee, ignoree).
4. Si une etape de vote est active et qu'une session de vote est ouverte, vous pouvez voter directement depuis la page de decision.
5. Le resultat du vote (seuil, participation, criteres Smith/TechComm) est affiche en temps reel.
## Exemple : Processus de Runtime Upgrade
Un Runtime Upgrade suit un processus en 5 etapes strictement definies :
```
1. Qualification -> Verification technique par le Comite Technique
2. Examen -> Review du code par les forgerons, discussion communautaire
3. Vote -> Vote WoT avec criteres Smith et TechComm obligatoires
4. Execution -> Deploiement du runtime sur le reseau
5. Reporting -> Compte-rendu post-deploiement, verification de stabilite
```
**Deroulement concret :**
1. Un membre cree une decision de type `runtime_upgrade` avec le contexte technique (version, changelog, hash du wasm).
2. Il ajoute les 5 etapes dans l'ordre.
3. Il avance la decision : l'etape "Qualification" devient active. Le Comite Technique verifie le build.
4. Une fois la qualification validee, il avance : l'etape "Examen" devient active. Les forgerons reviewent le code.
5. Il avance : l'etape "Vote" devient active. Une session de vote est creee avec le protocole incluant les criteres Smith et TechComm.
6. Les membres votent. Le seuil WoT s'applique avec inertie. Les criteres Smith (`ceil(SmithWotSize^S)`) et TechComm (`ceil(CoTecSize^T)`) doivent aussi etre atteints.
7. Si adopte, il avance : l'etape "Execution" devient active. Le runtime est deploye.
8. Il avance : l'etape "Reporting" devient active. Le compte-rendu est redige.
9. Il avance une derniere fois : la decision passe en statut `closed`.
## Lien avec les documents
Quand une decision de type "document change" est adoptee, la modification proposee est automatiquement appliquee a l'item du document concerne. L'ancienne version est conservee dans l'historique.
Quand une decision de type `document_change` est adoptee, la modification proposee est automatiquement appliquee a l'item du document concerne. L'ancienne version est conservee dans l'historique des versions (`item_versions`).
## Lien avec les mandats
Les decisions de type `mandate_vote` sont utilisees pour les elections de mandats. La decision contient une etape de vote qui determine le resultat de l'election. Voir la section [Mandats](/user/mandates) pour plus de details.

View File

@@ -7,51 +7,166 @@ description: Guide des mandats sur Glibredecision
## Principe
Un mandat est une responsabilite attribuee a un membre de la communaute pour une duree determinee, apres validation par vote collectif. Les mandats permettent de formaliser les roles au sein de la gouvernance Duniter.
Un mandat est une responsabilite attribuee a un membre de la communaute pour une duree determinee, apres validation par vote collectif. Les mandats permettent de formaliser les roles au sein de la gouvernance Duniter et d'assurer la redevabilite des mandataires.
## Types de mandats
| Type | Description |
| --------- | ------------------------------------------------------- |
| TechComm | Mandat de membre du Comite Technique |
| Smith | Mandat lie au role de forgeron (Smith) |
| Custom | Mandat personnalise pour tout autre role |
| Type | Description | Cas d'usage |
| ---------- | ------------------------------------------------------- | --------------------------------------------------------- |
| `techcomm` | Mandat de membre du Comite Technique | Administration du reseau, validation des runtime upgrades |
| `smith` | Mandat lie au role de forgeron (Smith) | Production de blocs, maintenance de la blockchain |
| `custom` | Mandat personnalise pour tout autre role | Tresorier, secretaire, moderateur, etc. |
Chaque type de mandat peut avoir des criteres de vote specifiques. Par exemple, un mandat `techcomm` peut exiger un critere TechComm (`ceil(CoTecSize^T)`), tandis qu'un mandat `smith` peut exiger un critere Smith (`ceil(SmithWotSize^S)`).
## Cycle de vie d'un mandat
Un mandat progresse a travers les etapes suivantes :
Un mandat progresse a travers les statuts suivants :
```
Brouillon --> Candidature --> Vote --> Actif --> Rapport --> Termine
--> Revoque
| |
+--> Rejet (si le +--> Revoque (revocation
vote echoue) anticipee)
```
| Etape | Description |
| Statut | Description |
| ------------ | ------------------------------------------------------------ |
| Formulation | Definition du mandat, de ses objectifs et de sa duree |
| Candidature | Periode de depot des candidatures |
| Vote | Vote collectif pour designer le mandataire |
| Assignation | Attribution du mandat au candidat elu |
| Rapport | Periode de reporting sur l'execution du mandat |
| Completion | Fin normale du mandat a echeance |
| Revocation | Fin anticipee du mandat (en cas de manquement) |
| `draft` | Brouillon initial. Le mandat est en cours de definition et peut etre modifie ou supprime. |
| `candidacy` | Periode de depot des candidatures. Les membres interesses se declarent candidats. |
| `voting` | Vote collectif pour designer le mandataire. Une session de vote est ouverte. |
| `active` | Le mandat est en cours. Le mandataire exerce ses responsabilites. |
| `reporting` | Periode de reporting. Le mandataire rend compte de ses actions. |
| `completed` | Fin normale du mandat a echeance. Le bilan est archive. |
| `revoked` | Fin anticipee du mandat suite a une revocation. |
## Consulter les mandats
## Etapes d'un mandat (mandate_steps)
1. Rendez-vous dans la section **Mandats**.
2. Filtrez par type (techcomm, smith, custom) ou statut.
3. Chaque mandat affiche le titulaire, les dates et les etapes.
Chaque mandat est compose d'etapes ordonnees qui definissent le processus complet. Les types d'etapes disponibles sont :
| Type d'etape | Description |
| -------------- | ------------------------------------------------------------ |
| `formulation` | Definition du mandat, de ses objectifs et de sa duree |
| `candidacy` | Periode de depot et d'examen des candidatures |
| `vote` | Vote collectif pour designer le mandataire |
| `assignment` | Attribution formelle du mandat au candidat elu |
| `reporting` | Periode de reporting sur l'execution du mandat |
| `completion` | Fin normale du mandat a echeance |
| `revocation` | Fin anticipee du mandat (en cas de manquement) |
Chaque etape possede un statut individuel :
| Statut | Description |
| ----------- | ---------------------------------------------------------- |
| `pending` | L'etape n'a pas encore commence |
| `active` | L'etape est en cours |
| `completed` | L'etape est terminee avec succes |
| `skipped` | L'etape a ete ignoree (non applicable) |
## Comment le workflow avance
Le workflow d'un mandat avance via l'action **"Avancer"** (endpoint `POST /mandates/{id}/advance`). Le fonctionnement est identique a celui des decisions :
1. **S'il y a une etape active** : elle est marquee `completed`, et la prochaine etape `pending` est activee.
2. **S'il n'y a pas d'etape active** : la premiere etape `pending` est activee. Si le mandat est en `draft`, il passe automatiquement en `candidacy`.
3. **Si toutes les etapes sont terminees** : le statut global du mandat avance au statut suivant dans le cycle de vie.
Les statuts terminaux (`completed` et `revoked`) bloquent tout avancement ulterieur.
## Creer un mandat
Les membres authentifies peuvent proposer un nouveau mandat :
1. Cliquez sur **Nouveau mandat**.
2. Renseignez le titre, la description et le type.
3. Definissez les dates de debut et de fin.
4. Ajoutez les etapes du processus.
5. Le mandat passe en phase de candidature puis de vote.
2. Renseignez le **titre** : intitule du role ou de la mission.
3. Renseignez la **description** : objectifs, responsabilites, criteres de reussite.
4. Choisissez le **type de mandat** : `techcomm`, `smith` ou `custom`.
5. Optionnellement, liez le mandat a une **decision** existante de type `mandate_vote`.
6. **Ajoutez les etapes** dans l'ordre souhaite (formulation, candidature, vote, assignation, reporting, completion).
7. Soumettez. Le mandat est cree en statut `draft`.
8. Utilisez l'action **"Avancer"** pour demarrer le processus.
## Candidatures
Pendant la phase de candidature (`candidacy`), les membres de la communaute peuvent se declarer candidats. La page du mandat affiche la liste des candidats avec leur profil Duniter (adresse SS58, statut WoT, statut Smith/TechComm).
## Vote pour un mandat
Quand l'etape de type `vote` est active, une session de vote doit etre creee via l'action **"Creer une session de vote"** (endpoint `POST /mandates/{id}/steps/{step_id}/create-vote-session`).
La session de vote fonctionne exactement comme pour les decisions :
- Snapshot des tailles WoT, Smith, TechComm
- Application de la formule de seuil avec inertie
- Criteres supplementaires Smith/TechComm selon le protocole choisi
- Cloture automatique ou manuelle
Le resultat du vote determine si le mandat est attribue au candidat elu.
## Assignation du mandataire
Apres un vote reussi, le mandataire est assigne au mandat via l'action **"Assigner"** (endpoint `POST /mandates/{id}/assign`). Cette action :
- Definit le `mandatee_id` : l'identite Duniter du membre elu
- Definit les dates de debut et de fin du mandat
- Fait passer le mandat en statut `active`
Le corps de la requete contient l'identifiant de l'identite Duniter du mandataire :
```json
{
"mandatee_id": "uuid-de-l-identite-duniter"
}
```
## Reporting et redevabilite
Pendant la phase active du mandat, le mandataire est tenu de rendre compte de ses actions. Quand l'etape de `reporting` est active :
- Le mandataire peut publier des comptes-rendus via le champ `outcome` de l'etape
- Les membres de la communaute peuvent consulter les rapports
- Le bilan est archive dans le sanctuaire a la fin du mandat
## Revocation
Un mandat actif peut etre revoque de maniere anticipee via l'action **"Revoquer"** (endpoint `POST /mandates/{id}/revoke`). La revocation :
- Passe le statut du mandat a `revoked`
- Met fin immediatement aux responsabilites du mandataire
- Archive le motif de revocation
La revocation est une action de gouvernance qui peut necessiter un vote prealable (decision de type `mandate_vote` avec motif de revocation).
## Suppression
Seuls les mandats au statut "brouillon" peuvent etre supprimes. Une fois le processus de candidature lance, le mandat reste dans le systeme pour tracabilite.
Seuls les mandats au statut `draft` peuvent etre supprimes via l'action **"Supprimer"** (endpoint `DELETE /mandates/{id}`). Une fois le processus de candidature lance, le mandat reste dans le systeme pour tracabilite.
## Consulter les mandats
1. Rendez-vous dans la section **Mandats**.
2. Filtrez par type (`techcomm`, `smith`, `custom`) ou par statut.
3. Chaque mandat affiche le titulaire, les dates, les etapes et leur statut.
4. La page de detail montre le workflow complet avec l'historique des etapes.
## Exemple : Election d'un membre du Comite Technique
```
1. Formulation -> Definition du poste : responsabilites, duree (1 an), competences requises
2. Candidature -> Les membres Smith se declarent candidats pendant 14 jours
3. Vote -> Vote WoT avec critere TechComm obligatoire, duree 30 jours
4. Assignation -> Attribution du mandat au candidat elu
5. Reporting -> Rapport d'activite trimestriel
6. Completion -> Fin du mandat apres 1 an, bilan final archive
```
**Deroulement concret :**
1. Un membre cree un mandat de type `techcomm` avec la description du poste.
2. Il ajoute les 6 etapes dans l'ordre.
3. Il avance le mandat : l'etape "Formulation" est activee, puis completee.
4. Il avance : la phase de candidature s'ouvre. Les candidats se declarent.
5. Il avance : l'etape "Vote" est activee. Une session de vote est creee.
6. Les membres votent. Le seuil WoT et le critere TechComm doivent etre atteints.
7. Si adopte, il avance : l'etape "Assignation" est activee. Le mandataire est designe via l'action "Assigner".
8. Il avance : le mandat passe en phase active. Le mandataire exerce ses fonctions.
9. A l'echeance, l'etape "Reporting" est activee pour le bilan.
10. Il avance une derniere fois : le mandat passe en statut `completed`.

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
/**
* Cadrage form component for creating or editing a decision.
*
* Provides all fields needed for the initial decision setup:
* title, description, context, decision type, and voting protocol.
*/
import type { DecisionCreate } from '~/stores/decisions'
const props = defineProps<{
modelValue: DecisionCreate
submitting?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: DecisionCreate]
'submit': []
}>()
const decisionTypeOptions = [
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
{ label: 'Modification de document', value: 'document_change' },
{ label: 'Vote de mandat', value: 'mandate_vote' },
{ label: 'Changement de parametre', value: 'parameter_change' },
{ label: 'Autre', value: 'other' },
]
function updateField<K extends keyof DecisionCreate>(field: K, value: DecisionCreate[K]) {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
const isValid = computed(() => {
return props.modelValue.title?.trim() && props.modelValue.decision_type
})
function onSubmit() {
if (isValid.value) {
emit('submit')
}
}
</script>
<template>
<form class="space-y-6" @submit.prevent="onSubmit">
<!-- Titre -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Titre <span class="text-red-500">*</span>
</label>
<UInput
:model-value="modelValue.title"
placeholder="Titre de la decision..."
required
@update:model-value="updateField('title', $event as string)"
/>
</div>
<!-- Description -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description <span class="text-red-500">*</span>
</label>
<UTextarea
:model-value="modelValue.description ?? ''"
placeholder="Decrivez l'objet de cette decision..."
:rows="4"
@update:model-value="updateField('description', $event as string)"
/>
</div>
<!-- Contexte -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Contexte
</label>
<UTextarea
:model-value="modelValue.context ?? ''"
placeholder="Contexte, motivations, liens utiles..."
:rows="3"
@update:model-value="updateField('context', $event as string)"
/>
</div>
<!-- Type de decision -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Type de decision <span class="text-red-500">*</span>
</label>
<USelect
:model-value="modelValue.decision_type"
:items="decisionTypeOptions"
placeholder="Selectionnez un type..."
@update:model-value="updateField('decision_type', $event as string)"
/>
</div>
<!-- Protocole de vote -->
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Protocole de vote
</label>
<ProtocolsProtocolPicker
:model-value="modelValue.voting_protocol_id ?? null"
@update:model-value="updateField('voting_protocol_id', $event)"
/>
<p class="text-xs text-gray-500">
Optionnel. Peut etre defini ulterieurement pour chaque etape.
</p>
</div>
<!-- Submit -->
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
type="submit"
label="Creer la decision"
icon="i-lucide-plus"
color="primary"
:loading="submitting"
:disabled="!isValid"
/>
</div>
</form>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
/**
* Card component for displaying a decision in a list.
*
* Shows title, type badge, status badge, step count, and creation date.
* Navigates to the decision detail page on click.
*/
import type { Decision } from '~/stores/decisions'
const props = defineProps<{
decision: Decision
}>()
const typeLabel = (decisionType: string) => {
switch (decisionType) {
case 'runtime_upgrade': return 'Runtime upgrade'
case 'document_change': return 'Modif. document'
case 'mandate_vote': return 'Vote de mandat'
case 'parameter_change': return 'Param. change'
case 'other': return 'Autre'
default: return decisionType
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function navigate() {
navigateTo(`/decisions/${props.decision.id}`)
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
@click="navigate"
>
<div class="space-y-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-scale" class="text-gray-400" />
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ decision.title }}
</h3>
</div>
<CommonStatusBadge :status="decision.status" type="decision" />
</div>
<p v-if="decision.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ decision.description }}
</p>
<div class="flex items-center gap-3 flex-wrap">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(decision.decision_type) }}
</UBadge>
<span class="text-xs text-gray-500">
{{ decision.steps.length }} etape(s)
</span>
<span class="text-xs text-gray-500">
{{ formatDate(decision.created_at) }}
</span>
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
/**
* Visual stepper/timeline showing the decision workflow.
*
* Displays each step with its type icon, status badge, and dates.
* The active step is highlighted, completed steps show a checkmark.
*/
import type { DecisionStep } from '~/stores/decisions'
const props = defineProps<{
steps: DecisionStep[]
currentStatus: string
}>()
const emit = defineEmits<{
'create-vote-session': [step: DecisionStep]
}>()
const sortedSteps = computed(() => {
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
})
const stepTypeLabel = (stepType: string) => {
switch (stepType) {
case 'qualification': return 'Qualification'
case 'review': return 'Revue'
case 'vote': return 'Vote'
case 'execution': return 'Execution'
case 'reporting': return 'Compte rendu'
default: return stepType
}
}
const stepTypeIcon = (stepType: string) => {
switch (stepType) {
case 'qualification': return 'i-lucide-check-square'
case 'review': return 'i-lucide-eye'
case 'vote': return 'i-lucide-vote'
case 'execution': return 'i-lucide-play'
case 'reporting': return 'i-lucide-file-text'
default: return 'i-lucide-circle'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
</script>
<template>
<div>
<div v-if="sortedSteps.length === 0" class="text-center py-8">
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune etape definie pour cette decision</p>
</div>
<div v-else class="relative">
<!-- Timeline line -->
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
<!-- Steps -->
<div class="space-y-4">
<div
v-for="step in sortedSteps"
:key="step.id"
class="relative pl-12"
>
<!-- Timeline dot -->
<div
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
:class="{
'bg-green-500 border-green-500': step.status === 'completed',
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
'bg-yellow-400 border-yellow-400': step.status === 'pending',
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
}"
>
<UIcon
v-if="step.status === 'completed'"
name="i-lucide-check"
class="text-white text-xs"
/>
</div>
<UCard>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ stepTypeLabel(step.step_type) }}
</UBadge>
</div>
<CommonStatusBadge :status="step.status" type="decision" />
</div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
{{ step.title }}
</h3>
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
{{ step.description }}
</p>
<div class="text-xs text-gray-500">
Cree le {{ formatDate(step.created_at) }}
</div>
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
<UIcon name="i-lucide-flag" class="text-gray-400" />
<span class="text-sm text-gray-600 dark:text-gray-400">
Resultat : {{ step.outcome }}
</span>
</div>
<!-- Vote session actions -->
<div class="flex items-center gap-2 mt-2">
<UButton
v-if="step.vote_session_id"
size="xs"
variant="soft"
color="primary"
icon="i-lucide-vote"
label="Voir la session de vote"
/>
<UButton
v-else-if="step.step_type === 'vote' && (step.status === 'active' || step.status === 'pending')"
size="xs"
variant="soft"
color="primary"
icon="i-lucide-plus"
label="Creer une session de vote"
@click.stop="emit('create-vote-session', step)"
/>
</div>
</div>
</UCard>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
/**
* Card component for displaying a mandate in a list.
*
* Shows title, type badge, status badge, mandatee, date range.
* Navigates to the mandate detail page on click.
*/
import type { Mandate } from '~/stores/mandates'
const props = defineProps<{
mandate: Mandate
}>()
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
default: return mandateType
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
function navigate() {
navigateTo(`/mandates/${props.mandate.id}`)
}
</script>
<template>
<UCard
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
@click="navigate"
>
<div class="space-y-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-user-check" class="text-gray-400" />
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ mandate.title }}
</h3>
</div>
<CommonStatusBadge :status="mandate.status" type="mandate" />
</div>
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ mandate.description }}
</p>
<div class="flex items-center gap-3 flex-wrap">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(mandate.mandate_type) }}
</UBadge>
<span class="text-xs text-gray-500">
{{ mandate.steps.length }} etape(s)
</span>
<span v-if="mandate.mandatee_id" class="text-xs text-gray-500 flex items-center gap-1">
<UIcon name="i-lucide-user" class="text-xs" />
{{ mandate.mandatee_id.slice(0, 8) }}...
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-500">
<div>
<span class="block font-medium">Debut</span>
{{ formatDate(mandate.starts_at) }}
</div>
<div>
<span class="block font-medium">Fin</span>
{{ formatDate(mandate.ends_at) }}
</div>
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
/**
* Visual timeline for mandate lifecycle steps.
*
* Displays each step with its type, status, and visual indicators.
* Similar pattern to DecisionWorkflow but with mandate-specific step types.
*/
import type { MandateStep } from '~/stores/mandates'
const props = defineProps<{
steps: MandateStep[]
currentStatus: string
}>()
const sortedSteps = computed(() => {
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
})
const stepTypeLabel = (stepType: string) => {
switch (stepType) {
case 'candidacy': return 'Candidature'
case 'voting': return 'Vote'
case 'active': return 'Actif'
case 'reporting': return 'Rapport'
case 'completed': return 'Termine'
default: return stepType
}
}
const stepTypeIcon = (stepType: string) => {
switch (stepType) {
case 'candidacy': return 'i-lucide-user-plus'
case 'voting': return 'i-lucide-vote'
case 'active': return 'i-lucide-shield-check'
case 'reporting': return 'i-lucide-file-text'
case 'completed': return 'i-lucide-check-circle'
default: return 'i-lucide-circle'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
</script>
<template>
<div>
<div v-if="sortedSteps.length === 0" class="text-center py-8">
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune etape definie pour ce mandat</p>
</div>
<div v-else class="relative">
<!-- Timeline line -->
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
<!-- Steps -->
<div class="space-y-4">
<div
v-for="step in sortedSteps"
:key="step.id"
class="relative pl-12"
>
<!-- Timeline dot -->
<div
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
:class="{
'bg-green-500 border-green-500': step.status === 'completed',
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
'bg-yellow-400 border-yellow-400': step.status === 'pending',
'bg-red-500 border-red-500': step.status === 'revoked',
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
}"
>
<UIcon
v-if="step.status === 'completed'"
name="i-lucide-check"
class="text-white text-xs"
/>
<UIcon
v-else-if="step.status === 'revoked'"
name="i-lucide-x"
class="text-white text-xs"
/>
</div>
<UCard>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ stepTypeLabel(step.step_type) }}
</UBadge>
</div>
<CommonStatusBadge :status="step.status" type="mandate" />
</div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
{{ step.title }}
</h3>
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
{{ step.description }}
</p>
<div class="text-xs text-gray-500">
Cree le {{ formatDate(step.created_at) }}
</div>
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
<UIcon name="i-lucide-flag" class="text-gray-400" />
<span class="text-sm text-gray-600 dark:text-gray-400">
Resultat : {{ step.outcome }}
</span>
</div>
<div v-if="step.vote_session_id" class="mt-2">
<UButton
size="xs"
variant="soft"
color="primary"
icon="i-lucide-vote"
label="Voir la session de vote"
/>
</div>
</div>
</UCard>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { DecisionStep, DecisionStepCreate } from '~/stores/decisions'
const route = useRoute()
const decisions = useDecisionsStore()
@@ -18,26 +20,36 @@ watch(decisionId, async (newId) => {
}
})
// --- Status helpers ---
const statusColor = (status: string) => {
switch (status) {
case 'active':
case 'in_progress': return 'success'
case 'draft': return 'warning'
case 'completed': return 'info'
case 'qualification': return 'info'
case 'review': return 'info'
case 'voting': return 'primary'
case 'executed': return 'success'
case 'closed': return 'neutral'
case 'pending': return 'warning'
case 'active': return 'success'
case 'in_progress': return 'success'
case 'completed': return 'info'
default: return 'neutral'
}
}
const statusLabel = (status: string) => {
switch (status) {
case 'draft': return 'Brouillon'
case 'qualification': return 'Qualification'
case 'review': return 'Revue'
case 'voting': return 'En vote'
case 'executed': return 'Execute'
case 'closed': return 'Clos'
case 'pending': return 'En attente'
case 'active': return 'Actif'
case 'in_progress': return 'En cours'
case 'draft': return 'Brouillon'
case 'completed': return 'Termine'
case 'closed': return 'Ferme'
case 'pending': return 'En attente'
default: return status
}
}
@@ -47,33 +59,12 @@ const typeLabel = (decisionType: string) => {
case 'runtime_upgrade': return 'Runtime upgrade'
case 'document_change': return 'Modification de document'
case 'mandate_vote': return 'Vote de mandat'
case 'custom': return 'Personnalise'
case 'parameter_change': return 'Changement de parametre'
case 'other': return 'Autre'
default: return decisionType
}
}
const stepTypeLabel = (stepType: string) => {
switch (stepType) {
case 'qualification': return 'Qualification'
case 'review': return 'Revue'
case 'vote': return 'Vote'
case 'execution': return 'Execution'
case 'reporting': return 'Compte rendu'
default: return stepType
}
}
const stepTypeIcon = (stepType: string) => {
switch (stepType) {
case 'qualification': return 'i-lucide-check-square'
case 'review': return 'i-lucide-eye'
case 'vote': return 'i-lucide-vote'
case 'execution': return 'i-lucide-play'
case 'reporting': return 'i-lucide-file-text'
default: return 'i-lucide-circle'
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
@@ -82,10 +73,121 @@ function formatDate(dateStr: string): string {
})
}
const sortedSteps = computed(() => {
if (!decisions.current) return []
return [...decisions.current.steps].sort((a, b) => a.step_order - b.step_order)
// --- Terminal state check ---
const terminalStatuses = ['executed', 'closed']
const isTerminal = computed(() => {
if (!decisions.current) return true
return terminalStatuses.includes(decisions.current.status)
})
const isDraft = computed(() => decisions.current?.status === 'draft')
// --- Advance action ---
const advancing = ref(false)
async function handleAdvance() {
advancing.value = true
try {
await decisions.advance(decisionId.value)
} catch {
// Error handled by store
} finally {
advancing.value = false
}
}
// --- Create vote session ---
async function handleCreateVoteSession(step: DecisionStep) {
try {
await decisions.createVoteSession(decisionId.value, step.id)
} catch {
// Error handled by store
}
}
// --- Edit modal ---
const showEditModal = ref(false)
const editData = ref({
title: '',
description: '' as string | null,
context: '' as string | null,
})
const saving = ref(false)
function openEdit() {
if (!decisions.current) return
editData.value = {
title: decisions.current.title,
description: decisions.current.description,
context: decisions.current.context,
}
showEditModal.value = true
}
async function saveEdit() {
saving.value = true
try {
await decisions.update(decisionId.value, editData.value)
showEditModal.value = false
} catch {
// Error handled by store
} finally {
saving.value = false
}
}
// --- Delete ---
const showDeleteConfirm = ref(false)
const deleting = ref(false)
async function handleDelete() {
deleting.value = true
try {
await decisions.delete(decisionId.value)
navigateTo('/decisions')
} catch {
// Error handled by store
} finally {
deleting.value = false
showDeleteConfirm.value = false
}
}
// --- Add step ---
const showAddStep = ref(false)
const newStep = ref<DecisionStepCreate>({
step_type: 'qualification',
title: '',
description: '',
})
const addingStep = ref(false)
const stepTypeOptions = [
{ label: 'Qualification', value: 'qualification' },
{ label: 'Revue', value: 'review' },
{ label: 'Vote', value: 'vote' },
{ label: 'Execution', value: 'execution' },
{ label: 'Compte rendu', value: 'reporting' },
]
async function handleAddStep() {
addingStep.value = true
try {
await decisions.addStep(decisionId.value, newStep.value)
showAddStep.value = false
newStep.value = { step_type: 'qualification', title: '', description: '' }
} catch {
// Error handled by store
} finally {
addingStep.value = false
}
}
</script>
<template>
@@ -125,7 +227,8 @@ const sortedSteps = computed(() => {
<!-- Decision detail -->
<template v-else-if="decisions.current">
<!-- Header -->
<!-- Header with actions -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ decisions.current.title }}
@@ -140,6 +243,38 @@ const sortedSteps = computed(() => {
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<UButton
v-if="!isTerminal"
icon="i-lucide-fast-forward"
label="Avancer la decision"
color="primary"
variant="soft"
size="sm"
:loading="advancing"
@click="handleAdvance"
/>
<UButton
icon="i-lucide-pen-line"
label="Modifier"
variant="soft"
color="neutral"
size="sm"
@click="openEdit"
/>
<UButton
v-if="isDraft"
icon="i-lucide-trash-2"
label="Supprimer"
variant="soft"
color="error"
size="sm"
@click="showDeleteConfirm = true"
/>
</div>
</div>
<!-- Description & Context -->
<UCard v-if="decisions.current.description || decisions.current.context">
<div class="space-y-4">
@@ -184,88 +319,146 @@ const sortedSteps = computed(() => {
<!-- Steps timeline -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Etapes du processus
</h2>
<div v-if="sortedSteps.length === 0" class="text-center py-8">
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune etape definie pour cette decision</p>
</div>
<div v-else class="relative">
<!-- Timeline line -->
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
<!-- Steps -->
<div class="space-y-4">
<div
v-for="(step, index) in sortedSteps"
:key="step.id"
class="relative pl-12"
>
<!-- Timeline dot -->
<div
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
:class="{
'bg-green-500 border-green-500': step.status === 'completed',
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
'bg-yellow-400 border-yellow-400': step.status === 'pending',
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
}"
>
<UIcon
v-if="step.status === 'completed'"
name="i-lucide-check"
class="text-white text-xs"
/>
</div>
<UCard>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
<UBadge variant="subtle" color="neutral" size="xs">
{{ stepTypeLabel(step.step_type) }}
</UBadge>
</div>
<UBadge :color="statusColor(step.status)" variant="subtle" size="xs">
{{ statusLabel(step.status) }}
</UBadge>
</div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
{{ step.title }}
</h3>
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
{{ step.description }}
</p>
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
<UIcon name="i-lucide-flag" class="text-gray-400" />
<span class="text-sm text-gray-600 dark:text-gray-400">
Resultat : {{ step.outcome }}
</span>
</div>
<div v-if="step.vote_session_id" class="mt-2">
<UButton
size="xs"
v-if="!isTerminal"
icon="i-lucide-plus"
label="Ajouter une etape"
variant="soft"
color="primary"
icon="i-lucide-vote"
label="Voir la session de vote"
size="sm"
@click="showAddStep = true"
/>
</div>
<DecisionsDecisionWorkflow
:steps="decisions.current.steps"
:current-status="decisions.current.status"
@create-vote-session="handleCreateVoteSession"
/>
</div>
</UCard>
</template>
<!-- Edit modal -->
<UModal v-model:open="showEditModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Modifier la decision
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<UInput v-model="editData.title" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<UTextarea v-model="editData.description" :rows="4" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Contexte</label>
<UTextarea v-model="editData.context" :rows="3" />
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showEditModal = false"
/>
<UButton
label="Enregistrer"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!editData.title?.trim()"
@click="saveEdit"
/>
</div>
</div>
</template>
</UModal>
<!-- Delete confirmation modal -->
<UModal v-model:open="showDeleteConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">
Confirmer la suppression
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir supprimer cette decision ? Cette action est irreversible.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showDeleteConfirm = false"
/>
<UButton
label="Supprimer"
icon="i-lucide-trash-2"
color="error"
:loading="deleting"
@click="handleDelete"
/>
</div>
</div>
</template>
</UModal>
<!-- Add step modal -->
<UModal v-model:open="showAddStep">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Ajouter une etape
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Type d'etape <span class="text-red-500">*</span>
</label>
<USelect
v-model="newStep.step_type"
:items="stepTypeOptions"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<UInput v-model="newStep.title" placeholder="Titre de l'etape..." />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<UTextarea v-model="newStep.description" :rows="3" placeholder="Description de l'etape..." />
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showAddStep = false"
/>
<UButton
label="Ajouter"
icon="i-lucide-plus"
color="primary"
:loading="addingStep"
@click="handleAddStep"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>

View File

@@ -9,16 +9,18 @@ const typeOptions = [
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
{ label: 'Modification de document', value: 'document_change' },
{ label: 'Vote de mandat', value: 'mandate_vote' },
{ label: 'Personnalise', value: 'custom' },
{ label: 'Changement de parametre', value: 'parameter_change' },
{ label: 'Autre', value: 'other' },
]
const statusOptions = [
{ label: 'Tous les statuts', value: undefined },
{ label: 'Brouillon', value: 'draft' },
{ label: 'En cours', value: 'in_progress' },
{ label: 'Actif', value: 'active' },
{ label: 'Termine', value: 'completed' },
{ label: 'Ferme', value: 'closed' },
{ label: 'Qualification', value: 'qualification' },
{ label: 'Revue', value: 'review' },
{ label: 'En vote', value: 'voting' },
{ label: 'Execute', value: 'executed' },
{ label: 'Clos', value: 'closed' },
]
async function loadDecisions() {
@@ -38,10 +40,11 @@ watch([filterType, filterStatus], () => {
const statusColor = (status: string) => {
switch (status) {
case 'active':
case 'in_progress': return 'success'
case 'draft': return 'warning'
case 'completed': return 'info'
case 'qualification': return 'info'
case 'review': return 'info'
case 'voting': return 'primary'
case 'executed': return 'success'
case 'closed': return 'neutral'
default: return 'neutral'
}
@@ -49,11 +52,12 @@ const statusColor = (status: string) => {
const statusLabel = (status: string) => {
switch (status) {
case 'active': return 'Actif'
case 'in_progress': return 'En cours'
case 'draft': return 'Brouillon'
case 'completed': return 'Termine'
case 'closed': return 'Ferme'
case 'qualification': return 'Qualification'
case 'review': return 'Revue'
case 'voting': return 'En vote'
case 'executed': return 'Execute'
case 'closed': return 'Clos'
default: return status
}
}
@@ -63,7 +67,8 @@ const typeLabel = (decisionType: string) => {
case 'runtime_upgrade': return 'Runtime upgrade'
case 'document_change': return 'Modif. document'
case 'mandate_vote': return 'Vote de mandat'
case 'custom': return 'Personnalise'
case 'parameter_change': return 'Param. change'
case 'other': return 'Autre'
default: return decisionType
}
}
@@ -80,6 +85,7 @@ function formatDate(dateStr: string): string {
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Decisions
@@ -88,6 +94,13 @@ function formatDate(dateStr: string): string {
Processus de decision collectifs de la communaute
</p>
</div>
<UButton
to="/decisions/new"
icon="i-lucide-plus"
label="Nouvelle decision"
color="primary"
/>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-4">

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { DecisionCreate } from '~/stores/decisions'
const decisions = useDecisionsStore()
const formData = ref<DecisionCreate>({
title: '',
description: '',
context: '',
decision_type: 'other',
voting_protocol_id: null,
})
const submitting = ref(false)
async function onSubmit() {
submitting.value = true
try {
const decision = await decisions.create(formData.value)
if (decision) {
navigateTo(`/decisions/${decision.id}`)
}
} catch {
// Error is handled by the store
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-6">
<!-- Back link -->
<div>
<UButton
to="/decisions"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux decisions"
size="sm"
/>
</div>
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Nouvelle decision
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Cadrage d'un nouveau processus de decision collectif
</p>
</div>
<!-- Error -->
<UCard v-if="decisions.error">
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ decisions.error }}</p>
</div>
</UCard>
<!-- Form -->
<UCard>
<DecisionsDecisionCadrage
v-model="formData"
:submitting="submitting"
@submit="onSubmit"
/>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,471 @@
<script setup lang="ts">
const route = useRoute()
const mandates = useMandatesStore()
const mandateId = computed(() => route.params.id as string)
onMounted(async () => {
await mandates.fetchById(mandateId.value)
})
onUnmounted(() => {
mandates.clearCurrent()
})
watch(mandateId, async (newId) => {
if (newId) {
await mandates.fetchById(newId)
}
})
// --- Status helpers ---
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
default: return mandateType
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
// --- Terminal state check ---
const terminalStatuses = ['completed', 'revoked']
const isTerminal = computed(() => {
if (!mandates.current) return true
return terminalStatuses.includes(mandates.current.status)
})
const canRevoke = computed(() => {
if (!mandates.current) return false
return mandates.current.status === 'active'
})
// --- Advance action ---
const advancing = ref(false)
async function handleAdvance() {
advancing.value = true
try {
await mandates.advance(mandateId.value)
} catch {
// Error handled by store
} finally {
advancing.value = false
}
}
// --- Assign mandatee ---
const showAssignModal = ref(false)
const mandateeAddress = ref('')
const assigning = ref(false)
async function handleAssign() {
if (!mandateeAddress.value.trim()) return
assigning.value = true
try {
await mandates.assignMandatee(mandateId.value, mandateeAddress.value.trim())
showAssignModal.value = false
mandateeAddress.value = ''
} catch {
// Error handled by store
} finally {
assigning.value = false
}
}
// --- Revoke ---
const showRevokeConfirm = ref(false)
const revoking = ref(false)
async function handleRevoke() {
revoking.value = true
try {
await mandates.revoke(mandateId.value)
showRevokeConfirm.value = false
} catch {
// Error handled by store
} finally {
revoking.value = false
}
}
// --- Edit modal ---
const showEditModal = ref(false)
const editData = ref({
title: '',
description: '' as string | null,
})
const saving = ref(false)
function openEdit() {
if (!mandates.current) return
editData.value = {
title: mandates.current.title,
description: mandates.current.description,
}
showEditModal.value = true
}
async function saveEdit() {
saving.value = true
try {
await mandates.update(mandateId.value, editData.value)
showEditModal.value = false
} catch {
// Error handled by store
} finally {
saving.value = false
}
}
// --- Delete ---
const showDeleteConfirm = ref(false)
const deleting = ref(false)
const isDraft = computed(() => mandates.current?.status === 'draft')
async function handleDelete() {
deleting.value = true
try {
await mandates.delete(mandateId.value)
navigateTo('/mandates')
} catch {
// Error handled by store
} finally {
deleting.value = false
showDeleteConfirm.value = false
}
}
</script>
<template>
<div class="space-y-6">
<!-- Back link -->
<div>
<UButton
to="/mandates"
variant="ghost"
color="neutral"
icon="i-lucide-arrow-left"
label="Retour aux mandats"
size="sm"
/>
</div>
<!-- Loading state -->
<template v-if="mandates.loading">
<div class="space-y-4">
<USkeleton class="h-8 w-96" />
<USkeleton class="h-4 w-64" />
<div class="space-y-3 mt-8">
<USkeleton v-for="i in 4" :key="i" class="h-20 w-full" />
</div>
</div>
</template>
<!-- Error state -->
<template v-else-if="mandates.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ mandates.error }}</p>
</div>
</UCard>
</template>
<!-- Mandate detail -->
<template v-else-if="mandates.current">
<!-- Header with actions -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ mandates.current.title }}
</h1>
<div class="flex items-center gap-3 mt-2">
<UBadge variant="subtle" color="primary">
{{ typeLabel(mandates.current.mandate_type) }}
</UBadge>
<CommonStatusBadge :status="mandates.current.status" type="mandate" />
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<UButton
v-if="!isTerminal"
icon="i-lucide-fast-forward"
label="Avancer"
color="primary"
variant="soft"
size="sm"
:loading="advancing"
@click="handleAdvance"
/>
<UButton
v-if="!isTerminal && !mandates.current.mandatee_id"
icon="i-lucide-user-plus"
label="Assigner un mandataire"
variant="soft"
color="primary"
size="sm"
@click="showAssignModal = true"
/>
<UButton
icon="i-lucide-pen-line"
label="Modifier"
variant="soft"
color="neutral"
size="sm"
@click="openEdit"
/>
<UButton
v-if="canRevoke"
icon="i-lucide-shield-off"
label="Revoquer"
variant="soft"
color="error"
size="sm"
@click="showRevokeConfirm = true"
/>
<UButton
v-if="isDraft"
icon="i-lucide-trash-2"
label="Supprimer"
variant="soft"
color="error"
size="sm"
@click="showDeleteConfirm = true"
/>
</div>
</div>
<!-- Description -->
<UCard v-if="mandates.current.description">
<div>
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{{ mandates.current.description }}
</p>
</div>
</UCard>
<!-- Metadata -->
<UCard>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p class="text-gray-500">Mandataire</p>
<p class="font-medium text-gray-900 dark:text-white">
<template v-if="mandates.current.mandatee_id">
<span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}...</span>
</template>
<template v-else>
<span class="text-gray-400 italic">Non assigne</span>
</template>
</p>
</div>
<div>
<p class="text-gray-500">Debut</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.starts_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Fin</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.ends_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Nombre d'etapes</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ mandates.current.steps.length }}
</p>
</div>
</div>
</UCard>
<!-- Dates metadata -->
<UCard>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500">Cree le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.created_at) }}
</p>
</div>
<div>
<p class="text-gray-500">Mis a jour le</p>
<p class="font-medium text-gray-900 dark:text-white">
{{ formatDate(mandates.current.updated_at) }}
</p>
</div>
</div>
</UCard>
<!-- Steps timeline -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Etapes du mandat
</h2>
<MandatesMandateTimeline
:steps="mandates.current.steps"
:current-status="mandates.current.status"
/>
</div>
</template>
<!-- Assign mandatee modal -->
<UModal v-model:open="showAssignModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Assigner un mandataire
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Adresse du mandataire <span class="text-red-500">*</span>
</label>
<UInput
v-model="mandateeAddress"
placeholder="Adresse Duniter (ex: 5Grw...)
"
/>
<p class="text-xs text-gray-500">
Adresse SS58 du membre de la toile de confiance
</p>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showAssignModal = false"
/>
<UButton
label="Assigner"
icon="i-lucide-user-plus"
color="primary"
:loading="assigning"
:disabled="!mandateeAddress.trim()"
@click="handleAssign"
/>
</div>
</div>
</template>
</UModal>
<!-- Revoke confirmation modal -->
<UModal v-model:open="showRevokeConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">
Confirmer la revocation
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showRevokeConfirm = false"
/>
<UButton
label="Revoquer"
icon="i-lucide-shield-off"
color="error"
:loading="revoking"
@click="handleRevoke"
/>
</div>
</div>
</template>
</UModal>
<!-- Edit modal -->
<UModal v-model:open="showEditModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Modifier le mandat
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
<UInput v-model="editData.title" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<UTextarea v-model="editData.description" :rows="4" />
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showEditModal = false"
/>
<UButton
label="Enregistrer"
icon="i-lucide-save"
color="primary"
:loading="saving"
:disabled="!editData.title?.trim()"
@click="saveEdit"
/>
</div>
</div>
</template>
</UModal>
<!-- Delete confirmation modal -->
<UModal v-model:open="showDeleteConfirm">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-red-600">
Confirmer la suppression
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible.
</p>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showDeleteConfirm = false"
/>
<UButton
label="Supprimer"
icon="i-lucide-trash-2"
color="error"
:loading="deleting"
@click="handleDelete"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>

View File

@@ -1,37 +1,7 @@
<script setup lang="ts">
const { $api } = useApi()
import type { MandateCreate } from '~/stores/mandates'
interface MandateStep {
id: string
mandate_id: string
step_order: number
step_type: string
title: string | null
description: string | null
status: string
vote_session_id: string | null
outcome: string | null
created_at: string
}
interface Mandate {
id: string
title: string
description: string | null
mandate_type: string
status: string
mandatee_id: string | null
decision_id: string | null
starts_at: string | null
ends_at: string | null
created_at: string
updated_at: string
steps: MandateStep[]
}
const mandates = ref<Mandate[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const mandates = useMandatesStore()
const filterType = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
@@ -46,26 +16,19 @@ const typeOptions = [
const statusOptions = [
{ label: 'Tous les statuts', value: undefined },
{ label: 'Brouillon', value: 'draft' },
{ label: 'Candidature', value: 'candidacy' },
{ label: 'En vote', value: 'voting' },
{ label: 'Actif', value: 'active' },
{ label: 'Expire', value: 'expired' },
{ label: 'Rapport', value: 'reporting' },
{ label: 'Termine', value: 'completed' },
{ label: 'Revoque', value: 'revoked' },
]
async function loadMandates() {
loading.value = true
error.value = null
try {
const query: Record<string, string> = {}
if (filterType.value) query.mandate_type = filterType.value
if (filterStatus.value) query.status = filterStatus.value
mandates.value = await $api<Mandate[]>('/mandates/', { query })
} catch (err: any) {
error.value = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats'
} finally {
loading.value = false
}
await mandates.fetchAll({
mandate_type: filterType.value,
status: filterStatus.value,
})
}
onMounted(() => {
@@ -76,48 +39,43 @@ watch([filterType, filterStatus], () => {
loadMandates()
})
const statusColor = (status: string) => {
switch (status) {
case 'active': return 'success'
case 'draft': return 'warning'
case 'expired': return 'neutral'
case 'revoked': return 'error'
default: return 'neutral'
}
}
// --- Create mandate modal ---
const statusLabel = (status: string) => {
switch (status) {
case 'active': return 'Actif'
case 'draft': return 'Brouillon'
case 'expired': return 'Expire'
case 'revoked': return 'Revoque'
default: return status
}
}
const showCreateModal = ref(false)
const mandateTypeOptions = [
{ label: 'Comite technique', value: 'techcomm' },
{ label: 'Forgeron', value: 'smith' },
{ label: 'Personnalise', value: 'custom' },
]
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
default: return mandateType
}
}
const newMandate = ref<MandateCreate>({
title: '',
description: '',
mandate_type: 'techcomm',
})
const creating = ref(false)
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
async function handleCreate() {
creating.value = true
try {
const mandate = await mandates.create(newMandate.value)
showCreateModal.value = false
newMandate.value = { title: '', description: '', mandate_type: 'techcomm' }
if (mandate) {
navigateTo(`/mandates/${mandate.id}`)
}
} catch {
// Error handled by store
} finally {
creating.value = false
}
}
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Mandats
@@ -126,6 +84,13 @@ function formatDate(dateStr: string | null): string {
Mandats de gouvernance : comite technique, forgerons et roles specifiques
</p>
</div>
<UButton
icon="i-lucide-plus"
label="Nouveau mandat"
color="primary"
@click="showCreateModal = true"
/>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-4">
@@ -144,24 +109,24 @@ function formatDate(dateStr: string | null): string {
</div>
<!-- Loading state -->
<template v-if="loading">
<template v-if="mandates.loading">
<div class="space-y-3">
<USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />
</div>
</template>
<!-- Error state -->
<template v-else-if="error">
<template v-else-if="mandates.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ error }}</p>
<p>{{ mandates.error }}</p>
</div>
</UCard>
</template>
<!-- Empty state -->
<template v-else-if="mandates.length === 0">
<template v-else-if="mandates.list.length === 0">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-user-check" class="text-4xl text-gray-400 mb-3" />
@@ -173,50 +138,72 @@ function formatDate(dateStr: string | null): string {
<!-- Mandates list -->
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UCard
v-for="mandate in mandates"
<MandatesMandateCard
v-for="mandate in mandates.list"
:key="mandate.id"
class="hover:shadow-md transition-shadow"
>
<div class="space-y-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-user-check" class="text-gray-400" />
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ mandate.title }}
</h3>
</div>
<UBadge :color="statusColor(mandate.status)" variant="subtle" size="xs">
{{ statusLabel(mandate.status) }}
</UBadge>
</div>
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{{ mandate.description }}
</p>
<div class="flex items-center gap-3 flex-wrap">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(mandate.mandate_type) }}
</UBadge>
<span class="text-xs text-gray-500">
{{ mandate.steps.length }} etape(s)
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-500">
<div>
<span class="block font-medium">Debut</span>
{{ formatDate(mandate.starts_at) }}
</div>
<div>
<span class="block font-medium">Fin</span>
{{ formatDate(mandate.ends_at) }}
</div>
</div>
</div>
</UCard>
:mandate="mandate"
/>
</div>
</template>
<!-- Create mandate modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<form class="p-6 space-y-4" @submit.prevent="handleCreate">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Nouveau mandat
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Titre <span class="text-red-500">*</span>
</label>
<UInput
v-model="newMandate.title"
placeholder="Titre du mandat..."
required
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<UTextarea
v-model="newMandate.description"
placeholder="Description du mandat..."
:rows="3"
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Type de mandat <span class="text-red-500">*</span>
</label>
<USelect
v-model="newMandate.mandate_type"
:items="mandateTypeOptions"
/>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showCreateModal = false"
/>
<UButton
type="submit"
label="Creer"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newMandate.title?.trim()"
/>
</div>
</form>
</template>
</UModal>
</div>
</template>

View File

@@ -39,6 +39,20 @@ export interface DecisionCreate {
voting_protocol_id?: string | null
}
export interface DecisionUpdate {
title?: string
description?: string | null
context?: string | null
decision_type?: string
voting_protocol_id?: string | null
}
export interface DecisionStepCreate {
step_type: string
title?: string | null
description?: string | null
}
interface DecisionsState {
list: Decision[]
current: Decision | null
@@ -59,10 +73,12 @@ export const useDecisionsStore = defineStore('decisions', {
return (status: string) => state.list.filter(d => d.status === status)
},
activeDecisions: (state): Decision[] => {
return state.list.filter(d => d.status === 'active' || d.status === 'in_progress')
return state.list.filter(d =>
d.status === 'qualification' || d.status === 'review' || d.status === 'voting',
)
},
completedDecisions: (state): Decision[] => {
return state.list.filter(d => d.status === 'completed' || d.status === 'closed')
return state.list.filter(d => d.status === 'executed' || d.status === 'closed')
},
},
@@ -128,6 +144,108 @@ export const useDecisionsStore = defineStore('decisions', {
}
},
/**
* Update an existing decision.
*/
async update(id: string, data: DecisionUpdate) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Decision>(`/decisions/${id}`, {
method: 'PUT',
body: data,
})
if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(d => d.id === id)
if (idx >= 0) this.list[idx] = updated
return updated
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la mise a jour de la decision'
throw err
}
},
/**
* Delete a decision.
*/
async delete(id: string) {
this.error = null
try {
const { $api } = useApi()
await $api(`/decisions/${id}`, { method: 'DELETE' })
this.list = this.list.filter(d => d.id !== id)
if (this.current?.id === id) this.current = null
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la suppression de la decision'
throw err
}
},
/**
* Advance the decision to the next step in its workflow.
*/
async advance(id: string) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Decision>(`/decisions/${id}/advance`, {
method: 'POST',
})
if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(d => d.id === id)
if (idx >= 0) this.list[idx] = updated
return updated
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'avancement de la decision'
throw err
}
},
/**
* Add a step to a decision.
*/
async addStep(id: string, step: DecisionStepCreate) {
this.error = null
try {
const { $api } = useApi()
const newStep = await $api<DecisionStep>(`/decisions/${id}/steps`, {
method: 'POST',
body: step,
})
if (this.current?.id === id) {
this.current.steps.push(newStep)
}
return newStep
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape'
throw err
}
},
/**
* Create a vote session for a specific step.
*/
async createVoteSession(decisionId: string, stepId: string) {
this.error = null
try {
const { $api } = useApi()
const result = await $api<any>(`/decisions/${decisionId}/steps/${stepId}/create-vote-session`, {
method: 'POST',
})
// Refresh decision to get updated step with vote_session_id
await this.fetchById(decisionId)
return result
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la session de vote'
throw err
}
},
/**
* Clear the current decision.
*/

View File

@@ -0,0 +1,279 @@
/**
* Mandates store: governance mandates and their lifecycle steps.
*
* Maps to the backend /api/v1/mandates endpoints.
*/
export interface MandateStep {
id: string
mandate_id: string
step_order: number
step_type: string
title: string | null
description: string | null
status: string
vote_session_id: string | null
outcome: string | null
created_at: string
}
export interface Mandate {
id: string
title: string
description: string | null
mandate_type: string
status: string
mandatee_id: string | null
decision_id: string | null
starts_at: string | null
ends_at: string | null
created_at: string
updated_at: string
steps: MandateStep[]
}
export interface MandateCreate {
title: string
description?: string | null
mandate_type: string
decision_id?: string | null
starts_at?: string | null
ends_at?: string | null
}
export interface MandateUpdate {
title?: string
description?: string | null
mandate_type?: string
starts_at?: string | null
ends_at?: string | null
}
export interface MandateStepCreate {
step_type: string
title?: string | null
description?: string | null
}
interface MandatesState {
list: Mandate[]
current: Mandate | null
loading: boolean
error: string | null
}
export const useMandatesStore = defineStore('mandates', {
state: (): MandatesState => ({
list: [],
current: null,
loading: false,
error: null,
}),
getters: {
byStatus: (state) => {
return (status: string) => state.list.filter(m => m.status === status)
},
activeMandates: (state): Mandate[] => {
return state.list.filter(m => m.status === 'active')
},
completedMandates: (state): Mandate[] => {
return state.list.filter(m => m.status === 'completed')
},
},
actions: {
/**
* Fetch all mandates with optional filters.
*/
async fetchAll(params?: { mandate_type?: string; status?: string }) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const query: Record<string, string> = {}
if (params?.mandate_type) query.mandate_type = params.mandate_type
if (params?.status) query.status = params.status
this.list = await $api<Mandate[]>('/mandates/', { query })
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats'
} finally {
this.loading = false
}
},
/**
* Fetch a single mandate by ID with all its steps.
*/
async fetchById(id: string) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
this.current = await $api<Mandate>(`/mandates/${id}`)
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Mandat introuvable'
} finally {
this.loading = false
}
},
/**
* Create a new mandate.
*/
async create(payload: MandateCreate) {
this.loading = true
this.error = null
try {
const { $api } = useApi()
const mandate = await $api<Mandate>('/mandates/', {
method: 'POST',
body: payload,
})
this.list.unshift(mandate)
return mandate
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du mandat'
throw err
} finally {
this.loading = false
}
},
/**
* Update an existing mandate.
*/
async update(id: string, data: MandateUpdate) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}`, {
method: 'PUT',
body: data,
})
if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated
return updated
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la mise a jour du mandat'
throw err
}
},
/**
* Delete a mandate.
*/
async delete(id: string) {
this.error = null
try {
const { $api } = useApi()
await $api(`/mandates/${id}`, { method: 'DELETE' })
this.list = this.list.filter(m => m.id !== id)
if (this.current?.id === id) this.current = null
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la suppression du mandat'
throw err
}
},
/**
* Advance the mandate to the next step in its workflow.
*/
async advance(id: string) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/advance`, {
method: 'POST',
})
if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated
return updated
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'avancement du mandat'
throw err
}
},
/**
* Add a step to a mandate.
*/
async addStep(id: string, step: MandateStepCreate) {
this.error = null
try {
const { $api } = useApi()
const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, {
method: 'POST',
body: step,
})
if (this.current?.id === id) {
this.current.steps.push(newStep)
}
return newStep
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape'
throw err
}
},
/**
* Assign a mandatee to the mandate.
*/
async assignMandatee(id: string, mandateeId: string) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/assign`, {
method: 'POST',
body: { mandatee_id: mandateeId },
})
if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated
return updated
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'assignation du mandataire'
throw err
}
},
/**
* Revoke the mandate.
*/
async revoke(id: string) {
this.error = null
try {
const { $api } = useApi()
const updated = await $api<Mandate>(`/mandates/${id}/revoke`, {
method: 'POST',
})
if (this.current?.id === id) this.current = updated
const idx = this.list.findIndex(m => m.id === id)
if (idx >= 0) this.list[idx] = updated
return updated
} catch (err: any) {
this.error = err?.data?.detail || err?.message || 'Erreur lors de la revocation du mandat'
throw err
}
},
/**
* Clear the current mandate.
*/
clearCurrent() {
this.current = null
},
},
})