feat(poll): enforce auditable lifecycle transitions
This commit is contained in:
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user