feat(poll): retire responses without erasing history
This commit is contained in:
@@ -17,6 +17,8 @@ from govoplan_core.core.poll import (
|
|||||||
PollOptionUpdateCommand,
|
PollOptionUpdateCommand,
|
||||||
PollRef,
|
PollRef,
|
||||||
PollResponseRef,
|
PollResponseRef,
|
||||||
|
PollResponseRetirementCommand,
|
||||||
|
PollResponseRetirementRef,
|
||||||
PollSchedulingProvider,
|
PollSchedulingProvider,
|
||||||
PollSubmitResponseCommand,
|
PollSubmitResponseCommand,
|
||||||
PollUpdateCommand,
|
PollUpdateCommand,
|
||||||
@@ -70,6 +72,7 @@ from govoplan_poll.backend.service import (
|
|||||||
poll_owner_ref,
|
poll_owner_ref,
|
||||||
poll_result_summary_by_id,
|
poll_result_summary_by_id,
|
||||||
response_datetime,
|
response_datetime,
|
||||||
|
retire_poll_responses,
|
||||||
remove_poll_option,
|
remove_poll_option,
|
||||||
reorder_poll_options,
|
reorder_poll_options,
|
||||||
revoke_poll_invitation_with_replay,
|
revoke_poll_invitation_with_replay,
|
||||||
@@ -769,6 +772,40 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
|
|||||||
raise PollCapabilityError(str(exc)) from exc
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
return _response_ref(response) if response is not None else None
|
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
|
@staticmethod
|
||||||
def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
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:
|
def _token_hash(token: str) -> str:
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from govoplan_core.core.poll import (
|
|||||||
PollCapabilityError,
|
PollCapabilityError,
|
||||||
PollOptionOrderCommand,
|
PollOptionOrderCommand,
|
||||||
PollOptionUpdateCommand,
|
PollOptionUpdateCommand,
|
||||||
|
PollResponseRetirementCommand,
|
||||||
|
PollResponseRetirementProvider,
|
||||||
PollSchedulingProvider,
|
PollSchedulingProvider,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -132,6 +134,62 @@ class PollResponseEditingTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(len(first.answers), 2)
|
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:
|
def test_fully_invalidated_response_is_no_longer_active_or_counted(self) -> None:
|
||||||
poll = self._poll()
|
poll = self._poll()
|
||||||
response = submit_poll_response(
|
response = submit_poll_response(
|
||||||
|
|||||||
Reference in New Issue
Block a user