feat(responses): support safe option editing
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
123
tests/test_response_editing.py
Normal file
123
tests/test_response_editing.py
Normal 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()
|
||||||
Reference in New Issue
Block a user