Add poll workflow and signed participation support

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

View File

@@ -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 = {