feat(responses): support safe option editing

This commit is contained in:
2026-07-20 18:20:33 +02:00
parent 65e2d1fd65
commit e0de54b756
3 changed files with 250 additions and 13 deletions

View File

@@ -3,11 +3,13 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from govoplan_core.core.poll import ( from govoplan_core.core.poll import (
PollAnswerRef,
PollCapabilityError, PollCapabilityError,
PollCreateCommand, PollCreateCommand,
PollInvitationCommand, PollInvitationCommand,
PollInvitationRef, PollInvitationRef,
PollOptionRef, PollOptionRef,
PollOptionUpdateCommand,
PollRef, PollRef,
PollResponseRef, PollResponseRef,
PollSchedulingProvider, PollSchedulingProvider,
@@ -33,6 +35,7 @@ from govoplan_poll.backend.service import (
open_poll, open_poll,
poll_result_summary_by_id, poll_result_summary_by_id,
submit_poll_response, 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 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): class SqlPollSchedulingProvider(PollSchedulingProvider):
def create_poll( def create_poll(
self, self,
@@ -151,11 +175,7 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
) )
except PollError as exc: except PollError as exc:
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
return PollResponseRef( return _response_ref(response)
invitation_id=_response_invitation_id(response),
submitted_at=response.submitted_at,
respondent_id=response.respondent_id,
)
def update_poll( def update_poll(
self, self,
@@ -179,6 +199,30 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
session.flush() session.flush()
return _poll_ref(poll) 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: 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) 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) responses = list_poll_responses(session, tenant_id=tenant_id, poll_id=poll_id)
except PollError as exc: except PollError as exc:
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
return tuple( return tuple(_response_ref(response) for response in responses)
PollResponseRef(
invitation_id=_response_invitation_id(response),
submitted_at=response.submitted_at,
respondent_id=response.respondent_id,
)
for response in responses
)
@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:

View File

@@ -732,6 +732,83 @@ def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -
return poll 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: def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payload: PollSubmitResponseRequest) -> PollResponse:
poll = _lock_poll_for_response( poll = _lock_poll_for_response(
session, session,

View File

@@ -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()