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