From fc9b7c287c1a1c67926b23e14eab01fc13ad1f9e Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 12 Jul 2026 17:32:07 +0200 Subject: [PATCH] Add poll workflow and signed participation support --- README.md | 16 ++ src/govoplan_poll/backend/db/__init__.py | 4 +- src/govoplan_poll/backend/db/models.py | 31 +++- src/govoplan_poll/backend/manifest.py | 7 +- ...4c5d6e7f_v018_poll_workflow_invitations.py | 68 +++++++ src/govoplan_poll/backend/router.py | 86 +++++++++ src/govoplan_poll/backend/schemas.py | 46 ++++- src/govoplan_poll/backend/service.py | 166 +++++++++++++++++- tests/test_manifest.py | 2 + tests/test_service.py | 109 +++++++++++- 10 files changed, 519 insertions(+), 16 deletions(-) create mode 100644 src/govoplan_poll/backend/migrations/versions/2a3b4c5d6e7f_v018_poll_workflow_invitations.py diff --git a/README.md b/README.md index e6a6f24..d6f7fb4 100644 --- a/README.md +++ b/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 diff --git a/src/govoplan_poll/backend/db/__init__.py b/src/govoplan_poll/backend/db/__init__.py index 1bed0d3..bd9a5ae 100644 --- a/src/govoplan_poll/backend/db/__init__.py +++ b/src/govoplan_poll/backend/db/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse +from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse -__all__ = ["Poll", "PollOption", "PollResponse"] +__all__ = ["Poll", "PollInvitation", "PollOption", "PollResponse"] diff --git a/src/govoplan_poll/backend/db/models.py b/src/govoplan_poll/backend/db/models.py index 77e9f92..8a0db36 100644 --- a/src/govoplan_poll/backend/db/models.py +++ b/src/govoplan_poll/backend/db/models.py @@ -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"] diff --git a/src/govoplan_poll/backend/manifest.py b/src/govoplan_poll/backend/manifest.py index 50889d0..a914657 100644 --- a/src/govoplan_poll/backend/manifest.py +++ b/src/govoplan_poll/backend/manifest.py @@ -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", diff --git a/src/govoplan_poll/backend/migrations/versions/2a3b4c5d6e7f_v018_poll_workflow_invitations.py b/src/govoplan_poll/backend/migrations/versions/2a3b4c5d6e7f_v018_poll_workflow_invitations.py new file mode 100644 index 0000000..731036e --- /dev/null +++ b/src/govoplan_poll/backend/migrations/versions/2a3b4c5d6e7f_v018_poll_workflow_invitations.py @@ -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") diff --git a/src/govoplan_poll/backend/router.py b/src/govoplan_poll/backend/router.py index 074f454..797f5ec 100644 --- a/src/govoplan_poll/backend/router.py +++ b/src/govoplan_poll/backend/router.py @@ -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)) diff --git a/src/govoplan_poll/backend/schemas.py b/src/govoplan_poll/backend/schemas.py index 246ea14..ee49a8a 100644 --- a/src/govoplan_poll/backend/schemas.py +++ b/src/govoplan_poll/backend/schemas.py @@ -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) diff --git a/src/govoplan_poll/backend/service.py b/src/govoplan_poll/backend/service.py index 3d9ed66..d7747cc 100644 --- a/src/govoplan_poll/backend/service.py +++ b/src/govoplan_poll/backend/service.py @@ -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 = { diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 4244667..f8feb43 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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}) diff --git a/tests/test_service.py b/tests/test_service.py index 56071cf..27839e2 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -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()