diff --git a/README.md b/README.md index d6f7fb4..0461651 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 953dad2..a59e639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/govoplan_poll/__init__.py b/src/govoplan_poll/__init__.py index 2c31ecd..b690968 100644 --- a/src/govoplan_poll/__init__.py +++ b/src/govoplan_poll/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.1.8" +__version__ = "0.1.9" diff --git a/src/govoplan_poll/backend/db/__init__.py b/src/govoplan_poll/backend/db/__init__.py index bd9a5ae..4617728 100644 --- a/src/govoplan_poll/backend/db/__init__.py +++ b/src/govoplan_poll/backend/db/__init__.py @@ -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"] diff --git a/src/govoplan_poll/backend/db/models.py b/src/govoplan_poll/backend/db/models.py index 8a0db36..27145dc 100644 --- a/src/govoplan_poll/backend/db/models.py +++ b/src/govoplan_poll/backend/db/models.py @@ -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"] diff --git a/src/govoplan_poll/backend/manifest.py b/src/govoplan_poll/backend/manifest.py index bfdd867..b0f469b 100644 --- a/src/govoplan_poll/backend/manifest.py +++ b/src/govoplan_poll/backend/manifest.py @@ -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", diff --git a/src/govoplan_poll/backend/migrations/versions/3b4c5d6e7f8a_v019_poll_lifecycle.py b/src/govoplan_poll/backend/migrations/versions/3b4c5d6e7f8a_v019_poll_lifecycle.py new file mode 100644 index 0000000..a111347 --- /dev/null +++ b/src/govoplan_poll/backend/migrations/versions/3b4c5d6e7f8a_v019_poll_lifecycle.py @@ -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") diff --git a/src/govoplan_poll/backend/router.py b/src/govoplan_poll/backend/router.py index 1b537cc..8ce11eb 100644 --- a/src/govoplan_poll/backend/router.py +++ b/src/govoplan_poll/backend/router.py @@ -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) diff --git a/src/govoplan_poll/backend/schemas.py b/src/govoplan_poll/backend/schemas.py index ee49a8a..04b8c9f 100644 --- a/src/govoplan_poll/backend/schemas.py +++ b/src/govoplan_poll/backend/schemas.py @@ -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): diff --git a/src/govoplan_poll/backend/service.py b/src/govoplan_poll/backend/service.py index e180178..761355a 100644 --- a/src/govoplan_poll/backend/service.py +++ b/src/govoplan_poll/backend/service.py @@ -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() - if poll.closed_at is None: - poll.closed_at = poll.decided_at +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 = 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 + ], } diff --git a/src/govoplan_poll/backend/transitions.py b/src/govoplan_poll/backend/transitions.py new file mode 100644 index 0000000..88d6002 --- /dev/null +++ b/src/govoplan_poll/backend/transitions.py @@ -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", +] diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 59eea82..2ab9110 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -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() diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..4df739c --- /dev/null +++ b/tests/test_lifecycle.py @@ -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() diff --git a/tests/test_service.py b/tests/test_service.py index b16d8f1..67268b5 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -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()