Add poll workflow and signed participation support
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user