feat(poll): enforce auditable lifecycle transitions
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "0.1.9"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""v0.1.9 configurable Poll lifecycle history
|
||||
|
||||
Revision ID: 3b4c5d6e7f8a
|
||||
Revises: 2a3b4c5d6e7f
|
||||
Create Date: 2026-07-20 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "3b4c5d6e7f8a"
|
||||
down_revision = "2a3b4c5d6e7f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("poll_polls", sa.Column("archived_from_status", sa.String(length=30), nullable=True))
|
||||
op.create_table(
|
||||
"poll_lifecycle_transitions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("poll_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("action", sa.String(length=30), nullable=False),
|
||||
sa.Column("from_status", sa.String(length=30), nullable=False),
|
||||
sa.Column("to_status", sa.String(length=30), nullable=False),
|
||||
sa.Column("decision_option_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("previous_decision_option_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_api_key_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["poll_id"],
|
||||
["poll_polls.id"],
|
||||
name=op.f("fk_poll_lifecycle_transitions_poll_id_poll_polls"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_poll_lifecycle_transitions")),
|
||||
sa.UniqueConstraint("poll_id", "idempotency_key", name="uq_poll_lifecycle_transition_idempotency"),
|
||||
)
|
||||
op.create_index(op.f("ix_poll_lifecycle_transitions_action"), "poll_lifecycle_transitions", ["action"], unique=False)
|
||||
op.create_index(op.f("ix_poll_lifecycle_transitions_actor_api_key_id"), "poll_lifecycle_transitions", ["actor_api_key_id"], unique=False)
|
||||
op.create_index(op.f("ix_poll_lifecycle_transitions_actor_user_id"), "poll_lifecycle_transitions", ["actor_user_id"], unique=False)
|
||||
op.create_index(op.f("ix_poll_lifecycle_transitions_poll_id"), "poll_lifecycle_transitions", ["poll_id"], unique=False)
|
||||
op.create_index(op.f("ix_poll_lifecycle_transitions_tenant_id"), "poll_lifecycle_transitions", ["tenant_id"], unique=False)
|
||||
op.create_index(
|
||||
"ix_poll_lifecycle_transition_poll_created",
|
||||
"poll_lifecycle_transitions",
|
||||
["poll_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_poll_lifecycle_transition_tenant_poll",
|
||||
"poll_lifecycle_transitions",
|
||||
["tenant_id", "poll_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("poll_lifecycle_transitions")
|
||||
op.drop_column("poll_polls", "archived_from_status")
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from 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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
183
src/govoplan_poll/backend/transitions.py
Normal file
183
src/govoplan_poll/backend/transitions.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user