Compare commits
4 Commits
f4d5cac40d
...
2c776437b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c776437b4 | |||
| 12b3175b07 | |||
| e0de54b756 | |||
| 65e2d1fd65 |
30
README.md
30
README.md
@@ -63,6 +63,36 @@ Poll stores the shared poll/options/responses/result summary. Scheduling owns
|
|||||||
the domain workflow around rooms, calendars, free/busy checks, reminders,
|
the domain workflow around rooms, calendars, free/busy checks, reminders,
|
||||||
appointment creation, and optional Workflow integration.
|
appointment creation, and optional Workflow integration.
|
||||||
|
|
||||||
|
## Poll lifecycle
|
||||||
|
|
||||||
|
Poll configures its lifecycle through the separate, pure transition engine in
|
||||||
|
`backend/transitions.py`. The default policy is:
|
||||||
|
|
||||||
|
- `draft` → `open` or `archived`
|
||||||
|
- `open` → `draft`, `closed`, or `archived`
|
||||||
|
- `closed` → `open`, `decided`, or `archived`
|
||||||
|
- `decided` → `open`, `decided` (an explicit re-decision), or `archived`
|
||||||
|
- `archived` → the status from which the Poll was archived
|
||||||
|
|
||||||
|
Reopening and archiving preserve responses, close history, and decision
|
||||||
|
metadata. Every applied transition has a Poll-owned lifecycle audit record.
|
||||||
|
Re-deciding always appends a record and supersedes the current decision. An
|
||||||
|
exact retry carrying the same `Idempotency-Key` is a no-op and returns the
|
||||||
|
original transition record; reusing that key for a different action or option
|
||||||
|
is rejected. Without an idempotency identity, a repeated transition is invalid
|
||||||
|
except for the deliberately auditable `decided` → `decided` action.
|
||||||
|
|
||||||
|
Poll API representations expose every lifecycle action with its availability
|
||||||
|
and, when unavailable, the policy reason. Management clients can use the
|
||||||
|
`GET /poll/polls/{poll_id}/transitions` endpoint to read the durable history
|
||||||
|
instead of duplicating the matrix in their UI.
|
||||||
|
|
||||||
|
Polls created through the v0.1.9 contract start in `draft` or `open`; later
|
||||||
|
states are entered through the transition engine. Older archived rows may not
|
||||||
|
contain the status from which they were archived. Their prior state cannot be
|
||||||
|
inferred safely, so unarchive restores them to the least-permissive `draft`
|
||||||
|
state and marks that legacy fallback explicitly in the audit metadata.
|
||||||
|
|
||||||
## Development Install
|
## Development Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-poll"
|
name = "govoplan-poll"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
description = "GovOPlaN lightweight poll and availability decision module seed."
|
description = "GovOPlaN lightweight poll and availability decision module seed."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.8"
|
__version__ = "0.1.9"
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -29,10 +31,12 @@ from govoplan_poll.backend.service import (
|
|||||||
create_poll_invitation,
|
create_poll_invitation,
|
||||||
decide_poll,
|
decide_poll,
|
||||||
get_poll,
|
get_poll,
|
||||||
|
get_poll_response_for_respondents,
|
||||||
list_poll_responses,
|
list_poll_responses,
|
||||||
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 +53,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 +176,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 +200,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 +290,28 @@ 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),
|
def get_response(
|
||||||
submitted_at=response.submitted_at,
|
self,
|
||||||
respondent_id=response.respondent_id,
|
session: object,
|
||||||
)
|
*,
|
||||||
for response in responses
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
respondent_ids: Sequence[str],
|
||||||
|
invitation_id: str | None = None,
|
||||||
|
) -> PollResponseRef | None:
|
||||||
|
try:
|
||||||
|
response = get_poll_response_for_respondents(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_ids=tuple(respondent_ids),
|
||||||
|
invitation_id=invitation_id,
|
||||||
)
|
)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
return _response_ref(response) if response is not None else None
|
||||||
|
|
||||||
@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:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
|
||||||
|
|
||||||
__all__ = ["Poll", "PollInvitation", "PollOption", "PollResponse"]
|
__all__ = ["Poll", "PollInvitation", "PollLifecycleTransition", "PollOption", "PollResponse"]
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class Poll(Base, TimestampMixin):
|
|||||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
decided_option_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
decided_option_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
archived_from_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
created_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
created_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
@@ -60,6 +61,11 @@ class Poll(Base, TimestampMixin):
|
|||||||
options: Mapped[list["PollOption"]] = relationship(back_populates="poll", cascade="all, delete-orphan", order_by="PollOption.position")
|
options: Mapped[list["PollOption"]] = relationship(back_populates="poll", cascade="all, delete-orphan", order_by="PollOption.position")
|
||||||
responses: Mapped[list["PollResponse"]] = relationship(back_populates="poll", cascade="all, delete-orphan")
|
responses: Mapped[list["PollResponse"]] = relationship(back_populates="poll", cascade="all, delete-orphan")
|
||||||
invitations: Mapped[list["PollInvitation"]] = relationship(back_populates="poll", cascade="all, delete-orphan")
|
invitations: Mapped[list["PollInvitation"]] = relationship(back_populates="poll", cascade="all, delete-orphan")
|
||||||
|
lifecycle_transitions: Mapped[list["PollLifecycleTransition"]] = relationship(
|
||||||
|
back_populates="poll",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="PollLifecycleTransition.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PollOption(Base, TimestampMixin):
|
class PollOption(Base, TimestampMixin):
|
||||||
@@ -127,4 +133,28 @@ class PollInvitation(Base, TimestampMixin):
|
|||||||
poll: Mapped[Poll] = relationship(back_populates="invitations")
|
poll: Mapped[Poll] = relationship(back_populates="invitations")
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Poll", "PollInvitation", "PollOption", "PollResponse", "new_uuid"]
|
class PollLifecycleTransition(Base, TimestampMixin):
|
||||||
|
__tablename__ = "poll_lifecycle_transitions"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("poll_id", "idempotency_key", name="uq_poll_lifecycle_transition_idempotency"),
|
||||||
|
Index("ix_poll_lifecycle_transition_poll_created", "poll_id", "created_at"),
|
||||||
|
Index("ix_poll_lifecycle_transition_tenant_poll", "tenant_id", "poll_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
poll_id: Mapped[str] = mapped_column(ForeignKey("poll_polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
|
from_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
to_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
decision_option_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
previous_decision_option_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
actor_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
actor_api_key_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
poll: Mapped[Poll] = relationship(back_populates="lifecycle_transitions")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Poll", "PollInvitation", "PollLifecycleTransition", "PollOption", "PollResponse", "new_uuid"]
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from govoplan_poll.backend.db import models as poll_models # noqa: F401 - popul
|
|||||||
|
|
||||||
MODULE_ID = "poll"
|
MODULE_ID = "poll"
|
||||||
MODULE_NAME = "Poll"
|
MODULE_NAME = "Poll"
|
||||||
MODULE_VERSION = "0.1.8"
|
MODULE_VERSION = "0.1.9"
|
||||||
READ_SCOPE = "poll:poll:read"
|
READ_SCOPE = "poll:poll:read"
|
||||||
WRITE_SCOPE = "poll:poll:write"
|
WRITE_SCOPE = "poll:poll:write"
|
||||||
ADMIN_SCOPE = "poll:poll:admin"
|
ADMIN_SCOPE = "poll:poll:admin"
|
||||||
@@ -115,11 +115,11 @@ manifest = ModuleManifest(
|
|||||||
optional_dependencies=("access", "calendar", "campaigns", "portal", "mail", "notifications", "forms_runtime"),
|
optional_dependencies=("access", "calendar", "campaigns", "portal", "mail", "notifications", "forms_runtime"),
|
||||||
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="poll.option_selection", version="0.1.8"),
|
ModuleInterfaceProvider(name="poll.option_selection", version="0.1.9"),
|
||||||
ModuleInterfaceProvider(name="poll.availability_matrix", version="0.1.8"),
|
ModuleInterfaceProvider(name="poll.availability_matrix", version="0.1.9"),
|
||||||
ModuleInterfaceProvider(name="poll.response_collection", version="0.1.8"),
|
ModuleInterfaceProvider(name="poll.response_collection", version="0.1.9"),
|
||||||
ModuleInterfaceProvider(name="poll.workflow_context", version="0.1.8"),
|
ModuleInterfaceProvider(name="poll.workflow_context", version="0.1.9"),
|
||||||
ModuleInterfaceProvider(name="poll.signed_participation", version="0.1.8"),
|
ModuleInterfaceProvider(name="poll.signed_participation", version="0.1.9"),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
@@ -134,6 +134,7 @@ manifest = ModuleManifest(
|
|||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=drop_table_retirement_provider(
|
||||||
poll_models.Poll,
|
poll_models.Poll,
|
||||||
poll_models.PollInvitation,
|
poll_models.PollInvitation,
|
||||||
|
poll_models.PollLifecycleTransition,
|
||||||
poll_models.PollOption,
|
poll_models.PollOption,
|
||||||
poll_models.PollResponse,
|
poll_models.PollResponse,
|
||||||
label="Poll",
|
label="Poll",
|
||||||
@@ -144,6 +145,7 @@ manifest = ModuleManifest(
|
|||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
poll_models.Poll,
|
poll_models.Poll,
|
||||||
poll_models.PollInvitation,
|
poll_models.PollInvitation,
|
||||||
|
poll_models.PollLifecycleTransition,
|
||||||
poll_models.PollOption,
|
poll_models.PollOption,
|
||||||
poll_models.PollResponse,
|
poll_models.PollResponse,
|
||||||
label="Poll",
|
label="Poll",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""v0.1.9 configurable Poll lifecycle history
|
||||||
|
|
||||||
|
Revision ID: 3b4c5d6e7f8a
|
||||||
|
Revises: 2a3b4c5d6e7f
|
||||||
|
Create Date: 2026-07-20 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "3b4c5d6e7f8a"
|
||||||
|
down_revision = "2a3b4c5d6e7f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("poll_polls", sa.Column("archived_from_status", sa.String(length=30), nullable=True))
|
||||||
|
op.create_table(
|
||||||
|
"poll_lifecycle_transitions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("poll_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("action", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("from_status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("to_status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("decision_option_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("previous_decision_option_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("actor_api_key_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["poll_id"],
|
||||||
|
["poll_polls.id"],
|
||||||
|
name=op.f("fk_poll_lifecycle_transitions_poll_id_poll_polls"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_poll_lifecycle_transitions")),
|
||||||
|
sa.UniqueConstraint("poll_id", "idempotency_key", name="uq_poll_lifecycle_transition_idempotency"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_poll_lifecycle_transitions_action"), "poll_lifecycle_transitions", ["action"], unique=False)
|
||||||
|
op.create_index(op.f("ix_poll_lifecycle_transitions_actor_api_key_id"), "poll_lifecycle_transitions", ["actor_api_key_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_poll_lifecycle_transitions_actor_user_id"), "poll_lifecycle_transitions", ["actor_user_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_poll_lifecycle_transitions_poll_id"), "poll_lifecycle_transitions", ["poll_id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_poll_lifecycle_transitions_tenant_id"), "poll_lifecycle_transitions", ["tenant_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"ix_poll_lifecycle_transition_poll_created",
|
||||||
|
"poll_lifecycle_transitions",
|
||||||
|
["poll_id", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_poll_lifecycle_transition_tenant_poll",
|
||||||
|
"poll_lifecycle_transitions",
|
||||||
|
["tenant_id", "poll_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("poll_lifecycle_transitions")
|
||||||
|
op.drop_column("poll_polls", "archived_from_status")
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
@@ -12,6 +14,8 @@ from govoplan_poll.backend.schemas import (
|
|||||||
PollInvitationCreateRequest,
|
PollInvitationCreateRequest,
|
||||||
PollInvitationListResponse,
|
PollInvitationListResponse,
|
||||||
PollInvitationResponse,
|
PollInvitationResponse,
|
||||||
|
PollLifecycleResponse,
|
||||||
|
PollLifecycleTransitionResponse,
|
||||||
PollListResponse,
|
PollListResponse,
|
||||||
PollResponse,
|
PollResponse,
|
||||||
PollResponseItem,
|
PollResponseItem,
|
||||||
@@ -19,21 +23,22 @@ from govoplan_poll.backend.schemas import (
|
|||||||
PollResultSummaryResponse,
|
PollResultSummaryResponse,
|
||||||
PollStatusResponse,
|
PollStatusResponse,
|
||||||
PollSubmitResponseRequest,
|
PollSubmitResponseRequest,
|
||||||
|
PollTransitionRequest,
|
||||||
|
PollTransitionAvailabilityResponse,
|
||||||
PollUpdateRequest,
|
PollUpdateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_poll.backend.service import (
|
from govoplan_poll.backend.service import (
|
||||||
PollError,
|
PollError,
|
||||||
close_poll,
|
|
||||||
create_poll_invitation,
|
create_poll_invitation,
|
||||||
create_poll,
|
create_poll,
|
||||||
decide_poll,
|
|
||||||
get_poll_by_invitation_token,
|
get_poll_by_invitation_token,
|
||||||
get_visible_poll,
|
get_visible_poll,
|
||||||
list_poll_responses,
|
list_poll_responses,
|
||||||
list_poll_invitations,
|
list_poll_invitations,
|
||||||
|
list_poll_lifecycle_transitions,
|
||||||
list_visible_polls,
|
list_visible_polls,
|
||||||
open_poll,
|
|
||||||
poll_invitation_response,
|
poll_invitation_response,
|
||||||
|
poll_lifecycle_transition_response,
|
||||||
poll_response,
|
poll_response,
|
||||||
poll_response_item,
|
poll_response_item,
|
||||||
poll_result_summary_by_id,
|
poll_result_summary_by_id,
|
||||||
@@ -41,6 +46,7 @@ from govoplan_poll.backend.service import (
|
|||||||
revoke_poll_invitation,
|
revoke_poll_invitation,
|
||||||
submit_poll_response,
|
submit_poll_response,
|
||||||
submit_poll_response_with_token,
|
submit_poll_response_with_token,
|
||||||
|
transition_poll,
|
||||||
update_poll,
|
update_poll,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +73,45 @@ def _poll_response(poll) -> PollResponse:
|
|||||||
return PollResponse.model_validate(poll_response(poll))
|
return PollResponse.model_validate(poll_response(poll))
|
||||||
|
|
||||||
|
|
||||||
|
def _transition_status_response(result) -> PollStatusResponse:
|
||||||
|
return PollStatusResponse(
|
||||||
|
poll=_poll_response(result.poll),
|
||||||
|
transition=PollLifecycleTransitionResponse.model_validate(
|
||||||
|
poll_lifecycle_transition_response(result.transition)
|
||||||
|
),
|
||||||
|
replayed=result.replayed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_transition(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
poll_id: str,
|
||||||
|
action: str,
|
||||||
|
option_id: str | None = None,
|
||||||
|
option_key: str | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> PollStatusResponse:
|
||||||
|
try:
|
||||||
|
result = transition_poll(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action=action,
|
||||||
|
option_id=option_id,
|
||||||
|
option_key=option_key,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
actor_user_id=getattr(principal.user, "id", None),
|
||||||
|
actor_api_key_id=getattr(principal.api_key, "id", None) if principal.api_key else None,
|
||||||
|
)
|
||||||
|
except PollError as exc:
|
||||||
|
raise _poll_http_error(exc) from exc
|
||||||
|
response = _transition_status_response(result)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
|
def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
|
||||||
candidates = (
|
candidates = (
|
||||||
principal.account_id,
|
principal.account_id,
|
||||||
@@ -175,50 +220,161 @@ def api_update_poll(
|
|||||||
@router.post("/polls/{poll_id}/open", response_model=PollStatusResponse)
|
@router.post("/polls/{poll_id}/open", response_model=PollStatusResponse)
|
||||||
def api_open_poll(
|
def api_open_poll(
|
||||||
poll_id: str,
|
poll_id: str,
|
||||||
|
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key", max_length=255)] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> PollStatusResponse:
|
) -> PollStatusResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
try:
|
return _apply_transition(
|
||||||
poll = open_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
session,
|
||||||
except PollError as exc:
|
principal,
|
||||||
raise _poll_http_error(exc) from exc
|
poll_id=poll_id,
|
||||||
response = PollStatusResponse(poll=_poll_response(poll))
|
action="open",
|
||||||
session.commit()
|
idempotency_key=idempotency_key,
|
||||||
return response
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/polls/{poll_id}/draft", response_model=PollStatusResponse)
|
||||||
|
def api_draft_poll(
|
||||||
|
poll_id: str,
|
||||||
|
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key", max_length=255)] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PollStatusResponse:
|
||||||
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
return _apply_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="draft",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/polls/{poll_id}/close", response_model=PollStatusResponse)
|
@router.post("/polls/{poll_id}/close", response_model=PollStatusResponse)
|
||||||
def api_close_poll(
|
def api_close_poll(
|
||||||
poll_id: str,
|
poll_id: str,
|
||||||
|
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key", max_length=255)] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> PollStatusResponse:
|
) -> PollStatusResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
try:
|
return _apply_transition(
|
||||||
poll = close_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
session,
|
||||||
except PollError as exc:
|
principal,
|
||||||
raise _poll_http_error(exc) from exc
|
poll_id=poll_id,
|
||||||
response = PollStatusResponse(poll=_poll_response(poll))
|
action="close",
|
||||||
session.commit()
|
idempotency_key=idempotency_key,
|
||||||
return response
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/polls/{poll_id}/decide", response_model=PollStatusResponse)
|
@router.post("/polls/{poll_id}/decide", response_model=PollStatusResponse)
|
||||||
def api_decide_poll(
|
def api_decide_poll(
|
||||||
poll_id: str,
|
poll_id: str,
|
||||||
payload: PollDecisionRequest,
|
payload: PollDecisionRequest,
|
||||||
|
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key", max_length=255)] = None,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> PollStatusResponse:
|
) -> PollStatusResponse:
|
||||||
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
return _apply_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="decide",
|
||||||
|
option_id=payload.option_id,
|
||||||
|
option_key=payload.option_key,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/polls/{poll_id}/archive", response_model=PollStatusResponse)
|
||||||
|
def api_archive_poll(
|
||||||
|
poll_id: str,
|
||||||
|
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key", max_length=255)] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PollStatusResponse:
|
||||||
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
return _apply_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="archive",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/polls/{poll_id}/unarchive", response_model=PollStatusResponse)
|
||||||
|
def api_unarchive_poll(
|
||||||
|
poll_id: str,
|
||||||
|
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key", max_length=255)] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PollStatusResponse:
|
||||||
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
return _apply_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="unarchive",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/polls/{poll_id}/transitions", response_model=PollStatusResponse)
|
||||||
|
def api_transition_poll(
|
||||||
|
poll_id: str,
|
||||||
|
payload: PollTransitionRequest,
|
||||||
|
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key", max_length=255)] = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PollStatusResponse:
|
||||||
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
return _apply_transition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action=payload.action,
|
||||||
|
option_id=payload.option_id,
|
||||||
|
option_key=payload.option_key,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/polls/{poll_id}/transitions", response_model=PollLifecycleResponse)
|
||||||
|
def api_poll_lifecycle(
|
||||||
|
poll_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PollLifecycleResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
poll = decide_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
poll = get_visible_poll(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
actor_ids=_principal_actor_ids(principal),
|
||||||
|
can_manage=True,
|
||||||
|
)
|
||||||
|
history = list_poll_lifecycle_transitions(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
)
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
response = PollStatusResponse(poll=_poll_response(poll))
|
projected = _poll_response(poll)
|
||||||
session.commit()
|
return PollLifecycleResponse(
|
||||||
return response
|
poll_id=poll.id,
|
||||||
|
status=poll.status,
|
||||||
|
archived_from_status=poll.archived_from_status,
|
||||||
|
actions=[PollTransitionAvailabilityResponse.model_validate(item) for item in projected.lifecycle_actions],
|
||||||
|
history=[
|
||||||
|
PollLifecycleTransitionResponse.model_validate(poll_lifecycle_transition_response(item))
|
||||||
|
for item in history
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/polls/{poll_id}/responses", response_model=PollResponseItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/polls/{poll_id}/responses", response_model=PollResponseItem, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||||||
|
|
||||||
PollKind = Literal["single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"]
|
PollKind = Literal["single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"]
|
||||||
PollStatus = Literal["draft", "open", "closed", "decided", "archived"]
|
PollStatus = Literal["draft", "open", "closed", "decided", "archived"]
|
||||||
|
PollInitialStatus = Literal["draft", "open"]
|
||||||
|
PollTransitionAction = Literal["open", "draft", "close", "decide", "archive", "unarchive"]
|
||||||
PollVisibility = Literal["private", "tenant", "public", "unlisted"]
|
PollVisibility = Literal["private", "tenant", "public", "unlisted"]
|
||||||
PollResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
PollResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
||||||
AvailabilityValue = Literal["available", "maybe", "unavailable"]
|
AvailabilityValue = Literal["available", "maybe", "unavailable"]
|
||||||
@@ -30,7 +32,7 @@ class PollCreateRequest(BaseModel):
|
|||||||
slug: str | None = Field(default=None, max_length=120)
|
slug: str | None = Field(default=None, max_length=120)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
kind: PollKind = "single_choice"
|
kind: PollKind = "single_choice"
|
||||||
status: PollStatus = "draft"
|
status: PollInitialStatus = "draft"
|
||||||
visibility: PollVisibility = "private"
|
visibility: PollVisibility = "private"
|
||||||
result_visibility: PollResultVisibility = "after_close"
|
result_visibility: PollResultVisibility = "after_close"
|
||||||
context_module: str | None = Field(default=None, max_length=100)
|
context_module: str | None = Field(default=None, max_length=100)
|
||||||
@@ -85,6 +87,13 @@ class PollOptionResponse(BaseModel):
|
|||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PollTransitionAvailabilityResponse(BaseModel):
|
||||||
|
action: PollTransitionAction
|
||||||
|
target_status: PollStatus | None = None
|
||||||
|
available: bool
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PollResponseItem(BaseModel):
|
class PollResponseItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
respondent_id: str | None = None
|
respondent_id: str | None = None
|
||||||
@@ -120,11 +129,13 @@ class PollResponse(BaseModel):
|
|||||||
closed_at: datetime | None = None
|
closed_at: datetime | None = None
|
||||||
decided_at: datetime | None = None
|
decided_at: datetime | None = None
|
||||||
decided_option_id: str | None = None
|
decided_option_id: str | None = None
|
||||||
|
archived_from_status: str | None = None
|
||||||
created_by_user_id: str | None = None
|
created_by_user_id: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
options: list[PollOptionResponse] = Field(default_factory=list)
|
options: list[PollOptionResponse] = Field(default_factory=list)
|
||||||
|
lifecycle_actions: list[PollTransitionAvailabilityResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PollListResponse(BaseModel):
|
class PollListResponse(BaseModel):
|
||||||
@@ -175,8 +186,40 @@ class PollDecisionRequest(BaseModel):
|
|||||||
option_key: str | None = None
|
option_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PollTransitionRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
action: PollTransitionAction
|
||||||
|
option_id: str | None = None
|
||||||
|
option_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PollLifecycleTransitionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
action: PollTransitionAction
|
||||||
|
from_status: PollStatus
|
||||||
|
to_status: PollStatus
|
||||||
|
decision_option_id: str | None = None
|
||||||
|
previous_decision_option_id: str | None = None
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
actor_user_id: str | None = None
|
||||||
|
actor_api_key_id: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class PollStatusResponse(BaseModel):
|
class PollStatusResponse(BaseModel):
|
||||||
poll: PollResponse
|
poll: PollResponse
|
||||||
|
transition: PollLifecycleTransitionResponse | None = None
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PollLifecycleResponse(BaseModel):
|
||||||
|
poll_id: str
|
||||||
|
status: PollStatus
|
||||||
|
archived_from_status: str | None = None
|
||||||
|
actions: list[PollTransitionAvailabilityResponse] = Field(default_factory=list)
|
||||||
|
history: list[PollLifecycleTransitionResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PollResponseListResponse(BaseModel):
|
class PollResponseListResponse(BaseModel):
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
|
||||||
from govoplan_poll.backend.schemas import (
|
from govoplan_poll.backend.schemas import (
|
||||||
PollCreateRequest,
|
PollCreateRequest,
|
||||||
PollDecisionRequest,
|
PollDecisionRequest,
|
||||||
@@ -18,6 +19,7 @@ from govoplan_poll.backend.schemas import (
|
|||||||
PollSubmitResponseRequest,
|
PollSubmitResponseRequest,
|
||||||
PollUpdateRequest,
|
PollUpdateRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_poll.backend.transitions import DEFAULT_POLL_TRANSITION_ENGINE, PollTransitionEngine
|
||||||
|
|
||||||
|
|
||||||
class PollError(ValueError):
|
class PollError(ValueError):
|
||||||
@@ -25,7 +27,7 @@ class PollError(ValueError):
|
|||||||
|
|
||||||
|
|
||||||
POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"}
|
POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"}
|
||||||
POLL_STATUSES = {"draft", "open", "closed", "decided", "archived"}
|
POLL_INITIAL_STATUSES = {"draft", "open"}
|
||||||
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"}
|
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"}
|
||||||
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
|
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
|
||||||
YES_NO_OPTIONS = (
|
YES_NO_OPTIONS = (
|
||||||
@@ -39,6 +41,13 @@ YES_NO_MAYBE_OPTIONS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PollTransitionResult:
|
||||||
|
poll: Poll
|
||||||
|
transition: PollLifecycleTransition
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
def slugify(value: str, *, fallback: str = "poll") -> str:
|
def slugify(value: str, *, fallback: str = "poll") -> str:
|
||||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||||
return slug or fallback
|
return slug or fallback
|
||||||
@@ -141,8 +150,8 @@ def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None
|
|||||||
def _ensure_valid_poll_payload(payload: PollCreateRequest) -> tuple[list[PollOptionInput], int, int | None]:
|
def _ensure_valid_poll_payload(payload: PollCreateRequest) -> tuple[list[PollOptionInput], int, int | None]:
|
||||||
if payload.kind not in POLL_KINDS:
|
if payload.kind not in POLL_KINDS:
|
||||||
raise PollError(f"Unsupported poll kind: {payload.kind}")
|
raise PollError(f"Unsupported poll kind: {payload.kind}")
|
||||||
if payload.status not in POLL_STATUSES:
|
if payload.status not in POLL_INITIAL_STATUSES:
|
||||||
raise PollError(f"Unsupported poll status: {payload.status}")
|
raise PollError("Poll initial status must be draft or open")
|
||||||
if payload.opens_at is not None and payload.closes_at is not None and payload.closes_at <= payload.opens_at:
|
if payload.opens_at is not None and payload.closes_at is not None and payload.closes_at <= payload.opens_at:
|
||||||
raise PollError("closes_at must be after opens_at")
|
raise PollError("closes_at must be after opens_at")
|
||||||
options = _normalize_options(payload.kind, payload.options)
|
options = _normalize_options(payload.kind, payload.options)
|
||||||
@@ -297,7 +306,9 @@ def poll_results_are_visible(
|
|||||||
if poll.result_visibility == "public":
|
if poll.result_visibility == "public":
|
||||||
return True
|
return True
|
||||||
if poll.result_visibility == "after_close":
|
if poll.result_visibility == "after_close":
|
||||||
if poll.status in {"closed", "decided", "archived"}:
|
if poll.status in {"closed", "decided"}:
|
||||||
|
return True
|
||||||
|
if poll.status == "archived" and poll.archived_from_status in {"closed", "decided"}:
|
||||||
return True
|
return True
|
||||||
return poll.closes_at is not None and response_datetime(poll.closes_at) <= _now()
|
return poll.closes_at is not None and response_datetime(poll.closes_at) <= _now()
|
||||||
if poll.result_visibility == "after_response" and ids:
|
if poll.result_visibility == "after_response" and ids:
|
||||||
@@ -370,38 +381,260 @@ def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: Poll
|
|||||||
return poll
|
return poll
|
||||||
|
|
||||||
|
|
||||||
def open_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
|
def _lock_poll_for_transition(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
|
||||||
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
poll = (
|
||||||
if poll.status == "archived":
|
session.query(Poll)
|
||||||
raise PollError("Archived polls cannot be opened")
|
.filter(Poll.tenant_id == tenant_id, Poll.id == poll_id, Poll.deleted_at.is_(None))
|
||||||
if poll.closes_at is not None and response_datetime(poll.closes_at) <= _now():
|
.populate_existing()
|
||||||
raise PollError("Poll close time is already in the past")
|
.with_for_update()
|
||||||
poll.status = "open"
|
.first()
|
||||||
poll.closed_at = None
|
)
|
||||||
session.flush()
|
if poll is None:
|
||||||
|
raise PollError("Poll not found")
|
||||||
return poll
|
return poll
|
||||||
|
|
||||||
|
|
||||||
def close_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
|
def _normalize_idempotency_key(value: str | None) -> str | None:
|
||||||
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
if value is None:
|
||||||
if poll.status == "archived":
|
return None
|
||||||
raise PollError("Archived polls cannot be closed")
|
normalized = value.strip()
|
||||||
poll.status = "closed"
|
if not normalized:
|
||||||
poll.closed_at = _now()
|
raise PollError("Idempotency key cannot be empty")
|
||||||
session.flush()
|
if len(normalized) > 255:
|
||||||
return poll
|
raise PollError("Idempotency key cannot be longer than 255 characters")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def decide_poll(session: Session, *, tenant_id: str, poll_id: str, payload: PollDecisionRequest) -> Poll:
|
def _transition_for_idempotency_key(
|
||||||
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
session: Session,
|
||||||
option = _option_by_id_or_key(poll, option_id=payload.option_id, option_key=payload.option_key)
|
*,
|
||||||
poll.status = "decided"
|
poll_id: str,
|
||||||
poll.decided_option_id = option.id
|
idempotency_key: str | None,
|
||||||
poll.decided_at = _now()
|
) -> PollLifecycleTransition | None:
|
||||||
|
if idempotency_key is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
session.query(PollLifecycleTransition)
|
||||||
|
.filter(
|
||||||
|
PollLifecycleTransition.poll_id == poll_id,
|
||||||
|
PollLifecycleTransition.idempotency_key == idempotency_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def transition_poll(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
action: str,
|
||||||
|
option_id: str | None = None,
|
||||||
|
option_key: str | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
actor_api_key_id: str | None = None,
|
||||||
|
transition_engine: PollTransitionEngine = DEFAULT_POLL_TRANSITION_ENGINE,
|
||||||
|
) -> PollTransitionResult:
|
||||||
|
"""Apply one policy-controlled transition and append its durable audit record."""
|
||||||
|
|
||||||
|
poll = _lock_poll_for_transition(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
normalized_key = _normalize_idempotency_key(idempotency_key)
|
||||||
|
decision_option = None
|
||||||
|
if action == "decide":
|
||||||
|
decision_option = _option_by_id_or_key(poll, option_id=option_id, option_key=option_key)
|
||||||
|
elif option_id is not None or option_key is not None:
|
||||||
|
raise PollError("Only a decide transition accepts a poll option")
|
||||||
|
|
||||||
|
existing = _transition_for_idempotency_key(
|
||||||
|
session,
|
||||||
|
poll_id=poll.id,
|
||||||
|
idempotency_key=normalized_key,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
requested_option_id = decision_option.id if decision_option is not None else None
|
||||||
|
if existing.action != action or existing.decision_option_id != requested_option_id:
|
||||||
|
raise PollError("Idempotency key was already used for a different poll transition")
|
||||||
|
return PollTransitionResult(poll=poll, transition=existing, replayed=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = transition_engine.plan(
|
||||||
|
current_status=poll.status,
|
||||||
|
action=action,
|
||||||
|
archived_from_status=poll.archived_from_status,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise PollError(str(exc)) from exc
|
||||||
|
|
||||||
|
previous_decision_option_id = poll.decided_option_id
|
||||||
|
now = _now()
|
||||||
|
used_legacy_unarchive_fallback = action == "unarchive" and poll.archived_from_status is None
|
||||||
|
cleared_expired_closes_at: datetime | None = None
|
||||||
|
if action == "open" and poll.closes_at is not None:
|
||||||
|
normalized_closes_at = response_datetime(poll.closes_at)
|
||||||
|
if normalized_closes_at is not None and normalized_closes_at <= now:
|
||||||
|
cleared_expired_closes_at = normalized_closes_at
|
||||||
|
poll.closes_at = None
|
||||||
|
|
||||||
|
if action == "archive":
|
||||||
|
poll.archived_from_status = plan.from_status
|
||||||
|
elif action == "unarchive":
|
||||||
|
poll.archived_from_status = None
|
||||||
|
elif action == "close":
|
||||||
|
poll.closed_at = now
|
||||||
|
elif action == "decide":
|
||||||
|
assert decision_option is not None
|
||||||
|
poll.decided_option_id = decision_option.id
|
||||||
|
poll.decided_at = now
|
||||||
if poll.closed_at is None:
|
if poll.closed_at is None:
|
||||||
poll.closed_at = poll.decided_at
|
poll.closed_at = now
|
||||||
|
|
||||||
|
poll.status = plan.to_status
|
||||||
|
transition_metadata: dict[str, Any] = {}
|
||||||
|
if action == "archive":
|
||||||
|
transition_metadata["archived_from_status"] = plan.from_status
|
||||||
|
elif action == "unarchive":
|
||||||
|
transition_metadata["restored_status"] = plan.to_status
|
||||||
|
if used_legacy_unarchive_fallback:
|
||||||
|
transition_metadata["legacy_restore_fallback"] = {
|
||||||
|
"reason": "missing_archived_from_status",
|
||||||
|
"restored_status": plan.to_status,
|
||||||
|
}
|
||||||
|
if cleared_expired_closes_at is not None:
|
||||||
|
transition_metadata["cleared_expired_closes_at"] = cleared_expired_closes_at.isoformat()
|
||||||
|
transition = PollLifecycleTransition(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
action=action,
|
||||||
|
from_status=plan.from_status,
|
||||||
|
to_status=plan.to_status,
|
||||||
|
decision_option_id=decision_option.id if decision_option is not None else None,
|
||||||
|
previous_decision_option_id=previous_decision_option_id,
|
||||||
|
idempotency_key=normalized_key,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_api_key_id=actor_api_key_id,
|
||||||
|
metadata_=transition_metadata,
|
||||||
|
)
|
||||||
|
session.add(transition)
|
||||||
session.flush()
|
session.flush()
|
||||||
return poll
|
return PollTransitionResult(poll=poll, transition=transition)
|
||||||
|
|
||||||
|
|
||||||
|
def open_poll(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> Poll:
|
||||||
|
return transition_poll(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="open",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
).poll
|
||||||
|
|
||||||
|
|
||||||
|
def draft_poll(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> Poll:
|
||||||
|
return transition_poll(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="draft",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
).poll
|
||||||
|
|
||||||
|
|
||||||
|
def close_poll(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> Poll:
|
||||||
|
return transition_poll(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="close",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
).poll
|
||||||
|
|
||||||
|
|
||||||
|
def decide_poll(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
payload: PollDecisionRequest,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> Poll:
|
||||||
|
return transition_poll(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="decide",
|
||||||
|
option_id=payload.option_id,
|
||||||
|
option_key=payload.option_key,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
).poll
|
||||||
|
|
||||||
|
|
||||||
|
def archive_poll(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> Poll:
|
||||||
|
return transition_poll(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="archive",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
).poll
|
||||||
|
|
||||||
|
|
||||||
|
def unarchive_poll(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> Poll:
|
||||||
|
return transition_poll(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
action="unarchive",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
).poll
|
||||||
|
|
||||||
|
|
||||||
|
def list_poll_lifecycle_transitions(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
) -> list[PollLifecycleTransition]:
|
||||||
|
get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
return (
|
||||||
|
session.query(PollLifecycleTransition)
|
||||||
|
.filter(
|
||||||
|
PollLifecycleTransition.tenant_id == tenant_id,
|
||||||
|
PollLifecycleTransition.poll_id == poll_id,
|
||||||
|
)
|
||||||
|
.order_by(PollLifecycleTransition.created_at.asc(), PollLifecycleTransition.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _assert_poll_accepts_responses(poll: Poll, *, now: datetime | None = None) -> None:
|
def _assert_poll_accepts_responses(poll: Poll, *, now: datetime | None = None) -> None:
|
||||||
@@ -518,6 +751,125 @@ def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -
|
|||||||
return poll
|
return poll
|
||||||
|
|
||||||
|
|
||||||
|
def get_poll_response_for_respondents(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
respondent_ids: tuple[str, ...],
|
||||||
|
invitation_id: str | None = None,
|
||||||
|
) -> PollResponse | None:
|
||||||
|
"""Resolve a response from server-trusted respondent identities."""
|
||||||
|
|
||||||
|
get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
ids = tuple(dict.fromkeys(value for value in respondent_ids if value))
|
||||||
|
if ids:
|
||||||
|
response = (
|
||||||
|
session.query(PollResponse)
|
||||||
|
.filter(
|
||||||
|
PollResponse.tenant_id == tenant_id,
|
||||||
|
PollResponse.poll_id == poll_id,
|
||||||
|
PollResponse.respondent_id.in_(ids),
|
||||||
|
PollResponse.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(PollResponse.submitted_at.desc(), PollResponse.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if response is not None:
|
||||||
|
return response
|
||||||
|
if invitation_id is None:
|
||||||
|
return None
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
response
|
||||||
|
for response in reversed(list_poll_responses(session, tenant_id=tenant_id, poll_id=poll_id))
|
||||||
|
if (response.metadata_ or {}).get("invitation_id") == invitation_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
if responses and not poll.allow_response_update:
|
||||||
|
raise PollError("Poll options cannot be edited after responses when response updates are disabled")
|
||||||
|
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
|
||||||
|
if not retained_answers:
|
||||||
|
response.deleted_at = _now()
|
||||||
|
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,
|
||||||
@@ -564,7 +916,31 @@ def poll_option_response(option: PollOption) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def poll_response(poll: Poll) -> dict[str, Any]:
|
def poll_lifecycle_transition_response(transition: PollLifecycleTransition) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": transition.id,
|
||||||
|
"action": transition.action,
|
||||||
|
"from_status": transition.from_status,
|
||||||
|
"to_status": transition.to_status,
|
||||||
|
"decision_option_id": transition.decision_option_id,
|
||||||
|
"previous_decision_option_id": transition.previous_decision_option_id,
|
||||||
|
"idempotency_key": transition.idempotency_key,
|
||||||
|
"actor_user_id": transition.actor_user_id,
|
||||||
|
"actor_api_key_id": transition.actor_api_key_id,
|
||||||
|
"created_at": response_datetime(transition.created_at),
|
||||||
|
"metadata": transition.metadata_ or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def poll_response(
|
||||||
|
poll: Poll,
|
||||||
|
*,
|
||||||
|
transition_engine: PollTransitionEngine = DEFAULT_POLL_TRANSITION_ENGINE,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
lifecycle_actions = transition_engine.available_actions(
|
||||||
|
current_status=poll.status,
|
||||||
|
archived_from_status=poll.archived_from_status,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"id": poll.id,
|
"id": poll.id,
|
||||||
"tenant_id": poll.tenant_id,
|
"tenant_id": poll.tenant_id,
|
||||||
@@ -589,11 +965,21 @@ def poll_response(poll: Poll) -> dict[str, Any]:
|
|||||||
"closed_at": response_datetime(poll.closed_at),
|
"closed_at": response_datetime(poll.closed_at),
|
||||||
"decided_at": response_datetime(poll.decided_at),
|
"decided_at": response_datetime(poll.decided_at),
|
||||||
"decided_option_id": poll.decided_option_id,
|
"decided_option_id": poll.decided_option_id,
|
||||||
|
"archived_from_status": poll.archived_from_status,
|
||||||
"created_by_user_id": poll.created_by_user_id,
|
"created_by_user_id": poll.created_by_user_id,
|
||||||
"created_at": response_datetime(poll.created_at),
|
"created_at": response_datetime(poll.created_at),
|
||||||
"updated_at": response_datetime(poll.updated_at),
|
"updated_at": response_datetime(poll.updated_at),
|
||||||
"metadata": poll.metadata_ or {},
|
"metadata": poll.metadata_ or {},
|
||||||
"options": [poll_option_response(option) for option in _active_options(poll)],
|
"options": [poll_option_response(option) for option in _active_options(poll)],
|
||||||
|
"lifecycle_actions": [
|
||||||
|
{
|
||||||
|
"action": item.action,
|
||||||
|
"target_status": item.target_status,
|
||||||
|
"available": item.available,
|
||||||
|
"reason": item.reason,
|
||||||
|
}
|
||||||
|
for item in lifecycle_actions
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
196
src/govoplan_poll/backend/transitions.py
Normal file
196
src/govoplan_poll/backend/transitions.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
POLL_STATUSES = frozenset({"draft", "open", "closed", "decided", "archived"})
|
||||||
|
POLL_TRANSITION_ACTIONS = ("open", "draft", "close", "decide", "archive", "unarchive")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PollTransitionRule:
|
||||||
|
"""One configurable lifecycle action.
|
||||||
|
|
||||||
|
Archive restoration is deliberately represented as a dynamic target. The
|
||||||
|
engine remains a pure policy component; persistence and domain side effects
|
||||||
|
stay in the Poll service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
action: str
|
||||||
|
source_statuses: frozenset[str]
|
||||||
|
target_status: str | None
|
||||||
|
restore_archived_status: bool = False
|
||||||
|
missing_restore_target_status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PollTransitionPlan:
|
||||||
|
action: str
|
||||||
|
from_status: str
|
||||||
|
to_status: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PollTransitionAvailability:
|
||||||
|
action: str
|
||||||
|
target_status: str | None
|
||||||
|
available: bool
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PollTransitionPolicy:
|
||||||
|
"""Validated, replaceable configuration for a Poll lifecycle."""
|
||||||
|
|
||||||
|
def __init__(self, rules: Mapping[str, PollTransitionRule]) -> None:
|
||||||
|
normalized = dict(rules)
|
||||||
|
if set(normalized) != set(POLL_TRANSITION_ACTIONS):
|
||||||
|
missing = sorted(set(POLL_TRANSITION_ACTIONS) - set(normalized))
|
||||||
|
extra = sorted(set(normalized) - set(POLL_TRANSITION_ACTIONS))
|
||||||
|
raise ValueError(f"Poll transition policy has missing actions {missing} and extra actions {extra}")
|
||||||
|
for action, rule in normalized.items():
|
||||||
|
if rule.action != action:
|
||||||
|
raise ValueError(f"Poll transition rule key {action!r} does not match action {rule.action!r}")
|
||||||
|
if not rule.source_statuses <= POLL_STATUSES:
|
||||||
|
raise ValueError(f"Poll transition rule {action!r} has unknown source statuses")
|
||||||
|
if rule.restore_archived_status:
|
||||||
|
if rule.target_status is not None:
|
||||||
|
raise ValueError(f"Restoring transition {action!r} cannot have a fixed target")
|
||||||
|
if rule.missing_restore_target_status not in (POLL_STATUSES - {"archived"}) | {None}:
|
||||||
|
raise ValueError(f"Restoring transition {action!r} has an invalid missing-origin target")
|
||||||
|
elif rule.target_status not in POLL_STATUSES:
|
||||||
|
raise ValueError(f"Poll transition rule {action!r} has an unknown target status")
|
||||||
|
elif rule.missing_restore_target_status is not None:
|
||||||
|
raise ValueError(f"Non-restoring transition {action!r} cannot have a missing-origin target")
|
||||||
|
self._rules = MappingProxyType(normalized)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rules(self) -> Mapping[str, PollTransitionRule]:
|
||||||
|
return self._rules
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_POLL_TRANSITION_POLICY = PollTransitionPolicy(
|
||||||
|
{
|
||||||
|
"open": PollTransitionRule(
|
||||||
|
action="open",
|
||||||
|
source_statuses=frozenset({"draft", "closed", "decided"}),
|
||||||
|
target_status="open",
|
||||||
|
),
|
||||||
|
"draft": PollTransitionRule(
|
||||||
|
action="draft",
|
||||||
|
source_statuses=frozenset({"open"}),
|
||||||
|
target_status="draft",
|
||||||
|
),
|
||||||
|
"close": PollTransitionRule(
|
||||||
|
action="close",
|
||||||
|
source_statuses=frozenset({"open"}),
|
||||||
|
target_status="closed",
|
||||||
|
),
|
||||||
|
"decide": PollTransitionRule(
|
||||||
|
action="decide",
|
||||||
|
source_statuses=frozenset({"closed", "decided"}),
|
||||||
|
target_status="decided",
|
||||||
|
),
|
||||||
|
"archive": PollTransitionRule(
|
||||||
|
action="archive",
|
||||||
|
source_statuses=frozenset({"draft", "open", "closed", "decided"}),
|
||||||
|
target_status="archived",
|
||||||
|
),
|
||||||
|
"unarchive": PollTransitionRule(
|
||||||
|
action="unarchive",
|
||||||
|
source_statuses=frozenset({"archived"}),
|
||||||
|
target_status=None,
|
||||||
|
restore_archived_status=True,
|
||||||
|
missing_restore_target_status="draft",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PollTransitionEngine:
|
||||||
|
"""Pure lifecycle planner backed by an injected transition policy."""
|
||||||
|
|
||||||
|
def __init__(self, policy: PollTransitionPolicy = DEFAULT_POLL_TRANSITION_POLICY) -> None:
|
||||||
|
self.policy = policy
|
||||||
|
|
||||||
|
def plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
current_status: str,
|
||||||
|
action: str,
|
||||||
|
archived_from_status: str | None = None,
|
||||||
|
) -> PollTransitionPlan:
|
||||||
|
if current_status not in POLL_STATUSES:
|
||||||
|
raise ValueError(f"Unknown poll status: {current_status}")
|
||||||
|
rule = self.policy.rules.get(action)
|
||||||
|
if rule is None:
|
||||||
|
raise ValueError(f"Unknown poll transition action: {action}")
|
||||||
|
if current_status not in rule.source_statuses:
|
||||||
|
raise ValueError(f"Poll transition {action!r} is not allowed from status {current_status!r}")
|
||||||
|
|
||||||
|
target_status = rule.target_status
|
||||||
|
if rule.restore_archived_status:
|
||||||
|
if archived_from_status is None and rule.missing_restore_target_status is not None:
|
||||||
|
target_status = rule.missing_restore_target_status
|
||||||
|
elif archived_from_status not in POLL_STATUSES - {"archived"}:
|
||||||
|
raise ValueError("Archived poll has no valid status to restore")
|
||||||
|
else:
|
||||||
|
target_status = archived_from_status
|
||||||
|
assert target_status is not None
|
||||||
|
return PollTransitionPlan(action=action, from_status=current_status, to_status=target_status)
|
||||||
|
|
||||||
|
def available_actions(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
current_status: str,
|
||||||
|
archived_from_status: str | None = None,
|
||||||
|
) -> tuple[PollTransitionAvailability, ...]:
|
||||||
|
actions: list[PollTransitionAvailability] = []
|
||||||
|
for action in POLL_TRANSITION_ACTIONS:
|
||||||
|
rule = self.policy.rules[action]
|
||||||
|
target_status = (
|
||||||
|
archived_from_status or rule.missing_restore_target_status
|
||||||
|
if rule.restore_archived_status
|
||||||
|
else rule.target_status
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.plan(
|
||||||
|
current_status=current_status,
|
||||||
|
action=action,
|
||||||
|
archived_from_status=archived_from_status,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
actions.append(
|
||||||
|
PollTransitionAvailability(
|
||||||
|
action=action,
|
||||||
|
target_status=target_status,
|
||||||
|
available=False,
|
||||||
|
reason=str(exc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
actions.append(
|
||||||
|
PollTransitionAvailability(
|
||||||
|
action=action,
|
||||||
|
target_status=target_status,
|
||||||
|
available=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_POLL_TRANSITION_ENGINE = PollTransitionEngine()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_POLL_TRANSITION_ENGINE",
|
||||||
|
"DEFAULT_POLL_TRANSITION_POLICY",
|
||||||
|
"POLL_STATUSES",
|
||||||
|
"POLL_TRANSITION_ACTIONS",
|
||||||
|
"PollTransitionAvailability",
|
||||||
|
"PollTransitionEngine",
|
||||||
|
"PollTransitionPlan",
|
||||||
|
"PollTransitionPolicy",
|
||||||
|
"PollTransitionRule",
|
||||||
|
]
|
||||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
from govoplan_core.core.access import PrincipalRef
|
from govoplan_core.core.access import PrincipalRef
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
|
||||||
from govoplan_poll.backend.router import (
|
from govoplan_poll.backend.router import (
|
||||||
api_create_poll,
|
api_create_poll,
|
||||||
api_get_poll,
|
api_get_poll,
|
||||||
@@ -39,7 +39,13 @@ class PollAuthorizationTests(unittest.TestCase):
|
|||||||
self.engine = create_engine("sqlite:///:memory:")
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
Base.metadata.create_all(
|
Base.metadata.create_all(
|
||||||
self.engine,
|
self.engine,
|
||||||
tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__, PollInvitation.__table__],
|
tables=[
|
||||||
|
Poll.__table__,
|
||||||
|
PollOption.__table__,
|
||||||
|
PollResponse.__table__,
|
||||||
|
PollInvitation.__table__,
|
||||||
|
PollLifecycleTransition.__table__,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self.Session = sessionmaker(bind=self.engine)
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
self.session: Session = self.Session()
|
self.session: Session = self.Session()
|
||||||
@@ -48,7 +54,13 @@ class PollAuthorizationTests(unittest.TestCase):
|
|||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(
|
Base.metadata.drop_all(
|
||||||
self.engine,
|
self.engine,
|
||||||
tables=[PollInvitation.__table__, PollResponse.__table__, PollOption.__table__, Poll.__table__],
|
tables=[
|
||||||
|
PollLifecycleTransition.__table__,
|
||||||
|
PollInvitation.__table__,
|
||||||
|
PollResponse.__table__,
|
||||||
|
PollOption.__table__,
|
||||||
|
Poll.__table__,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
|
|||||||
467
tests/test_lifecycle.py
Normal file
467
tests/test_lifecycle.py
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_poll.backend.db.models import (
|
||||||
|
Poll,
|
||||||
|
PollInvitation,
|
||||||
|
PollLifecycleTransition,
|
||||||
|
PollOption,
|
||||||
|
PollResponse,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.router import api_poll_lifecycle, api_transition_poll
|
||||||
|
from govoplan_poll.backend.schemas import (
|
||||||
|
PollAnswerInput,
|
||||||
|
PollCreateRequest,
|
||||||
|
PollOptionInput,
|
||||||
|
PollSubmitResponseRequest,
|
||||||
|
PollTransitionRequest,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.service import (
|
||||||
|
PollError,
|
||||||
|
create_poll,
|
||||||
|
list_poll_lifecycle_transitions,
|
||||||
|
poll_response,
|
||||||
|
poll_results_are_visible,
|
||||||
|
response_datetime,
|
||||||
|
submit_poll_response,
|
||||||
|
transition_poll,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.transitions import (
|
||||||
|
DEFAULT_POLL_TRANSITION_POLICY,
|
||||||
|
PollTransitionEngine,
|
||||||
|
PollTransitionPolicy,
|
||||||
|
PollTransitionRule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WRITE_SCOPE = "poll:poll:write"
|
||||||
|
|
||||||
|
|
||||||
|
class PollLifecycleTests(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__,
|
||||||
|
PollInvitation.__table__,
|
||||||
|
PollLifecycleTransition.__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=[
|
||||||
|
PollLifecycleTransition.__table__,
|
||||||
|
PollInvitation.__table__,
|
||||||
|
PollResponse.__table__,
|
||||||
|
PollOption.__table__,
|
||||||
|
Poll.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _poll(self, *, status: str = "draft", title: str = "Decision") -> Poll:
|
||||||
|
return create_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="owner",
|
||||||
|
payload=PollCreateRequest(
|
||||||
|
title=title,
|
||||||
|
kind="single_choice",
|
||||||
|
status=status,
|
||||||
|
visibility="tenant",
|
||||||
|
options=[
|
||||||
|
PollOptionInput(key="yes", label="Yes"),
|
||||||
|
PollOptionInput(key="no", label="No"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _principal() -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="owner",
|
||||||
|
membership_id="owner-membership",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset({WRITE_SCOPE}),
|
||||||
|
display_name="Owner",
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="owner"),
|
||||||
|
user=SimpleNamespace(id="owner-membership"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_policy_exposes_every_allowed_and_rejected_transition(self) -> None:
|
||||||
|
expected = {
|
||||||
|
"draft": {"open", "archive"},
|
||||||
|
"open": {"draft", "close", "archive"},
|
||||||
|
"closed": {"open", "decide", "archive"},
|
||||||
|
"decided": {"open", "decide", "archive"},
|
||||||
|
"archived": {"unarchive"},
|
||||||
|
}
|
||||||
|
engine = PollTransitionEngine()
|
||||||
|
|
||||||
|
for status, allowed in expected.items():
|
||||||
|
archived_from_status = "closed" if status == "archived" else None
|
||||||
|
availability = engine.available_actions(
|
||||||
|
current_status=status,
|
||||||
|
archived_from_status=archived_from_status,
|
||||||
|
)
|
||||||
|
self.assertEqual({item.action for item in availability if item.available}, allowed)
|
||||||
|
for item in availability:
|
||||||
|
if item.available:
|
||||||
|
self.assertIsNone(item.reason)
|
||||||
|
else:
|
||||||
|
self.assertTrue(item.reason)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "not allowed"):
|
||||||
|
engine.plan(current_status="draft", action="close")
|
||||||
|
|
||||||
|
def test_injected_policy_governs_service_transition_and_projection(self) -> None:
|
||||||
|
rules = dict(DEFAULT_POLL_TRANSITION_POLICY.rules)
|
||||||
|
rules["close"] = PollTransitionRule(
|
||||||
|
action="close",
|
||||||
|
source_statuses=frozenset({"draft", "open"}),
|
||||||
|
target_status="closed",
|
||||||
|
)
|
||||||
|
custom_engine = PollTransitionEngine(PollTransitionPolicy(rules))
|
||||||
|
poll = self._poll()
|
||||||
|
|
||||||
|
projected = poll_response(poll, transition_engine=custom_engine)
|
||||||
|
close_action = next(item for item in projected["lifecycle_actions"] if item["action"] == "close")
|
||||||
|
result = transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="close",
|
||||||
|
transition_engine=custom_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(close_action["available"])
|
||||||
|
self.assertEqual(result.poll.status, "closed")
|
||||||
|
self.assertEqual(result.transition.from_status, "draft")
|
||||||
|
|
||||||
|
def test_only_draft_or_open_are_valid_initial_states(self) -> None:
|
||||||
|
for initial_status in ("closed", "decided", "archived"):
|
||||||
|
with self.subTest(initial_status=initial_status):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
PollCreateRequest(title="Invalid initial state", kind="yes_no", status=initial_status)
|
||||||
|
|
||||||
|
bypassed_schema = PollCreateRequest(title="Invalid initial state", kind="yes_no").model_copy(
|
||||||
|
update={"status": "closed"}
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(PollError, "initial status must be draft or open"):
|
||||||
|
create_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="owner",
|
||||||
|
payload=bypassed_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reopening_preserves_responses_close_history_and_previous_decision(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||||
|
response = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=PollSubmitResponseRequest(
|
||||||
|
respondent_id="participant",
|
||||||
|
answers=[PollAnswerInput(option_key="yes")],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
|
||||||
|
first_closed_at = poll.closed_at
|
||||||
|
transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="decide",
|
||||||
|
option_key="yes",
|
||||||
|
)
|
||||||
|
decided_at = poll.decided_at
|
||||||
|
decided_option_id = poll.decided_option_id
|
||||||
|
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||||
|
|
||||||
|
self.assertEqual(poll.status, "open")
|
||||||
|
self.assertEqual(response_datetime(poll.closed_at), response_datetime(first_closed_at))
|
||||||
|
self.assertEqual(response_datetime(poll.decided_at), response_datetime(decided_at))
|
||||||
|
self.assertEqual(poll.decided_option_id, decided_option_id)
|
||||||
|
self.assertEqual(response.deleted_at, None)
|
||||||
|
self.assertEqual(response.answers[0]["option_key"], "yes")
|
||||||
|
history = list_poll_lifecycle_transitions(self.session, tenant_id="tenant-1", poll_id=poll.id)
|
||||||
|
self.assertEqual([item.action for item in history], ["open", "close", "decide", "open"])
|
||||||
|
|
||||||
|
def test_direct_redecision_is_audited_and_exact_retry_is_idempotent(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
|
||||||
|
first = transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="decide",
|
||||||
|
option_key="yes",
|
||||||
|
idempotency_key="decision-1",
|
||||||
|
)
|
||||||
|
replay = transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="decide",
|
||||||
|
option_key="yes",
|
||||||
|
idempotency_key="decision-1",
|
||||||
|
)
|
||||||
|
second = transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="decide",
|
||||||
|
option_key="no",
|
||||||
|
idempotency_key="decision-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(first.replayed)
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(replay.transition.id, first.transition.id)
|
||||||
|
self.assertFalse(second.replayed)
|
||||||
|
self.assertEqual(second.transition.previous_decision_option_id, first.transition.decision_option_id)
|
||||||
|
self.assertEqual(poll.decided_option_id, second.transition.decision_option_id)
|
||||||
|
self.assertEqual(
|
||||||
|
self.session.query(PollLifecycleTransition)
|
||||||
|
.filter(
|
||||||
|
PollLifecycleTransition.poll_id == poll.id,
|
||||||
|
PollLifecycleTransition.action == "decide",
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PollError, "different poll transition"):
|
||||||
|
transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="decide",
|
||||||
|
option_key="yes",
|
||||||
|
idempotency_key="decision-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_repeated_exact_transition_without_an_identity_is_rejected(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PollError, "not allowed"):
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||||
|
|
||||||
|
def test_archive_and_unarchive_restore_status_and_append_audit_history(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
|
||||||
|
response = PollResponse(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
respondent_id="participant",
|
||||||
|
answers=[{"option_key": "yes"}],
|
||||||
|
submitted_at=poll.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(response)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
archived = transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="archive",
|
||||||
|
idempotency_key="archive-1",
|
||||||
|
)
|
||||||
|
restored = transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="unarchive",
|
||||||
|
idempotency_key="unarchive-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(archived.transition.to_status, "archived")
|
||||||
|
self.assertEqual(restored.transition.to_status, "closed")
|
||||||
|
self.assertEqual(poll.status, "closed")
|
||||||
|
self.assertIsNone(poll.archived_from_status)
|
||||||
|
self.assertIsNone(response.deleted_at)
|
||||||
|
self.assertEqual(
|
||||||
|
[item.action for item in poll.lifecycle_transitions],
|
||||||
|
["open", "close", "archive", "unarchive"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_archive_without_origin_restores_to_draft_with_audit_marker(self) -> None:
|
||||||
|
poll = Poll(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="legacy-archive",
|
||||||
|
title="Legacy archive",
|
||||||
|
kind="yes_no",
|
||||||
|
status="archived",
|
||||||
|
visibility="tenant",
|
||||||
|
result_visibility="after_close",
|
||||||
|
workflow_steps=[],
|
||||||
|
allow_anonymous=False,
|
||||||
|
allow_response_update=True,
|
||||||
|
min_choices=1,
|
||||||
|
)
|
||||||
|
self.session.add(poll)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
restored = transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="unarchive",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(restored.poll.status, "draft")
|
||||||
|
self.assertEqual(restored.transition.to_status, "draft")
|
||||||
|
self.assertEqual(
|
||||||
|
restored.transition.metadata_["legacy_restore_fallback"],
|
||||||
|
{
|
||||||
|
"reason": "missing_archived_from_status",
|
||||||
|
"restored_status": "draft",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reopening_clears_only_an_expired_deadline_and_preserves_close_history(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="open")
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="close")
|
||||||
|
closed_at = poll.closed_at
|
||||||
|
expired_deadline = datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
|
poll.closes_at = expired_deadline
|
||||||
|
|
||||||
|
reopened = transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="open",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(reopened.poll.status, "open")
|
||||||
|
self.assertIsNone(reopened.poll.closes_at)
|
||||||
|
self.assertEqual(response_datetime(reopened.poll.closed_at), response_datetime(closed_at))
|
||||||
|
self.assertEqual(
|
||||||
|
reopened.transition.metadata_["cleared_expired_closes_at"],
|
||||||
|
expired_deadline.isoformat(),
|
||||||
|
)
|
||||||
|
submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=PollSubmitResponseRequest(
|
||||||
|
respondent_id="participant-after-reopen",
|
||||||
|
answers=[PollAnswerInput(option_key="yes")],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
future_deadline = datetime.now(timezone.utc) + timedelta(days=1)
|
||||||
|
future_poll = self._poll(title="Future deadline")
|
||||||
|
future_poll.closes_at = future_deadline
|
||||||
|
opened = transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=future_poll.id,
|
||||||
|
action="open",
|
||||||
|
)
|
||||||
|
self.assertEqual(response_datetime(opened.poll.closes_at), future_deadline)
|
||||||
|
self.assertNotIn("cleared_expired_closes_at", opened.transition.metadata_)
|
||||||
|
|
||||||
|
def test_archiving_draft_or_open_poll_does_not_grant_after_close_visibility(self) -> None:
|
||||||
|
for initial_status in ("draft", "open"):
|
||||||
|
with self.subTest(initial_status=initial_status):
|
||||||
|
poll = self._poll(status=initial_status, title=f"Archive from {initial_status}")
|
||||||
|
transition_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
action="archive",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
poll_results_are_visible(
|
||||||
|
self.session,
|
||||||
|
poll=poll,
|
||||||
|
actor_ids=("participant",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
closed_poll = self._poll(title="Archive from closed")
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=closed_poll.id, action="open")
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=closed_poll.id, action="close")
|
||||||
|
transition_poll(self.session, tenant_id="tenant-1", poll_id=closed_poll.id, action="archive")
|
||||||
|
self.assertTrue(
|
||||||
|
poll_results_are_visible(
|
||||||
|
self.session,
|
||||||
|
poll=closed_poll,
|
||||||
|
actor_ids=("participant",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_generic_api_returns_action_availability_history_and_actor(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
principal = self._principal()
|
||||||
|
|
||||||
|
opened = api_transition_poll(
|
||||||
|
poll.id,
|
||||||
|
PollTransitionRequest(action="open"),
|
||||||
|
idempotency_key="open-api-1",
|
||||||
|
session=self.session,
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
replay = api_transition_poll(
|
||||||
|
poll.id,
|
||||||
|
PollTransitionRequest(action="open"),
|
||||||
|
idempotency_key="open-api-1",
|
||||||
|
session=self.session,
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
lifecycle = api_poll_lifecycle(poll.id, session=self.session, principal=principal)
|
||||||
|
|
||||||
|
self.assertEqual(opened.poll.status, "open")
|
||||||
|
self.assertFalse(opened.replayed)
|
||||||
|
self.assertTrue(replay.replayed)
|
||||||
|
self.assertEqual(len(lifecycle.history), 1)
|
||||||
|
self.assertEqual(lifecycle.history[0].actor_user_id, "owner-membership")
|
||||||
|
self.assertEqual(
|
||||||
|
{item.action for item in lifecycle.actions if item.available},
|
||||||
|
{"draft", "close", "archive"},
|
||||||
|
)
|
||||||
|
|
||||||
|
draft = self._poll(title="Draft cannot close")
|
||||||
|
with self.assertRaises(HTTPException) as rejected:
|
||||||
|
api_transition_poll(
|
||||||
|
draft.id,
|
||||||
|
PollTransitionRequest(action="close"),
|
||||||
|
idempotency_key=None,
|
||||||
|
session=self.session,
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
self.assertEqual(rejected.exception.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
179
tests/test_response_editing.py
Normal file
179
tests/test_response_editing.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.poll import PollCapabilityError, 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, *, allow_response_update: bool = True) -> 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,
|
||||||
|
allow_response_update=allow_response_update,
|
||||||
|
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"])
|
||||||
|
current = provider.get_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
respondent_ids=("person-1",),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(current)
|
||||||
|
self.assertEqual([answer.value for answer in current.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)
|
||||||
|
|
||||||
|
def test_fully_invalidated_response_is_no_longer_active_or_counted(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
response = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=PollSubmitResponseRequest(
|
||||||
|
respondent_id="person-1",
|
||||||
|
answers=[PollAnswerInput(option_id=poll.options[0].id, value="available")],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SqlPollSchedulingProvider().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(response.answers, [])
|
||||||
|
self.assertIsNotNone(response.deleted_at)
|
||||||
|
self.assertEqual(
|
||||||
|
SqlPollSchedulingProvider().list_responses(self.session, tenant_id="tenant-1", poll_id=poll.id),
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_option_change_is_rejected_when_responses_cannot_be_updated(self) -> None:
|
||||||
|
poll = self._poll(allow_response_update=False)
|
||||||
|
response = self._submit_both(poll, "person-1")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PollCapabilityError, "response updates are disabled"):
|
||||||
|
SqlPollSchedulingProvider().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(poll.options[0].label, "Monday")
|
||||||
|
self.assertEqual(len(response.answers), 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.core.poll import PollResponseRef, PollResponseSubmissionProvider, PollSchedulingProvider
|
from govoplan_core.core.poll import PollResponseRef, PollResponseSubmissionProvider, PollSchedulingProvider
|
||||||
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
|
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
|
||||||
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
|
||||||
from govoplan_poll.backend.schemas import (
|
from govoplan_poll.backend.schemas import (
|
||||||
PollAnswerInput,
|
PollAnswerInput,
|
||||||
PollCreateRequest,
|
PollCreateRequest,
|
||||||
@@ -33,7 +33,13 @@ class PollServiceTests(unittest.TestCase):
|
|||||||
self.engine = create_engine("sqlite:///:memory:")
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
Base.metadata.create_all(
|
Base.metadata.create_all(
|
||||||
self.engine,
|
self.engine,
|
||||||
tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__, PollInvitation.__table__],
|
tables=[
|
||||||
|
Poll.__table__,
|
||||||
|
PollOption.__table__,
|
||||||
|
PollResponse.__table__,
|
||||||
|
PollInvitation.__table__,
|
||||||
|
PollLifecycleTransition.__table__,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self.Session = sessionmaker(bind=self.engine)
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
self.session: Session = self.Session()
|
self.session: Session = self.Session()
|
||||||
@@ -42,7 +48,13 @@ class PollServiceTests(unittest.TestCase):
|
|||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(
|
Base.metadata.drop_all(
|
||||||
self.engine,
|
self.engine,
|
||||||
tables=[PollInvitation.__table__, PollResponse.__table__, PollOption.__table__, Poll.__table__],
|
tables=[
|
||||||
|
PollLifecycleTransition.__table__,
|
||||||
|
PollInvitation.__table__,
|
||||||
|
PollResponse.__table__,
|
||||||
|
PollOption.__table__,
|
||||||
|
Poll.__table__,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user