Add poll workflow and signed participation support
This commit is contained in:
16
README.md
16
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user