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

@@ -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