feat(core): define auditable poll response retirement
This commit is contained in:
@@ -110,6 +110,22 @@ class PollSubmitResponseCommand:
|
|||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRetirementCommand:
|
||||||
|
"""Retire live responses while preserving their auditable history.
|
||||||
|
|
||||||
|
Owning modules provide every server-trusted identity that can refer to the
|
||||||
|
participant. Providers must soft-delete matching live responses, retain
|
||||||
|
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
respondent_ids: tuple[str, ...] = ()
|
||||||
|
invitation_id: str | None = None
|
||||||
|
reason: str = "participant_removed"
|
||||||
|
idempotency_key: str = ""
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PollOptionRef:
|
class PollOptionRef:
|
||||||
id: str
|
id: str
|
||||||
@@ -150,6 +166,16 @@ class PollResponseRef:
|
|||||||
answers: tuple[PollAnswerRef, ...] = ()
|
answers: tuple[PollAnswerRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRetirementRef:
|
||||||
|
"""Outcome of an auditable response-retirement request."""
|
||||||
|
|
||||||
|
response_ids: tuple[str, ...] = ()
|
||||||
|
retired_at: datetime | None = None
|
||||||
|
newly_retired_count: int = 0
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class PollSchedulingProvider(Protocol):
|
class PollSchedulingProvider(Protocol):
|
||||||
def create_poll(
|
def create_poll(
|
||||||
@@ -275,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollResponseRetirementProvider(Protocol):
|
||||||
|
"""Optional extension for owning modules that remove participants."""
|
||||||
|
|
||||||
|
def retire_responses(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollResponseRetirementCommand,
|
||||||
|
) -> PollResponseRetirementRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||||
if registry is None or not hasattr(registry, "has_capability"):
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
return None
|
return None
|
||||||
@@ -289,3 +330,20 @@ def poll_response_submission_provider(
|
|||||||
) -> PollResponseSubmissionProvider | None:
|
) -> PollResponseSubmissionProvider | None:
|
||||||
provider = poll_scheduling_provider(registry)
|
provider = poll_scheduling_provider(registry)
|
||||||
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_response_retirement_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PollResponseRetirementProvider | None:
|
||||||
|
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
|
||||||
|
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, PollResponseRetirementProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,36 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
|
from govoplan_core.core.poll import (
|
||||||
|
CAPABILITY_POLL_SCHEDULING,
|
||||||
|
PollAnswerRef,
|
||||||
|
PollOptionUpdateCommand,
|
||||||
|
PollResponseRef,
|
||||||
|
PollResponseRetirementCommand,
|
||||||
|
PollResponseRetirementProvider,
|
||||||
|
poll_response_retirement_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _RetirementProvider:
|
||||||
|
def retire_responses(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, capability: object | None) -> None:
|
||||||
|
self.capability_value = capability
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
name == CAPABILITY_POLL_SCHEDULING
|
||||||
|
and self.capability_value is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.capability_value
|
||||||
|
|
||||||
|
|
||||||
class PollContractTests(unittest.TestCase):
|
class PollContractTests(unittest.TestCase):
|
||||||
@@ -31,6 +60,25 @@ class PollContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(command.label, "Tuesday 10:00")
|
self.assertEqual(command.label, "Tuesday 10:00")
|
||||||
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
||||||
|
|
||||||
|
def test_response_retirement_is_an_optional_idempotent_extension(self) -> None:
|
||||||
|
command = PollResponseRetirementCommand(
|
||||||
|
respondent_ids=("account-1", "alice@example.test"),
|
||||||
|
invitation_id="invitation-1",
|
||||||
|
reason="scheduling_participant_removed",
|
||||||
|
idempotency_key="scheduling:request-1:participant-1:removed",
|
||||||
|
metadata={"source_module": "scheduling"},
|
||||||
|
)
|
||||||
|
provider = _RetirementProvider()
|
||||||
|
|
||||||
|
self.assertEqual(command.respondent_ids[0], "account-1")
|
||||||
|
self.assertIsInstance(provider, PollResponseRetirementProvider)
|
||||||
|
self.assertIs(
|
||||||
|
poll_response_retirement_provider(_Registry(provider)),
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
self.assertIsNone(poll_response_retirement_provider(_Registry(object())))
|
||||||
|
self.assertIsNone(poll_response_retirement_provider(None))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user