"""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 _MANDATE_STATUS_ORDER = [ "draft", "candidacy", "voting", "active", "reporting", "completed", ] async def advance_mandate(mandate_id: uuid.UUID, db: AsyncSession) -> Mandate: """Move a mandate to its next step. Completes the current active step and activates the next pending step. If no more steps remain, the mandate status advances to the next phase. Parameters ---------- mandate_id: UUID of the Mandate to advance. db: Async database session. Returns ------- Mandate The updated mandate. Raises ------ ValueError If the mandate is not found, already completed/revoked, or no further advancement is possible. """ 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}") steps: list[MandateStep] = sorted(mandate.steps, key=lambda s: s.step_order) # Find the current active step active_step: MandateStep | None = None for step in steps: if step.status == "active": active_step = step break if active_step is not None: # Complete the active step active_step.status = "completed" # Activate the next pending step next_step: MandateStep | None = None for step in steps: if step.step_order > active_step.step_order and step.status == "pending": next_step = step break if next_step is not None: next_step.status = "active" else: # No more steps: advance mandate status _advance_mandate_status(mandate) else: # No active step: activate the first pending one first_pending: MandateStep | None = None for step in steps: if step.status == "pending": first_pending = step break if first_pending is not None: first_pending.status = "active" # Move out of draft if mandate.status == "draft": mandate.status = "candidacy" else: # All steps completed: advance status _advance_mandate_status(mandate) await db.commit() await db.refresh(mandate) return mandate def _advance_mandate_status(mandate: Mandate) -> None: """Move a mandate to its next status in the lifecycle.""" try: current_index = _MANDATE_STATUS_ORDER.index(mandate.status) except ValueError: return 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