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 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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user