feat(poll): retire responses without erasing history
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user