Add initial polling backend logic
This commit is contained in:
498
src/govoplan_poll/backend/service.py
Normal file
498
src/govoplan_poll/backend/service.py
Normal 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))
|
||||
Reference in New Issue
Block a user