diff --git a/src/govoplan_core/core/poll.py b/src/govoplan_core/core/poll.py index 9918dc4..d394e28 100644 --- a/src/govoplan_core/core/poll.py +++ b/src/govoplan_core/core/poll.py @@ -58,6 +58,21 @@ class PollUpdateCommand: closes_at: datetime | None +@dataclass(frozen=True, slots=True) +class PollOptionUpdateCommand: + """Complete mutable option snapshot supplied by an owning module. + + Providers must treat an exact repeat as a no-op. When the snapshot + changes, answers for this option are invalidated while answers for other + options are retained. + """ + + label: str + description: str | None = None + value: Mapping[str, object] | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + @dataclass(frozen=True, slots=True) class PollInvitationCommand: respondent_id: str | None = None @@ -102,6 +117,14 @@ class PollInvitationRef: token: str +@dataclass(frozen=True, slots=True) +class PollAnswerRef: + option_id: str | None = None + option_key: str | None = None + value: object = None + rank: int | None = None + + @dataclass(frozen=True, slots=True) class PollResponseRef: invitation_id: str | None @@ -110,6 +133,9 @@ class PollResponseRef: # consumer can use it to reconcile an authenticated response without # trusting client-supplied invitation metadata. respondent_id: str | None = None + # Optional for backwards compatibility. Providers that support response + # editing expose the authoritative, still-valid answers here. + answers: tuple[PollAnswerRef, ...] = () @runtime_checkable @@ -144,6 +170,19 @@ class PollSchedulingProvider(Protocol): ) -> PollRef: ... + def update_option( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + option_id: str, + command: PollOptionUpdateCommand, + ) -> PollOptionRef: + """Update one option and invalidate only answers for that option.""" + + ... + def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef: ... diff --git a/tests/test_poll_contract.py b/tests/test_poll_contract.py new file mode 100644 index 0000000..af7fe49 --- /dev/null +++ b/tests/test_poll_contract.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone + +from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef + + +class PollContractTests(unittest.TestCase): + def test_response_ref_remains_backwards_compatible_and_can_carry_answers(self) -> None: + submitted_at = datetime(2026, 7, 20, tzinfo=timezone.utc) + + legacy = PollResponseRef(None, submitted_at) + detailed = PollResponseRef( + invitation_id="invitation-1", + submitted_at=submitted_at, + respondent_id="person-1", + answers=(PollAnswerRef(option_id="option-1", option_key="slot-1", value="available"),), + ) + + self.assertEqual(legacy.answers, ()) + self.assertEqual(detailed.answers[0].value, "available") + + def test_option_update_command_is_a_complete_immutable_snapshot(self) -> None: + command = PollOptionUpdateCommand( + label="Tuesday 10:00", + value={"slot_id": "slot-1"}, + metadata={"source": "scheduling"}, + ) + + self.assertEqual(command.label, "Tuesday 10:00") + self.assertEqual(command.value, {"slot_id": "slot-1"}) + + +if __name__ == "__main__": + unittest.main()