feat(poll): enforce auditable lifecycle transitions

This commit is contained in:
2026-07-20 18:18:20 +02:00
parent f4d5cac40d
commit 65e2d1fd65
14 changed files with 1161 additions and 68 deletions

View File

@@ -63,6 +63,30 @@ 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.
## Development Install ## Development Install
```bash ```bash

View File

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

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.8" __version__ = "0.1.9"

View File

@@ -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"]

View File

@@ -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"]

View File

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

View File

@@ -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")

View File

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

View File

@@ -8,6 +8,7 @@ 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"]
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"]
@@ -85,6 +86,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 +128,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 +185,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):

View File

@@ -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, POLL_STATUSES
class PollError(ValueError): class PollError(ValueError):
@@ -25,7 +27,6 @@ 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"}
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 +40,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
@@ -370,38 +378,244 @@ 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 poll.closed_at is None: if idempotency_key is None:
poll.closed_at = poll.decided_at 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,
) -> 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 = DEFAULT_POLL_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()
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:
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
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:
@@ -564,7 +778,27 @@ def poll_option_response(option: PollOption) -> 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) -> dict[str, Any]: def poll_response(poll: Poll) -> dict[str, Any]:
lifecycle_actions = DEFAULT_POLL_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 +823,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
],
} }

View File

@@ -0,0 +1,183 @@
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
@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")
elif rule.target_status not in POLL_STATUSES:
raise ValueError(f"Poll transition rule {action!r} has an unknown target status")
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,
),
}
)
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 not in POLL_STATUSES - {"archived"}:
raise ValueError("Archived poll has no valid status to restore")
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 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",
]

View File

@@ -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()

321
tests/test_lifecycle.py Normal file
View File

@@ -0,0 +1,321 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from fastapi import HTTPException
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,
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_policy_is_injectable_without_changing_the_engine(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))
plan = custom_engine.plan(current_status="draft", action="close")
self.assertEqual(plan.to_status, "closed")
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(status="closed")
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).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(status="closed")
poll.closed_at = poll.created_at
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], ["archive", "unarchive"])
def test_legacy_archive_without_restore_status_is_not_silently_unarchived(self) -> None:
poll = self._poll(status="archived")
with self.assertRaisesRegex(PollError, "no valid status to restore"):
transition_poll(self.session, tenant_id="tenant-1", poll_id=poll.id, action="unarchive")
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()

View File

@@ -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()