feat(poll): reorder options without invalidating answers
This commit is contained in:
@@ -11,6 +11,7 @@ from govoplan_core.core.poll import (
|
||||
PollCreateCommand,
|
||||
PollInvitationCommand,
|
||||
PollInvitationRef,
|
||||
PollOptionOrderCommand,
|
||||
PollOptionRequest,
|
||||
PollOptionRef,
|
||||
PollOptionUpdateCommand,
|
||||
@@ -70,6 +71,7 @@ from govoplan_poll.backend.service import (
|
||||
poll_result_summary_by_id,
|
||||
response_datetime,
|
||||
remove_poll_option,
|
||||
reorder_poll_options,
|
||||
revoke_poll_invitation_with_replay,
|
||||
set_poll_workflow_context,
|
||||
submit_poll_response,
|
||||
@@ -82,7 +84,13 @@ def _poll_ref(poll: object) -> PollRef:
|
||||
return PollRef(
|
||||
id=poll.id,
|
||||
status=poll.status,
|
||||
options=tuple(PollOptionRef(id=option.id, position=option.position) for option in poll.options),
|
||||
options=tuple(
|
||||
PollOptionRef(id=option.id, position=option.position)
|
||||
for option in sorted(
|
||||
(option for option in poll.options if option.deleted_at is None),
|
||||
key=lambda item: (item.position, item.id),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -610,6 +618,31 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
|
||||
raise PollCapabilityError(str(exc)) from exc
|
||||
return PollOptionRef(id=option.id, position=option.position)
|
||||
|
||||
def reorder_options(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollOptionOrderCommand,
|
||||
) -> PollRef:
|
||||
try:
|
||||
mutation_owner = _stored_owner(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
poll_id=poll_id,
|
||||
)
|
||||
poll = reorder_poll_options(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
poll_id=poll_id,
|
||||
option_ids=command.option_ids,
|
||||
mutation_owner=mutation_owner,
|
||||
)
|
||||
except PollError as exc:
|
||||
raise PollCapabilityError(str(exc)) from exc
|
||||
return _poll_ref(poll)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ manifest = ModuleManifest(
|
||||
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="poll.option_selection", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name="poll.option_ordering", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name="poll.availability_matrix", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name="poll.response_collection", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name="poll.workflow_context", version=MODULE_VERSION),
|
||||
|
||||
@@ -1382,6 +1382,61 @@ def update_poll_option(
|
||||
return option
|
||||
|
||||
|
||||
def reorder_poll_options(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
option_ids: tuple[str, ...],
|
||||
mutation_owner: PollMutationOwner | None = None,
|
||||
) -> Poll:
|
||||
"""Atomically replace the complete active option order.
|
||||
|
||||
Position is presentation state, so existing answers remain attached to
|
||||
their stable option identities. The Poll row is locked first to keep the
|
||||
lock order consistent with response and option mutations.
|
||||
"""
|
||||
|
||||
poll = _lock_poll_for_response(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
poll_id=poll_id,
|
||||
)
|
||||
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
|
||||
requested_ids = tuple(option_ids)
|
||||
if len(requested_ids) != len(set(requested_ids)):
|
||||
raise PollError("Poll option order cannot contain duplicate options")
|
||||
options = (
|
||||
session.query(PollOption)
|
||||
.filter(
|
||||
PollOption.tenant_id == tenant_id,
|
||||
PollOption.poll_id == poll.id,
|
||||
PollOption.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(PollOption.id.asc())
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.all()
|
||||
)
|
||||
by_id = {option.id: option for option in options}
|
||||
if set(requested_ids) != set(by_id):
|
||||
raise PollError(
|
||||
"Poll option order must contain every active option exactly once"
|
||||
)
|
||||
current_ids = tuple(
|
||||
option.id
|
||||
for option in sorted(options, key=lambda item: (item.position, item.id))
|
||||
)
|
||||
if requested_ids == current_ids:
|
||||
return poll
|
||||
if poll.status not in {"draft", "open"}:
|
||||
raise PollError("Only draft or open poll options can be reordered")
|
||||
for position, option_id in enumerate(requested_ids):
|
||||
by_id[option_id].position = position
|
||||
session.flush()
|
||||
return poll
|
||||
|
||||
|
||||
def _locked_active_poll_responses(session: Session, *, poll: Poll) -> list[PollResponse]:
|
||||
return (
|
||||
session.query(PollResponse)
|
||||
|
||||
Reference in New Issue
Block a user