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:
|
and authenticated API routes for:
|
||||||
|
|
||||||
- single-choice, multiple-choice, yes/no, ranked-choice, and availability polls
|
- single-choice, multiple-choice, yes/no, ranked-choice, and availability polls
|
||||||
|
- yes/no/maybe polls for tentative decisions
|
||||||
- opening, closing, and deciding polls
|
- opening, closing, and deciding polls
|
||||||
- submitting and updating respondent answers
|
- submitting and updating respondent answers
|
||||||
- listing responses and reading result summaries
|
- listing responses and reading result summaries
|
||||||
|
- signed participation links for reduced/no-Access participation
|
||||||
|
- context and workflow metadata for modules such as Scheduling
|
||||||
|
|
||||||
|
## Scheduling As A Poll-Backed Workflow
|
||||||
|
|
||||||
|
Scheduling should use Poll as the reusable response collection primitive, not
|
||||||
|
reimplement availability polling. A scheduling flow is modeled as an
|
||||||
|
`availability` poll with `context_module="scheduling"`, a scheduling-owned
|
||||||
|
context resource id, and workflow steps such as collect availability, rank
|
||||||
|
candidate slots, decide, notify participants, and hand off to Calendar or
|
||||||
|
Appointments.
|
||||||
|
|
||||||
|
Poll stores the shared poll/options/responses/result summary. Scheduling owns
|
||||||
|
the domain workflow around rooms, calendars, free/busy checks, reminders,
|
||||||
|
appointment creation, and optional Workflow integration.
|
||||||
|
|
||||||
## Development Install
|
## Development Install
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
||||||
|
|
||||||
__all__ = ["Poll", "PollOption", "PollResponse"]
|
__all__ = ["Poll", "PollInvitation", "PollOption", "PollResponse"]
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ class Poll(Base, TimestampMixin):
|
|||||||
status: Mapped[str] = mapped_column(String(30), default="draft", nullable=False, index=True)
|
status: Mapped[str] = mapped_column(String(30), default="draft", nullable=False, index=True)
|
||||||
visibility: Mapped[str] = mapped_column(String(30), default="private", nullable=False, index=True)
|
visibility: Mapped[str] = mapped_column(String(30), default="private", nullable=False, index=True)
|
||||||
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
|
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
|
||||||
|
context_module: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
context_resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
context_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
workflow_state: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
workflow_steps: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
allow_response_update: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
allow_response_update: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
min_choices: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
min_choices: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
@@ -54,6 +59,7 @@ class Poll(Base, TimestampMixin):
|
|||||||
|
|
||||||
options: Mapped[list["PollOption"]] = relationship(back_populates="poll", cascade="all, delete-orphan", order_by="PollOption.position")
|
options: Mapped[list["PollOption"]] = relationship(back_populates="poll", cascade="all, delete-orphan", order_by="PollOption.position")
|
||||||
responses: Mapped[list["PollResponse"]] = relationship(back_populates="poll", cascade="all, delete-orphan")
|
responses: Mapped[list["PollResponse"]] = relationship(back_populates="poll", cascade="all, delete-orphan")
|
||||||
|
invitations: Mapped[list["PollInvitation"]] = relationship(back_populates="poll", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class PollOption(Base, TimestampMixin):
|
class PollOption(Base, TimestampMixin):
|
||||||
@@ -98,4 +104,27 @@ class PollResponse(Base, TimestampMixin):
|
|||||||
poll: Mapped[Poll] = relationship(back_populates="responses")
|
poll: Mapped[Poll] = relationship(back_populates="responses")
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Poll", "PollOption", "PollResponse", "new_uuid"]
|
class PollInvitation(Base, TimestampMixin):
|
||||||
|
__tablename__ = "poll_invitations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("token_hash", name="uq_poll_invitations_token_hash"),
|
||||||
|
Index("ix_poll_invitations_poll", "tenant_id", "poll_id"),
|
||||||
|
Index("ix_poll_invitations_poll_email", "poll_id", "email"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
poll_id: Mapped[str] = mapped_column(ForeignKey("poll_polls.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
respondent_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
respondent_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(320), nullable=True, index=True)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
poll: Mapped[Poll] = relationship(back_populates="invitations")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Poll", "PollInvitation", "PollOption", "PollResponse", "new_uuid"]
|
||||||
|
|||||||
@@ -84,10 +84,11 @@ DOCUMENTATION = (
|
|||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
from govoplan_poll.backend.db.models import Poll, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollResponse
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"polls": session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None)).count(),
|
"polls": session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None)).count(),
|
||||||
|
"poll_invitations": session.query(PollInvitation).filter(PollInvitation.tenant_id == tenant_id).count(),
|
||||||
"poll_responses": session.query(PollResponse).filter(PollResponse.tenant_id == tenant_id, PollResponse.deleted_at.is_(None)).count(),
|
"poll_responses": session.query(PollResponse).filter(PollResponse.tenant_id == tenant_id, PollResponse.deleted_at.is_(None)).count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +110,8 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="poll.option_selection", version="0.1.8"),
|
ModuleInterfaceProvider(name="poll.option_selection", version="0.1.8"),
|
||||||
ModuleInterfaceProvider(name="poll.availability_matrix", version="0.1.8"),
|
ModuleInterfaceProvider(name="poll.availability_matrix", version="0.1.8"),
|
||||||
ModuleInterfaceProvider(name="poll.response_collection", version="0.1.8"),
|
ModuleInterfaceProvider(name="poll.response_collection", version="0.1.8"),
|
||||||
|
ModuleInterfaceProvider(name="poll.workflow_context", version="0.1.8"),
|
||||||
|
ModuleInterfaceProvider(name="poll.signed_participation", version="0.1.8"),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
@@ -121,6 +124,7 @@ manifest = ModuleManifest(
|
|||||||
retirement_supported=True,
|
retirement_supported=True,
|
||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=drop_table_retirement_provider(
|
||||||
poll_models.Poll,
|
poll_models.Poll,
|
||||||
|
poll_models.PollInvitation,
|
||||||
poll_models.PollOption,
|
poll_models.PollOption,
|
||||||
poll_models.PollResponse,
|
poll_models.PollResponse,
|
||||||
label="Poll",
|
label="Poll",
|
||||||
@@ -130,6 +134,7 @@ manifest = ModuleManifest(
|
|||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
poll_models.Poll,
|
poll_models.Poll,
|
||||||
|
poll_models.PollInvitation,
|
||||||
poll_models.PollOption,
|
poll_models.PollOption,
|
||||||
poll_models.PollResponse,
|
poll_models.PollResponse,
|
||||||
label="Poll",
|
label="Poll",
|
||||||
|
|||||||
@@ -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 (
|
from govoplan_poll.backend.schemas import (
|
||||||
PollCreateRequest,
|
PollCreateRequest,
|
||||||
PollDecisionRequest,
|
PollDecisionRequest,
|
||||||
|
PollInvitationCreateRequest,
|
||||||
|
PollInvitationListResponse,
|
||||||
|
PollInvitationResponse,
|
||||||
PollListResponse,
|
PollListResponse,
|
||||||
PollResponse,
|
PollResponse,
|
||||||
PollResponseItem,
|
PollResponseItem,
|
||||||
@@ -21,16 +24,22 @@ from govoplan_poll.backend.schemas import (
|
|||||||
from govoplan_poll.backend.service import (
|
from govoplan_poll.backend.service import (
|
||||||
PollError,
|
PollError,
|
||||||
close_poll,
|
close_poll,
|
||||||
|
create_poll_invitation,
|
||||||
create_poll,
|
create_poll,
|
||||||
decide_poll,
|
decide_poll,
|
||||||
|
get_poll_by_invitation_token,
|
||||||
get_poll,
|
get_poll,
|
||||||
list_poll_responses,
|
list_poll_responses,
|
||||||
|
list_poll_invitations,
|
||||||
list_polls,
|
list_polls,
|
||||||
open_poll,
|
open_poll,
|
||||||
|
poll_invitation_response,
|
||||||
poll_response,
|
poll_response,
|
||||||
poll_response_item,
|
poll_response_item,
|
||||||
poll_result_summary_by_id,
|
poll_result_summary_by_id,
|
||||||
|
revoke_poll_invitation,
|
||||||
submit_poll_response,
|
submit_poll_response,
|
||||||
|
submit_poll_response_with_token,
|
||||||
update_poll,
|
update_poll,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,6 +55,8 @@ def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
|||||||
def _poll_http_error(exc: PollError) -> HTTPException:
|
def _poll_http_error(exc: PollError) -> HTTPException:
|
||||||
if str(exc) == "Poll not found":
|
if str(exc) == "Poll not found":
|
||||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
if str(exc) == "Poll invitation not found":
|
||||||
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
@@ -189,3 +200,78 @@ def api_poll_summary(
|
|||||||
return PollResultSummaryResponse.model_validate(poll_result_summary_by_id(session, tenant_id=principal.tenant_id, poll_id=poll_id))
|
return PollResultSummaryResponse.model_validate(poll_result_summary_by_id(session, tenant_id=principal.tenant_id, poll_id=poll_id))
|
||||||
except PollError as exc:
|
except PollError as exc:
|
||||||
raise _poll_http_error(exc) from exc
|
raise _poll_http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/polls/{poll_id}/invitations", response_model=PollInvitationResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def api_create_poll_invitation(
|
||||||
|
poll_id: str,
|
||||||
|
payload: PollInvitationCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PollInvitationResponse:
|
||||||
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
invitation, token = create_poll_invitation(session, tenant_id=principal.tenant_id, poll_id=poll_id, payload=payload)
|
||||||
|
except PollError as exc:
|
||||||
|
raise _poll_http_error(exc) from exc
|
||||||
|
return PollInvitationResponse.model_validate(poll_invitation_response(invitation, token=token))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/polls/{poll_id}/invitations", response_model=PollInvitationListResponse)
|
||||||
|
def api_list_poll_invitations(
|
||||||
|
poll_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PollInvitationListResponse:
|
||||||
|
_require_scope(principal, READ_SCOPE)
|
||||||
|
try:
|
||||||
|
invitations = list_poll_invitations(session, tenant_id=principal.tenant_id, poll_id=poll_id)
|
||||||
|
except PollError as exc:
|
||||||
|
raise _poll_http_error(exc) from exc
|
||||||
|
return PollInvitationListResponse(
|
||||||
|
invitations=[PollInvitationResponse.model_validate(poll_invitation_response(invitation)) for invitation in invitations]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/polls/{poll_id}/invitations/{invitation_id}", response_model=PollInvitationResponse)
|
||||||
|
def api_revoke_poll_invitation(
|
||||||
|
poll_id: str,
|
||||||
|
invitation_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PollInvitationResponse:
|
||||||
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
invitation = revoke_poll_invitation(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
invitation_id=invitation_id,
|
||||||
|
)
|
||||||
|
except PollError as exc:
|
||||||
|
raise _poll_http_error(exc) from exc
|
||||||
|
return PollInvitationResponse.model_validate(poll_invitation_response(invitation))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/public/{token}", response_model=PollResponse)
|
||||||
|
def api_get_public_poll(
|
||||||
|
token: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> PollResponse:
|
||||||
|
try:
|
||||||
|
return _poll_response(get_poll_by_invitation_token(session, token=token))
|
||||||
|
except PollError as exc:
|
||||||
|
raise _poll_http_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/public/{token}/responses", response_model=PollResponseItem, status_code=status.HTTP_201_CREATED)
|
||||||
|
def api_submit_public_poll_response(
|
||||||
|
token: str,
|
||||||
|
payload: PollSubmitResponseRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> PollResponseItem:
|
||||||
|
try:
|
||||||
|
response = submit_poll_response_with_token(session, token=token, payload=payload)
|
||||||
|
except PollError as exc:
|
||||||
|
raise _poll_http_error(exc) from exc
|
||||||
|
return PollResponseItem.model_validate(poll_response_item(response))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any, Literal
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
PollKind = Literal["single_choice", "multiple_choice", "yes_no", "ranked_choice", "availability"]
|
PollKind = Literal["single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"]
|
||||||
PollStatus = Literal["draft", "open", "closed", "decided", "archived"]
|
PollStatus = Literal["draft", "open", "closed", "decided", "archived"]
|
||||||
PollVisibility = Literal["private", "tenant", "public", "unlisted"]
|
PollVisibility = Literal["private", "tenant", "public", "unlisted"]
|
||||||
PollResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
PollResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
||||||
@@ -33,6 +33,11 @@ class PollCreateRequest(BaseModel):
|
|||||||
status: PollStatus = "draft"
|
status: PollStatus = "draft"
|
||||||
visibility: PollVisibility = "private"
|
visibility: PollVisibility = "private"
|
||||||
result_visibility: PollResultVisibility = "after_close"
|
result_visibility: PollResultVisibility = "after_close"
|
||||||
|
context_module: str | None = Field(default=None, max_length=100)
|
||||||
|
context_resource_type: str | None = Field(default=None, max_length=100)
|
||||||
|
context_resource_id: str | None = Field(default=None, max_length=255)
|
||||||
|
workflow_state: str | None = Field(default=None, max_length=100)
|
||||||
|
workflow_steps: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
allow_anonymous: bool = False
|
allow_anonymous: bool = False
|
||||||
allow_response_update: bool = True
|
allow_response_update: bool = True
|
||||||
min_choices: int = Field(default=1, ge=0)
|
min_choices: int = Field(default=1, ge=0)
|
||||||
@@ -56,6 +61,11 @@ class PollUpdateRequest(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
visibility: PollVisibility | None = None
|
visibility: PollVisibility | None = None
|
||||||
result_visibility: PollResultVisibility | None = None
|
result_visibility: PollResultVisibility | None = None
|
||||||
|
context_module: str | None = Field(default=None, max_length=100)
|
||||||
|
context_resource_type: str | None = Field(default=None, max_length=100)
|
||||||
|
context_resource_id: str | None = Field(default=None, max_length=255)
|
||||||
|
workflow_state: str | None = Field(default=None, max_length=100)
|
||||||
|
workflow_steps: list[dict[str, Any]] | None = None
|
||||||
allow_anonymous: bool | None = None
|
allow_anonymous: bool | None = None
|
||||||
allow_response_update: bool | None = None
|
allow_response_update: bool | None = None
|
||||||
min_choices: int | None = Field(default=None, ge=0)
|
min_choices: int | None = Field(default=None, ge=0)
|
||||||
@@ -96,6 +106,11 @@ class PollResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
visibility: str
|
visibility: str
|
||||||
result_visibility: str
|
result_visibility: str
|
||||||
|
context_module: str | None = None
|
||||||
|
context_resource_type: str | None = None
|
||||||
|
context_resource_id: str | None = None
|
||||||
|
workflow_state: str | None = None
|
||||||
|
workflow_steps: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
allow_anonymous: bool
|
allow_anonymous: bool
|
||||||
allow_response_update: bool
|
allow_response_update: bool
|
||||||
min_choices: int
|
min_choices: int
|
||||||
@@ -166,3 +181,32 @@ class PollStatusResponse(BaseModel):
|
|||||||
|
|
||||||
class PollResponseListResponse(BaseModel):
|
class PollResponseListResponse(BaseModel):
|
||||||
responses: list[PollResponseItem] = Field(default_factory=list)
|
responses: list[PollResponseItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PollInvitationCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
respondent_id: str | None = Field(default=None, max_length=255)
|
||||||
|
respondent_label: str | None = Field(default=None, max_length=500)
|
||||||
|
email: str | None = Field(default=None, max_length=320)
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PollInvitationResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
poll_id: str
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
token: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
last_used_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PollInvitationListResponse(BaseModel):
|
||||||
|
invitations: list[PollInvitationResponse] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
||||||
from govoplan_poll.backend.schemas import (
|
from govoplan_poll.backend.schemas import (
|
||||||
PollCreateRequest,
|
PollCreateRequest,
|
||||||
PollDecisionRequest,
|
PollDecisionRequest,
|
||||||
|
PollInvitationCreateRequest,
|
||||||
PollOptionInput,
|
PollOptionInput,
|
||||||
PollSubmitResponseRequest,
|
PollSubmitResponseRequest,
|
||||||
PollUpdateRequest,
|
PollUpdateRequest,
|
||||||
@@ -21,14 +24,19 @@ class PollError(ValueError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "ranked_choice", "availability"}
|
POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"}
|
||||||
POLL_STATUSES = {"draft", "open", "closed", "decided", "archived"}
|
POLL_STATUSES = {"draft", "open", "closed", "decided", "archived"}
|
||||||
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "ranked_choice"}
|
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"}
|
||||||
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
|
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
|
||||||
YES_NO_OPTIONS = (
|
YES_NO_OPTIONS = (
|
||||||
PollOptionInput(key="yes", label="Yes"),
|
PollOptionInput(key="yes", label="Yes"),
|
||||||
PollOptionInput(key="no", label="No"),
|
PollOptionInput(key="no", label="No"),
|
||||||
)
|
)
|
||||||
|
YES_NO_MAYBE_OPTIONS = (
|
||||||
|
PollOptionInput(key="yes", label="Yes"),
|
||||||
|
PollOptionInput(key="no", label="No"),
|
||||||
|
PollOptionInput(key="maybe", label="Maybe"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def slugify(value: str, *, fallback: str = "poll") -> str:
|
def slugify(value: str, *, fallback: str = "poll") -> str:
|
||||||
@@ -85,6 +93,10 @@ def _normalize_options(kind: str, options: list[PollOptionInput]) -> list[PollOp
|
|||||||
normalized = list(options)
|
normalized = list(options)
|
||||||
if kind == "yes_no" and not normalized:
|
if kind == "yes_no" and not normalized:
|
||||||
normalized = list(YES_NO_OPTIONS)
|
normalized = list(YES_NO_OPTIONS)
|
||||||
|
if kind == "yes_no_maybe" and not normalized:
|
||||||
|
normalized = list(YES_NO_MAYBE_OPTIONS)
|
||||||
|
if kind == "yes_no_maybe" and len(normalized) < 3:
|
||||||
|
raise PollError("yes_no_maybe polls require at least three options")
|
||||||
if kind in CHOICE_POLL_KINDS and len(normalized) < 2:
|
if kind in CHOICE_POLL_KINDS and len(normalized) < 2:
|
||||||
raise PollError(f"{kind} polls require at least two options")
|
raise PollError(f"{kind} polls require at least two options")
|
||||||
if kind == "availability" and not normalized:
|
if kind == "availability" and not normalized:
|
||||||
@@ -109,7 +121,7 @@ def _normalize_options(kind: str, options: list[PollOptionInput]) -> list[PollOp
|
|||||||
|
|
||||||
|
|
||||||
def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None, option_count: int) -> tuple[int, int | None]:
|
def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None, option_count: int) -> tuple[int, int | None]:
|
||||||
if kind in {"single_choice", "yes_no"}:
|
if kind in {"single_choice", "yes_no", "yes_no_maybe"}:
|
||||||
return 1, 1
|
return 1, 1
|
||||||
if kind == "ranked_choice":
|
if kind == "ranked_choice":
|
||||||
if min_choices < 1:
|
if min_choices < 1:
|
||||||
@@ -150,6 +162,11 @@ def create_poll(session: Session, *, tenant_id: str, user_id: str | None, payloa
|
|||||||
status=payload.status,
|
status=payload.status,
|
||||||
visibility=payload.visibility,
|
visibility=payload.visibility,
|
||||||
result_visibility=payload.result_visibility,
|
result_visibility=payload.result_visibility,
|
||||||
|
context_module=payload.context_module,
|
||||||
|
context_resource_type=payload.context_resource_type,
|
||||||
|
context_resource_id=payload.context_resource_id,
|
||||||
|
workflow_state=payload.workflow_state,
|
||||||
|
workflow_steps=payload.workflow_steps,
|
||||||
allow_anonymous=payload.allow_anonymous,
|
allow_anonymous=payload.allow_anonymous,
|
||||||
allow_response_update=payload.allow_response_update,
|
allow_response_update=payload.allow_response_update,
|
||||||
min_choices=min_choices,
|
min_choices=min_choices,
|
||||||
@@ -198,10 +215,23 @@ def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: Poll
|
|||||||
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
if poll.status in {"closed", "decided", "archived"}:
|
if poll.status in {"closed", "decided", "archived"}:
|
||||||
raise PollError("Closed, decided, or archived polls cannot be edited")
|
raise PollError("Closed, decided, or archived polls cannot be edited")
|
||||||
for field in ("title", "description", "visibility", "result_visibility", "allow_anonymous", "allow_response_update"):
|
for field in (
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"visibility",
|
||||||
|
"result_visibility",
|
||||||
|
"context_module",
|
||||||
|
"context_resource_type",
|
||||||
|
"context_resource_id",
|
||||||
|
"workflow_state",
|
||||||
|
"allow_anonymous",
|
||||||
|
"allow_response_update",
|
||||||
|
):
|
||||||
value = getattr(payload, field)
|
value = getattr(payload, field)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
setattr(poll, field, value)
|
setattr(poll, field, value)
|
||||||
|
if payload.workflow_steps is not None:
|
||||||
|
poll.workflow_steps = payload.workflow_steps
|
||||||
if payload.min_choices is not None or payload.max_choices is not None:
|
if payload.min_choices is not None or payload.max_choices is not None:
|
||||||
min_choices = poll.min_choices if payload.min_choices is None else payload.min_choices
|
min_choices = poll.min_choices if payload.min_choices is None else payload.min_choices
|
||||||
max_choices = poll.max_choices if payload.max_choices is None else payload.max_choices
|
max_choices = poll.max_choices if payload.max_choices is None else payload.max_choices
|
||||||
@@ -329,7 +359,7 @@ def _normalize_availability_answers(poll: Poll, payload: PollSubmitResponseReque
|
|||||||
|
|
||||||
|
|
||||||
def normalize_response_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
|
def normalize_response_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
|
||||||
if poll.kind in {"single_choice", "multiple_choice", "yes_no"}:
|
if poll.kind in {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe"}:
|
||||||
return _normalize_choice_answers(poll, payload)
|
return _normalize_choice_answers(poll, payload)
|
||||||
if poll.kind == "ranked_choice":
|
if poll.kind == "ranked_choice":
|
||||||
return _normalize_ranked_answers(poll, payload)
|
return _normalize_ranked_answers(poll, payload)
|
||||||
@@ -402,6 +432,11 @@ def poll_response(poll: Poll) -> dict[str, Any]:
|
|||||||
"status": poll.status,
|
"status": poll.status,
|
||||||
"visibility": poll.visibility,
|
"visibility": poll.visibility,
|
||||||
"result_visibility": poll.result_visibility,
|
"result_visibility": poll.result_visibility,
|
||||||
|
"context_module": poll.context_module,
|
||||||
|
"context_resource_type": poll.context_resource_type,
|
||||||
|
"context_resource_id": poll.context_resource_id,
|
||||||
|
"workflow_state": poll.workflow_state,
|
||||||
|
"workflow_steps": poll.workflow_steps or [],
|
||||||
"allow_anonymous": poll.allow_anonymous,
|
"allow_anonymous": poll.allow_anonymous,
|
||||||
"allow_response_update": poll.allow_response_update,
|
"allow_response_update": poll.allow_response_update,
|
||||||
"min_choices": poll.min_choices,
|
"min_choices": poll.min_choices,
|
||||||
@@ -442,6 +477,125 @@ def list_poll_responses(session: Session, *, tenant_id: str, poll_id: str) -> li
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _token_hash(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_poll_invitation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
payload: PollInvitationCreateRequest,
|
||||||
|
) -> tuple[PollInvitation, str]:
|
||||||
|
get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
if payload.expires_at is not None and response_datetime(payload.expires_at) <= _now():
|
||||||
|
raise PollError("Invitation expiry must be in the future")
|
||||||
|
for _attempt in range(5):
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = _token_hash(token)
|
||||||
|
exists = session.query(PollInvitation).filter(PollInvitation.token_hash == token_hash).first()
|
||||||
|
if exists is None:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise PollError("Could not create a unique poll invitation token")
|
||||||
|
invitation = PollInvitation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
respondent_id=payload.respondent_id,
|
||||||
|
respondent_label=payload.respondent_label,
|
||||||
|
email=payload.email,
|
||||||
|
expires_at=payload.expires_at,
|
||||||
|
metadata_=payload.metadata,
|
||||||
|
)
|
||||||
|
session.add(invitation)
|
||||||
|
session.flush()
|
||||||
|
return invitation, token
|
||||||
|
|
||||||
|
|
||||||
|
def list_poll_invitations(session: Session, *, tenant_id: str, poll_id: str) -> list[PollInvitation]:
|
||||||
|
get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
return (
|
||||||
|
session.query(PollInvitation)
|
||||||
|
.filter(PollInvitation.tenant_id == tenant_id, PollInvitation.poll_id == poll_id)
|
||||||
|
.order_by(PollInvitation.created_at.asc(), PollInvitation.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_poll_invitation(session: Session, *, tenant_id: str, poll_id: str, invitation_id: str) -> PollInvitation:
|
||||||
|
invitation = (
|
||||||
|
session.query(PollInvitation)
|
||||||
|
.filter(PollInvitation.tenant_id == tenant_id, PollInvitation.poll_id == poll_id, PollInvitation.id == invitation_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if invitation is None:
|
||||||
|
raise PollError("Poll invitation not found")
|
||||||
|
return invitation
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_poll_invitation(session: Session, *, tenant_id: str, poll_id: str, invitation_id: str) -> PollInvitation:
|
||||||
|
invitation = get_poll_invitation(session, tenant_id=tenant_id, poll_id=poll_id, invitation_id=invitation_id)
|
||||||
|
if invitation.revoked_at is None:
|
||||||
|
invitation.revoked_at = _now()
|
||||||
|
session.flush()
|
||||||
|
return invitation
|
||||||
|
|
||||||
|
|
||||||
|
def get_poll_invitation_by_token(session: Session, *, token: str) -> PollInvitation:
|
||||||
|
invitation = session.query(PollInvitation).filter(PollInvitation.token_hash == _token_hash(token)).first()
|
||||||
|
if invitation is None or invitation.poll.deleted_at is not None:
|
||||||
|
raise PollError("Poll invitation not found")
|
||||||
|
if invitation.revoked_at is not None:
|
||||||
|
raise PollError("Poll invitation has been revoked")
|
||||||
|
if invitation.expires_at is not None and response_datetime(invitation.expires_at) <= _now():
|
||||||
|
raise PollError("Poll invitation has expired")
|
||||||
|
return invitation
|
||||||
|
|
||||||
|
|
||||||
|
def get_poll_by_invitation_token(session: Session, *, token: str) -> Poll:
|
||||||
|
return get_poll_invitation_by_token(session, token=token).poll
|
||||||
|
|
||||||
|
|
||||||
|
def submit_poll_response_with_token(session: Session, *, token: str, payload: PollSubmitResponseRequest) -> PollResponse:
|
||||||
|
invitation = get_poll_invitation_by_token(session, token=token)
|
||||||
|
metadata = dict(payload.metadata)
|
||||||
|
metadata.setdefault("invitation_id", invitation.id)
|
||||||
|
response_payload = PollSubmitResponseRequest(
|
||||||
|
respondent_id=invitation.respondent_id or f"invitation:{invitation.id}",
|
||||||
|
respondent_label=payload.respondent_label or invitation.respondent_label or invitation.email,
|
||||||
|
answers=payload.answers,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
response = submit_poll_response(
|
||||||
|
session,
|
||||||
|
tenant_id=invitation.tenant_id,
|
||||||
|
poll_id=invitation.poll_id,
|
||||||
|
payload=response_payload,
|
||||||
|
)
|
||||||
|
invitation.last_used_at = response.submitted_at
|
||||||
|
session.flush()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def poll_invitation_response(invitation: PollInvitation, *, token: str | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": invitation.id,
|
||||||
|
"poll_id": invitation.poll_id,
|
||||||
|
"respondent_id": invitation.respondent_id,
|
||||||
|
"respondent_label": invitation.respondent_label,
|
||||||
|
"email": invitation.email,
|
||||||
|
"token": token,
|
||||||
|
"expires_at": response_datetime(invitation.expires_at),
|
||||||
|
"revoked_at": response_datetime(invitation.revoked_at),
|
||||||
|
"last_used_at": response_datetime(invitation.last_used_at),
|
||||||
|
"created_at": response_datetime(invitation.created_at),
|
||||||
|
"updated_at": response_datetime(invitation.updated_at),
|
||||||
|
"metadata": invitation.metadata_ or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def poll_result_summary(poll: Poll) -> dict[str, Any]:
|
def poll_result_summary(poll: Poll) -> dict[str, Any]:
|
||||||
options = _active_options(poll)
|
options = _active_options(poll)
|
||||||
option_results = {
|
option_results = {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class PollManifestTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.provides_interfaces})
|
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.provides_interfaces})
|
||||||
|
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.provides_interfaces})
|
||||||
|
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.provides_interfaces})
|
||||||
self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions})
|
self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,21 +6,41 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
|
||||||
from govoplan_poll.backend.schemas import PollAnswerInput, PollCreateRequest, PollOptionInput, PollSubmitResponseRequest
|
from govoplan_poll.backend.schemas import (
|
||||||
from govoplan_poll.backend.service import PollError, create_poll, open_poll, poll_result_summary_by_id, submit_poll_response
|
PollAnswerInput,
|
||||||
|
PollCreateRequest,
|
||||||
|
PollInvitationCreateRequest,
|
||||||
|
PollOptionInput,
|
||||||
|
PollSubmitResponseRequest,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.service import (
|
||||||
|
PollError,
|
||||||
|
create_poll,
|
||||||
|
create_poll_invitation,
|
||||||
|
open_poll,
|
||||||
|
poll_result_summary_by_id,
|
||||||
|
submit_poll_response,
|
||||||
|
submit_poll_response_with_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PollServiceTests(unittest.TestCase):
|
class PollServiceTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.engine = create_engine("sqlite:///:memory:")
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
Base.metadata.create_all(self.engine, tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__])
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[Poll.__table__, PollOption.__table__, PollResponse.__table__, PollInvitation.__table__],
|
||||||
|
)
|
||||||
self.Session = sessionmaker(bind=self.engine)
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
self.session: Session = self.Session()
|
self.session: Session = self.Session()
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.session.close()
|
self.session.close()
|
||||||
Base.metadata.drop_all(self.engine, tables=[PollResponse.__table__, PollOption.__table__, Poll.__table__])
|
Base.metadata.drop_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[PollInvitation.__table__, PollResponse.__table__, PollOption.__table__, Poll.__table__],
|
||||||
|
)
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
|
|
||||||
def test_single_choice_response_can_update_existing_respondent(self) -> None:
|
def test_single_choice_response_can_update_existing_respondent(self) -> None:
|
||||||
@@ -74,6 +94,56 @@ class PollServiceTests(unittest.TestCase):
|
|||||||
payload=PollSubmitResponseRequest(answers=[PollAnswerInput(option_key="yes")]),
|
payload=PollSubmitResponseRequest(answers=[PollAnswerInput(option_key="yes")]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_yes_no_maybe_uses_default_options(self) -> None:
|
||||||
|
poll = create_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=PollCreateRequest(
|
||||||
|
title="Tentative decision",
|
||||||
|
kind="yes_no_maybe",
|
||||||
|
allow_anonymous=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
open_poll(self.session, tenant_id="tenant-1", poll_id=poll.id)
|
||||||
|
|
||||||
|
response = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=PollSubmitResponseRequest(answers=[PollAnswerInput(option_key="maybe")]),
|
||||||
|
)
|
||||||
|
summary = poll_result_summary_by_id(self.session, tenant_id="tenant-1", poll_id=poll.id)
|
||||||
|
|
||||||
|
self.assertEqual([option.key for option in poll.options], ["yes", "no", "maybe"])
|
||||||
|
self.assertEqual(response.answers[0]["option_key"], "maybe")
|
||||||
|
self.assertEqual(summary["response_count"], 1)
|
||||||
|
|
||||||
|
def test_poll_can_store_scheduling_workflow_context(self) -> None:
|
||||||
|
poll = create_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=PollCreateRequest(
|
||||||
|
title="Find a steering group slot",
|
||||||
|
kind="availability",
|
||||||
|
context_module="scheduling",
|
||||||
|
context_resource_type="scheduling_request",
|
||||||
|
context_resource_id="request-1",
|
||||||
|
workflow_state="collecting_availability",
|
||||||
|
workflow_steps=[
|
||||||
|
{"id": "collect", "label": "Collect availability", "status": "active"},
|
||||||
|
{"id": "decide", "label": "Choose slot", "status": "pending"},
|
||||||
|
],
|
||||||
|
options=[PollOptionInput(key="slot-1", label="Monday")],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(poll.context_module, "scheduling")
|
||||||
|
self.assertEqual(poll.context_resource_id, "request-1")
|
||||||
|
self.assertEqual(poll.workflow_state, "collecting_availability")
|
||||||
|
self.assertEqual(poll.workflow_steps[0]["id"], "collect")
|
||||||
|
|
||||||
def test_ranked_choice_summary_uses_borda_score(self) -> None:
|
def test_ranked_choice_summary_uses_borda_score(self) -> None:
|
||||||
poll = create_poll(
|
poll = create_poll(
|
||||||
self.session,
|
self.session,
|
||||||
@@ -176,6 +246,35 @@ class PollServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(values["slot-1"], {"available": 1, "unavailable": 1})
|
self.assertEqual(values["slot-1"], {"available": 1, "unavailable": 1})
|
||||||
self.assertEqual(values["slot-2"], {"maybe": 1, "available": 1})
|
self.assertEqual(values["slot-2"], {"maybe": 1, "available": 1})
|
||||||
|
|
||||||
|
def test_signed_invitation_can_submit_without_anonymous_policy(self) -> None:
|
||||||
|
poll = create_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=PollCreateRequest(
|
||||||
|
title="External vote",
|
||||||
|
kind="yes_no_maybe",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
open_poll(self.session, tenant_id="tenant-1", poll_id=poll.id)
|
||||||
|
invitation, token = create_poll_invitation(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=PollInvitationCreateRequest(respondent_label="External participant"),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = submit_poll_response_with_token(
|
||||||
|
self.session,
|
||||||
|
token=token,
|
||||||
|
payload=PollSubmitResponseRequest(answers=[PollAnswerInput(option_key="yes")]),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.respondent_id, f"invitation:{invitation.id}")
|
||||||
|
self.assertEqual(response.respondent_label, "External participant")
|
||||||
|
self.assertEqual(response.metadata_["invitation_id"], invitation.id)
|
||||||
|
self.assertIsNotNone(invitation.last_used_at)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user