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
The first implementation slice should add runtime APIs and WebUI routes around
the existing manifest seed:
The first backend implementation slice adds runtime APIs and storage around
poll-backed scheduling requests:
- backend manifest, permissions, DTOs, and APIs for internal polls
- WebUI route contribution for poll creation and response collection
- capability contracts for calendar free/busy, access/IDM lookup,
mail/notification sending, portal participation links, appointment handoff,
and workflow/task follow-up
- scheduling request, candidate slot, and participant storage
- automatic `govoplan-poll` availability poll creation
- signed poll invitations for internal or external participants
- request lifecycle APIs: draft, collecting, closed, decided, handed off, cancelled
- 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.

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 pathlib import Path
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 (
DocumentationTopic,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
PermissionDefinition,
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_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(
id=MODULE_ID,
name=MODULE_NAME,
@@ -96,6 +132,29 @@ manifest = ModuleManifest(
),
permissions=PERMISSIONS,
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,
)

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.assertIn("auth.principalResolver", manifest.optional_capabilities)
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.workflow_context", {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()