From e0de54b756f91493ef387dc84ce322c4b2787b0f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 20 Jul 2026 18:20:33 +0200 Subject: [PATCH] feat(responses): support safe option editing --- src/govoplan_poll/backend/capabilities.py | 63 ++++++++--- src/govoplan_poll/backend/service.py | 77 ++++++++++++++ tests/test_response_editing.py | 123 ++++++++++++++++++++++ 3 files changed, 250 insertions(+), 13 deletions(-) create mode 100644 tests/test_response_editing.py diff --git a/src/govoplan_poll/backend/capabilities.py b/src/govoplan_poll/backend/capabilities.py index 90d832c..8f42005 100644 --- a/src/govoplan_poll/backend/capabilities.py +++ b/src/govoplan_poll/backend/capabilities.py @@ -3,11 +3,13 @@ from __future__ import annotations from collections.abc import Mapping, Sequence from govoplan_core.core.poll import ( + PollAnswerRef, PollCapabilityError, PollCreateCommand, PollInvitationCommand, PollInvitationRef, PollOptionRef, + PollOptionUpdateCommand, PollRef, PollResponseRef, PollSchedulingProvider, @@ -33,6 +35,7 @@ from govoplan_poll.backend.service import ( open_poll, poll_result_summary_by_id, submit_poll_response, + update_poll_option, ) @@ -49,6 +52,27 @@ def _response_invitation_id(response: object) -> str | None: return value if isinstance(value, str) else None +def _response_ref(response: object) -> PollResponseRef: + answers: list[PollAnswerRef] = [] + for answer in response.answers or []: + if not isinstance(answer, Mapping): + continue + answers.append( + PollAnswerRef( + option_id=answer.get("option_id"), + option_key=answer.get("option_key"), + value=answer.get("value"), + rank=answer.get("rank"), + ) + ) + return PollResponseRef( + invitation_id=_response_invitation_id(response), + submitted_at=response.submitted_at, + respondent_id=response.respondent_id, + answers=tuple(answers), + ) + + class SqlPollSchedulingProvider(PollSchedulingProvider): def create_poll( self, @@ -151,11 +175,7 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc - return PollResponseRef( - invitation_id=_response_invitation_id(response), - submitted_at=response.submitted_at, - respondent_id=response.respondent_id, - ) + return _response_ref(response) def update_poll( self, @@ -179,6 +199,30 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): session.flush() return _poll_ref(poll) + def update_option( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + option_id: str, + command: PollOptionUpdateCommand, + ) -> PollOptionRef: + try: + option = update_poll_option( + session, + tenant_id=tenant_id, + poll_id=poll_id, + option_id=option_id, + label=command.label, + description=command.description, + value=dict(command.value) if command.value is not None else None, + metadata=dict(command.metadata), + ) + except PollError as exc: + raise PollCapabilityError(str(exc)) from exc + return PollOptionRef(id=option.id, position=option.position) + def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef: return self._transition(open_poll, session, tenant_id=tenant_id, poll_id=poll_id) @@ -245,14 +289,7 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): responses = list_poll_responses(session, tenant_id=tenant_id, poll_id=poll_id) except PollError as exc: raise PollCapabilityError(str(exc)) from exc - return tuple( - PollResponseRef( - invitation_id=_response_invitation_id(response), - submitted_at=response.submitted_at, - respondent_id=response.respondent_id, - ) - for response in responses - ) + return tuple(_response_ref(response) for response in responses) @staticmethod def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef: diff --git a/src/govoplan_poll/backend/service.py b/src/govoplan_poll/backend/service.py index 761355a..9308e8a 100644 --- a/src/govoplan_poll/backend/service.py +++ b/src/govoplan_poll/backend/service.py @@ -732,6 +732,83 @@ def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) - return poll +def update_poll_option( + session: Session, + *, + tenant_id: str, + poll_id: str, + option_id: str, + label: str, + description: str | None, + value: dict[str, Any] | None, + metadata: dict[str, Any], +) -> PollOption: + """Update one option and invalidate only answers bound to that option. + + The Poll row is locked first, matching response submission. The owning + API commits the option and response JSON updates in the same transaction. + """ + + poll = _lock_poll_for_response( + session, + tenant_id=tenant_id, + poll_id=poll_id, + ) + if poll.status not in {"draft", "open"}: + raise PollError("Only draft or open poll options can be edited") + option = ( + session.query(PollOption) + .filter( + PollOption.tenant_id == tenant_id, + PollOption.poll_id == poll.id, + PollOption.id == option_id, + PollOption.deleted_at.is_(None), + ) + .populate_existing() + .with_for_update() + .one_or_none() + ) + if option is None: + raise PollError("Unknown poll option") + normalized_value = dict(value) if value is not None else None + normalized_metadata = dict(metadata) + changed = ( + option.label != label + or option.description != description + or option.value != normalized_value + or (option.metadata_ or {}) != normalized_metadata + ) + if not changed: + return option + + responses = ( + session.query(PollResponse) + .filter( + PollResponse.tenant_id == tenant_id, + PollResponse.poll_id == poll.id, + PollResponse.deleted_at.is_(None), + ) + .order_by(PollResponse.id.asc()) + .populate_existing() + .with_for_update() + .all() + ) + option.label = label + option.description = description + option.value = normalized_value + option.metadata_ = normalized_metadata + for response in responses: + retained_answers = [ + answer + for answer in (response.answers or []) + if not isinstance(answer, dict) or answer.get("option_id") != option.id + ] + if retained_answers != (response.answers or []): + response.answers = retained_answers + session.flush() + return option + + def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payload: PollSubmitResponseRequest) -> PollResponse: poll = _lock_poll_for_response( session, diff --git a/tests/test_response_editing.py b/tests/test_response_editing.py new file mode 100644 index 0000000..78740db --- /dev/null +++ b/tests/test_response_editing.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.core.poll import PollOptionUpdateCommand, PollSchedulingProvider +from govoplan_core.db.base import Base +from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider +from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse +from govoplan_poll.backend.schemas import PollAnswerInput, PollCreateRequest, PollOptionInput, PollSubmitResponseRequest +from govoplan_poll.backend.service import create_poll, submit_poll_response + + +class PollResponseEditingTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__], + ) + self.Session = sessionmaker(bind=self.engine) + self.session: Session = self.Session() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all( + self.engine, + tables=[PollResponse.__table__, PollOption.__table__, Poll.__table__], + ) + self.engine.dispose() + + def _poll(self) -> Poll: + return create_poll( + self.session, + tenant_id="tenant-1", + user_id="organizer-1", + payload=PollCreateRequest( + title="Availability", + kind="availability", + status="open", + min_choices=1, + max_choices=2, + options=[ + PollOptionInput( + key="slot-1", + label="Monday", + value={"slot_id": "slot-1"}, + metadata={"source": "scheduling"}, + ), + PollOptionInput( + key="slot-2", + label="Tuesday", + value={"slot_id": "slot-2"}, + ), + ], + ), + ) + + def _submit_both(self, poll: Poll, respondent_id: str) -> PollResponse: + return submit_poll_response( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + payload=PollSubmitResponseRequest( + respondent_id=respondent_id, + answers=[ + PollAnswerInput(option_id=poll.options[0].id, value="available"), + PollAnswerInput(option_id=poll.options[1].id, value="maybe"), + ], + ), + ) + + def test_provider_projects_answers_and_option_change_invalidates_only_that_option(self) -> None: + poll = self._poll() + first = self._submit_both(poll, "person-1") + second = self._submit_both(poll, "person-2") + provider = SqlPollSchedulingProvider() + + self.assertIsInstance(provider, PollSchedulingProvider) + projected = provider.list_responses( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + ) + self.assertEqual([answer.value for answer in projected[0].answers], ["available", "maybe"]) + + option_ref = provider.update_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + option_id=poll.options[0].id, + command=PollOptionUpdateCommand( + label="Monday, revised", + value={"slot_id": "slot-1"}, + metadata={"source": "scheduling"}, + ), + ) + + self.assertEqual(option_ref.id, poll.options[0].id) + self.assertEqual(first.answers, [{"option_id": poll.options[1].id, "option_key": "slot-2", "value": "maybe"}]) + self.assertEqual(second.answers, [{"option_id": poll.options[1].id, "option_key": "slot-2", "value": "maybe"}]) + + # Re-answer, then replay the exact option snapshot. No response data is + # invalidated because the option did not actually change. + first = self._submit_both(poll, "person-1") + provider.update_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + option_id=poll.options[0].id, + command=PollOptionUpdateCommand( + label="Monday, revised", + value={"slot_id": "slot-1"}, + metadata={"source": "scheduling"}, + ), + ) + self.assertEqual(len(first.answers), 2) + + +if __name__ == "__main__": + unittest.main()