fix(poll): harden lifecycle policy boundaries

This commit is contained in:
2026-07-20 18:42:07 +02:00
parent 12b3175b07
commit 2c776437b4
5 changed files with 212 additions and 23 deletions

View File

@@ -19,7 +19,7 @@ from govoplan_poll.backend.schemas import (
PollSubmitResponseRequest,
PollUpdateRequest,
)
from govoplan_poll.backend.transitions import DEFAULT_POLL_TRANSITION_ENGINE, POLL_STATUSES
from govoplan_poll.backend.transitions import DEFAULT_POLL_TRANSITION_ENGINE, PollTransitionEngine
class PollError(ValueError):
@@ -27,6 +27,7 @@ class PollError(ValueError):
POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"}
POLL_INITIAL_STATUSES = {"draft", "open"}
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"}
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
YES_NO_OPTIONS = (
@@ -149,8 +150,8 @@ def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None
def _ensure_valid_poll_payload(payload: PollCreateRequest) -> tuple[list[PollOptionInput], int, int | None]:
if payload.kind not in POLL_KINDS:
raise PollError(f"Unsupported poll kind: {payload.kind}")
if payload.status not in POLL_STATUSES:
raise PollError(f"Unsupported poll status: {payload.status}")
if payload.status not in POLL_INITIAL_STATUSES:
raise PollError("Poll initial status must be draft or open")
if payload.opens_at is not None and payload.closes_at is not None and payload.closes_at <= payload.opens_at:
raise PollError("closes_at must be after opens_at")
options = _normalize_options(payload.kind, payload.options)
@@ -305,7 +306,9 @@ def poll_results_are_visible(
if poll.result_visibility == "public":
return True
if poll.result_visibility == "after_close":
if poll.status in {"closed", "decided", "archived"}:
if poll.status in {"closed", "decided"}:
return True
if poll.status == "archived" and poll.archived_from_status in {"closed", "decided"}:
return True
return poll.closes_at is not None and response_datetime(poll.closes_at) <= _now()
if poll.result_visibility == "after_response" and ids:
@@ -431,6 +434,7 @@ def transition_poll(
idempotency_key: str | None = None,
actor_user_id: str | None = None,
actor_api_key_id: str | None = None,
transition_engine: PollTransitionEngine = DEFAULT_POLL_TRANSITION_ENGINE,
) -> PollTransitionResult:
"""Apply one policy-controlled transition and append its durable audit record."""
@@ -454,7 +458,7 @@ def transition_poll(
return PollTransitionResult(poll=poll, transition=existing, replayed=True)
try:
plan = DEFAULT_POLL_TRANSITION_ENGINE.plan(
plan = transition_engine.plan(
current_status=poll.status,
action=action,
archived_from_status=poll.archived_from_status,
@@ -464,6 +468,14 @@ def transition_poll(
previous_decision_option_id = poll.decided_option_id
now = _now()
used_legacy_unarchive_fallback = action == "unarchive" and poll.archived_from_status is None
cleared_expired_closes_at: datetime | None = None
if action == "open" and poll.closes_at is not None:
normalized_closes_at = response_datetime(poll.closes_at)
if normalized_closes_at is not None and normalized_closes_at <= now:
cleared_expired_closes_at = normalized_closes_at
poll.closes_at = None
if action == "archive":
poll.archived_from_status = plan.from_status
elif action == "unarchive":
@@ -483,6 +495,13 @@ def transition_poll(
transition_metadata["archived_from_status"] = plan.from_status
elif action == "unarchive":
transition_metadata["restored_status"] = plan.to_status
if used_legacy_unarchive_fallback:
transition_metadata["legacy_restore_fallback"] = {
"reason": "missing_archived_from_status",
"restored_status": plan.to_status,
}
if cleared_expired_closes_at is not None:
transition_metadata["cleared_expired_closes_at"] = cleared_expired_closes_at.isoformat()
transition = PollLifecycleTransition(
tenant_id=tenant_id,
poll_id=poll.id,
@@ -913,8 +932,12 @@ def poll_lifecycle_transition_response(transition: PollLifecycleTransition) -> d
}
def poll_response(poll: Poll) -> dict[str, Any]:
lifecycle_actions = DEFAULT_POLL_TRANSITION_ENGINE.available_actions(
def poll_response(
poll: Poll,
*,
transition_engine: PollTransitionEngine = DEFAULT_POLL_TRANSITION_ENGINE,
) -> dict[str, Any]:
lifecycle_actions = transition_engine.available_actions(
current_status=poll.status,
archived_from_status=poll.archived_from_status,
)