feat(poll): retire responses without erasing history

This commit is contained in:
2026-07-22 03:32:05 +02:00
parent 8292c9d709
commit 8ceb3935f5
3 changed files with 192 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ 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
@@ -1748,6 +1749,102 @@ def list_poll_responses(
)
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."""
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")
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 normalized_ids:
conditions.append(PollResponse.respondent_id.in_(normalized_ids))
if normalized_invitation_id is not None:
conditions.append(
PollResponse.metadata_["invitation_id"].as_string()
== normalized_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()
.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
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
def _token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()