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

@@ -100,13 +100,18 @@ either module just to find a meeting time.
## First Package Scaffold Decision ## First Package Scaffold Decision
The first implementation slice should add runtime APIs and WebUI routes around The first backend implementation slice adds runtime APIs and storage around
the existing manifest seed: poll-backed scheduling requests:
- backend manifest, permissions, DTOs, and APIs for internal polls - scheduling request, candidate slot, and participant storage
- WebUI route contribution for poll creation and response collection - automatic `govoplan-poll` availability poll creation
- capability contracts for calendar free/busy, access/IDM lookup, - signed poll invitations for internal or external participants
mail/notification sending, portal participation links, appointment handoff, - request lifecycle APIs: draft, collecting, closed, decided, handed off, cancelled
and workflow/task follow-up - result summaries sourced from Poll response aggregation
- calendar integration preferences and handoff metadata, without hard-coupling
Scheduling to Calendar internals
The next slices should add the WebUI, notification delivery, real Calendar
free/busy checks, and Calendar/Appointments event creation after decision.
The active backlog lives in Gitea issues. The active backlog lives in Gitea issues.

View File

@@ -0,0 +1,5 @@
from __future__ import annotations
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
__all__ = ["SchedulingCandidateSlot", "SchedulingParticipant", "SchedulingRequest"]

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class SchedulingRequest(Base, TimestampMixin):
__tablename__ = "scheduling_requests"
__table_args__ = (
Index("ix_scheduling_requests_tenant_status", "tenant_id", "status"),
Index("ix_scheduling_requests_tenant_poll", "tenant_id", "poll_id"),
Index("ix_scheduling_requests_deadline", "tenant_id", "deadline_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(String(500))
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
status: Mapped[str] = mapped_column(String(40), default="draft", nullable=False, index=True)
poll_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
selected_slot_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
organizer_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
deadline_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
allow_external_participants: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
calendar_hold_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
create_calendar_event_on_decision: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
calendar_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
handed_off_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
slots: Mapped[list["SchedulingCandidateSlot"]] = relationship(
back_populates="request",
cascade="all, delete-orphan",
order_by="SchedulingCandidateSlot.position",
)
participants: Mapped[list["SchedulingParticipant"]] = relationship(
back_populates="request",
cascade="all, delete-orphan",
order_by="SchedulingParticipant.created_at",
)
class SchedulingCandidateSlot(Base, TimestampMixin):
__tablename__ = "scheduling_candidate_slots"
__table_args__ = (
Index("ix_scheduling_candidate_slots_request_position", "request_id", "position"),
Index("ix_scheduling_candidate_slots_range", "tenant_id", "start_at", "end_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_id: Mapped[str] = mapped_column(ForeignKey("scheduling_requests.id", ondelete="CASCADE"), nullable=False, index=True)
poll_option_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
label: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
location: Mapped[str | None] = mapped_column(String(500))
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
request: Mapped[SchedulingRequest] = relationship(back_populates="slots")
class SchedulingParticipant(Base, TimestampMixin):
__tablename__ = "scheduling_participants"
__table_args__ = (
Index("ix_scheduling_participants_request", "tenant_id", "request_id"),
Index("ix_scheduling_participants_request_email", "request_id", "email"),
Index("ix_scheduling_participants_request_respondent", "request_id", "respondent_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
request_id: Mapped[str] = mapped_column(ForeignKey("scheduling_requests.id", ondelete="CASCADE"), nullable=False, index=True)
respondent_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(500), nullable=True)
email: Mapped[str | None] = mapped_column(String(320), nullable=True, index=True)
participant_type: Mapped[str] = mapped_column(String(30), default="external", nullable=False, index=True)
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
status: Mapped[str] = mapped_column(String(40), default="invited", nullable=False, index=True)
poll_invitation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
last_invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
request: Mapped[SchedulingRequest] = relationship(back_populates="participants")
__all__ = ["SchedulingCandidateSlot", "SchedulingParticipant", "SchedulingRequest", "new_uuid"]

View File

@@ -1,14 +1,21 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationTopic, DocumentationTopic,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
ModuleInterfaceRequirement, ModuleInterfaceRequirement,
ModuleManifest, ModuleManifest,
PermissionDefinition, PermissionDefinition,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.db.base import Base
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
MODULE_ID = "scheduling" MODULE_ID = "scheduling"
MODULE_NAME = "Scheduling" MODULE_NAME = "Scheduling"
@@ -77,6 +84,35 @@ DOCUMENTATION = (
), ),
) )
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
return {
"scheduling_requests": (
session.query(SchedulingRequest)
.filter(SchedulingRequest.tenant_id == tenant_id, SchedulingRequest.deleted_at.is_(None))
.count()
),
"scheduling_candidate_slots": (
session.query(SchedulingCandidateSlot)
.filter(SchedulingCandidateSlot.tenant_id == tenant_id, SchedulingCandidateSlot.deleted_at.is_(None))
.count()
),
"scheduling_participants": (
session.query(SchedulingParticipant)
.filter(SchedulingParticipant.tenant_id == tenant_id, SchedulingParticipant.deleted_at.is_(None))
.count()
),
}
def _scheduling_router(_context: ModuleContext):
from govoplan_scheduling.backend.router import router
return router
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
@@ -96,6 +132,29 @@ manifest = ModuleManifest(
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
route_factory=_scheduling_router,
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant,
label="Scheduling",
),
retirement_notes="Destructive retirement drops scheduling-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant,
label="Scheduling",
),
),
documentation=DOCUMENTATION, documentation=DOCUMENTATION,
) )

View File

@@ -0,0 +1 @@
from __future__ import annotations

View File

@@ -0,0 +1,156 @@
"""v0.1.8 scheduling baseline
Revision ID: 3c4d5e6f7a8b
Revises: None
Create Date: 2026-07-12 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "3c4d5e6f7a8b"
down_revision = None
branch_labels = None
depends_on = "2a3b4c5d6e7f"
def upgrade() -> None:
op.create_table(
"scheduling_requests",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("location", sa.String(length=500), nullable=True),
sa.Column("timezone", sa.String(length=100), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("poll_id", sa.String(length=36), nullable=True),
sa.Column("selected_slot_id", sa.String(length=36), nullable=True),
sa.Column("organizer_user_id", sa.String(length=36), nullable=True),
sa.Column("deadline_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("allow_external_participants", sa.Boolean(), nullable=False),
sa.Column("allow_participant_updates", sa.Boolean(), nullable=False),
sa.Column("result_visibility", sa.String(length=30), nullable=False),
sa.Column("calendar_integration_enabled", sa.Boolean(), nullable=False),
sa.Column("calendar_id", sa.String(length=36), nullable=True),
sa.Column("calendar_freebusy_enabled", sa.Boolean(), nullable=False),
sa.Column("calendar_hold_enabled", sa.Boolean(), nullable=False),
sa.Column("create_calendar_event_on_decision", sa.Boolean(), nullable=False),
sa.Column("calendar_event_id", sa.String(length=36), nullable=True),
sa.Column("handed_off_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_scheduling_requests")),
)
op.create_index(op.f("ix_scheduling_requests_calendar_event_id"), "scheduling_requests", ["calendar_event_id"], unique=False)
op.create_index(op.f("ix_scheduling_requests_calendar_id"), "scheduling_requests", ["calendar_id"], unique=False)
op.create_index(op.f("ix_scheduling_requests_deadline_at"), "scheduling_requests", ["deadline_at"], unique=False)
op.create_index(op.f("ix_scheduling_requests_deleted_at"), "scheduling_requests", ["deleted_at"], unique=False)
op.create_index(op.f("ix_scheduling_requests_organizer_user_id"), "scheduling_requests", ["organizer_user_id"], unique=False)
op.create_index(op.f("ix_scheduling_requests_poll_id"), "scheduling_requests", ["poll_id"], unique=False)
op.create_index(op.f("ix_scheduling_requests_selected_slot_id"), "scheduling_requests", ["selected_slot_id"], unique=False)
op.create_index(op.f("ix_scheduling_requests_status"), "scheduling_requests", ["status"], unique=False)
op.create_index(op.f("ix_scheduling_requests_tenant_id"), "scheduling_requests", ["tenant_id"], unique=False)
op.create_index("ix_scheduling_requests_deadline", "scheduling_requests", ["tenant_id", "deadline_at"], unique=False)
op.create_index("ix_scheduling_requests_tenant_poll", "scheduling_requests", ["tenant_id", "poll_id"], unique=False)
op.create_index("ix_scheduling_requests_tenant_status", "scheduling_requests", ["tenant_id", "status"], unique=False)
op.create_table(
"scheduling_candidate_slots",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("poll_option_id", sa.String(length=36), nullable=True),
sa.Column("label", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("end_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("timezone", sa.String(length=100), nullable=False),
sa.Column("location", sa.String(length=500), nullable=True),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["request_id"],
["scheduling_requests.id"],
name=op.f("fk_scheduling_candidate_slots_request_id_scheduling_requests"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_scheduling_candidate_slots")),
)
op.create_index(op.f("ix_scheduling_candidate_slots_deleted_at"), "scheduling_candidate_slots", ["deleted_at"], unique=False)
op.create_index(op.f("ix_scheduling_candidate_slots_end_at"), "scheduling_candidate_slots", ["end_at"], unique=False)
op.create_index(op.f("ix_scheduling_candidate_slots_poll_option_id"), "scheduling_candidate_slots", ["poll_option_id"], unique=False)
op.create_index(op.f("ix_scheduling_candidate_slots_request_id"), "scheduling_candidate_slots", ["request_id"], unique=False)
op.create_index(op.f("ix_scheduling_candidate_slots_start_at"), "scheduling_candidate_slots", ["start_at"], unique=False)
op.create_index(op.f("ix_scheduling_candidate_slots_tenant_id"), "scheduling_candidate_slots", ["tenant_id"], unique=False)
op.create_index(
"ix_scheduling_candidate_slots_range",
"scheduling_candidate_slots",
["tenant_id", "start_at", "end_at"],
unique=False,
)
op.create_index(
"ix_scheduling_candidate_slots_request_position",
"scheduling_candidate_slots",
["request_id", "position"],
unique=False,
)
op.create_table(
"scheduling_participants",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("respondent_id", sa.String(length=255), nullable=True),
sa.Column("display_name", sa.String(length=500), nullable=True),
sa.Column("email", sa.String(length=320), nullable=True),
sa.Column("participant_type", sa.String(length=30), nullable=False),
sa.Column("required", sa.Boolean(), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("poll_invitation_id", sa.String(length=36), nullable=True),
sa.Column("last_invited_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("responded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["request_id"],
["scheduling_requests.id"],
name=op.f("fk_scheduling_participants_request_id_scheduling_requests"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_scheduling_participants")),
)
op.create_index(op.f("ix_scheduling_participants_deleted_at"), "scheduling_participants", ["deleted_at"], unique=False)
op.create_index(op.f("ix_scheduling_participants_email"), "scheduling_participants", ["email"], unique=False)
op.create_index(op.f("ix_scheduling_participants_last_invited_at"), "scheduling_participants", ["last_invited_at"], unique=False)
op.create_index(op.f("ix_scheduling_participants_participant_type"), "scheduling_participants", ["participant_type"], unique=False)
op.create_index(op.f("ix_scheduling_participants_poll_invitation_id"), "scheduling_participants", ["poll_invitation_id"], unique=False)
op.create_index(op.f("ix_scheduling_participants_request_id"), "scheduling_participants", ["request_id"], unique=False)
op.create_index(op.f("ix_scheduling_participants_responded_at"), "scheduling_participants", ["responded_at"], unique=False)
op.create_index(op.f("ix_scheduling_participants_respondent_id"), "scheduling_participants", ["respondent_id"], unique=False)
op.create_index(op.f("ix_scheduling_participants_status"), "scheduling_participants", ["status"], unique=False)
op.create_index(op.f("ix_scheduling_participants_tenant_id"), "scheduling_participants", ["tenant_id"], unique=False)
op.create_index("ix_scheduling_participants_request", "scheduling_participants", ["tenant_id", "request_id"], unique=False)
op.create_index("ix_scheduling_participants_request_email", "scheduling_participants", ["request_id", "email"], unique=False)
op.create_index(
"ix_scheduling_participants_request_respondent",
"scheduling_participants",
["request_id", "respondent_id"],
unique=False,
)
def downgrade() -> None:
op.drop_table("scheduling_participants")
op.drop_table("scheduling_candidate_slots")
op.drop_table("scheduling_requests")

View File

@@ -0,0 +1 @@
from __future__ import annotations

View File

@@ -0,0 +1,185 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_poll.backend.schemas import PollResultSummaryResponse
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
SchedulingDecisionRequest,
SchedulingRequestCreateRequest,
SchedulingRequestListResponse,
SchedulingRequestResponse,
SchedulingRequestUpdateRequest,
SchedulingStatusResponse,
SchedulingSummaryResponse,
)
from govoplan_scheduling.backend.service import (
SchedulingError,
cancel_scheduling_request,
close_scheduling_request,
create_scheduling_request,
decide_scheduling_request,
get_scheduling_request,
list_scheduling_requests,
open_scheduling_request,
scheduling_request_response,
scheduling_request_summary,
update_scheduling_request,
)
router = APIRouter(prefix="/scheduling", tags=["scheduling"])
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
if str(exc) == "Scheduling request not found":
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _request_response(request, *, invitation_tokens: dict[str, str] | None = None) -> SchedulingRequestResponse:
return SchedulingRequestResponse.model_validate(
scheduling_request_response(request, invitation_tokens=invitation_tokens)
)
@router.get("/requests", response_model=SchedulingRequestListResponse)
def api_list_scheduling_requests(
status_filter: str | None = Query(default=None, alias="status"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestListResponse:
_require_scope(principal, READ_SCOPE)
requests = list_scheduling_requests(session, tenant_id=principal.tenant_id, status=status_filter)
return SchedulingRequestListResponse(requests=[_request_response(request) for request in requests])
@router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED)
def api_create_scheduling_request(
payload: SchedulingRequestCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request, invitation_tokens = create_scheduling_request(
session,
tenant_id=principal.tenant_id,
user_id=principal.account_id,
payload=payload,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return _request_response(request, invitation_tokens=invitation_tokens)
@router.get("/requests/{request_id}", response_model=SchedulingRequestResponse)
def api_get_scheduling_request(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, READ_SCOPE)
try:
return _request_response(get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id))
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse)
def api_update_scheduling_request(
request_id: str,
payload: SchedulingRequestUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
try:
return _request_response(
update_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
@router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse)
def api_open_scheduling_request(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
@router.post("/requests/{request_id}/close", response_model=SchedulingStatusResponse)
def api_close_scheduling_request(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
@router.post("/requests/{request_id}/decide", response_model=SchedulingStatusResponse)
def api_decide_scheduling_request(
request_id: str,
payload: SchedulingDecisionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request = decide_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
def api_cancel_scheduling_request(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
@router.get("/requests/{request_id}/summary", response_model=SchedulingSummaryResponse)
def api_scheduling_summary(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingSummaryResponse:
_require_scope(principal, READ_SCOPE)
try:
request = get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
summary = scheduling_request_summary(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingSummaryResponse(
request=_request_response(request),
poll_summary=PollResultSummaryResponse.model_validate(summary),
)

View File

@@ -0,0 +1,165 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_poll.backend.schemas import PollResultSummaryResponse
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
SchedulingParticipantType = Literal["internal", "external", "resource"]
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
class SchedulingCalendarPreferences(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool = False
calendar_id: str | None = Field(default=None, max_length=36)
freebusy_enabled: bool = False
tentative_holds_enabled: bool = False
create_event_on_decision: bool = False
class SchedulingCandidateSlotInput(BaseModel):
model_config = ConfigDict(extra="forbid")
label: str | None = Field(default=None, max_length=500)
description: str | None = None
start_at: datetime
end_at: datetime
timezone: str | None = Field(default=None, max_length=100)
location: str | None = Field(default=None, max_length=500)
metadata: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_range(self) -> "SchedulingCandidateSlotInput":
if self.end_at <= self.start_at:
raise ValueError("end_at must be after start_at")
return self
class SchedulingParticipantInput(BaseModel):
model_config = ConfigDict(extra="forbid")
respondent_id: str | None = Field(default=None, max_length=255)
display_name: str | None = Field(default=None, max_length=500)
email: str | None = Field(default=None, max_length=320)
participant_type: SchedulingParticipantType = "external"
required: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingRequestCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=500)
description: str | None = None
location: str | None = Field(default=None, max_length=500)
timezone: str = Field(default="UTC", max_length=100)
status: Literal["draft", "collecting"] = "draft"
deadline_at: datetime | None = None
allow_external_participants: bool = True
allow_participant_updates: bool = True
result_visibility: SchedulingResultVisibility = "after_close"
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
create_participant_invitations: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingRequestUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str | None = Field(default=None, min_length=1, max_length=500)
description: str | None = None
location: str | None = Field(default=None, max_length=500)
deadline_at: datetime | None = None
allow_external_participants: bool | None = None
allow_participant_updates: bool | None = None
result_visibility: SchedulingResultVisibility | None = None
calendar: SchedulingCalendarPreferences | None = None
metadata: dict[str, Any] | None = None
class SchedulingCandidateSlotResponse(BaseModel):
id: str
poll_option_id: str | None = None
label: str
description: str | None = None
start_at: datetime
end_at: datetime
timezone: str
location: str | None = None
position: int
metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingParticipantResponse(BaseModel):
id: str
respondent_id: str | None = None
display_name: str | None = None
email: str | None = None
participant_type: str
required: bool
status: str
poll_invitation_id: str | None = None
invitation_token: str | None = None
last_invited_at: datetime | None = None
responded_at: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingRequestResponse(BaseModel):
id: str
tenant_id: str
title: str
description: str | None = None
location: str | None = None
timezone: str
status: str
poll_id: str | None = None
selected_slot_id: str | None = None
organizer_user_id: str | None = None
deadline_at: datetime | None = None
allow_external_participants: bool
allow_participant_updates: bool
result_visibility: str
calendar_integration_enabled: bool
calendar_id: str | None = None
calendar_freebusy_enabled: bool
calendar_hold_enabled: bool
create_calendar_event_on_decision: bool
calendar_event_id: str | None = None
handed_off_at: datetime | None = None
cancelled_at: datetime | None = None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
slots: list[SchedulingCandidateSlotResponse] = Field(default_factory=list)
participants: list[SchedulingParticipantResponse] = Field(default_factory=list)
class SchedulingRequestListResponse(BaseModel):
requests: list[SchedulingRequestResponse] = Field(default_factory=list)
class SchedulingStatusResponse(BaseModel):
request: SchedulingRequestResponse
class SchedulingDecisionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slot_id: str | None = None
poll_option_id: str | None = None
handoff_to_calendar: bool | None = None
class SchedulingSummaryResponse(BaseModel):
request: SchedulingRequestResponse
poll_summary: PollResultSummaryResponse

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)
],
}

View File

@@ -18,6 +18,8 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertFalse(manifest.required_capabilities) self.assertFalse(manifest.required_capabilities)
self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("evaluation", manifest.optional_dependencies) self.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})

167
tests/test_service.py Normal file
View File

@@ -0,0 +1,167 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.base import Base
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
from govoplan_poll.backend.service import get_poll, submit_poll_response_with_token
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.schemas import (
SchedulingCalendarPreferences,
SchedulingCandidateSlotInput,
SchedulingDecisionRequest,
SchedulingParticipantInput,
SchedulingRequestCreateRequest,
)
from govoplan_scheduling.backend.service import (
SchedulingError,
close_scheduling_request,
create_scheduling_request,
decide_scheduling_request,
scheduling_request_summary,
)
class SchedulingServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
Poll.__table__,
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
SchedulingRequest.__table__,
SchedulingCandidateSlot.__table__,
SchedulingParticipant.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[
SchedulingParticipant.__table__,
SchedulingCandidateSlot.__table__,
SchedulingRequest.__table__,
PollInvitation.__table__,
PollResponse.__table__,
PollOption.__table__,
Poll.__table__,
],
)
self.engine.dispose()
def _payload(self) -> SchedulingRequestCreateRequest:
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
return SchedulingRequestCreateRequest(
title="Steering group",
description="Find a slot",
location="Room 1",
timezone="Europe/Berlin",
status="collecting",
deadline_at=start + timedelta(days=3),
calendar=SchedulingCalendarPreferences(
enabled=True,
calendar_id="calendar-1",
freebusy_enabled=True,
create_event_on_decision=True,
),
slots=[
SchedulingCandidateSlotInput(start_at=start, end_at=start + timedelta(hours=1), label="Monday 09:00"),
SchedulingCandidateSlotInput(start_at=start + timedelta(days=1), end_at=start + timedelta(days=1, hours=1), label="Tuesday 09:00"),
],
participants=[
SchedulingParticipantInput(display_name="Alice", email="alice@example.test"),
SchedulingParticipantInput(display_name="Bob", email="bob@example.test", required=False),
],
)
def test_create_request_creates_poll_slots_and_signed_invitations(self) -> None:
request, tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
)
poll = get_poll(self.session, tenant_id="tenant-1", poll_id=request.poll_id)
self.assertEqual(request.status, "collecting")
self.assertEqual(request.calendar_id, "calendar-1")
self.assertEqual(poll.kind, "availability")
self.assertEqual(poll.status, "open")
self.assertEqual(poll.context_module, "scheduling")
self.assertEqual(poll.context_resource_id, request.id)
self.assertEqual(len(request.slots), 2)
self.assertTrue(all(slot.poll_option_id for slot in request.slots))
self.assertEqual(len(tokens), 2)
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants))
def test_signed_response_summary_and_decision_handoff(self) -> None:
request, tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
)
first_participant = request.participants[0]
first_slot = request.slots[0]
second_slot = request.slots[1]
submit_poll_response_with_token(
self.session,
token=tokens[first_participant.id],
payload=PollSubmitResponseRequest(
answers=[
PollAnswerInput(option_id=first_slot.poll_option_id, value="available"),
PollAnswerInput(option_id=second_slot.poll_option_id, value="maybe"),
]
),
)
summary = scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)
self.assertEqual(summary["response_count"], 1)
self.assertEqual(first_participant.status, "responded")
self.assertIsNotNone(first_participant.responded_at)
values = {result["option_id"]: result["values"] for result in summary["option_results"]}
self.assertEqual(values[first_slot.poll_option_id]["available"], 1)
self.assertEqual(values[second_slot.poll_option_id]["maybe"], 1)
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
decided = decide_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
payload=SchedulingDecisionRequest(slot_id=first_slot.id, handoff_to_calendar=True),
)
poll = get_poll(self.session, tenant_id="tenant-1", poll_id=request.poll_id)
self.assertEqual(decided.status, "handed_off")
self.assertEqual(decided.selected_slot_id, first_slot.id)
self.assertEqual(decided.metadata_["calendar_handoff"]["status"], "pending")
self.assertEqual(poll.status, "decided")
self.assertEqual(poll.workflow_state, "handed_off")
def test_external_participants_can_be_rejected(self) -> None:
payload = self._payload().model_copy(update={"allow_external_participants": False})
with self.assertRaisesRegex(SchedulingError, "External participants are not allowed"):
create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=payload,
)
if __name__ == "__main__":
unittest.main()