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:
- single-choice, multiple-choice, yes/no, ranked-choice, and availability polls
- yes/no/maybe polls for tentative decisions
- opening, closing, and deciding polls
- submitting and updating respondent answers
- 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

View File

@@ -1,5 +1,5 @@
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)
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)
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_response_update: Mapped[bool] = mapped_column(Boolean, default=True, 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")
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):
@@ -98,4 +104,27 @@ class PollResponse(Base, TimestampMixin):
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]:
from govoplan_poll.backend.db.models import Poll, PollResponse
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollResponse
return {
"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(),
}
@@ -109,6 +110,8 @@ manifest = ModuleManifest(
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"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -121,6 +124,7 @@ manifest = ModuleManifest(
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
poll_models.Poll,
poll_models.PollInvitation,
poll_models.PollOption,
poll_models.PollResponse,
label="Poll",
@@ -130,6 +134,7 @@ manifest = ModuleManifest(
uninstall_guard_providers=(
persistent_table_uninstall_guard(
poll_models.Poll,
poll_models.PollInvitation,
poll_models.PollOption,
poll_models.PollResponse,
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 (
PollCreateRequest,
PollDecisionRequest,
PollInvitationCreateRequest,
PollInvitationListResponse,
PollInvitationResponse,
PollListResponse,
PollResponse,
PollResponseItem,
@@ -21,16 +24,22 @@ from govoplan_poll.backend.schemas import (
from govoplan_poll.backend.service import (
PollError,
close_poll,
create_poll_invitation,
create_poll,
decide_poll,
get_poll_by_invitation_token,
get_poll,
list_poll_responses,
list_poll_invitations,
list_polls,
open_poll,
poll_invitation_response,
poll_response,
poll_response_item,
poll_result_summary_by_id,
revoke_poll_invitation,
submit_poll_response,
submit_poll_response_with_token,
update_poll,
)
@@ -46,6 +55,8 @@ def _require_scope(principal: ApiPrincipal, scope: str) -> None:
def _poll_http_error(exc: PollError) -> HTTPException:
if str(exc) == "Poll not found":
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))
@@ -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))
except PollError as 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
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"]
PollVisibility = Literal["private", "tenant", "public", "unlisted"]
PollResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
@@ -33,6 +33,11 @@ class PollCreateRequest(BaseModel):
status: PollStatus = "draft"
visibility: PollVisibility = "private"
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_response_update: bool = True
min_choices: int = Field(default=1, ge=0)
@@ -56,6 +61,11 @@ class PollUpdateRequest(BaseModel):
description: str | None = None
visibility: PollVisibility | 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_response_update: bool | None = None
min_choices: int | None = Field(default=None, ge=0)
@@ -96,6 +106,11 @@ class PollResponse(BaseModel):
status: str
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_response_update: bool
min_choices: int
@@ -166,3 +181,32 @@ class PollStatusResponse(BaseModel):
class PollResponseListResponse(BaseModel):
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
import hashlib
import re
import secrets
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, PollOption, PollResponse
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.schemas import (
PollCreateRequest,
PollDecisionRequest,
PollInvitationCreateRequest,
PollOptionInput,
PollSubmitResponseRequest,
PollUpdateRequest,
@@ -21,14 +24,19 @@ class PollError(ValueError):
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"}
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"}
YES_NO_OPTIONS = (
PollOptionInput(key="yes", label="Yes"),
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:
@@ -85,6 +93,10 @@ def _normalize_options(kind: str, options: list[PollOptionInput]) -> list[PollOp
normalized = list(options)
if kind == "yes_no" and not normalized:
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:
raise PollError(f"{kind} polls require at least two options")
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]:
if kind in {"single_choice", "yes_no"}:
if kind in {"single_choice", "yes_no", "yes_no_maybe"}:
return 1, 1
if kind == "ranked_choice":
if min_choices < 1:
@@ -150,6 +162,11 @@ def create_poll(session: Session, *, tenant_id: str, user_id: str | None, payloa
status=payload.status,
visibility=payload.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_response_update=payload.allow_response_update,
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)
if poll.status in {"closed", "decided", "archived"}:
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)
if value is not None:
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:
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
@@ -329,7 +359,7 @@ def _normalize_availability_answers(poll: Poll, payload: PollSubmitResponseReque
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)
if poll.kind == "ranked_choice":
return _normalize_ranked_answers(poll, payload)
@@ -402,6 +432,11 @@ def poll_response(poll: Poll) -> dict[str, Any]:
"status": poll.status,
"visibility": poll.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_response_update": poll.allow_response_update,
"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]:
options = _active_options(poll)
option_results = {

View File

@@ -19,6 +19,8 @@ class PollManifestTests(unittest.TestCase):
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
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})

View File

@@ -6,21 +6,41 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.base import Base
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse
from govoplan_poll.backend.schemas import PollAnswerInput, PollCreateRequest, PollOptionInput, PollSubmitResponseRequest
from govoplan_poll.backend.service import PollError, create_poll, open_poll, poll_result_summary_by_id, submit_poll_response
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.schemas import (
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):
def setUp(self) -> None:
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: Session = self.Session()
def tearDown(self) -> None:
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()
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")]),
)
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:
poll = create_poll(
self.session,
@@ -176,6 +246,35 @@ class PollServiceTests(unittest.TestCase):
self.assertEqual(values["slot-1"], {"available": 1, "unavailable": 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__":
unittest.main()