2243 lines
74 KiB
Python
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))
|