Add initial polling backend logic

This commit is contained in:
2026-07-12 16:26:45 +02:00
parent ddf1fcc217
commit d0e68f5aa6
12 changed files with 1325 additions and 0 deletions

View File

@@ -0,0 +1,498 @@
from __future__ import annotations
import re
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.schemas import (
PollCreateRequest,
PollDecisionRequest,
PollOptionInput,
PollSubmitResponseRequest,
PollUpdateRequest,
)
class PollError(ValueError):
pass
POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "ranked_choice", "availability"}
POLL_STATUSES = {"draft", "open", "closed", "decided", "archived"}
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "ranked_choice"}
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
YES_NO_OPTIONS = (
PollOptionInput(key="yes", label="Yes"),
PollOptionInput(key="no", label="No"),
)
def slugify(value: str, *, fallback: str = "poll") -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or fallback
def response_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _now() -> datetime:
return utcnow()
def _active_options(poll: Poll) -> list[PollOption]:
return [option for option in poll.options if option.deleted_at is None]
def _active_responses(poll: Poll) -> list[PollResponse]:
return [response for response in poll.responses if response.deleted_at is None]
def _option_by_id_or_key(poll: Poll, *, option_id: str | None = None, option_key: str | None = None) -> PollOption:
for option in _active_options(poll):
if option_id is not None and option.id == option_id:
return option
if option_key is not None and option.key == option_key:
return option
raise PollError("Unknown poll option")
def _poll_slug_exists(session: Session, *, tenant_id: str, slug: str, exclude_poll_id: str | None = None) -> bool:
query = session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.slug == slug, Poll.deleted_at.is_(None))
if exclude_poll_id is not None:
query = query.filter(Poll.id != exclude_poll_id)
return session.query(query.exists()).scalar()
def _unique_slug(session: Session, *, tenant_id: str, slug: str) -> str:
candidate = slug
suffix = 2
while _poll_slug_exists(session, tenant_id=tenant_id, slug=candidate):
candidate = f"{slug}-{suffix}"
suffix += 1
return candidate
def _normalize_options(kind: str, options: list[PollOptionInput]) -> list[PollOptionInput]:
normalized = list(options)
if kind == "yes_no" and not normalized:
normalized = list(YES_NO_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:
raise PollError("Availability polls require at least one candidate slot option")
seen: set[str] = set()
result: list[PollOptionInput] = []
for position, option in enumerate(normalized):
key = option.key or slugify(option.label, fallback=f"option-{position + 1}")
if key in seen:
raise PollError(f"Duplicate poll option key: {key}")
seen.add(key)
result.append(
PollOptionInput(
key=key,
label=option.label,
description=option.description,
value=option.value,
metadata=option.metadata,
)
)
return result
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"}:
return 1, 1
if kind == "ranked_choice":
if min_choices < 1:
min_choices = 1
if max_choices is None:
max_choices = option_count
if min_choices > option_count:
raise PollError("min_choices cannot be greater than the number of options")
if max_choices is not None:
if max_choices < min_choices:
raise PollError("max_choices cannot be smaller than min_choices")
if max_choices > option_count:
raise PollError("max_choices cannot be greater than the number of options")
return min_choices, max_choices
def _ensure_valid_poll_payload(payload: PollCreateRequest) -> tuple[list[PollOptionInput], int, int | None]:
if payload.kind not in POLL_KINDS:
raise PollError(f"Unsupported poll kind: {payload.kind}")
if payload.status not in POLL_STATUSES:
raise PollError(f"Unsupported poll status: {payload.status}")
if payload.opens_at is not None and payload.closes_at is not None and payload.closes_at <= payload.opens_at:
raise PollError("closes_at must be after opens_at")
options = _normalize_options(payload.kind, payload.options)
min_choices, max_choices = _validate_choice_bounds(payload.kind, payload.min_choices, payload.max_choices, len(options))
return options, min_choices, max_choices
def create_poll(session: Session, *, tenant_id: str, user_id: str | None, payload: PollCreateRequest) -> Poll:
options, min_choices, max_choices = _ensure_valid_poll_payload(payload)
base_slug = slugify(payload.slug or payload.title)
poll = Poll(
tenant_id=tenant_id,
slug=_unique_slug(session, tenant_id=tenant_id, slug=base_slug),
title=payload.title,
description=payload.description,
kind=payload.kind,
status=payload.status,
visibility=payload.visibility,
result_visibility=payload.result_visibility,
allow_anonymous=payload.allow_anonymous,
allow_response_update=payload.allow_response_update,
min_choices=min_choices,
max_choices=max_choices,
opens_at=payload.opens_at,
closes_at=payload.closes_at,
created_by_user_id=user_id,
metadata_=payload.metadata,
)
session.add(poll)
session.flush()
for position, option in enumerate(options):
session.add(
PollOption(
tenant_id=tenant_id,
poll_id=poll.id,
key=option.key or f"option-{position + 1}",
label=option.label,
description=option.description,
position=position,
value=option.value,
metadata_=option.metadata,
)
)
session.flush()
return poll
def list_polls(session: Session, *, tenant_id: str, status: str | None = None, kind: str | None = None) -> list[Poll]:
query = session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None))
if status:
query = query.filter(Poll.status == status)
if kind:
query = query.filter(Poll.kind == kind)
return query.order_by(Poll.created_at.desc(), Poll.title.asc()).all()
def get_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.id == poll_id, Poll.deleted_at.is_(None)).first()
if poll is None:
raise PollError("Poll not found")
return poll
def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: PollUpdateRequest) -> 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"):
value = getattr(payload, field)
if value is not None:
setattr(poll, field, value)
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
min_choices, max_choices = _validate_choice_bounds(poll.kind, min_choices, max_choices, len(_active_options(poll)))
poll.min_choices = min_choices
poll.max_choices = max_choices
if payload.opens_at is not None:
poll.opens_at = payload.opens_at
if payload.closes_at is not None:
poll.closes_at = payload.closes_at
if poll.opens_at is not None and poll.closes_at is not None and poll.closes_at <= poll.opens_at:
raise PollError("closes_at must be after opens_at")
if payload.metadata is not None:
poll.metadata_ = payload.metadata
session.flush()
return poll
def open_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
if poll.status == "archived":
raise PollError("Archived polls cannot be opened")
if poll.closes_at is not None and response_datetime(poll.closes_at) <= _now():
raise PollError("Poll close time is already in the past")
poll.status = "open"
poll.closed_at = None
session.flush()
return poll
def close_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
if poll.status == "archived":
raise PollError("Archived polls cannot be closed")
poll.status = "closed"
poll.closed_at = _now()
session.flush()
return poll
def decide_poll(session: Session, *, tenant_id: str, poll_id: str, payload: PollDecisionRequest) -> Poll:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
option = _option_by_id_or_key(poll, option_id=payload.option_id, option_key=payload.option_key)
poll.status = "decided"
poll.decided_option_id = option.id
poll.decided_at = _now()
if poll.closed_at is None:
poll.closed_at = poll.decided_at
session.flush()
return poll
def _assert_poll_accepts_responses(poll: Poll, *, now: datetime | None = None) -> None:
now = now or _now()
if poll.status != "open":
raise PollError("Poll is not open")
if poll.opens_at is not None and response_datetime(poll.opens_at) > now:
raise PollError("Poll is not open yet")
if poll.closes_at is not None and response_datetime(poll.closes_at) <= now:
raise PollError("Poll is already closed")
def _resolve_answer_option(poll: Poll, answer) -> PollOption:
if answer.option_id is None and answer.option_key is None:
raise PollError("Poll answers must reference an option_id or option_key")
return _option_by_id_or_key(poll, option_id=answer.option_id, option_key=answer.option_key)
def _normalize_choice_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
selected: list[PollOption] = []
seen: set[str] = set()
for answer in payload.answers:
option = _resolve_answer_option(poll, answer)
if option.id in seen:
raise PollError("Poll responses cannot select the same option more than once")
seen.add(option.id)
selected.append(option)
if len(selected) < poll.min_choices:
raise PollError(f"Poll response requires at least {poll.min_choices} option(s)")
if poll.max_choices is not None and len(selected) > poll.max_choices:
raise PollError(f"Poll response allows at most {poll.max_choices} option(s)")
return [{"option_id": option.id, "option_key": option.key, "value": True} for option in selected]
def _normalize_ranked_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
selected: list[tuple[int, PollOption]] = []
seen_options: set[str] = set()
seen_ranks: set[int] = set()
for position, answer in enumerate(payload.answers, start=1):
option = _resolve_answer_option(poll, answer)
rank = answer.rank or position
if option.id in seen_options:
raise PollError("Ranked responses cannot rank the same option more than once")
if rank in seen_ranks:
raise PollError("Ranked responses cannot reuse the same rank")
seen_options.add(option.id)
seen_ranks.add(rank)
selected.append((rank, option))
if len(selected) < poll.min_choices:
raise PollError(f"Ranked response requires at least {poll.min_choices} ranked option(s)")
if poll.max_choices is not None and len(selected) > poll.max_choices:
raise PollError(f"Ranked response allows at most {poll.max_choices} ranked option(s)")
return [
{"option_id": option.id, "option_key": option.key, "rank": rank}
for rank, option in sorted(selected, key=lambda item: item[0])
]
def _normalize_availability_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
selected: list[dict[str, Any]] = []
seen: set[str] = set()
for answer in payload.answers:
option = _resolve_answer_option(poll, answer)
if option.id in seen:
raise PollError("Availability responses cannot answer the same slot more than once")
if answer.value not in AVAILABILITY_VALUES:
raise PollError("Availability responses must use available, maybe, or unavailable")
seen.add(option.id)
selected.append({"option_id": option.id, "option_key": option.key, "value": answer.value})
if len(selected) < poll.min_choices:
raise PollError(f"Availability response requires at least {poll.min_choices} slot answer(s)")
if poll.max_choices is not None and len(selected) > poll.max_choices:
raise PollError(f"Availability response allows at most {poll.max_choices} slot answer(s)")
return selected
def normalize_response_answers(poll: Poll, payload: PollSubmitResponseRequest) -> list[dict[str, Any]]:
if poll.kind in {"single_choice", "multiple_choice", "yes_no"}:
return _normalize_choice_answers(poll, payload)
if poll.kind == "ranked_choice":
return _normalize_ranked_answers(poll, payload)
if poll.kind == "availability":
return _normalize_availability_answers(poll, payload)
raise PollError(f"Unsupported poll kind: {poll.kind}")
def _existing_response(session: Session, *, poll: Poll, respondent_id: str | None) -> PollResponse | None:
if respondent_id is None:
return None
return (
session.query(PollResponse)
.filter(PollResponse.poll_id == poll.id, PollResponse.respondent_id == respondent_id, PollResponse.deleted_at.is_(None))
.order_by(PollResponse.submitted_at.desc())
.first()
)
def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payload: PollSubmitResponseRequest) -> PollResponse:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_accepts_responses(poll)
if payload.respondent_id is None and not poll.allow_anonymous:
raise PollError("Anonymous responses are not allowed for this poll")
answers = normalize_response_answers(poll, payload)
existing = _existing_response(session, poll=poll, respondent_id=payload.respondent_id)
if existing is not None:
if not poll.allow_response_update:
raise PollError("Response updates are not allowed for this poll")
existing.answers = answers
existing.respondent_label = payload.respondent_label
existing.submitted_at = _now()
existing.metadata_ = payload.metadata
session.flush()
return existing
response = PollResponse(
tenant_id=tenant_id,
poll_id=poll.id,
respondent_id=payload.respondent_id,
respondent_label=payload.respondent_label,
answers=answers,
submitted_at=_now(),
metadata_=payload.metadata,
)
session.add(response)
session.flush()
return response
def poll_option_response(option: PollOption) -> dict[str, Any]:
return {
"id": option.id,
"key": option.key,
"label": option.label,
"description": option.description,
"position": option.position,
"value": option.value,
"metadata": option.metadata_ or {},
}
def poll_response(poll: Poll) -> dict[str, Any]:
return {
"id": poll.id,
"tenant_id": poll.tenant_id,
"slug": poll.slug,
"title": poll.title,
"description": poll.description,
"kind": poll.kind,
"status": poll.status,
"visibility": poll.visibility,
"result_visibility": poll.result_visibility,
"allow_anonymous": poll.allow_anonymous,
"allow_response_update": poll.allow_response_update,
"min_choices": poll.min_choices,
"max_choices": poll.max_choices,
"opens_at": response_datetime(poll.opens_at),
"closes_at": response_datetime(poll.closes_at),
"closed_at": response_datetime(poll.closed_at),
"decided_at": response_datetime(poll.decided_at),
"decided_option_id": poll.decided_option_id,
"created_by_user_id": poll.created_by_user_id,
"created_at": response_datetime(poll.created_at),
"updated_at": response_datetime(poll.updated_at),
"metadata": poll.metadata_ or {},
"options": [poll_option_response(option) for option in _active_options(poll)],
}
def poll_response_item(response: PollResponse) -> dict[str, Any]:
return {
"id": response.id,
"respondent_id": response.respondent_id,
"respondent_label": response.respondent_label,
"answers": response.answers or [],
"submitted_at": response_datetime(response.submitted_at),
"created_at": response_datetime(response.created_at),
"updated_at": response_datetime(response.updated_at),
"metadata": response.metadata_ or {},
}
def list_poll_responses(session: Session, *, tenant_id: str, poll_id: str) -> list[PollResponse]:
get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
return (
session.query(PollResponse)
.filter(PollResponse.tenant_id == tenant_id, PollResponse.poll_id == poll_id, PollResponse.deleted_at.is_(None))
.order_by(PollResponse.submitted_at.asc(), PollResponse.id.asc())
.all()
)
def poll_result_summary(poll: Poll) -> dict[str, Any]:
options = _active_options(poll)
option_results = {
option.id: {
"option_id": option.id,
"option_key": option.key,
"label": option.label,
"count": 0,
"score": 0,
"values": {},
"ranks": {},
}
for option in options
}
option_count = len(options)
responses = _active_responses(poll)
for response in responses:
for answer in response.answers or []:
option_id = answer.get("option_id")
if option_id not in option_results:
continue
result = option_results[option_id]
if poll.kind == "ranked_choice":
rank = int(answer.get("rank") or option_count)
result["count"] += 1
result["score"] += max(option_count - rank + 1, 0)
result["ranks"][rank] = result["ranks"].get(rank, 0) + 1
elif poll.kind == "availability":
value = str(answer.get("value") or "unavailable")
result["values"][value] = result["values"].get(value, 0) + 1
if value == "available":
result["count"] += 1
result["score"] += 2
elif value == "maybe":
result["score"] += 1
else:
result["count"] += 1
result["score"] += 1
result_list = list(option_results.values())
score_field = "score" if poll.kind in {"ranked_choice", "availability"} else "count"
best_score = max((int(result[score_field]) for result in result_list), default=0)
leading = [str(result["option_id"]) for result in result_list if best_score > 0 and int(result[score_field]) == best_score]
return {
"poll_id": poll.id,
"kind": poll.kind,
"status": poll.status,
"response_count": len(responses),
"option_results": result_list,
"leading_option_ids": leading,
}
def poll_result_summary_by_id(session: Session, *, tenant_id: str, poll_id: str) -> dict[str, Any]:
return poll_result_summary(get_poll(session, tenant_id=tenant_id, poll_id=poll_id))