Add poll-backed scheduling backend
This commit is contained in:
19
README.md
19
README.md
@@ -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.
|
||||
|
||||
5
src/govoplan_scheduling/backend/db/__init__.py
Normal file
5
src/govoplan_scheduling/backend/db/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
|
||||
|
||||
__all__ = ["SchedulingCandidateSlot", "SchedulingParticipant", "SchedulingRequest"]
|
||||
112
src/govoplan_scheduling/backend/db/models.py
Normal file
112
src/govoplan_scheduling/backend/db/models.py
Normal 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"]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
1
src/govoplan_scheduling/backend/migrations/__init__.py
Normal file
1
src/govoplan_scheduling/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
185
src/govoplan_scheduling/backend/router.py
Normal file
185
src/govoplan_scheduling/backend/router.py
Normal 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),
|
||||
)
|
||||
165
src/govoplan_scheduling/backend/schemas.py
Normal file
165
src/govoplan_scheduling/backend/schemas.py
Normal 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
|
||||
513
src/govoplan_scheduling/backend/service.py
Normal file
513
src/govoplan_scheduling/backend/service.py
Normal 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)
|
||||
],
|
||||
}
|
||||
@@ -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
167
tests/test_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user