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

@@ -17,6 +17,8 @@ from govoplan_core.core.poll import (
PollOptionUpdateCommand,
PollRef,
PollResponseRef,
PollResponseRetirementCommand,
PollResponseRetirementRef,
PollSchedulingProvider,
PollSubmitResponseCommand,
PollUpdateCommand,
@@ -70,6 +72,7 @@ from govoplan_poll.backend.service import (
poll_owner_ref,
poll_result_summary_by_id,
response_datetime,
retire_poll_responses,
remove_poll_option,
reorder_poll_options,
revoke_poll_invitation_with_replay,
@@ -769,6 +772,40 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
raise PollCapabilityError(str(exc)) from exc
return _response_ref(response) if response is not None else None
def retire_responses(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollResponseRetirementCommand,
) -> PollResponseRetirementRef:
try:
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
responses, retired_at, retired_count, replayed = retire_poll_responses(
session,
tenant_id=tenant_id,
poll_id=poll_id,
respondent_ids=command.respondent_ids,
invitation_id=command.invitation_id,
reason=command.reason,
idempotency_key=command.idempotency_key,
metadata=dict(command.metadata),
mutation_owner=mutation_owner,
)
except PollError as exc:
raise PollCapabilityError(str(exc)) from exc
return PollResponseRetirementRef(
response_ids=tuple(response.id for response in responses),
retired_at=response_datetime(retired_at),
newly_retired_count=retired_count,
replayed=replayed,
)
@staticmethod
def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
try:

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()

View File

@@ -9,6 +9,8 @@ from govoplan_core.core.poll import (
PollCapabilityError,
PollOptionOrderCommand,
PollOptionUpdateCommand,
PollResponseRetirementCommand,
PollResponseRetirementProvider,
PollSchedulingProvider,
)
from govoplan_core.db.base import Base
@@ -132,6 +134,62 @@ class PollResponseEditingTests(unittest.TestCase):
)
self.assertEqual(len(first.answers), 2)
def test_provider_retires_response_from_live_results_but_keeps_audit_history(self) -> None:
poll = self._poll()
response = self._submit_both(poll, "person-1")
response.metadata_ = {
"invitation_id": "invitation-1",
"participant_email": "alice@example.test",
}
provider = SqlPollSchedulingProvider()
command = PollResponseRetirementCommand(
respondent_ids=("person-1", "alice@example.test"),
invitation_id="invitation-1",
reason="scheduling_participant_removed",
idempotency_key="scheduling:request-1:participant-1:removed",
metadata={
"source_module": "scheduling",
"source_resource_type": "participant",
"source_resource_id": "participant-1",
},
)
self.assertIsInstance(provider, PollResponseRetirementProvider)
retired = provider.retire_responses(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
command=command,
)
self.assertEqual(retired.response_ids, (response.id,))
self.assertEqual(retired.newly_retired_count, 1)
self.assertFalse(retired.replayed)
self.assertIsNotNone(response.deleted_at)
self.assertEqual(len(response.answers), 2)
self.assertEqual(
response.metadata_["response_retirement"]["idempotency_key"],
command.idempotency_key,
)
self.assertEqual(
provider.list_responses(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
),
(),
)
replayed = provider.retire_responses(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
command=command,
)
self.assertTrue(replayed.replayed)
self.assertEqual(replayed.newly_retired_count, 0)
self.assertEqual(replayed.response_ids, (response.id,))
def test_fully_invalidated_response_is_no_longer_active_or_counted(self) -> None:
poll = self._poll()
response = submit_poll_response(