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,
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
```bash

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-poll"
version = "0.1.8"
version = "0.1.9"
description = "GovOPlaN lightweight poll and availability decision module seed."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-core>=0.1.9",
]
[tool.setuptools.packages.find]

View File

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

View File

@@ -1,5 +1,5 @@
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)
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)
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)
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)
@@ -60,6 +61,11 @@ class Poll(Base, TimestampMixin):
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")
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):
@@ -127,4 +133,28 @@ class PollInvitation(Base, TimestampMixin):
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_NAME = "Poll"
MODULE_VERSION = "0.1.8"
MODULE_VERSION = "0.1.9"
READ_SCOPE = "poll:poll:read"
WRITE_SCOPE = "poll:poll:write"
ADMIN_SCOPE = "poll:poll:admin"
@@ -115,11 +115,11 @@ manifest = ModuleManifest(
optional_dependencies=("access", "calendar", "campaigns", "portal", "mail", "notifications", "forms_runtime"),
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
provides_interfaces=(
ModuleInterfaceProvider(name="poll.option_selection", version="0.1.8"),
ModuleInterfaceProvider(name="poll.availability_matrix", version="0.1.8"),
ModuleInterfaceProvider(name="poll.response_collection", version="0.1.8"),
ModuleInterfaceProvider(name="poll.workflow_context", version="0.1.8"),
ModuleInterfaceProvider(name="poll.signed_participation", version="0.1.8"),
ModuleInterfaceProvider(name="poll.option_selection", version="0.1.9"),
ModuleInterfaceProvider(name="poll.availability_matrix", version="0.1.9"),
ModuleInterfaceProvider(name="poll.response_collection", version="0.1.9"),
ModuleInterfaceProvider(name="poll.workflow_context", version="0.1.9"),
ModuleInterfaceProvider(name="poll.signed_participation", version="0.1.9"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -134,6 +134,7 @@ manifest = ModuleManifest(
retirement_provider=drop_table_retirement_provider(
poll_models.Poll,
poll_models.PollInvitation,
poll_models.PollLifecycleTransition,
poll_models.PollOption,
poll_models.PollResponse,
label="Poll",
@@ -144,6 +145,7 @@ manifest = ModuleManifest(
persistent_table_uninstall_guard(
poll_models.Poll,
poll_models.PollInvitation,
poll_models.PollLifecycleTransition,
poll_models.PollOption,
poll_models.PollResponse,
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 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 govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
@@ -12,6 +14,8 @@ from govoplan_poll.backend.schemas import (
PollInvitationCreateRequest,
PollInvitationListResponse,
PollInvitationResponse,
PollLifecycleResponse,
PollLifecycleTransitionResponse,
PollListResponse,
PollResponse,
PollResponseItem,
@@ -19,21 +23,22 @@ from govoplan_poll.backend.schemas import (
PollResultSummaryResponse,
PollStatusResponse,
PollSubmitResponseRequest,
PollTransitionRequest,
PollTransitionAvailabilityResponse,
PollUpdateRequest,
)
from govoplan_poll.backend.service import (
PollError,
close_poll,
create_poll_invitation,
create_poll,
decide_poll,
get_poll_by_invitation_token,
get_visible_poll,
list_poll_responses,
list_poll_invitations,
list_poll_lifecycle_transitions,
list_visible_polls,
open_poll,
poll_invitation_response,
poll_lifecycle_transition_response,
poll_response,
poll_response_item,
poll_result_summary_by_id,
@@ -41,6 +46,7 @@ from govoplan_poll.backend.service import (
revoke_poll_invitation,
submit_poll_response,
submit_poll_response_with_token,
transition_poll,
update_poll,
)
@@ -67,6 +73,45 @@ def _poll_response(poll) -> PollResponse:
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, ...]:
candidates = (
principal.account_id,
@@ -175,50 +220,161 @@ def api_update_poll(
@router.post("/polls/{poll_id}/open", response_model=PollStatusResponse)
def api_open_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)
try:
poll = open_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
except PollError as exc:
raise _poll_http_error(exc) from exc
response = PollStatusResponse(poll=_poll_response(poll))
session.commit()
return response
return _apply_transition(
session,
principal,
poll_id=poll_id,
action="open",
idempotency_key=idempotency_key,
)
@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)
def api_close_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)
try:
poll = close_poll(session, tenant_id=principal.tenant_id, poll_id=poll_id)
except PollError as exc:
raise _poll_http_error(exc) from exc
response = PollStatusResponse(poll=_poll_response(poll))
session.commit()
return response
return _apply_transition(
session,
principal,
poll_id=poll_id,
action="close",
idempotency_key=idempotency_key,
)
@router.post("/polls/{poll_id}/decide", response_model=PollStatusResponse)
def api_decide_poll(
poll_id: str,
payload: PollDecisionRequest,
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="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)
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:
raise _poll_http_error(exc) from exc
response = PollStatusResponse(poll=_poll_response(poll))
session.commit()
return response
projected = _poll_response(poll)
return PollLifecycleResponse(
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)

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"]
PollStatus = Literal["draft", "open", "closed", "decided", "archived"]
PollTransitionAction = Literal["open", "draft", "close", "decide", "archive", "unarchive"]
PollVisibility = Literal["private", "tenant", "public", "unlisted"]
PollResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
AvailabilityValue = Literal["available", "maybe", "unavailable"]
@@ -85,6 +86,13 @@ class PollOptionResponse(BaseModel):
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):
id: str
respondent_id: str | None = None
@@ -120,11 +128,13 @@ class PollResponse(BaseModel):
closed_at: datetime | None = None
decided_at: datetime | None = None
decided_option_id: str | None = None
archived_from_status: str | None = None
created_by_user_id: str | None = None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
options: list[PollOptionResponse] = Field(default_factory=list)
lifecycle_actions: list[PollTransitionAvailabilityResponse] = Field(default_factory=list)
class PollListResponse(BaseModel):
@@ -175,8 +185,40 @@ class PollDecisionRequest(BaseModel):
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):
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):

View File

@@ -3,13 +3,14 @@ from __future__ import annotations
import hashlib
import re
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
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 (
PollCreateRequest,
PollDecisionRequest,
@@ -18,6 +19,7 @@ from govoplan_poll.backend.schemas import (
PollSubmitResponseRequest,
PollUpdateRequest,
)
from govoplan_poll.backend.transitions import DEFAULT_POLL_TRANSITION_ENGINE, POLL_STATUSES
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_STATUSES = {"draft", "open", "closed", "decided", "archived"}
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"}
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
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:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or fallback
@@ -370,38 +378,244 @@ def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: Poll
return poll
def open_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
if poll.status == "archived":
raise PollError("Archived polls cannot be opened")
if poll.closes_at is not None and response_datetime(poll.closes_at) <= _now():
raise PollError("Poll close time is already in the past")
poll.status = "open"
poll.closed_at = None
session.flush()
def _lock_poll_for_transition(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = (
session.query(Poll)
.filter(Poll.tenant_id == tenant_id, Poll.id == poll_id, Poll.deleted_at.is_(None))
.populate_existing()
.with_for_update()
.first()
)
if poll is None:
raise PollError("Poll not found")
return poll
def close_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
if poll.status == "archived":
raise PollError("Archived polls cannot be closed")
poll.status = "closed"
poll.closed_at = _now()
session.flush()
return poll
def _normalize_idempotency_key(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
if not normalized:
raise PollError("Idempotency key cannot be empty")
if len(normalized) > 255:
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:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
option = _option_by_id_or_key(poll, option_id=payload.option_id, option_key=payload.option_key)
poll.status = "decided"
poll.decided_option_id = option.id
poll.decided_at = _now()
def _transition_for_idempotency_key(
session: Session,
*,
poll_id: str,
idempotency_key: str | None,
) -> 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,
) -> 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 = 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
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()
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:
@@ -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]:
lifecycle_actions = DEFAULT_POLL_TRANSITION_ENGINE.available_actions(
current_status=poll.status,
archived_from_status=poll.archived_from_status,
)
return {
"id": poll.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),
"decided_at": response_datetime(poll.decided_at),
"decided_option_id": poll.decided_option_id,
"archived_from_status": poll.archived_from_status,
"created_by_user_id": poll.created_by_user_id,
"created_at": response_datetime(poll.created_at),
"updated_at": response_datetime(poll.updated_at),
"metadata": poll.metadata_ or {},
"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.core.access import PrincipalRef
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 (
api_create_poll,
api_get_poll,
@@ -39,7 +39,13 @@ class PollAuthorizationTests(unittest.TestCase):
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
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: Session = self.Session()
@@ -48,7 +54,13 @@ class PollAuthorizationTests(unittest.TestCase):
self.session.close()
Base.metadata.drop_all(
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()

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.core.poll import PollResponseRef, PollResponseSubmissionProvider, PollSchedulingProvider
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 (
PollAnswerInput,
PollCreateRequest,
@@ -33,7 +33,13 @@ class PollServiceTests(unittest.TestCase):
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
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: Session = self.Session()
@@ -42,7 +48,13 @@ class PollServiceTests(unittest.TestCase):
self.session.close()
Base.metadata.drop_all(
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()