Add poll-backed scheduling backend

This commit is contained in:
2026-07-12 18:12:20 +02:00
parent cfea0edb97
commit ace32a2a3d
12 changed files with 1378 additions and 7 deletions

View File

@@ -0,0 +1,513 @@
from __future__ import annotations
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.schemas import PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput
from govoplan_poll.backend.db.models import PollResponse
from govoplan_poll.backend.service import (
PollError,
close_poll,
create_poll,
create_poll_invitation,
decide_poll,
get_poll,
open_poll,
poll_result_summary_by_id,
)
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.schemas import (
SchedulingDecisionRequest,
SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest,
)
class SchedulingError(ValueError):
pass
WORKFLOW_DRAFT = "draft"
WORKFLOW_COLLECTING = "collecting_availability"
WORKFLOW_CLOSED = "availability_closed"
WORKFLOW_DECIDED = "slot_decided"
WORKFLOW_HANDED_OFF = "handed_off"
WORKFLOW_CANCELLED = "cancelled"
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 _slot_label(start_at: datetime, end_at: datetime, *, timezone_name: str) -> str:
return f"{response_datetime(start_at).isoformat()} - {response_datetime(end_at).isoformat()} ({timezone_name})"
def _workflow_steps(state: str) -> list[dict[str, Any]]:
order = [
("draft", "Draft"),
("collecting_availability", "Collect availability"),
("availability_closed", "Close poll"),
("slot_decided", "Choose slot"),
("handed_off", "Hand off"),
]
active_index = next((index for index, (step_id, _label) in enumerate(order) if step_id == state), 0)
steps: list[dict[str, Any]] = []
for index, (step_id, label) in enumerate(order):
if step_id == state:
status = "active"
elif index < active_index:
status = "done"
else:
status = "pending"
steps.append({"id": step_id, "label": label, "status": status})
if state == WORKFLOW_CANCELLED:
steps.append({"id": "cancelled", "label": "Cancelled", "status": "active"})
return steps
def _poll_status_for_request(status: str) -> str:
if status == "collecting":
return "open"
return "draft"
def _active_slots(request: SchedulingRequest) -> list[SchedulingCandidateSlot]:
return [slot for slot in request.slots if slot.deleted_at is None]
def _active_participants(request: SchedulingRequest) -> list[SchedulingParticipant]:
return [participant for participant in request.participants if participant.deleted_at is None]
def _poll_option_inputs(request: SchedulingRequest) -> list[PollOptionInput]:
options: list[PollOptionInput] = []
for slot in _active_slots(request):
options.append(
PollOptionInput(
key=f"slot-{slot.position + 1}",
label=slot.label,
description=slot.description,
value={
"slot_id": slot.id,
"start_at": response_datetime(slot.start_at).isoformat(),
"end_at": response_datetime(slot.end_at).isoformat(),
"timezone": slot.timezone,
"location": slot.location,
},
metadata=slot.metadata_ or {},
)
)
return options
def _poll_workflow_state(request: SchedulingRequest) -> str:
return {
"draft": WORKFLOW_DRAFT,
"collecting": WORKFLOW_COLLECTING,
"closed": WORKFLOW_CLOSED,
"decided": WORKFLOW_DECIDED,
"handed_off": WORKFLOW_HANDED_OFF,
"cancelled": WORKFLOW_CANCELLED,
}.get(request.status, WORKFLOW_DRAFT)
def _sync_poll_workflow(session: Session, *, tenant_id: str, request: SchedulingRequest) -> None:
if request.poll_id is None:
return
poll = get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
state = _poll_workflow_state(request)
poll.workflow_state = state
poll.workflow_steps = _workflow_steps(state)
poll.context_module = "scheduling"
poll.context_resource_type = "scheduling_request"
poll.context_resource_id = request.id
def _set_calendar_preferences(request: SchedulingRequest, payload) -> None:
calendar = payload.calendar
request.calendar_integration_enabled = calendar.enabled
request.calendar_id = calendar.calendar_id
request.calendar_freebusy_enabled = calendar.freebusy_enabled
request.calendar_hold_enabled = calendar.tentative_holds_enabled
request.create_calendar_event_on_decision = calendar.create_event_on_decision
if not calendar.enabled:
request.calendar_freebusy_enabled = False
request.calendar_hold_enabled = False
request.create_calendar_event_on_decision = False
def refresh_participant_response_state(session: Session, *, request: SchedulingRequest) -> None:
if request.poll_id is None:
return
by_invitation_id = {
participant.poll_invitation_id: participant
for participant in _active_participants(request)
if participant.poll_invitation_id is not None
}
if not by_invitation_id:
return
responses = (
session.query(PollResponse)
.filter(PollResponse.tenant_id == request.tenant_id, PollResponse.poll_id == request.poll_id, PollResponse.deleted_at.is_(None))
.all()
)
changed = False
for response in responses:
invitation_id = (response.metadata_ or {}).get("invitation_id")
participant = by_invitation_id.get(invitation_id)
if participant is not None and participant.status != "responded":
participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at)
changed = True
if changed:
session.flush()
def create_scheduling_request(
session: Session,
*,
tenant_id: str,
user_id: str | None,
payload: SchedulingRequestCreateRequest,
) -> tuple[SchedulingRequest, dict[str, str]]:
if not payload.allow_external_participants:
for participant_input in payload.participants:
if participant_input.participant_type == "external":
raise SchedulingError("External participants are not allowed for this scheduling request")
request = SchedulingRequest(
tenant_id=tenant_id,
title=payload.title,
description=payload.description,
location=payload.location,
timezone=payload.timezone,
status=payload.status,
organizer_user_id=user_id,
deadline_at=payload.deadline_at,
allow_external_participants=payload.allow_external_participants,
allow_participant_updates=payload.allow_participant_updates,
result_visibility=payload.result_visibility,
metadata_=payload.metadata,
)
_set_calendar_preferences(request, payload)
session.add(request)
session.flush()
for position, slot_input in enumerate(payload.slots):
timezone_name = slot_input.timezone or payload.timezone
slot = SchedulingCandidateSlot(
tenant_id=tenant_id,
request_id=request.id,
label=slot_input.label or _slot_label(slot_input.start_at, slot_input.end_at, timezone_name=timezone_name),
description=slot_input.description,
start_at=slot_input.start_at,
end_at=slot_input.end_at,
timezone=timezone_name,
location=slot_input.location or payload.location,
position=position,
metadata_=slot_input.metadata,
)
session.add(slot)
for participant_input in payload.participants:
participant = SchedulingParticipant(
tenant_id=tenant_id,
request_id=request.id,
respondent_id=participant_input.respondent_id,
display_name=participant_input.display_name,
email=participant_input.email,
participant_type=participant_input.participant_type,
required=participant_input.required,
status="draft" if payload.status == "draft" else "invited",
metadata_=participant_input.metadata,
)
session.add(participant)
session.flush()
try:
poll = create_poll(
session,
tenant_id=tenant_id,
user_id=user_id,
payload=PollCreateRequest(
title=payload.title,
description=payload.description,
kind="availability",
status=_poll_status_for_request(payload.status),
visibility="unlisted" if payload.allow_external_participants else "tenant",
result_visibility=payload.result_visibility,
context_module="scheduling",
context_resource_type="scheduling_request",
context_resource_id=request.id,
workflow_state=_poll_workflow_state(request),
workflow_steps=_workflow_steps(_poll_workflow_state(request)),
allow_anonymous=False,
allow_response_update=payload.allow_participant_updates,
min_choices=1,
max_choices=len(_active_slots(request)),
opens_at=None,
closes_at=payload.deadline_at,
options=_poll_option_inputs(request),
metadata={"scheduling_request_id": request.id, "calendar": payload.calendar.model_dump()},
),
)
except PollError as exc:
raise SchedulingError(str(exc)) from exc
request.poll_id = poll.id
options_by_position = {option.position: option.id for option in poll.options}
for slot in _active_slots(request):
slot.poll_option_id = options_by_position.get(slot.position)
invitation_tokens: dict[str, str] = {}
if payload.create_participant_invitations:
for participant in _active_participants(request):
try:
invitation, token = create_poll_invitation(
session,
tenant_id=tenant_id,
poll_id=poll.id,
payload=PollInvitationCreateRequest(
respondent_id=participant.respondent_id,
respondent_label=participant.display_name,
email=participant.email,
expires_at=payload.deadline_at,
metadata={"scheduling_request_id": request.id, "scheduling_participant_id": participant.id},
),
)
except PollError as exc:
raise SchedulingError(str(exc)) from exc
participant.poll_invitation_id = invitation.id
participant.last_invited_at = _now()
participant.status = "invited"
invitation_tokens[participant.id] = token
session.flush()
return request, invitation_tokens
def list_scheduling_requests(session: Session, *, tenant_id: str, status: str | None = None) -> list[SchedulingRequest]:
query = session.query(SchedulingRequest).filter(SchedulingRequest.tenant_id == tenant_id, SchedulingRequest.deleted_at.is_(None))
if status:
query = query.filter(SchedulingRequest.status == status)
return query.order_by(SchedulingRequest.created_at.desc(), SchedulingRequest.title.asc()).all()
def get_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
request = (
session.query(SchedulingRequest)
.filter(SchedulingRequest.tenant_id == tenant_id, SchedulingRequest.id == request_id, SchedulingRequest.deleted_at.is_(None))
.first()
)
if request is None:
raise SchedulingError("Scheduling request not found")
return request
def update_scheduling_request(
session: Session,
*,
tenant_id: str,
request_id: str,
payload: SchedulingRequestUpdateRequest,
) -> SchedulingRequest:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.status in {"decided", "handed_off", "cancelled", "archived"}:
raise SchedulingError("Decided, handed off, cancelled, or archived scheduling requests cannot be edited")
for field in ("title", "description", "location", "deadline_at", "allow_external_participants", "allow_participant_updates", "result_visibility"):
value = getattr(payload, field)
if value is not None:
setattr(request, field, value)
if payload.calendar is not None:
_set_calendar_preferences(request, payload)
if payload.metadata is not None:
request.metadata_ = payload.metadata
session.flush()
return request
def open_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
try:
open_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
except PollError as exc:
raise SchedulingError(str(exc)) from exc
request.status = "collecting"
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request
def close_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
try:
close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
except PollError as exc:
raise SchedulingError(str(exc)) from exc
request.status = "closed"
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request
def decide_scheduling_request(
session: Session,
*,
tenant_id: str,
request_id: str,
payload: SchedulingDecisionRequest,
) -> SchedulingRequest:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
slot = _selected_slot(request, slot_id=payload.slot_id, poll_option_id=payload.poll_option_id)
try:
decide_poll(
session,
tenant_id=tenant_id,
poll_id=request.poll_id,
payload=PollDecisionRequest(option_id=slot.poll_option_id),
)
except PollError as exc:
raise SchedulingError(str(exc)) from exc
request.selected_slot_id = slot.id
request.status = "decided"
if payload.handoff_to_calendar or (
payload.handoff_to_calendar is None and request.create_calendar_event_on_decision
):
request.handed_off_at = _now()
request.status = "handed_off"
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {
"status": "pending",
"reason": "Calendar event creation is an optional integration step.",
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request
def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
request.status = "cancelled"
request.cancelled_at = _now()
if request.poll_id is not None:
try:
poll = get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
if poll.status not in {"closed", "decided", "archived"}:
close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
except PollError as exc:
raise SchedulingError(str(exc)) from exc
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request
def _selected_slot(
request: SchedulingRequest,
*,
slot_id: str | None = None,
poll_option_id: str | None = None,
) -> SchedulingCandidateSlot:
for slot in _active_slots(request):
if slot_id is not None and slot.id == slot_id:
return slot
if poll_option_id is not None and slot.poll_option_id == poll_option_id:
return slot
raise SchedulingError("Scheduling candidate slot not found")
def scheduling_request_summary(session: Session, *, tenant_id: str, request_id: str) -> dict[str, Any]:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
try:
summary = poll_result_summary_by_id(session, tenant_id=tenant_id, poll_id=request.poll_id)
except PollError as exc:
raise SchedulingError(str(exc)) from exc
refresh_participant_response_state(session, request=request)
return summary
def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]:
return {
"id": slot.id,
"poll_option_id": slot.poll_option_id,
"label": slot.label,
"description": slot.description,
"start_at": response_datetime(slot.start_at),
"end_at": response_datetime(slot.end_at),
"timezone": slot.timezone,
"location": slot.location,
"position": slot.position,
"metadata": slot.metadata_ or {},
}
def scheduling_participant_response(participant: SchedulingParticipant, *, invitation_token: str | None = None) -> dict[str, Any]:
return {
"id": participant.id,
"respondent_id": participant.respondent_id,
"display_name": participant.display_name,
"email": participant.email,
"participant_type": participant.participant_type,
"required": participant.required,
"status": participant.status,
"poll_invitation_id": participant.poll_invitation_id,
"invitation_token": invitation_token,
"last_invited_at": response_datetime(participant.last_invited_at),
"responded_at": response_datetime(participant.responded_at),
"metadata": participant.metadata_ or {},
}
def scheduling_request_response(request: SchedulingRequest, *, invitation_tokens: dict[str, str] | None = None) -> dict[str, Any]:
invitation_tokens = invitation_tokens or {}
return {
"id": request.id,
"tenant_id": request.tenant_id,
"title": request.title,
"description": request.description,
"location": request.location,
"timezone": request.timezone,
"status": request.status,
"poll_id": request.poll_id,
"selected_slot_id": request.selected_slot_id,
"organizer_user_id": request.organizer_user_id,
"deadline_at": response_datetime(request.deadline_at),
"allow_external_participants": request.allow_external_participants,
"allow_participant_updates": request.allow_participant_updates,
"result_visibility": request.result_visibility,
"calendar_integration_enabled": request.calendar_integration_enabled,
"calendar_id": request.calendar_id,
"calendar_freebusy_enabled": request.calendar_freebusy_enabled,
"calendar_hold_enabled": request.calendar_hold_enabled,
"create_calendar_event_on_decision": request.create_calendar_event_on_decision,
"calendar_event_id": request.calendar_event_id,
"handed_off_at": response_datetime(request.handed_off_at),
"cancelled_at": response_datetime(request.cancelled_at),
"created_at": response_datetime(request.created_at),
"updated_at": response_datetime(request.updated_at),
"metadata": request.metadata_ or {},
"slots": [scheduling_slot_response(slot) for slot in _active_slots(request)],
"participants": [
scheduling_participant_response(participant, invitation_token=invitation_tokens.get(participant.id))
for participant in _active_participants(request)
],
}