refactor: make poll mutation effects explicit

This commit is contained in:
2026-07-29 19:01:57 +02:00
parent fc0246b0f0
commit 652b7e1593
3 changed files with 625 additions and 117 deletions

View File

@@ -14,6 +14,16 @@ 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,
@@ -266,21 +276,15 @@ def _normalize_options(kind: str, options: list[PollOptionInput]) -> list[PollOp
def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None, option_count: int) -> tuple[int, int | None]:
if kind in {"single_choice", "yes_no", "yes_no_maybe"}:
return 1, 1
if kind == "ranked_choice":
if min_choices < 1:
min_choices = 1
if max_choices is None:
max_choices = option_count
if min_choices > option_count:
raise PollError("min_choices cannot be greater than the number of options")
if max_choices is not None:
if max_choices < min_choices:
raise PollError("max_choices cannot be smaller than min_choices")
if max_choices > option_count:
raise PollError("max_choices cannot be greater than the number of options")
return min_choices, max_choices
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]:
@@ -562,14 +566,31 @@ def update_poll(
) -> Poll:
poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
ownership_fields = {
"context_module",
"context_resource_type",
"context_resource_id",
"workflow_state",
"workflow_steps",
}
if mutation_owner is None and ownership_fields & payload.model_fields_set:
_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",
@@ -596,41 +617,6 @@ def update_poll(
)
if requested_owner != mutation_owner:
raise PollError(OWNED_POLL_MUTATION_REQUIRED)
if poll.status in {"closed", "decided", "archived"}:
raise PollError("Closed, decided, or archived polls cannot be edited")
for field in (
"title",
"description",
"visibility",
"result_visibility",
"context_module",
"context_resource_type",
"context_resource_id",
"workflow_state",
"allow_anonymous",
"allow_response_update",
):
value = getattr(payload, field)
if value is not None:
setattr(poll, field, value)
if payload.workflow_steps is not None:
poll.workflow_steps = payload.workflow_steps
if payload.min_choices is not None or payload.max_choices is not None:
min_choices = poll.min_choices if payload.min_choices is None else payload.min_choices
max_choices = poll.max_choices if payload.max_choices is None else payload.max_choices
min_choices, max_choices = _validate_choice_bounds(poll.kind, min_choices, max_choices, len(_active_options(poll)))
poll.min_choices = min_choices
poll.max_choices = max_choices
if payload.opens_at is not None:
poll.opens_at = payload.opens_at
if payload.closes_at is not None:
poll.closes_at = payload.closes_at
if poll.opens_at is not None and poll.closes_at is not None and poll.closes_at <= poll.opens_at:
raise PollError("closes_at must be after opens_at")
if payload.metadata is not None:
poll.metadata_ = payload.metadata
session.flush()
return poll
def set_poll_workflow_context(
@@ -1520,8 +1506,13 @@ def update_poll_option(
return option
responses = _locked_active_poll_responses(session, poll=poll)
if responses and not poll.allow_response_update:
raise PollError("Poll options cannot be edited after responses when response updates are disabled")
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
@@ -1713,8 +1704,13 @@ def remove_poll_option(
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)
if responses and not poll.allow_response_update:
raise PollError("Poll options cannot be edited after responses when response updates are disabled")
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(
@@ -1911,20 +1907,15 @@ def retire_poll_responses(
) -> tuple[list[PollResponse], datetime | None, int, bool]:
"""Soft-delete owner-selected responses without erasing their answers."""
normalized_ids = tuple(
dict.fromkeys(value.strip() for value in respondent_ids if value.strip())
)
normalized_invitation_id = (
invitation_id.strip() if invitation_id and invitation_id.strip() else None
)
normalized_reason = reason.strip()
normalized_key = idempotency_key.strip()
if not normalized_ids and normalized_invitation_id is None:
raise PollError("Response retirement requires a trusted participant identity")
if not normalized_reason or len(normalized_reason) > 120:
raise PollError("Response retirement reason is invalid")
if not normalized_key or len(normalized_key) > 255:
raise PollError("Response retirement idempotency key is invalid")
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(
@@ -1934,12 +1925,12 @@ def retire_poll_responses(
)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
conditions = []
if normalized_ids:
conditions.append(PollResponse.respondent_id.in_(normalized_ids))
if normalized_invitation_id is not None:
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()
== normalized_invitation_id
== selector.invitation_id
)
responses = (
session.query(PollResponse)
@@ -1951,46 +1942,29 @@ def retire_poll_responses(
.order_by(PollResponse.submitted_at.asc(), PollResponse.id.asc())
.populate_existing()
.with_for_update()
.limit(MAX_RETIREMENT_RESPONSES + 1)
.all()
)
active = [response for response in responses if response.deleted_at is None]
if active:
retired_at = _now()
retirement = {
"idempotency_key": normalized_key,
"reason": normalized_reason,
"retired_at": retired_at.isoformat(),
"context": dict(metadata),
}
for response in active:
response.metadata_ = {
**(response.metadata_ or {}),
"response_retirement": retirement,
}
response.deleted_at = retired_at
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 active, retired_at, len(active), False
replayed = [
response
for response in responses
if isinstance((response.metadata_ or {}).get("response_retirement"), dict)
and (response.metadata_ or {})["response_retirement"].get(
"idempotency_key"
)
== normalized_key
]
if replayed:
retired_at = max(
(
response_datetime(response.deleted_at)
for response in replayed
if response.deleted_at is not None
),
default=None,
)
return replayed, retired_at, 0, True
return [], None, 0, False
return (
list(plan.responses),
plan.retired_at,
plan.newly_retired_count,
plan.disposition == "replay",
)
def _token_hash(token: str) -> str: