feat(poll): enforce auditable lifecycle transitions

This commit is contained in:
2026-07-20 18:18:20 +02:00
parent f4d5cac40d
commit 65e2d1fd65
14 changed files with 1161 additions and 68 deletions

View File

@@ -3,13 +3,14 @@ from __future__ import annotations
import hashlib
import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.db.base import utcnow
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.schemas import (
PollCreateRequest,
PollDecisionRequest,
@@ -18,6 +19,7 @@ from govoplan_poll.backend.schemas import (
PollSubmitResponseRequest,
PollUpdateRequest,
)
from govoplan_poll.backend.transitions import DEFAULT_POLL_TRANSITION_ENGINE, POLL_STATUSES
class PollError(ValueError):
@@ -25,7 +27,6 @@ class PollError(ValueError):
POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"}
POLL_STATUSES = {"draft", "open", "closed", "decided", "archived"}
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"}
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
YES_NO_OPTIONS = (
@@ -39,6 +40,13 @@ YES_NO_MAYBE_OPTIONS = (
)
@dataclass(frozen=True)
class PollTransitionResult:
poll: Poll
transition: PollLifecycleTransition
replayed: bool = False
def slugify(value: str, *, fallback: str = "poll") -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or fallback
@@ -370,38 +378,244 @@ def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: Poll
return poll
def open_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
if poll.status == "archived":
raise PollError("Archived polls cannot be opened")
if poll.closes_at is not None and response_datetime(poll.closes_at) <= _now():
raise PollError("Poll close time is already in the past")
poll.status = "open"
poll.closed_at = None
session.flush()
def _lock_poll_for_transition(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = (
session.query(Poll)
.filter(Poll.tenant_id == tenant_id, Poll.id == poll_id, Poll.deleted_at.is_(None))
.populate_existing()
.with_for_update()
.first()
)
if poll is None:
raise PollError("Poll not found")
return poll
def close_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
if poll.status == "archived":
raise PollError("Archived polls cannot be closed")
poll.status = "closed"
poll.closed_at = _now()
session.flush()
return poll
def _normalize_idempotency_key(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
if not normalized:
raise PollError("Idempotency key cannot be empty")
if len(normalized) > 255:
raise PollError("Idempotency key cannot be longer than 255 characters")
return normalized
def decide_poll(session: Session, *, tenant_id: str, poll_id: str, payload: PollDecisionRequest) -> Poll:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
option = _option_by_id_or_key(poll, option_id=payload.option_id, option_key=payload.option_key)
poll.status = "decided"
poll.decided_option_id = option.id
poll.decided_at = _now()
if poll.closed_at is None:
poll.closed_at = poll.decided_at
def _transition_for_idempotency_key(
session: Session,
*,
poll_id: str,
idempotency_key: str | None,
) -> PollLifecycleTransition | None:
if idempotency_key is None:
return None
return (
session.query(PollLifecycleTransition)
.filter(
PollLifecycleTransition.poll_id == poll_id,
PollLifecycleTransition.idempotency_key == idempotency_key,
)
.one_or_none()
)
def transition_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
action: str,
option_id: str | None = None,
option_key: str | None = None,
idempotency_key: str | None = None,
actor_user_id: str | None = None,
actor_api_key_id: str | None = None,
) -> PollTransitionResult:
"""Apply one policy-controlled transition and append its durable audit record."""
poll = _lock_poll_for_transition(session, tenant_id=tenant_id, poll_id=poll_id)
normalized_key = _normalize_idempotency_key(idempotency_key)
decision_option = None
if action == "decide":
decision_option = _option_by_id_or_key(poll, option_id=option_id, option_key=option_key)
elif option_id is not None or option_key is not None:
raise PollError("Only a decide transition accepts a poll option")
existing = _transition_for_idempotency_key(
session,
poll_id=poll.id,
idempotency_key=normalized_key,
)
if existing is not None:
requested_option_id = decision_option.id if decision_option is not None else None
if existing.action != action or existing.decision_option_id != requested_option_id:
raise PollError("Idempotency key was already used for a different poll transition")
return PollTransitionResult(poll=poll, transition=existing, replayed=True)
try:
plan = DEFAULT_POLL_TRANSITION_ENGINE.plan(
current_status=poll.status,
action=action,
archived_from_status=poll.archived_from_status,
)
except ValueError as exc:
raise PollError(str(exc)) from exc
previous_decision_option_id = poll.decided_option_id
now = _now()
if action == "archive":
poll.archived_from_status = plan.from_status
elif action == "unarchive":
poll.archived_from_status = None
elif action == "close":
poll.closed_at = now
elif action == "decide":
assert decision_option is not None
poll.decided_option_id = decision_option.id
poll.decided_at = now
if poll.closed_at is None:
poll.closed_at = now
poll.status = plan.to_status
transition_metadata: dict[str, Any] = {}
if action == "archive":
transition_metadata["archived_from_status"] = plan.from_status
elif action == "unarchive":
transition_metadata["restored_status"] = plan.to_status
transition = PollLifecycleTransition(
tenant_id=tenant_id,
poll_id=poll.id,
action=action,
from_status=plan.from_status,
to_status=plan.to_status,
decision_option_id=decision_option.id if decision_option is not None else None,
previous_decision_option_id=previous_decision_option_id,
idempotency_key=normalized_key,
actor_user_id=actor_user_id,
actor_api_key_id=actor_api_key_id,
metadata_=transition_metadata,
)
session.add(transition)
session.flush()
return poll
return PollTransitionResult(poll=poll, transition=transition)
def open_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="open",
idempotency_key=idempotency_key,
).poll
def draft_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="draft",
idempotency_key=idempotency_key,
).poll
def close_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="close",
idempotency_key=idempotency_key,
).poll
def decide_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
payload: PollDecisionRequest,
idempotency_key: str | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="decide",
option_id=payload.option_id,
option_key=payload.option_key,
idempotency_key=idempotency_key,
).poll
def archive_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="archive",
idempotency_key=idempotency_key,
).poll
def unarchive_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="unarchive",
idempotency_key=idempotency_key,
).poll
def list_poll_lifecycle_transitions(
session: Session,
*,
tenant_id: str,
poll_id: str,
) -> list[PollLifecycleTransition]:
get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
return (
session.query(PollLifecycleTransition)
.filter(
PollLifecycleTransition.tenant_id == tenant_id,
PollLifecycleTransition.poll_id == poll_id,
)
.order_by(PollLifecycleTransition.created_at.asc(), PollLifecycleTransition.id.asc())
.all()
)
def _assert_poll_accepts_responses(poll: Poll, *, now: datetime | None = None) -> None:
@@ -564,7 +778,27 @@ def poll_option_response(option: PollOption) -> dict[str, Any]:
}
def poll_lifecycle_transition_response(transition: PollLifecycleTransition) -> dict[str, Any]:
return {
"id": transition.id,
"action": transition.action,
"from_status": transition.from_status,
"to_status": transition.to_status,
"decision_option_id": transition.decision_option_id,
"previous_decision_option_id": transition.previous_decision_option_id,
"idempotency_key": transition.idempotency_key,
"actor_user_id": transition.actor_user_id,
"actor_api_key_id": transition.actor_api_key_id,
"created_at": response_datetime(transition.created_at),
"metadata": transition.metadata_ or {},
}
def poll_response(poll: Poll) -> dict[str, Any]:
lifecycle_actions = DEFAULT_POLL_TRANSITION_ENGINE.available_actions(
current_status=poll.status,
archived_from_status=poll.archived_from_status,
)
return {
"id": poll.id,
"tenant_id": poll.tenant_id,
@@ -589,11 +823,21 @@ def poll_response(poll: Poll) -> dict[str, Any]:
"closed_at": response_datetime(poll.closed_at),
"decided_at": response_datetime(poll.decided_at),
"decided_option_id": poll.decided_option_id,
"archived_from_status": poll.archived_from_status,
"created_by_user_id": poll.created_by_user_id,
"created_at": response_datetime(poll.created_at),
"updated_at": response_datetime(poll.updated_at),
"metadata": poll.metadata_ or {},
"options": [poll_option_response(option) for option in _active_options(poll)],
"lifecycle_actions": [
{
"action": item.action,
"target_status": item.target_status,
"available": item.available,
"reason": item.reason,
}
for item in lifecycle_actions
],
}