diff --git a/src/govoplan_poll/backend/capabilities.py b/src/govoplan_poll/backend/capabilities.py index d7e1bc1..6909353 100644 --- a/src/govoplan_poll/backend/capabilities.py +++ b/src/govoplan_poll/backend/capabilities.py @@ -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: diff --git a/src/govoplan_poll/backend/service.py b/src/govoplan_poll/backend/service.py index 3a738fd..e30e830 100644 --- a/src/govoplan_poll/backend/service.py +++ b/src/govoplan_poll/backend/service.py @@ -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() diff --git a/tests/test_response_editing.py b/tests/test_response_editing.py index 3ea0c39..54d5a42 100644 --- a/tests/test_response_editing.py +++ b/tests/test_response_editing.py @@ -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(