Files
govoplan-poll/src/govoplan_poll/backend/service.py

2243 lines
74 KiB
Python

from __future__ import annotations
import hashlib
import logging
import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
from govoplan_core.db.base import utcnow
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.mutation_plans import (
MAX_RETIREMENT_RESPONSES,
OWNERSHIP_FIELDS,
PollMutationPlanError,
decide_existing_response_impact,
normalize_retirement_selector,
plan_poll_update,
plan_response_retirement,
validate_choice_bounds,
)
from govoplan_poll.backend.schemas import (
PollCreateRequest,
PollDecisionRequest,
PollInvitationCreateRequest,
PollOptionInput,
PollSubmitResponseRequest,
PollUpdateRequest,
)
from govoplan_poll.backend.transitions import DEFAULT_POLL_TRANSITION_ENGINE, PollTransitionEngine
logger = logging.getLogger("govoplan.poll.lifecycle")
class PollError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class PollMutationOwner:
module_id: str
resource_type: str
resource_id: str
GOVERNED_PARTICIPATION_REQUIRED = "Poll participation is governed by its owning module"
GOVERNED_PARTICIPATION_GATEWAY_MISMATCH = "Participation gateway does not own this poll"
GOVERNED_INVITATION_CAPABILITY_REQUIRED = (
"Governed invitations must be created through the participation gateway capability"
)
OWNED_POLL_MUTATION_REQUIRED = "Poll mutations must use its owning module capability"
OWNED_POLL_PROJECTION_RESTRICTED = (
"Poll respondent and invitation data is managed by its owning module"
)
POLL_OWNERSHIP_FIELDS_RESTRICTED = (
"Poll context and workflow fields require an owning module capability"
)
INVALID_POLL_OWNER = "Poll owning module context is invalid"
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"}
ACTIVE_RESPONSE_UNIQUE_INDEX = "uq_poll_responses_active_respondent"
YES_NO_OPTIONS = (
PollOptionInput(key="yes", label="Yes"),
PollOptionInput(key="no", label="No"),
)
YES_NO_MAYBE_OPTIONS = (
PollOptionInput(key="yes", label="Yes"),
PollOptionInput(key="no", label="No"),
PollOptionInput(key="maybe", label="Maybe"),
)
@dataclass(frozen=True)
class PollTransitionResult:
poll: Poll
transition: PollLifecycleTransition | None
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
def response_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _now() -> datetime:
return utcnow()
def poll_owner_ref(
*,
module_id: object,
resource_type: object,
resource_id: object,
) -> PollMutationOwner:
values = (module_id, resource_type, resource_id)
if any(not isinstance(value, str) or not value.strip() for value in values):
raise PollError(INVALID_POLL_OWNER)
return PollMutationOwner(
module_id=module_id,
resource_type=resource_type,
resource_id=resource_id,
)
def poll_mutation_owner(poll: Poll) -> PollMutationOwner | None:
"""Resolve one immutable owner from module context and gateway state."""
context_values = (
poll.context_module,
poll.context_resource_type,
poll.context_resource_id,
)
context_owner = None
if any(value is not None for value in context_values):
context_owner = poll_owner_ref(
module_id=poll.context_module,
resource_type=poll.context_resource_type,
resource_id=poll.context_resource_id,
)
gateway_owner = None
if poll.participation_gateway_ is not None:
gateway = poll.participation_gateway_
if not isinstance(gateway, dict) or set(gateway) != {
"module_id",
"resource_type",
"resource_id",
}:
raise PollError(INVALID_POLL_OWNER)
gateway_owner = poll_owner_ref(
module_id=gateway.get("module_id"),
resource_type=gateway.get("resource_type"),
resource_id=gateway.get("resource_id"),
)
if (
context_owner is not None
and gateway_owner is not None
and context_owner != gateway_owner
):
raise PollError(INVALID_POLL_OWNER)
return context_owner or gateway_owner
def _assert_poll_mutation_owner(
poll: Poll,
*,
mutation_owner: PollMutationOwner | None,
) -> None:
expected = poll_mutation_owner(poll)
if expected != mutation_owner:
raise PollError(OWNED_POLL_MUTATION_REQUIRED)
def assert_generic_poll_projection_allowed(poll: Poll) -> None:
"""Keep raw module-owned respondent data behind its policy-owning module."""
if poll_mutation_owner(poll) is not None:
raise PollError(OWNED_POLL_PROJECTION_RESTRICTED)
def _assert_poll_projection_owner(
poll: Poll,
*,
projection_owner: PollMutationOwner | None,
) -> None:
if poll_mutation_owner(poll) != projection_owner:
raise PollError(OWNED_POLL_PROJECTION_RESTRICTED)
_SENSITIVE_METADATA_TOKENS = {"credential", "password", "secret", "token"}
def assert_no_sensitive_participation_metadata(value: Any) -> None:
"""Reject secret-like keys before public participation metadata is stored."""
if isinstance(value, dict):
for key, nested in value.items():
key_text = str(key)
key_text = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key_text)
key_text = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "_", key_text)
key_tokens = {
token
for token in re.split(r"[^a-z0-9]+", key_text.casefold())
if token
}
compact_key = re.sub(r"[^a-z0-9]+", "", key_text.casefold())
if key_tokens & _SENSITIVE_METADATA_TOKENS or any(
token in compact_key for token in _SENSITIVE_METADATA_TOKENS
):
raise PollError("Sensitive participation metadata keys are not allowed")
assert_no_sensitive_participation_metadata(nested)
elif isinstance(value, (list, tuple)):
for nested in value:
assert_no_sensitive_participation_metadata(nested)
def _active_options(poll: Poll) -> list[PollOption]:
return [option for option in poll.options if option.deleted_at is None]
def _active_responses(poll: Poll) -> list[PollResponse]:
return [response for response in poll.responses if response.deleted_at is None]
def _option_by_id_or_key(poll: Poll, *, option_id: str | None = None, option_key: str | None = None) -> PollOption:
for option in _active_options(poll):
if option_id is not None and option.id == option_id:
return option
if option_key is not None and option.key == option_key:
return option
raise PollError("Unknown poll option")
def _poll_slug_exists(session: Session, *, tenant_id: str, slug: str, exclude_poll_id: str | None = None) -> bool:
query = session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.slug == slug, Poll.deleted_at.is_(None))
if exclude_poll_id is not None:
query = query.filter(Poll.id != exclude_poll_id)
return session.query(query.exists()).scalar()
def _unique_slug(session: Session, *, tenant_id: str, slug: str) -> str:
candidate = slug
suffix = 2
while _poll_slug_exists(session, tenant_id=tenant_id, slug=candidate):
candidate = f"{slug}-{suffix}"
suffix += 1
return candidate
def _normalize_options(kind: str, options: list[PollOptionInput]) -> list[PollOptionInput]:
normalized = list(options)
if kind == "yes_no" and not normalized:
normalized = list(YES_NO_OPTIONS)
if kind == "yes_no_maybe" and not normalized:
normalized = list(YES_NO_MAYBE_OPTIONS)
if kind == "yes_no_maybe" and len(normalized) < 3:
raise PollError("yes_no_maybe polls require at least three options")
if kind in CHOICE_POLL_KINDS and len(normalized) < 2:
raise PollError(f"{kind} polls require at least two options")
if kind == "availability" and not normalized:
raise PollError("Availability polls require at least one candidate slot option")
seen: set[str] = set()
result: list[PollOptionInput] = []
for position, option in enumerate(normalized):
key = option.key or slugify(option.label, fallback=f"option-{position + 1}")
if key in seen:
raise PollError(f"Duplicate poll option key: {key}")
seen.add(key)
result.append(
PollOptionInput(
key=key,
label=option.label,
description=option.description,
value=option.value,
metadata=option.metadata,
)
)
return result
def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None, option_count: int) -> tuple[int, int | None]:
try:
return validate_choice_bounds(
kind,
min_choices,
max_choices,
option_count,
)
except PollMutationPlanError as exc:
raise PollError(str(exc)) from exc
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_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)
min_choices, max_choices = _validate_choice_bounds(payload.kind, payload.min_choices, payload.max_choices, len(options))
return options, min_choices, max_choices
def _declared_poll_owner(payload: PollCreateRequest) -> PollMutationOwner | None:
context_values = (
payload.context_module,
payload.context_resource_type,
payload.context_resource_id,
)
if not any(value is not None for value in context_values):
return None
return poll_owner_ref(
module_id=payload.context_module,
resource_type=payload.context_resource_type,
resource_id=payload.context_resource_id,
)
def create_poll(
session: Session,
*,
tenant_id: str,
user_id: str | None,
payload: PollCreateRequest,
mutation_owner: PollMutationOwner | None = None,
) -> Poll:
declared_owner = _declared_poll_owner(payload)
declares_workflow = payload.workflow_state is not None or bool(payload.workflow_steps)
if declared_owner != mutation_owner or (declares_workflow and mutation_owner is None):
raise PollError(POLL_OWNERSHIP_FIELDS_RESTRICTED)
options, min_choices, max_choices = _ensure_valid_poll_payload(payload)
base_slug = slugify(payload.slug or payload.title)
poll = Poll(
tenant_id=tenant_id,
slug=_unique_slug(session, tenant_id=tenant_id, slug=base_slug),
title=payload.title,
description=payload.description,
kind=payload.kind,
status=payload.status,
visibility=payload.visibility,
result_visibility=payload.result_visibility,
context_module=payload.context_module,
context_resource_type=payload.context_resource_type,
context_resource_id=payload.context_resource_id,
workflow_state=payload.workflow_state,
workflow_steps=payload.workflow_steps,
allow_anonymous=payload.allow_anonymous,
allow_response_update=payload.allow_response_update,
min_choices=min_choices,
max_choices=max_choices,
opens_at=payload.opens_at,
closes_at=payload.closes_at,
created_by_user_id=user_id,
metadata_=payload.metadata,
)
session.add(poll)
session.flush()
for position, option in enumerate(options):
session.add(
PollOption(
tenant_id=tenant_id,
poll_id=poll.id,
key=option.key or f"option-{position + 1}",
label=option.label,
description=option.description,
position=position,
value=option.value,
metadata_=option.metadata,
)
)
session.flush()
return poll
def list_polls(
session: Session,
*,
tenant_id: str,
status: str | None = None,
kind: str | None = None,
limit: int = 100,
) -> list[Poll]:
query = (
session.query(Poll)
.options(selectinload(Poll.options))
.filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None))
)
if status:
query = query.filter(Poll.status == status)
if kind:
query = query.filter(Poll.kind == kind)
return (
query.order_by(Poll.created_at.desc(), Poll.title.asc())
.limit(max(1, min(limit, 200)))
.all()
)
def get_poll(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)).first()
if poll is None:
raise PollError("Poll not found")
return poll
def _actor_ids(values: tuple[str, ...]) -> tuple[str, ...]:
return tuple(dict.fromkeys(value for value in values if value))
def _has_active_invitation(session: Session, *, poll: Poll, actor_ids: tuple[str, ...]) -> bool:
ids = _actor_ids(actor_ids)
if not ids:
return False
invitations = (
session.query(PollInvitation)
.filter(
PollInvitation.tenant_id == poll.tenant_id,
PollInvitation.poll_id == poll.id,
PollInvitation.respondent_id.in_(ids),
PollInvitation.revoked_at.is_(None),
)
.all()
)
now = _now()
return any(
invitation.expires_at is None or response_datetime(invitation.expires_at) > now
for invitation in invitations
)
def poll_is_visible(
session: Session,
*,
poll: Poll,
actor_ids: tuple[str, ...],
can_manage: bool = False,
) -> bool:
ids = _actor_ids(actor_ids)
if can_manage or (poll.created_by_user_id is not None and poll.created_by_user_id in ids):
return True
if poll.visibility in {"tenant", "public"}:
return True
if poll.visibility in {"private", "unlisted"}:
return _has_active_invitation(session, poll=poll, actor_ids=ids)
return False
def list_visible_polls(
session: Session,
*,
tenant_id: str,
actor_ids: tuple[str, ...],
can_manage: bool = False,
status: str | None = None,
kind: str | None = None,
limit: int = 100,
) -> list[Poll]:
query = (
session.query(Poll)
.options(selectinload(Poll.options))
.filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None))
)
if status:
query = query.filter(Poll.status == status)
if kind:
query = query.filter(Poll.kind == kind)
if not can_manage:
ids = _actor_ids(actor_ids)
invitation_exists = (
session.query(PollInvitation.id)
.filter(
PollInvitation.tenant_id == tenant_id,
PollInvitation.poll_id == Poll.id,
PollInvitation.respondent_id.in_(ids or ("",)),
PollInvitation.revoked_at.is_(None),
or_(
PollInvitation.expires_at.is_(None),
PollInvitation.expires_at > _now(),
),
)
.exists()
)
query = query.filter(
or_(
Poll.created_by_user_id.in_(ids or ("",)),
Poll.visibility.in_(("tenant", "public")),
invitation_exists,
)
)
return (
query.order_by(Poll.created_at.desc(), Poll.title.asc())
.limit(max(1, min(limit, 200)))
.all()
)
def get_visible_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
actor_ids: tuple[str, ...],
can_manage: bool = False,
) -> Poll:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
if not poll_is_visible(session, poll=poll, actor_ids=actor_ids, can_manage=can_manage):
# Do not disclose the existence of a private or unlisted poll.
raise PollError("Poll not found")
return poll
def poll_results_are_visible(
session: Session,
*,
poll: Poll,
actor_ids: tuple[str, ...],
can_manage: bool = False,
) -> bool:
ids = _actor_ids(actor_ids)
if can_manage or (poll.created_by_user_id is not None and poll.created_by_user_id in ids):
return True
if poll.result_visibility == "public":
return True
if poll.result_visibility == "after_close":
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:
return (
session.query(PollResponse)
.filter(
PollResponse.tenant_id == poll.tenant_id,
PollResponse.poll_id == poll.id,
PollResponse.respondent_id.in_(ids),
PollResponse.deleted_at.is_(None),
)
.first()
is not None
)
return False
def require_visible_poll_results(
session: Session,
*,
poll: Poll,
actor_ids: tuple[str, ...],
can_manage: bool = False,
) -> None:
if not poll_results_are_visible(
session,
poll=poll,
actor_ids=actor_ids,
can_manage=can_manage,
):
raise PollError("Poll results are not visible")
def update_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
payload: PollUpdateRequest,
mutation_owner: PollMutationOwner | None = None,
) -> Poll:
poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
_validate_poll_update_ownership(
poll,
payload,
mutation_owner=mutation_owner,
)
try:
plan = plan_poll_update(
poll,
payload.model_dump(exclude_unset=True),
active_option_count=len(_active_options(poll)),
)
except PollMutationPlanError as exc:
raise PollError(str(exc)) from exc
plan.apply(poll)
session.flush()
return poll
def _validate_poll_update_ownership(
poll: Poll,
payload: PollUpdateRequest,
*,
mutation_owner: PollMutationOwner | None,
) -> None:
if mutation_owner is None and OWNERSHIP_FIELDS & payload.model_fields_set:
raise PollError(POLL_OWNERSHIP_FIELDS_RESTRICTED)
context_fields = {
"context_module",
"context_resource_type",
"context_resource_id",
}
if mutation_owner is not None and context_fields & payload.model_fields_set:
requested_owner = poll_owner_ref(
module_id=(
payload.context_module
if payload.context_module is not None
else poll.context_module
),
resource_type=(
payload.context_resource_type
if payload.context_resource_type is not None
else poll.context_resource_type
),
resource_id=(
payload.context_resource_id
if payload.context_resource_id is not None
else poll.context_resource_id
),
)
if requested_owner != mutation_owner:
raise PollError(OWNED_POLL_MUTATION_REQUIRED)
def set_poll_workflow_context(
session: Session,
*,
tenant_id: str,
poll_id: str,
workflow_state: str,
workflow_steps: list[dict[str, object]],
context_module: str,
context_resource_type: str,
context_resource_id: str,
mutation_owner: PollMutationOwner | None,
requested_owner: PollMutationOwner,
) -> Poll:
"""Synchronize owner-controlled workflow context at any lifecycle state."""
poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
exact_requested_owner = poll_owner_ref(
module_id=context_module,
resource_type=context_resource_type,
resource_id=context_resource_id,
)
if (
requested_owner != exact_requested_owner
or mutation_owner != exact_requested_owner
):
raise PollError(OWNED_POLL_MUTATION_REQUIRED)
poll.workflow_state = workflow_state
poll.workflow_steps = workflow_steps
poll.context_module = context_module
poll.context_resource_type = context_resource_type
poll.context_resource_id = context_resource_id
session.flush()
return poll
def update_poll_snapshot(
session: Session,
*,
tenant_id: str,
poll_id: str,
title: str,
description: str | None,
visibility: str,
result_visibility: str,
allow_anonymous: bool,
allow_response_update: bool,
closes_at: datetime | None,
mutation_owner: PollMutationOwner | None,
) -> Poll:
"""Apply a trusted capability's complete mutable Poll policy snapshot."""
poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
if poll.status in {"closed", "decided", "archived"}:
raise PollError("Closed, decided, or archived polls cannot be edited")
if poll.opens_at is not None and closes_at is not None and closes_at <= poll.opens_at:
raise PollError("closes_at must be after opens_at")
poll.title = title
poll.description = description
poll.visibility = visibility
poll.result_visibility = result_visibility
poll.allow_anonymous = allow_anonymous
poll.allow_response_update = allow_response_update
poll.closes_at = closes_at
session.flush()
return poll
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 _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 _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_decision_option(
poll: Poll,
*,
action: str,
option_id: str | None,
option_key: str | None,
) -> PollOption | None:
if action == "decide":
return _option_by_id_or_key(poll, option_id=option_id, option_key=option_key)
if option_id is not None or option_key is not None:
raise PollError("Only a decide transition accepts a poll option")
return None
def _replayed_transition(
poll: Poll,
*,
existing: PollLifecycleTransition | None,
action: str,
decision_option: PollOption | None,
) -> PollTransitionResult | None:
if existing is None:
return 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)
def _latest_lifecycle_transition(
session: Session,
*,
poll_id: str,
) -> PollLifecycleTransition | None:
return (
session.query(PollLifecycleTransition)
.filter(PollLifecycleTransition.poll_id == poll_id)
.order_by(
PollLifecycleTransition.created_at.desc(),
PollLifecycleTransition.id.desc(),
)
.first()
)
def _is_exact_transition_noop(
session: Session,
*,
poll: Poll,
action: str,
decision_option: PollOption | None,
transition_engine: PollTransitionEngine,
) -> bool:
"""Recognize commands whose requested lifecycle state already holds."""
rule = transition_engine.policy.rules.get(action)
if rule is None:
return False
if action == "decide":
return (
rule.target_status == poll.status
and decision_option is not None
and decision_option.id == poll.decided_option_id
)
if rule.restore_archived_status:
latest = _latest_lifecycle_transition(session, poll_id=poll.id)
return (
latest is not None
and latest.action == action
and latest.to_status == poll.status
)
return rule.target_status == poll.status
def _log_duplicate_transition(
*,
poll: Poll,
action: str,
actor_user_id: str | None,
actor_api_key_id: str | None,
reason: str,
has_idempotency_key: bool,
) -> None:
"""Record command duplication as operational telemetry, not lifecycle audit."""
logger.info(
"Ignored repeated exact Poll lifecycle transition",
extra={
"event": "poll.lifecycle_transition.duplicate",
"tenant_id": poll.tenant_id,
"poll_id": poll.id,
"transition_action": action,
"actor_user_id": actor_user_id,
"actor_api_key_id": actor_api_key_id,
"duplicate_reason": reason,
"has_idempotency_key": has_idempotency_key,
},
)
def _clear_expired_close_for_open(poll: Poll, *, action: str, now: datetime) -> datetime | None:
if action != "open" or poll.closes_at is None:
return None
normalized_closes_at = response_datetime(poll.closes_at)
if normalized_closes_at is None or normalized_closes_at > now:
return None
poll.closes_at = None
return normalized_closes_at
def _apply_transition_state(
poll: Poll,
*,
action: str,
from_status: str,
to_status: str,
decision_option: PollOption | None,
now: datetime,
) -> None:
if action == "archive":
poll.archived_from_status = from_status
elif action == "unarchive":
poll.archived_from_status = None
elif action == "close":
poll.closed_at = now
elif action == "decide":
if decision_option is None:
raise PollError("A decide transition requires a poll option")
poll.decided_option_id = decision_option.id
poll.decided_at = now
if poll.closed_at is None:
poll.closed_at = now
poll.status = to_status
def _transition_audit_metadata(
*,
action: str,
from_status: str,
to_status: str,
used_legacy_unarchive_fallback: bool,
cleared_expired_closes_at: datetime | None,
) -> dict[str, Any]:
metadata: dict[str, Any] = {}
if action == "archive":
metadata["archived_from_status"] = from_status
elif action == "unarchive":
metadata["restored_status"] = to_status
if used_legacy_unarchive_fallback:
metadata["legacy_restore_fallback"] = {
"reason": "missing_archived_from_status",
"restored_status": to_status,
}
if cleared_expired_closes_at is not None:
metadata["cleared_expired_closes_at"] = cleared_expired_closes_at.isoformat()
return metadata
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,
mutation_owner: PollMutationOwner | None = None,
transition_engine: PollTransitionEngine = DEFAULT_POLL_TRANSITION_ENGINE,
) -> PollTransitionResult:
"""Apply a policy transition, or return an audit-free exact no-op."""
poll = _lock_poll_for_transition(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
normalized_key = _normalize_idempotency_key(idempotency_key)
decision_option = _transition_decision_option(
poll,
action=action,
option_id=option_id,
option_key=option_key,
)
existing = _transition_for_idempotency_key(
session,
poll_id=poll.id,
idempotency_key=normalized_key,
)
replayed = _replayed_transition(
poll,
existing=existing,
action=action,
decision_option=decision_option,
)
if replayed is not None:
_log_duplicate_transition(
poll=poll,
action=action,
actor_user_id=actor_user_id,
actor_api_key_id=actor_api_key_id,
reason="idempotency_key_replay",
has_idempotency_key=True,
)
return replayed
if _is_exact_transition_noop(
session,
poll=poll,
action=action,
decision_option=decision_option,
transition_engine=transition_engine,
):
_log_duplicate_transition(
poll=poll,
action=action,
actor_user_id=actor_user_id,
actor_api_key_id=actor_api_key_id,
reason="requested_state_already_active",
has_idempotency_key=normalized_key is not None,
)
return PollTransitionResult(poll=poll, transition=None, replayed=True)
try:
plan = 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()
used_legacy_unarchive_fallback = action == "unarchive" and poll.archived_from_status is None
cleared_expired_closes_at = _clear_expired_close_for_open(poll, action=action, now=now)
_apply_transition_state(
poll,
action=action,
from_status=plan.from_status,
to_status=plan.to_status,
decision_option=decision_option,
now=now,
)
transition_metadata = _transition_audit_metadata(
action=action,
from_status=plan.from_status,
to_status=plan.to_status,
used_legacy_unarchive_fallback=used_legacy_unarchive_fallback,
cleared_expired_closes_at=cleared_expired_closes_at,
)
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 PollTransitionResult(poll=poll, transition=transition)
def open_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="open",
idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll
def draft_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="draft",
idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll
def close_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="close",
idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll
def decide_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
payload: PollDecisionRequest,
idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | 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,
mutation_owner=mutation_owner,
).poll
def archive_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="archive",
idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll
def unarchive_poll(
session: Session,
*,
tenant_id: str,
poll_id: str,
idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll:
return transition_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
action="unarchive",
idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).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:
now = now or _now()
if poll.status != "open":
raise PollError("Poll is not open")
if poll.opens_at is not None and response_datetime(poll.opens_at) > now:
raise PollError("Poll is not open yet")
if poll.closes_at is not None and response_datetime(poll.closes_at) <= now:
raise PollError("Poll is already closed")
def _resolve_answer_option(poll: Poll, answer) -> PollOption:
if answer.option_id is None and answer.option_key is None:
raise PollError("Poll answers must reference an option_id or option_key")
return _option_by_id_or_key(poll, option_id=answer.option_id, option_key=answer.option_key)
def _normalize_choice_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
selected: list[PollOption] = []
seen: set[str] = set()
for answer in payload.answers:
option = _resolve_answer_option(poll, answer)
if option.id in seen:
raise PollError("Poll responses cannot select the same option more than once")
seen.add(option.id)
selected.append(option)
if len(selected) < poll.min_choices:
raise PollError(f"Poll response requires at least {poll.min_choices} option(s)")
if poll.max_choices is not None and len(selected) > poll.max_choices:
raise PollError(f"Poll response allows at most {poll.max_choices} option(s)")
return [{"option_id": option.id, "option_key": option.key, "value": True} for option in selected]
def _normalize_ranked_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
selected: list[tuple[int, PollOption]] = []
seen_options: set[str] = set()
seen_ranks: set[int] = set()
for position, answer in enumerate(payload.answers, start=1):
option = _resolve_answer_option(poll, answer)
rank = answer.rank or position
if option.id in seen_options:
raise PollError("Ranked responses cannot rank the same option more than once")
if rank in seen_ranks:
raise PollError("Ranked responses cannot reuse the same rank")
seen_options.add(option.id)
seen_ranks.add(rank)
selected.append((rank, option))
if len(selected) < poll.min_choices:
raise PollError(f"Ranked response requires at least {poll.min_choices} ranked option(s)")
if poll.max_choices is not None and len(selected) > poll.max_choices:
raise PollError(f"Ranked response allows at most {poll.max_choices} ranked option(s)")
return [
{"option_id": option.id, "option_key": option.key, "rank": rank}
for rank, option in sorted(selected, key=lambda item: item[0])
]
def _normalize_availability_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
selected: list[dict[str, Any]] = []
seen: set[str] = set()
for answer in payload.answers:
option = _resolve_answer_option(poll, answer)
if option.id in seen:
raise PollError("Availability responses cannot answer the same slot more than once")
if answer.value not in AVAILABILITY_VALUES:
raise PollError("Availability responses must use available, maybe, or unavailable")
seen.add(option.id)
selected.append({"option_id": option.id, "option_key": option.key, "value": answer.value})
if len(selected) < poll.min_choices:
raise PollError(f"Availability response requires at least {poll.min_choices} slot answer(s)")
if poll.max_choices is not None and len(selected) > poll.max_choices:
raise PollError(f"Availability response allows at most {poll.max_choices} slot answer(s)")
return selected
def normalize_response_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
if poll.kind in {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe"}:
return _normalize_choice_answers(poll, payload)
if poll.kind == "ranked_choice":
return _normalize_ranked_answers(poll, payload)
if poll.kind == "availability":
return _normalize_availability_answers(poll, payload)
raise PollError(f"Unsupported poll kind: {poll.kind}")
def _existing_response(session: Session, *, poll: Poll, respondent_id: str | None) -> PollResponse | None:
if respondent_id is None:
return None
return (
session.query(PollResponse)
.filter(PollResponse.poll_id == poll.id, PollResponse.respondent_id == respondent_id, PollResponse.deleted_at.is_(None))
.order_by(PollResponse.submitted_at.desc(), PollResponse.id.desc())
.first()
)
def _share_lock_poll_for_response(
session: Session,
*,
tenant_id: str,
poll_id: str,
) -> Poll:
"""Coordinate response writes with lifecycle changes without serializing peers."""
poll = (
session.query(Poll)
.filter(
Poll.tenant_id == tenant_id,
Poll.id == poll_id,
Poll.deleted_at.is_(None),
)
.populate_existing()
.with_for_update(read=True)
.first()
)
if poll is None:
raise PollError("Poll not found")
return poll
def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
"""Serialize policy-sensitive response and option mutations."""
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 _is_active_response_uniqueness_conflict(
session: Session,
error: IntegrityError,
) -> bool:
"""Recognize only the active identified-response invariant violation."""
original = error.orig
constraint_name = getattr(
getattr(original, "diag", None),
"constraint_name",
None,
)
if constraint_name is not None:
return constraint_name == ACTIVE_RESPONSE_UNIQUE_INDEX
if session.get_bind().dialect.name != "sqlite":
return False
message = " ".join(str(original).casefold().split())
return (
"unique constraint failed:" in message
and "poll_responses.poll_id" in message
and "poll_responses.respondent_id" in message
)
def _update_existing_response(
session: Session,
*,
poll: Poll,
response: PollResponse,
answers: list[dict[str, Any]],
respondent_label: str | None,
submitted_at: datetime,
metadata: dict[str, Any],
) -> PollResponse:
if not poll.allow_response_update:
raise PollError("Response updates are not allowed for this poll")
response.answers = answers
response.respondent_label = respondent_label
response.submitted_at = submitted_at
response.metadata_ = metadata
session.flush()
return response
def _insert_or_reconcile_identified_response(
session: Session,
*,
poll: Poll,
respondent_id: str,
respondent_label: str | None,
answers: list[dict[str, Any]],
submitted_at: datetime,
metadata: dict[str, Any],
conflict_validator: Callable[[PollResponse], None] | None = None,
) -> tuple[PollResponse, bool]:
"""Insert under a savepoint, or update the row that won the same race."""
try:
with session.begin_nested():
response = PollResponse(
tenant_id=poll.tenant_id,
poll_id=poll.id,
respondent_id=respondent_id,
respondent_label=respondent_label,
answers=answers,
submitted_at=submitted_at,
metadata_=metadata,
)
session.add(response)
session.flush()
except IntegrityError as error:
if not _is_active_response_uniqueness_conflict(session, error):
raise
winner = _existing_response(
session,
poll=poll,
respondent_id=respondent_id,
)
if winner is None:
# A correctly identified conflict always has a visible winner after
# the savepoint rollback. Preserve the database failure otherwise.
raise
if conflict_validator is not None:
conflict_validator(winner)
return (
_update_existing_response(
session,
poll=poll,
response=winner,
answers=answers,
respondent_label=respondent_label,
submitted_at=submitted_at,
metadata=metadata,
),
True,
)
return response, False
def poll_requires_governed_participation(*, poll: Poll) -> bool:
"""Whether responses must pass through the owning module's gateway."""
return poll_mutation_owner(poll) is not None
def _assert_ordinary_participation_allowed(*, poll: Poll) -> None:
if poll_requires_governed_participation(poll=poll):
raise PollError(GOVERNED_PARTICIPATION_REQUIRED)
def _assert_governed_invitation_gateway_allowed(
*,
poll: Poll,
gateway: dict[str, Any],
adopt: bool = False,
) -> None:
"""Bind governed invitations to one stable Poll owner.
Context-owned Polls use their declared module resource. A standalone Poll
may opt into governance; its first governed invitation establishes the
owner for all later invitations.
"""
try:
gateway_owner = poll_owner_ref(
module_id=gateway.get("module_id"),
resource_type=gateway.get("resource_type"),
resource_id=gateway.get("resource_id"),
)
expected_owner = poll_mutation_owner(poll)
except PollError as exc:
raise PollError(GOVERNED_PARTICIPATION_GATEWAY_MISMATCH) from exc
if expected_owner is not None and expected_owner != gateway_owner:
raise PollError(GOVERNED_PARTICIPATION_GATEWAY_MISMATCH)
if poll.participation_gateway_ is None and adopt:
poll.participation_gateway_ = dict(gateway)
elif expected_owner is None:
raise PollError(GOVERNED_PARTICIPATION_GATEWAY_MISMATCH)
def get_poll_response_for_respondents(
session: Session,
*,
tenant_id: str,
poll_id: str,
respondent_ids: tuple[str, ...],
invitation_id: str | None = None,
projection_owner: PollMutationOwner | None = None,
) -> PollResponse | None:
"""Resolve a response from server-trusted respondent identities."""
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_projection_owner(poll, projection_owner=projection_owner)
ids = tuple(dict.fromkeys(value for value in respondent_ids if value))
if ids:
response = (
session.query(PollResponse)
.filter(
PollResponse.tenant_id == tenant_id,
PollResponse.poll_id == poll_id,
PollResponse.respondent_id.in_(ids),
PollResponse.deleted_at.is_(None),
)
.order_by(PollResponse.submitted_at.desc(), PollResponse.id.desc())
.first()
)
if response is not None:
return response
if invitation_id is None:
return None
return next(
(
response
for response in reversed(
list_poll_responses(
session,
tenant_id=tenant_id,
poll_id=poll_id,
projection_owner=projection_owner,
)
)
if (response.metadata_ or {}).get("invitation_id") == invitation_id
),
None,
)
def update_poll_option(
session: Session,
*,
tenant_id: str,
poll_id: str,
option_id: str,
label: str,
description: str | None,
value: dict[str, Any] | None,
metadata: dict[str, Any],
mutation_owner: PollMutationOwner | None = None,
) -> PollOption:
"""Update one option and invalidate only answers bound to that option.
The Poll row is locked first, matching response submission. The owning
API commits the option and response JSON updates in the same transaction.
"""
poll = _lock_poll_for_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
if poll.status not in {"draft", "open"}:
raise PollError("Only draft or open poll options can be edited")
option = (
session.query(PollOption)
.filter(
PollOption.tenant_id == tenant_id,
PollOption.poll_id == poll.id,
PollOption.id == option_id,
PollOption.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one_or_none()
)
if option is None:
raise PollError("Unknown poll option")
normalized_value = dict(value) if value is not None else None
normalized_metadata = dict(metadata)
changed = (
option.label != label
or option.description != description
or option.value != normalized_value
or (option.metadata_ or {}) != normalized_metadata
)
if not changed:
return option
responses = _locked_active_poll_responses(session, poll=poll)
decision = decide_existing_response_impact(
"option_content",
has_active_responses=bool(responses),
allow_response_update=poll.allow_response_update,
)
if decision.disposition == "reject":
raise PollError(decision.reason)
option.label = label
option.description = description
option.value = normalized_value
option.metadata_ = normalized_metadata
_invalidate_option_answers(responses, option_id=option.id)
session.flush()
return option
def reorder_poll_options(
session: Session,
*,
tenant_id: str,
poll_id: str,
option_ids: tuple[str, ...],
mutation_owner: PollMutationOwner | None = None,
) -> Poll:
"""Atomically replace the complete active option order.
Position is presentation state, so existing answers remain attached to
their stable option identities. The Poll row is locked first to keep the
lock order consistent with response and option mutations.
"""
poll = _lock_poll_for_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
requested_ids = tuple(option_ids)
if len(requested_ids) != len(set(requested_ids)):
raise PollError("Poll option order cannot contain duplicate options")
options = (
session.query(PollOption)
.filter(
PollOption.tenant_id == tenant_id,
PollOption.poll_id == poll.id,
PollOption.deleted_at.is_(None),
)
.order_by(PollOption.id.asc())
.populate_existing()
.with_for_update()
.all()
)
by_id = {option.id: option for option in options}
if set(requested_ids) != set(by_id):
raise PollError(
"Poll option order must contain every active option exactly once"
)
current_ids = tuple(
option.id
for option in sorted(options, key=lambda item: (item.position, item.id))
)
if requested_ids == current_ids:
return poll
if poll.status not in {"draft", "open"}:
raise PollError("Only draft or open poll options can be reordered")
for position, option_id in enumerate(requested_ids):
by_id[option_id].position = position
session.flush()
return poll
def _locked_active_poll_responses(session: Session, *, poll: Poll) -> list[PollResponse]:
return (
session.query(PollResponse)
.filter(
PollResponse.tenant_id == poll.tenant_id,
PollResponse.poll_id == poll.id,
PollResponse.deleted_at.is_(None),
)
.order_by(PollResponse.id.asc())
.populate_existing()
.with_for_update()
.all()
)
def _invalidate_option_answers(
responses: list[PollResponse],
*,
option_id: str,
) -> int:
invalidated = 0
for response in responses:
retained_answers = [
answer
for answer in (response.answers or [])
if not isinstance(answer, dict) or answer.get("option_id") != option_id
]
if retained_answers != (response.answers or []):
invalidated += 1
response.answers = retained_answers
if not retained_answers:
response.deleted_at = _now()
return invalidated
def add_poll_option(
session: Session,
*,
tenant_id: str,
poll_id: str,
option: PollOptionInput,
mutation_owner: PollMutationOwner | None = None,
) -> tuple[PollOption, bool]:
"""Append an option; an exact keyed retry returns the existing option."""
poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
if not option.label.strip():
raise PollError("Poll option label cannot be empty")
key = option.key or slugify(option.label, fallback="option")
existing = (
session.query(PollOption)
.filter(PollOption.tenant_id == tenant_id, PollOption.poll_id == poll.id, PollOption.key == key)
.one_or_none()
)
normalized_value = dict(option.value) if option.value is not None else None
normalized_metadata = dict(option.metadata)
if existing is not None:
if (
existing.deleted_at is None
and existing.label == option.label
and existing.description == option.description
and existing.value == normalized_value
and (existing.metadata_ or {}) == normalized_metadata
):
return existing, True
raise PollError(f"Duplicate poll option key: {key}")
if poll.status not in {"draft", "open"}:
raise PollError("Only draft or open poll options can be edited")
position = max((stored.position for stored in poll.options), default=-1) + 1
created = PollOption(
tenant_id=tenant_id,
poll_id=poll.id,
key=key,
label=option.label,
description=option.description,
position=position,
value=normalized_value,
metadata_=normalized_metadata,
)
session.add(created)
_synchronize_mutable_choice_bounds(
poll,
active_option_count=len(_active_options(poll)) + 1,
)
session.flush()
return created, False
def remove_poll_option(
session: Session,
*,
tenant_id: str,
poll_id: str,
option_id: str,
mutation_owner: PollMutationOwner | None = None,
) -> tuple[PollOption, bool, int]:
"""Soft-remove an option and invalidate only answers tied to it."""
poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
option = (
session.query(PollOption)
.filter(
PollOption.tenant_id == tenant_id,
PollOption.poll_id == poll.id,
PollOption.id == option_id,
)
.populate_existing()
.with_for_update()
.one_or_none()
)
if option is None:
raise PollError("Unknown poll option")
if option.deleted_at is not None:
return option, True, 0
if poll.status not in {"draft", "open"}:
raise PollError("Only draft or open poll options can be edited")
remaining_count = sum(
stored.deleted_at is None and stored.id != option.id for stored in poll.options
)
required_count = 1 if poll.kind == "availability" else 2
if poll.kind == "yes_no_maybe":
required_count = 3
if remaining_count < required_count or poll.min_choices > remaining_count:
raise PollError("Poll option cannot be removed because too few options would remain")
responses = _locked_active_poll_responses(session, poll=poll)
decision = decide_existing_response_impact(
"option_remove",
has_active_responses=bool(responses),
allow_response_update=poll.allow_response_update,
)
if decision.disposition == "reject":
raise PollError(decision.reason)
invalidated = _invalidate_option_answers(responses, option_id=option.id)
option.deleted_at = _now()
_synchronize_mutable_choice_bounds(
poll,
active_option_count=remaining_count,
)
session.flush()
return option, False, invalidated
def _synchronize_mutable_choice_bounds(
poll: Poll,
*,
active_option_count: int,
) -> None:
"""Keep response bounds coherent after generic option mutations."""
if poll.kind == "availability":
poll.min_choices = 1
poll.max_choices = active_option_count
elif poll.max_choices is not None and poll.max_choices > active_option_count:
poll.max_choices = active_option_count
def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payload: PollSubmitResponseRequest) -> PollResponse:
assert_no_sensitive_participation_metadata(payload.metadata)
poll = _share_lock_poll_for_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
_assert_ordinary_participation_allowed(poll=poll)
_assert_poll_accepts_responses(poll)
if payload.respondent_id is None and not poll.allow_anonymous:
raise PollError("Anonymous responses are not allowed for this poll")
answers = normalize_response_answers(poll, payload)
existing = _existing_response(session, poll=poll, respondent_id=payload.respondent_id)
submitted_at = _now()
if existing is not None:
return _update_existing_response(
session,
poll=poll,
response=existing,
answers=answers,
respondent_label=payload.respondent_label,
submitted_at=submitted_at,
metadata=payload.metadata,
)
if payload.respondent_id is None:
response = PollResponse(
tenant_id=tenant_id,
poll_id=poll.id,
respondent_id=None,
respondent_label=payload.respondent_label,
answers=answers,
submitted_at=submitted_at,
metadata_=payload.metadata,
)
session.add(response)
session.flush()
return response
response, _reconciled = _insert_or_reconcile_identified_response(
session,
poll=poll,
respondent_id=payload.respondent_id,
respondent_label=payload.respondent_label,
answers=answers,
submitted_at=submitted_at,
metadata=payload.metadata,
)
return response
def poll_option_response(option: PollOption) -> dict[str, Any]:
return {
"id": option.id,
"key": option.key,
"label": option.label,
"description": option.description,
"position": option.position,
"value": option.value,
"metadata": option.metadata_ or {},
}
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,
*,
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,
)
return {
"id": poll.id,
"tenant_id": poll.tenant_id,
"slug": poll.slug,
"title": poll.title,
"description": poll.description,
"kind": poll.kind,
"status": poll.status,
"visibility": poll.visibility,
"result_visibility": poll.result_visibility,
"context_module": poll.context_module,
"context_resource_type": poll.context_resource_type,
"context_resource_id": poll.context_resource_id,
"workflow_state": poll.workflow_state,
"workflow_steps": poll.workflow_steps or [],
"allow_anonymous": poll.allow_anonymous,
"allow_response_update": poll.allow_response_update,
"min_choices": poll.min_choices,
"max_choices": poll.max_choices,
"opens_at": response_datetime(poll.opens_at),
"closes_at": response_datetime(poll.closes_at),
"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
],
}
def poll_response_item(response: PollResponse) -> dict[str, Any]:
return {
"id": response.id,
"respondent_id": response.respondent_id,
"respondent_label": response.respondent_label,
"answers": response.answers or [],
"submitted_at": response_datetime(response.submitted_at),
"created_at": response_datetime(response.created_at),
"updated_at": response_datetime(response.updated_at),
"metadata": response.metadata_ or {},
}
def list_poll_responses(
session: Session,
*,
tenant_id: str,
poll_id: str,
projection_owner: PollMutationOwner | None = None,
) -> list[PollResponse]:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_projection_owner(poll, projection_owner=projection_owner)
return (
session.query(PollResponse)
.filter(PollResponse.tenant_id == tenant_id, PollResponse.poll_id == poll_id, PollResponse.deleted_at.is_(None))
.order_by(PollResponse.submitted_at.asc(), PollResponse.id.asc())
.all()
)
def retire_poll_responses(
session: Session,
*,
tenant_id: str,
poll_id: str,
respondent_ids: tuple[str, ...],
invitation_id: str | None,
reason: str,
idempotency_key: str,
metadata: dict[str, Any],
mutation_owner: PollMutationOwner | None = None,
) -> tuple[list[PollResponse], datetime | None, int, bool]:
"""Soft-delete owner-selected responses without erasing their answers."""
try:
selector = normalize_retirement_selector(
respondent_ids=respondent_ids,
invitation_id=invitation_id,
reason=reason,
idempotency_key=idempotency_key,
)
except PollMutationPlanError as exc:
raise PollError(str(exc)) from exc
assert_no_sensitive_participation_metadata(metadata)
poll = _lock_poll_for_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
conditions = []
if selector.respondent_ids:
conditions.append(PollResponse.respondent_id.in_(selector.respondent_ids))
if selector.invitation_id is not None:
conditions.append(
PollResponse.metadata_["invitation_id"].as_string()
== selector.invitation_id
)
responses = (
session.query(PollResponse)
.filter(
PollResponse.tenant_id == tenant_id,
PollResponse.poll_id == poll.id,
or_(*conditions),
)
.order_by(PollResponse.submitted_at.asc(), PollResponse.id.asc())
.populate_existing()
.with_for_update()
.limit(MAX_RETIREMENT_RESPONSES + 1)
.all()
)
if len(responses) > MAX_RETIREMENT_RESPONSES:
raise PollError("Response retirement matches too many responses")
plan = plan_response_retirement(
responses,
idempotency_key=selector.idempotency_key,
now=_now(),
)
if plan.disposition == "retire":
plan.apply(
reason=selector.reason,
idempotency_key=selector.idempotency_key,
metadata=metadata,
)
session.flush()
return (
list(plan.responses),
plan.retired_at,
plan.newly_retired_count,
plan.disposition == "replay",
)
def _token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_poll_invitation(
session: Session,
*,
tenant_id: str,
poll_id: str,
payload: PollInvitationCreateRequest,
governed_capability: bool = False,
) -> tuple[PollInvitation, str]:
assert_no_sensitive_participation_metadata(payload.metadata)
if payload.expires_at is not None and response_datetime(payload.expires_at) <= _now():
raise PollError("Invitation expiry must be in the future")
# Invitation creation and direct response writes use the same Poll lock so
# a Poll cannot concurrently acquire a governed owner while an ordinary
# invitation or response slips through.
poll = _lock_poll_for_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
if payload.response_gateway is None:
_assert_ordinary_participation_allowed(poll=poll)
else:
if not governed_capability:
raise PollError(GOVERNED_INVITATION_CAPABILITY_REQUIRED)
_assert_governed_invitation_gateway_allowed(
poll=poll,
gateway=payload.response_gateway.model_dump(mode="json"),
adopt=True,
)
for _attempt in range(5):
token = secrets.token_urlsafe(32)
token_hash = _token_hash(token)
exists = session.query(PollInvitation).filter(PollInvitation.token_hash == token_hash).first()
if exists is None:
break
else:
raise PollError("Could not create a unique poll invitation token")
invitation = PollInvitation(
tenant_id=tenant_id,
poll_id=poll_id,
token_hash=token_hash,
respondent_id=payload.respondent_id,
respondent_label=payload.respondent_label,
email=payload.email,
expires_at=payload.expires_at,
response_gateway_=(
payload.response_gateway.model_dump(mode="json")
if payload.response_gateway is not None
else None
),
participation_policy_=(
payload.participation_policy.model_dump(mode="json")
if payload.participation_policy is not None
else None
),
metadata_=payload.metadata,
)
session.add(invitation)
session.flush()
return invitation, token
def list_poll_invitations(
session: Session,
*,
tenant_id: str,
poll_id: str,
projection_owner: PollMutationOwner | None = None,
) -> list[PollInvitation]:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_projection_owner(poll, projection_owner=projection_owner)
return (
session.query(PollInvitation)
.filter(PollInvitation.tenant_id == tenant_id, PollInvitation.poll_id == poll_id)
.order_by(PollInvitation.created_at.asc(), PollInvitation.id.asc())
.all()
)
def get_poll_invitation(session: Session, *, tenant_id: str, poll_id: str, invitation_id: str) -> PollInvitation:
invitation = (
session.query(PollInvitation)
.filter(PollInvitation.tenant_id == tenant_id, PollInvitation.poll_id == poll_id, PollInvitation.id == invitation_id)
.first()
)
if invitation is None:
raise PollError("Poll invitation not found")
return invitation
def revoke_poll_invitation_with_replay(
session: Session,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
mutation_owner: PollMutationOwner | None = None,
) -> tuple[PollInvitation, bool]:
"""Revoke under one row lock and report whether it was already revoked."""
poll = _lock_poll_for_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
invitation = (
session.query(PollInvitation)
.filter(
PollInvitation.tenant_id == tenant_id,
PollInvitation.poll_id == poll_id,
PollInvitation.id == invitation_id,
)
.populate_existing()
.with_for_update()
.one_or_none()
)
if invitation is None:
raise PollError("Poll invitation not found")
replayed = invitation.revoked_at is not None
if invitation.revoked_at is None:
invitation.revoked_at = _now()
session.flush()
return invitation, replayed
def revoke_poll_invitation(
session: Session,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
mutation_owner: PollMutationOwner | None = None,
) -> PollInvitation:
invitation, _replayed = revoke_poll_invitation_with_replay(
session,
tenant_id=tenant_id,
poll_id=poll_id,
invitation_id=invitation_id,
mutation_owner=mutation_owner,
)
return invitation
def get_poll_invitation_by_token(session: Session, *, token: str) -> PollInvitation:
invitation = session.query(PollInvitation).filter(PollInvitation.token_hash == _token_hash(token)).first()
if invitation is None or invitation.poll.deleted_at is not None:
raise PollError("Poll invitation not found")
if invitation.revoked_at is not None:
raise PollError("Poll invitation has been revoked")
if invitation.expires_at is not None and response_datetime(invitation.expires_at) <= _now():
raise PollError("Poll invitation has expired")
return invitation
def get_poll_by_invitation_token(session: Session, *, token: str) -> Poll:
invitation = get_poll_invitation_by_token(session, token=token)
if (
invitation.response_gateway_ is not None
or invitation.participation_policy_ is not None
or poll_requires_governed_participation(poll=invitation.poll)
):
# A gateway-bound invitation must never fall back to Poll's legacy
# public route, even if its policy data is corrupt or its gateway is
# temporarily unavailable.
raise PollError("Poll invitation not found")
return invitation.poll
def submit_poll_response_with_token(session: Session, *, token: str, payload: PollSubmitResponseRequest) -> PollResponse:
invitation = get_poll_invitation_by_token(session, token=token)
if (
invitation.response_gateway_ is not None
or invitation.participation_policy_ is not None
or poll_requires_governed_participation(poll=invitation.poll)
):
raise PollError("Poll invitation not found")
metadata = dict(payload.metadata)
metadata["invitation_id"] = invitation.id
response_payload = PollSubmitResponseRequest(
respondent_id=invitation.respondent_id or f"invitation:{invitation.id}",
respondent_label=payload.respondent_label or invitation.respondent_label or invitation.email,
answers=payload.answers,
metadata=metadata,
)
response = submit_poll_response(
session,
tenant_id=invitation.tenant_id,
poll_id=invitation.poll_id,
payload=response_payload,
)
invitation.last_used_at = response.submitted_at
session.flush()
return response
def poll_invitation_response(invitation: PollInvitation, *, token: str | None = None) -> dict[str, Any]:
return {
"id": invitation.id,
"poll_id": invitation.poll_id,
"respondent_id": invitation.respondent_id,
"respondent_label": invitation.respondent_label,
"email": invitation.email,
"token": token,
"expires_at": response_datetime(invitation.expires_at),
"revoked_at": response_datetime(invitation.revoked_at),
"last_used_at": response_datetime(invitation.last_used_at),
"created_at": response_datetime(invitation.created_at),
"updated_at": response_datetime(invitation.updated_at),
"response_gateway": invitation.response_gateway_,
"participation_policy": invitation.participation_policy_,
"metadata": invitation.metadata_ or {},
}
def poll_result_summary(poll: Poll) -> dict[str, Any]:
options = _active_options(poll)
option_results = {
option.id: {
"option_id": option.id,
"option_key": option.key,
"label": option.label,
"count": 0,
"score": 0,
"values": {},
"ranks": {},
}
for option in options
}
option_count = len(options)
responses = _active_responses(poll)
for response in responses:
for answer in response.answers or []:
option_id = answer.get("option_id")
if option_id not in option_results:
continue
result = option_results[option_id]
if poll.kind == "ranked_choice":
rank = int(answer.get("rank") or option_count)
result["count"] += 1
result["score"] += max(option_count - rank + 1, 0)
result["ranks"][rank] = result["ranks"].get(rank, 0) + 1
elif poll.kind == "availability":
value = str(answer.get("value") or "unavailable")
result["values"][value] = result["values"].get(value, 0) + 1
if value == "available":
result["count"] += 1
result["score"] += 2
elif value == "maybe":
result["score"] += 1
else:
result["count"] += 1
result["score"] += 1
result_list = list(option_results.values())
score_field = "score" if poll.kind in {"ranked_choice", "availability"} else "count"
best_score = max((int(result[score_field]) for result in result_list), default=0)
leading = [str(result["option_id"]) for result in result_list if best_score > 0 and int(result[score_field]) == best_score]
return {
"poll_id": poll.id,
"kind": poll.kind,
"status": poll.status,
"response_count": len(responses),
"option_results": result_list,
"leading_option_ids": leading,
}
def poll_result_summary_by_id(session: Session, *, tenant_id: str, poll_id: str) -> dict[str, Any]:
return poll_result_summary(get_poll(session, tenant_id=tenant_id, poll_id=poll_id))