Add poll workflow and signed participation support

This commit is contained in:
2026-07-12 17:32:07 +02:00
parent d0e68f5aa6
commit fc9b7c287c
10 changed files with 519 additions and 16 deletions

View File

@@ -43,9 +43,25 @@ storage, validation, result aggregation, tenant summaries, module migrations,
and authenticated API routes for: and authenticated API routes for:
- single-choice, multiple-choice, yes/no, ranked-choice, and availability polls - single-choice, multiple-choice, yes/no, ranked-choice, and availability polls
- yes/no/maybe polls for tentative decisions
- opening, closing, and deciding polls - opening, closing, and deciding polls
- submitting and updating respondent answers - submitting and updating respondent answers
- listing responses and reading result summaries - listing responses and reading result summaries
- signed participation links for reduced/no-Access participation
- context and workflow metadata for modules such as Scheduling
## Scheduling As A Poll-Backed Workflow
Scheduling should use Poll as the reusable response collection primitive, not
reimplement availability polling. A scheduling flow is modeled as an
`availability` poll with `context_module="scheduling"`, a scheduling-owned
context resource id, and workflow steps such as collect availability, rank
candidate slots, decide, notify participants, and hand off to Calendar or
Appointments.
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.
## Development Install ## Development Install

View File

@@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
__all__ = ["Poll", "PollOption", "PollResponse"] __all__ = ["Poll", "PollInvitation", "PollOption", "PollResponse"]

View File

@@ -39,6 +39,11 @@ class Poll(Base, TimestampMixin):
status: Mapped[str] = mapped_column(String(30), default="draft", nullable=False, index=True) status: Mapped[str] = mapped_column(String(30), default="draft", nullable=False, index=True)
visibility: Mapped[str] = mapped_column(String(30), default="private", nullable=False, index=True) visibility: Mapped[str] = mapped_column(String(30), default="private", nullable=False, index=True)
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False) result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
context_module: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
context_resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
context_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
workflow_state: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
workflow_steps: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
allow_response_update: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) allow_response_update: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
min_choices: Mapped[int] = mapped_column(Integer, default=1, nullable=False) min_choices: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
@@ -54,6 +59,7 @@ class Poll(Base, TimestampMixin):
options: Mapped[list["PollOption"]] = relationship(back_populates="poll", cascade="all, delete-orphan", order_by="PollOption.position") options: Mapped[list["PollOption"]] = relationship(back_populates="poll", cascade="all, delete-orphan", order_by="PollOption.position")
responses: Mapped[list["PollResponse"]] = relationship(back_populates="poll", cascade="all, delete-orphan") responses: Mapped[list["PollResponse"]] = relationship(back_populates="poll", cascade="all, delete-orphan")
invitations: Mapped[list["PollInvitation"]] = relationship(back_populates="poll", cascade="all, delete-orphan")
class PollOption(Base, TimestampMixin): class PollOption(Base, TimestampMixin):
@@ -98,4 +104,27 @@ class PollResponse(Base, TimestampMixin):
poll: Mapped[Poll] = relationship(back_populates="responses") poll: Mapped[Poll] = relationship(back_populates="responses")
__all__ = ["Poll", "PollOption", "PollResponse", "new_uuid"] class PollInvitation(Base, TimestampMixin):
__tablename__ = "poll_invitations"
__table_args__ = (
UniqueConstraint("token_hash", name="uq_poll_invitations_token_hash"),
Index("ix_poll_invitations_poll", "tenant_id", "poll_id"),
Index("ix_poll_invitations_poll_email", "poll_id", "email"),
)
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)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
respondent_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
respondent_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
email: Mapped[str | None] = mapped_column(String(320), nullable=True, index=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
poll: Mapped[Poll] = relationship(back_populates="invitations")
__all__ = ["Poll", "PollInvitation", "PollOption", "PollResponse", "new_uuid"]

View File

@@ -84,10 +84,11 @@ DOCUMENTATION = (
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_poll.backend.db.models import Poll, PollResponse from govoplan_poll.backend.db.models import Poll, PollInvitation, PollResponse
return { return {
"polls": session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None)).count(), "polls": session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None)).count(),
"poll_invitations": session.query(PollInvitation).filter(PollInvitation.tenant_id == tenant_id).count(),
"poll_responses": session.query(PollResponse).filter(PollResponse.tenant_id == tenant_id, PollResponse.deleted_at.is_(None)).count(), "poll_responses": session.query(PollResponse).filter(PollResponse.tenant_id == tenant_id, PollResponse.deleted_at.is_(None)).count(),
} }
@@ -109,6 +110,8 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="poll.option_selection", version="0.1.8"), ModuleInterfaceProvider(name="poll.option_selection", version="0.1.8"),
ModuleInterfaceProvider(name="poll.availability_matrix", 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.response_collection", version="0.1.8"),
ModuleInterfaceProvider(name="poll.workflow_context", version="0.1.8"),
ModuleInterfaceProvider(name="poll.signed_participation", version="0.1.8"),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
@@ -121,6 +124,7 @@ manifest = ModuleManifest(
retirement_supported=True, retirement_supported=True,
retirement_provider=drop_table_retirement_provider( retirement_provider=drop_table_retirement_provider(
poll_models.Poll, poll_models.Poll,
poll_models.PollInvitation,
poll_models.PollOption, poll_models.PollOption,
poll_models.PollResponse, poll_models.PollResponse,
label="Poll", label="Poll",
@@ -130,6 +134,7 @@ manifest = ModuleManifest(
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
poll_models.Poll, poll_models.Poll,
poll_models.PollInvitation,
poll_models.PollOption, poll_models.PollOption,
poll_models.PollResponse, poll_models.PollResponse,
label="Poll", label="Poll",

View File

@@ -0,0 +1,68 @@
"""v0.1.8 poll workflow context and invitations
Revision ID: 2a3b4c5d6e7f
Revises: 1f2e3d4c5b6a
Create Date: 2026-07-12 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "2a3b4c5d6e7f"
down_revision = "1f2e3d4c5b6a"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("poll_polls", sa.Column("context_module", sa.String(length=100), nullable=True))
op.add_column("poll_polls", sa.Column("context_resource_type", sa.String(length=100), nullable=True))
op.add_column("poll_polls", sa.Column("context_resource_id", sa.String(length=255), nullable=True))
op.add_column("poll_polls", sa.Column("workflow_state", sa.String(length=100), nullable=True))
op.add_column("poll_polls", sa.Column("workflow_steps", sa.JSON(), nullable=False, server_default="[]"))
op.create_index(op.f("ix_poll_polls_context_module"), "poll_polls", ["context_module"], unique=False)
op.create_index(op.f("ix_poll_polls_context_resource_id"), "poll_polls", ["context_resource_id"], unique=False)
op.create_index(op.f("ix_poll_polls_workflow_state"), "poll_polls", ["workflow_state"], unique=False)
op.create_table(
"poll_invitations",
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("token_hash", sa.String(length=64), nullable=False),
sa.Column("respondent_id", sa.String(length=255), nullable=True),
sa.Column("respondent_label", sa.String(length=500), nullable=True),
sa.Column("email", sa.String(length=320), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), 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_invitations_poll_id_poll_polls"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_poll_invitations")),
sa.UniqueConstraint("token_hash", name="uq_poll_invitations_token_hash"),
)
op.create_index(op.f("ix_poll_invitations_email"), "poll_invitations", ["email"], unique=False)
op.create_index(op.f("ix_poll_invitations_expires_at"), "poll_invitations", ["expires_at"], unique=False)
op.create_index(op.f("ix_poll_invitations_poll_id"), "poll_invitations", ["poll_id"], unique=False)
op.create_index("ix_poll_invitations_poll", "poll_invitations", ["tenant_id", "poll_id"], unique=False)
op.create_index("ix_poll_invitations_poll_email", "poll_invitations", ["poll_id", "email"], unique=False)
op.create_index(op.f("ix_poll_invitations_respondent_id"), "poll_invitations", ["respondent_id"], unique=False)
op.create_index(op.f("ix_poll_invitations_revoked_at"), "poll_invitations", ["revoked_at"], unique=False)
op.create_index(op.f("ix_poll_invitations_tenant_id"), "poll_invitations", ["tenant_id"], unique=False)
op.create_index(op.f("ix_poll_invitations_token_hash"), "poll_invitations", ["token_hash"], unique=False)
def downgrade() -> None:
op.drop_table("poll_invitations")
op.drop_index(op.f("ix_poll_polls_workflow_state"), table_name="poll_polls")
op.drop_index(op.f("ix_poll_polls_context_resource_id"), table_name="poll_polls")
op.drop_index(op.f("ix_poll_polls_context_module"), table_name="poll_polls")
op.drop_column("poll_polls", "workflow_steps")
op.drop_column("poll_polls", "workflow_state")
op.drop_column("poll_polls", "context_resource_id")
op.drop_column("poll_polls", "context_resource_type")
op.drop_column("poll_polls", "context_module")

View File

@@ -9,6 +9,9 @@ from govoplan_poll.backend.manifest import READ_SCOPE, RESPOND_SCOPE, WRITE_SCOP
from govoplan_poll.backend.schemas import ( from govoplan_poll.backend.schemas import (
PollCreateRequest, PollCreateRequest,
PollDecisionRequest, PollDecisionRequest,
PollInvitationCreateRequest,
PollInvitationListResponse,
PollInvitationResponse,
PollListResponse, PollListResponse,
PollResponse, PollResponse,
PollResponseItem, PollResponseItem,
@@ -21,16 +24,22 @@ from govoplan_poll.backend.schemas import (
from govoplan_poll.backend.service import ( from govoplan_poll.backend.service import (
PollError, PollError,
close_poll, close_poll,
create_poll_invitation,
create_poll, create_poll,
decide_poll, decide_poll,
get_poll_by_invitation_token,
get_poll, get_poll,
list_poll_responses, list_poll_responses,
list_poll_invitations,
list_polls, list_polls,
open_poll, open_poll,
poll_invitation_response,
poll_response, poll_response,
poll_response_item, poll_response_item,
poll_result_summary_by_id, poll_result_summary_by_id,
revoke_poll_invitation,
submit_poll_response, submit_poll_response,
submit_poll_response_with_token,
update_poll, update_poll,
) )
@@ -46,6 +55,8 @@ def _require_scope(principal: ApiPrincipal, scope: str) -> None:
def _poll_http_error(exc: PollError) -> HTTPException: def _poll_http_error(exc: PollError) -> HTTPException:
if str(exc) == "Poll not found": if str(exc) == "Poll not found":
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if str(exc) == "Poll invitation not found":
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
@@ -189,3 +200,78 @@ def api_poll_summary(
return PollResultSummaryResponse.model_validate(poll_result_summary_by_id(session, tenant_id=principal.tenant_id, poll_id=poll_id)) return PollResultSummaryResponse.model_validate(poll_result_summary_by_id(session, tenant_id=principal.tenant_id, poll_id=poll_id))
except PollError as exc: except PollError as exc:
raise _poll_http_error(exc) from exc raise _poll_http_error(exc) from exc
@router.post("/polls/{poll_id}/invitations", response_model=PollInvitationResponse, status_code=status.HTTP_201_CREATED)
def api_create_poll_invitation(
poll_id: str,
payload: PollInvitationCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PollInvitationResponse:
_require_scope(principal, WRITE_SCOPE)
try:
invitation, token = create_poll_invitation(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollInvitationResponse.model_validate(poll_invitation_response(invitation, token=token))
@router.get("/polls/{poll_id}/invitations", response_model=PollInvitationListResponse)
def api_list_poll_invitations(
poll_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PollInvitationListResponse:
_require_scope(principal, READ_SCOPE)
try:
invitations = list_poll_invitations(session, tenant_id=principal.tenant_id, poll_id=poll_id)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollInvitationListResponse(
invitations=[PollInvitationResponse.model_validate(poll_invitation_response(invitation)) for invitation in invitations]
)
@router.delete("/polls/{poll_id}/invitations/{invitation_id}", response_model=PollInvitationResponse)
def api_revoke_poll_invitation(
poll_id: str,
invitation_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PollInvitationResponse:
_require_scope(principal, WRITE_SCOPE)
try:
invitation = revoke_poll_invitation(
session,
tenant_id=principal.tenant_id,
poll_id=poll_id,
invitation_id=invitation_id,
)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollInvitationResponse.model_validate(poll_invitation_response(invitation))
@router.get("/public/{token}", response_model=PollResponse)
def api_get_public_poll(
token: str,
session: Session = Depends(get_session),
) -> PollResponse:
try:
return _poll_response(get_poll_by_invitation_token(session, token=token))
except PollError as exc:
raise _poll_http_error(exc) from exc
@router.post("/public/{token}/responses", response_model=PollResponseItem, status_code=status.HTTP_201_CREATED)
def api_submit_public_poll_response(
token: str,
payload: PollSubmitResponseRequest,
session: Session = Depends(get_session),
) -> PollResponseItem:
try:
response = submit_poll_response_with_token(session, token=token, payload=payload)
except PollError as exc:
raise _poll_http_error(exc) from exc
return PollResponseItem.model_validate(poll_response_item(response))

View File

@@ -6,7 +6,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
PollKind = Literal["single_choice", "multiple_choice", "yes_no", "ranked_choice", "availability"] PollKind = Literal["single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"]
PollStatus = Literal["draft", "open", "closed", "decided", "archived"] PollStatus = Literal["draft", "open", "closed", "decided", "archived"]
PollVisibility = Literal["private", "tenant", "public", "unlisted"] PollVisibility = Literal["private", "tenant", "public", "unlisted"]
PollResultVisibility = Literal["organizer", "after_response", "after_close", "public"] PollResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
@@ -33,6 +33,11 @@ class PollCreateRequest(BaseModel):
status: PollStatus = "draft" status: PollStatus = "draft"
visibility: PollVisibility = "private" visibility: PollVisibility = "private"
result_visibility: PollResultVisibility = "after_close" result_visibility: PollResultVisibility = "after_close"
context_module: str | None = Field(default=None, max_length=100)
context_resource_type: str | None = Field(default=None, max_length=100)
context_resource_id: str | None = Field(default=None, max_length=255)
workflow_state: str | None = Field(default=None, max_length=100)
workflow_steps: list[dict[str, Any]] = Field(default_factory=list)
allow_anonymous: bool = False allow_anonymous: bool = False
allow_response_update: bool = True allow_response_update: bool = True
min_choices: int = Field(default=1, ge=0) min_choices: int = Field(default=1, ge=0)
@@ -56,6 +61,11 @@ class PollUpdateRequest(BaseModel):
description: str | None = None description: str | None = None
visibility: PollVisibility | None = None visibility: PollVisibility | None = None
result_visibility: PollResultVisibility | None = None result_visibility: PollResultVisibility | None = None
context_module: str | None = Field(default=None, max_length=100)
context_resource_type: str | None = Field(default=None, max_length=100)
context_resource_id: str | None = Field(default=None, max_length=255)
workflow_state: str | None = Field(default=None, max_length=100)
workflow_steps: list[dict[str, Any]] | None = None
allow_anonymous: bool | None = None allow_anonymous: bool | None = None
allow_response_update: bool | None = None allow_response_update: bool | None = None
min_choices: int | None = Field(default=None, ge=0) min_choices: int | None = Field(default=None, ge=0)
@@ -96,6 +106,11 @@ class PollResponse(BaseModel):
status: str status: str
visibility: str visibility: str
result_visibility: str result_visibility: str
context_module: str | None = None
context_resource_type: str | None = None
context_resource_id: str | None = None
workflow_state: str | None = None
workflow_steps: list[dict[str, Any]] = Field(default_factory=list)
allow_anonymous: bool allow_anonymous: bool
allow_response_update: bool allow_response_update: bool
min_choices: int min_choices: int
@@ -166,3 +181,32 @@ class PollStatusResponse(BaseModel):
class PollResponseListResponse(BaseModel): class PollResponseListResponse(BaseModel):
responses: list[PollResponseItem] = Field(default_factory=list) responses: list[PollResponseItem] = Field(default_factory=list)
class PollInvitationCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
respondent_id: str | None = Field(default=None, max_length=255)
respondent_label: str | None = Field(default=None, max_length=500)
email: str | None = Field(default=None, max_length=320)
expires_at: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class PollInvitationResponse(BaseModel):
id: str
poll_id: str
respondent_id: str | None = None
respondent_label: str | None = None
email: str | None = None
token: str | None = None
expires_at: datetime | None = None
revoked_at: datetime | None = None
last_used_at: datetime | None = None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class PollInvitationListResponse(BaseModel):
invitations: list[PollInvitationResponse] = Field(default_factory=list)

View File

@@ -1,16 +1,19 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import re import re
import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.schemas import ( from govoplan_poll.backend.schemas import (
PollCreateRequest, PollCreateRequest,
PollDecisionRequest, PollDecisionRequest,
PollInvitationCreateRequest,
PollOptionInput, PollOptionInput,
PollSubmitResponseRequest, PollSubmitResponseRequest,
PollUpdateRequest, PollUpdateRequest,
@@ -21,14 +24,19 @@ class PollError(ValueError):
pass pass
POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "ranked_choice", "availability"} POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"}
POLL_STATUSES = {"draft", "open", "closed", "decided", "archived"} POLL_STATUSES = {"draft", "open", "closed", "decided", "archived"}
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "ranked_choice"} CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"}
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"} AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
YES_NO_OPTIONS = ( YES_NO_OPTIONS = (
PollOptionInput(key="yes", label="Yes"), PollOptionInput(key="yes", label="Yes"),
PollOptionInput(key="no", label="No"), PollOptionInput(key="no", label="No"),
) )
YES_NO_MAYBE_OPTIONS = (
PollOptionInput(key="yes", label="Yes"),
PollOptionInput(key="no", label="No"),
PollOptionInput(key="maybe", label="Maybe"),
)
def slugify(value: str, *, fallback: str = "poll") -> str: def slugify(value: str, *, fallback: str = "poll") -> str:
@@ -85,6 +93,10 @@ def _normalize_options(kind: str, options: list[PollOptionInput]) -> list[PollOp
normalized = list(options) normalized = list(options)
if kind == "yes_no" and not normalized: if kind == "yes_no" and not normalized:
normalized = list(YES_NO_OPTIONS) normalized = list(YES_NO_OPTIONS)
if kind == "yes_no_maybe" and not normalized:
normalized = list(YES_NO_MAYBE_OPTIONS)
if kind == "yes_no_maybe" and len(normalized) < 3:
raise PollError("yes_no_maybe polls require at least three options")
if kind in CHOICE_POLL_KINDS and len(normalized) < 2: if kind in CHOICE_POLL_KINDS and len(normalized) < 2:
raise PollError(f"{kind} polls require at least two options") raise PollError(f"{kind} polls require at least two options")
if kind == "availability" and not normalized: if kind == "availability" and not normalized:
@@ -109,7 +121,7 @@ def _normalize_options(kind: str, options: list[PollOptionInput]) -> list[PollOp
def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None, option_count: int) -> tuple[int, int | None]: def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None, option_count: int) -> tuple[int, int | None]:
if kind in {"single_choice", "yes_no"}: if kind in {"single_choice", "yes_no", "yes_no_maybe"}:
return 1, 1 return 1, 1
if kind == "ranked_choice": if kind == "ranked_choice":
if min_choices < 1: if min_choices < 1:
@@ -150,6 +162,11 @@ def create_poll(session: Session, *, tenant_id: str, user_id: str | None, payloa
status=payload.status, status=payload.status,
visibility=payload.visibility, visibility=payload.visibility,
result_visibility=payload.result_visibility, result_visibility=payload.result_visibility,
context_module=payload.context_module,
context_resource_type=payload.context_resource_type,
context_resource_id=payload.context_resource_id,
workflow_state=payload.workflow_state,
workflow_steps=payload.workflow_steps,
allow_anonymous=payload.allow_anonymous, allow_anonymous=payload.allow_anonymous,
allow_response_update=payload.allow_response_update, allow_response_update=payload.allow_response_update,
min_choices=min_choices, min_choices=min_choices,
@@ -198,10 +215,23 @@ def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: Poll
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id) poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
if poll.status in {"closed", "decided", "archived"}: if poll.status in {"closed", "decided", "archived"}:
raise PollError("Closed, decided, or archived polls cannot be edited") raise PollError("Closed, decided, or archived polls cannot be edited")
for field in ("title", "description", "visibility", "result_visibility", "allow_anonymous", "allow_response_update"): for field in (
"title",
"description",
"visibility",
"result_visibility",
"context_module",
"context_resource_type",
"context_resource_id",
"workflow_state",
"allow_anonymous",
"allow_response_update",
):
value = getattr(payload, field) value = getattr(payload, field)
if value is not None: if value is not None:
setattr(poll, field, value) setattr(poll, field, value)
if payload.workflow_steps is not None:
poll.workflow_steps = payload.workflow_steps
if payload.min_choices is not None or payload.max_choices is not None: if payload.min_choices is not None or payload.max_choices is not None:
min_choices = poll.min_choices if payload.min_choices is None else payload.min_choices min_choices = poll.min_choices if payload.min_choices is None else payload.min_choices
max_choices = poll.max_choices if payload.max_choices is None else payload.max_choices max_choices = poll.max_choices if payload.max_choices is None else payload.max_choices
@@ -329,7 +359,7 @@ def _normalize_availability_answers(poll: Poll, payload: PollSubmitResponseReque
def normalize_response_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]: def normalize_response_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
if poll.kind in {"single_choice", "multiple_choice", "yes_no"}: if poll.kind in {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe"}:
return _normalize_choice_answers(poll, payload) return _normalize_choice_answers(poll, payload)
if poll.kind == "ranked_choice": if poll.kind == "ranked_choice":
return _normalize_ranked_answers(poll, payload) return _normalize_ranked_answers(poll, payload)
@@ -402,6 +432,11 @@ def poll_response(poll: Poll) -> dict[str, Any]:
"status": poll.status, "status": poll.status,
"visibility": poll.visibility, "visibility": poll.visibility,
"result_visibility": poll.result_visibility, "result_visibility": poll.result_visibility,
"context_module": poll.context_module,
"context_resource_type": poll.context_resource_type,
"context_resource_id": poll.context_resource_id,
"workflow_state": poll.workflow_state,
"workflow_steps": poll.workflow_steps or [],
"allow_anonymous": poll.allow_anonymous, "allow_anonymous": poll.allow_anonymous,
"allow_response_update": poll.allow_response_update, "allow_response_update": poll.allow_response_update,
"min_choices": poll.min_choices, "min_choices": poll.min_choices,
@@ -442,6 +477,125 @@ def list_poll_responses(session: Session, *, tenant_id: str, poll_id: str) -> li
) )
def _token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_poll_invitation(
session: Session,
*,
tenant_id: str,
poll_id: str,
payload: PollInvitationCreateRequest,
) -> tuple[PollInvitation, str]:
get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
if payload.expires_at is not None and response_datetime(payload.expires_at) <= _now():
raise PollError("Invitation expiry must be in the future")
for _attempt in range(5):
token = secrets.token_urlsafe(32)
token_hash = _token_hash(token)
exists = session.query(PollInvitation).filter(PollInvitation.token_hash == token_hash).first()
if exists is None:
break
else:
raise PollError("Could not create a unique poll invitation token")
invitation = PollInvitation(
tenant_id=tenant_id,
poll_id=poll_id,
token_hash=token_hash,
respondent_id=payload.respondent_id,
respondent_label=payload.respondent_label,
email=payload.email,
expires_at=payload.expires_at,
metadata_=payload.metadata,
)
session.add(invitation)
session.flush()
return invitation, token
def list_poll_invitations(session: Session, *, tenant_id: str, poll_id: str) -> list[PollInvitation]:
get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
return (
session.query(PollInvitation)
.filter(PollInvitation.tenant_id == tenant_id, PollInvitation.poll_id == poll_id)
.order_by(PollInvitation.created_at.asc(), PollInvitation.id.asc())
.all()
)
def get_poll_invitation(session: Session, *, tenant_id: str, poll_id: str, invitation_id: str) -> PollInvitation:
invitation = (
session.query(PollInvitation)
.filter(PollInvitation.tenant_id == tenant_id, PollInvitation.poll_id == poll_id, PollInvitation.id == invitation_id)
.first()
)
if invitation is None:
raise PollError("Poll invitation not found")
return invitation
def revoke_poll_invitation(session: Session, *, tenant_id: str, poll_id: str, invitation_id: str) -> PollInvitation:
invitation = get_poll_invitation(session, tenant_id=tenant_id, poll_id=poll_id, invitation_id=invitation_id)
if invitation.revoked_at is None:
invitation.revoked_at = _now()
session.flush()
return invitation
def get_poll_invitation_by_token(session: Session, *, token: str) -> PollInvitation:
invitation = session.query(PollInvitation).filter(PollInvitation.token_hash == _token_hash(token)).first()
if invitation is None or invitation.poll.deleted_at is not None:
raise PollError("Poll invitation not found")
if invitation.revoked_at is not None:
raise PollError("Poll invitation has been revoked")
if invitation.expires_at is not None and response_datetime(invitation.expires_at) <= _now():
raise PollError("Poll invitation has expired")
return invitation
def get_poll_by_invitation_token(session: Session, *, token: str) -> Poll:
return get_poll_invitation_by_token(session, token=token).poll
def submit_poll_response_with_token(session: Session, *, token: str, payload: PollSubmitResponseRequest) -> PollResponse:
invitation = get_poll_invitation_by_token(session, token=token)
metadata = dict(payload.metadata)
metadata.setdefault("invitation_id", invitation.id)
response_payload = PollSubmitResponseRequest(
respondent_id=invitation.respondent_id or f"invitation:{invitation.id}",
respondent_label=payload.respondent_label or invitation.respondent_label or invitation.email,
answers=payload.answers,
metadata=metadata,
)
response = submit_poll_response(
session,
tenant_id=invitation.tenant_id,
poll_id=invitation.poll_id,
payload=response_payload,
)
invitation.last_used_at = response.submitted_at
session.flush()
return response
def poll_invitation_response(invitation: PollInvitation, *, token: str | None = None) -> dict[str, Any]:
return {
"id": invitation.id,
"poll_id": invitation.poll_id,
"respondent_id": invitation.respondent_id,
"respondent_label": invitation.respondent_label,
"email": invitation.email,
"token": token,
"expires_at": response_datetime(invitation.expires_at),
"revoked_at": response_datetime(invitation.revoked_at),
"last_used_at": response_datetime(invitation.last_used_at),
"created_at": response_datetime(invitation.created_at),
"updated_at": response_datetime(invitation.updated_at),
"metadata": invitation.metadata_ or {},
}
def poll_result_summary(poll: Poll) -> dict[str, Any]: def poll_result_summary(poll: Poll) -> dict[str, Any]:
options = _active_options(poll) options = _active_options(poll)
option_results = { option_results = {

View File

@@ -19,6 +19,8 @@ class PollManifestTests(unittest.TestCase):
self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.provides_interfaces}) self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.provides_interfaces})
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.provides_interfaces})
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.provides_interfaces})
self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions}) self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions})

View File

@@ -6,21 +6,41 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.schemas import PollAnswerInput, PollCreateRequest, PollOptionInput, PollSubmitResponseRequest from govoplan_poll.backend.schemas import (
from govoplan_poll.backend.service import PollError, create_poll, open_poll, poll_result_summary_by_id, submit_poll_response PollAnswerInput,
PollCreateRequest,
PollInvitationCreateRequest,
PollOptionInput,
PollSubmitResponseRequest,
)
from govoplan_poll.backend.service import (
PollError,
create_poll,
create_poll_invitation,
open_poll,
poll_result_summary_by_id,
submit_poll_response,
submit_poll_response_with_token,
)
class PollServiceTests(unittest.TestCase): class PollServiceTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:") self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine, tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__]) Base.metadata.create_all(
self.engine,
tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__, PollInvitation.__table__],
)
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session() self.session: Session = self.Session()
def tearDown(self) -> None: def tearDown(self) -> None:
self.session.close() self.session.close()
Base.metadata.drop_all(self.engine, tables=[PollResponse.__table__, PollOption.__table__, Poll.__table__]) Base.metadata.drop_all(
self.engine,
tables=[PollInvitation.__table__, PollResponse.__table__, PollOption.__table__, Poll.__table__],
)
self.engine.dispose() self.engine.dispose()
def test_single_choice_response_can_update_existing_respondent(self) -> None: def test_single_choice_response_can_update_existing_respondent(self) -> None:
@@ -74,6 +94,56 @@ class PollServiceTests(unittest.TestCase):
payload=PollSubmitResponseRequest(answers=[PollAnswerInput(option_key="yes")]), payload=PollSubmitResponseRequest(answers=[PollAnswerInput(option_key="yes")]),
) )
def test_yes_no_maybe_uses_default_options(self) -> None:
poll = create_poll(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=PollCreateRequest(
title="Tentative decision",
kind="yes_no_maybe",
allow_anonymous=True,
),
)
open_poll(self.session, tenant_id="tenant-1", poll_id=poll.id)
response = submit_poll_response(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
payload=PollSubmitResponseRequest(answers=[PollAnswerInput(option_key="maybe")]),
)
summary = poll_result_summary_by_id(self.session, tenant_id="tenant-1", poll_id=poll.id)
self.assertEqual([option.key for option in poll.options], ["yes", "no", "maybe"])
self.assertEqual(response.answers[0]["option_key"], "maybe")
self.assertEqual(summary["response_count"], 1)
def test_poll_can_store_scheduling_workflow_context(self) -> None:
poll = create_poll(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=PollCreateRequest(
title="Find a steering group slot",
kind="availability",
context_module="scheduling",
context_resource_type="scheduling_request",
context_resource_id="request-1",
workflow_state="collecting_availability",
workflow_steps=[
{"id": "collect", "label": "Collect availability", "status": "active"},
{"id": "decide", "label": "Choose slot", "status": "pending"},
],
options=[PollOptionInput(key="slot-1", label="Monday")],
),
)
self.assertEqual(poll.context_module, "scheduling")
self.assertEqual(poll.context_resource_id, "request-1")
self.assertEqual(poll.workflow_state, "collecting_availability")
self.assertEqual(poll.workflow_steps[0]["id"], "collect")
def test_ranked_choice_summary_uses_borda_score(self) -> None: def test_ranked_choice_summary_uses_borda_score(self) -> None:
poll = create_poll( poll = create_poll(
self.session, self.session,
@@ -176,6 +246,35 @@ class PollServiceTests(unittest.TestCase):
self.assertEqual(values["slot-1"], {"available": 1, "unavailable": 1}) self.assertEqual(values["slot-1"], {"available": 1, "unavailable": 1})
self.assertEqual(values["slot-2"], {"maybe": 1, "available": 1}) self.assertEqual(values["slot-2"], {"maybe": 1, "available": 1})
def test_signed_invitation_can_submit_without_anonymous_policy(self) -> None:
poll = create_poll(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=PollCreateRequest(
title="External vote",
kind="yes_no_maybe",
),
)
open_poll(self.session, tenant_id="tenant-1", poll_id=poll.id)
invitation, token = create_poll_invitation(
self.session,
tenant_id="tenant-1",
poll_id=poll.id,
payload=PollInvitationCreateRequest(respondent_label="External participant"),
)
response = submit_poll_response_with_token(
self.session,
token=token,
payload=PollSubmitResponseRequest(answers=[PollAnswerInput(option_key="yes")]),
)
self.assertEqual(response.respondent_id, f"invitation:{invitation.id}")
self.assertEqual(response.respondent_label, "External participant")
self.assertEqual(response.metadata_["invitation_id"], invitation.id)
self.assertIsNotNone(invitation.last_used_at)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()