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 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:

View File

@@ -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,