diff --git a/README.md b/README.md index a56d8fa..05337e3 100644 --- a/README.md +++ b/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. diff --git a/src/govoplan_scheduling/backend/db/__init__.py b/src/govoplan_scheduling/backend/db/__init__.py new file mode 100644 index 0000000..03f504d --- /dev/null +++ b/src/govoplan_scheduling/backend/db/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest + +__all__ = ["SchedulingCandidateSlot", "SchedulingParticipant", "SchedulingRequest"] diff --git a/src/govoplan_scheduling/backend/db/models.py b/src/govoplan_scheduling/backend/db/models.py new file mode 100644 index 0000000..ac03ae8 --- /dev/null +++ b/src/govoplan_scheduling/backend/db/models.py @@ -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"] diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index 0a52bd2..53a2dd4 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -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, ) diff --git a/src/govoplan_scheduling/backend/migrations/__init__.py b/src/govoplan_scheduling/backend/migrations/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/src/govoplan_scheduling/backend/migrations/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/govoplan_scheduling/backend/migrations/versions/3c4d5e6f7a8b_v018_scheduling_baseline.py b/src/govoplan_scheduling/backend/migrations/versions/3c4d5e6f7a8b_v018_scheduling_baseline.py new file mode 100644 index 0000000..bab41c3 --- /dev/null +++ b/src/govoplan_scheduling/backend/migrations/versions/3c4d5e6f7a8b_v018_scheduling_baseline.py @@ -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") diff --git a/src/govoplan_scheduling/backend/migrations/versions/__init__.py b/src/govoplan_scheduling/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/src/govoplan_scheduling/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py new file mode 100644 index 0000000..58a2629 --- /dev/null +++ b/src/govoplan_scheduling/backend/router.py @@ -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), + ) diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py new file mode 100644 index 0000000..ff60d4d --- /dev/null +++ b/src/govoplan_scheduling/backend/schemas.py @@ -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 diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py new file mode 100644 index 0000000..04ef9cc --- /dev/null +++ b/src/govoplan_scheduling/backend/service.py @@ -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) + ], + } diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 3a67dee..8e45387 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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}) diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..25d429e --- /dev/null +++ b/tests/test_service.py @@ -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()