Add scheduling calendar integration and WebUI

This commit is contained in:
2026-07-12 19:00:54 +02:00
parent ace32a2a3d
commit c1afce7bdb
19 changed files with 1702 additions and 25 deletions

View File

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

View File

@@ -77,6 +77,10 @@ class SchedulingCandidateSlot(Base, TimestampMixin):
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)
freebusy_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
freebusy_status: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
freebusy_conflicts: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
tentative_hold_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=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)
@@ -109,4 +113,31 @@ class SchedulingParticipant(Base, TimestampMixin):
request: Mapped[SchedulingRequest] = relationship(back_populates="participants")
__all__ = ["SchedulingCandidateSlot", "SchedulingParticipant", "SchedulingRequest", "new_uuid"]
class SchedulingNotification(Base, TimestampMixin):
__tablename__ = "scheduling_notifications"
__table_args__ = (
Index("ix_scheduling_notifications_request_status", "request_id", "status"),
Index("ix_scheduling_notifications_tenant_status", "tenant_id", "status"),
)
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)
participant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
event_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
channel: Mapped[str] = mapped_column(String(40), default="mail", nullable=False, index=True)
recipient: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(40), default="pending", nullable=False, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
error: Mapped[str | None] = mapped_column(Text)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
__all__ = [
"SchedulingCandidateSlot",
"SchedulingNotification",
"SchedulingParticipant",
"SchedulingRequest",
"new_uuid",
]

View File

@@ -6,11 +6,13 @@ from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPA
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
@@ -86,7 +88,7 @@ DOCUMENTATION = (
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
return {
"scheduling_requests": (
@@ -104,6 +106,11 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
.filter(SchedulingParticipant.tenant_id == tenant_id, SchedulingParticipant.deleted_at.is_(None))
.count()
),
"scheduling_notifications": (
session.query(SchedulingNotification)
.filter(SchedulingNotification.tenant_id == tenant_id, SchedulingNotification.status == "pending")
.count()
),
}
@@ -132,6 +139,12 @@ manifest = ModuleManifest(
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/scheduling-webui",
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
),
route_factory=_scheduling_router,
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
@@ -143,6 +156,7 @@ manifest = ModuleManifest(
scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant,
scheduling_models.SchedulingNotification,
label="Scheduling",
),
retirement_notes="Destructive retirement drops scheduling-owned database tables after the installer captures a database snapshot.",
@@ -152,6 +166,7 @@ manifest = ModuleManifest(
scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant,
scheduling_models.SchedulingNotification,
label="Scheduling",
),
),

View File

@@ -0,0 +1,74 @@
"""v0.1.8 scheduling calendar integration and notification outbox
Revision ID: 4d5e6f7a8b9c
Revises: 3c4d5e6f7a8b
Create Date: 2026-07-12 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4d5e6f7a8b9c"
down_revision = "3c4d5e6f7a8b"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_checked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_status", sa.String(length=40), nullable=True))
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("scheduling_candidate_slots", sa.Column("tentative_hold_event_id", sa.String(length=36), nullable=True))
op.create_index(op.f("ix_scheduling_candidate_slots_freebusy_status"), "scheduling_candidate_slots", ["freebusy_status"], unique=False)
op.create_index(
op.f("ix_scheduling_candidate_slots_tentative_hold_event_id"),
"scheduling_candidate_slots",
["tentative_hold_event_id"],
unique=False,
)
op.create_table(
"scheduling_notifications",
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("participant_id", sa.String(length=36), nullable=True),
sa.Column("event_kind", sa.String(length=40), nullable=False),
sa.Column("channel", sa.String(length=40), nullable=False),
sa.Column("recipient", sa.String(length=500), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("sent_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_notifications_request_id_scheduling_requests"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_scheduling_notifications")),
)
op.create_index(op.f("ix_scheduling_notifications_channel"), "scheduling_notifications", ["channel"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_event_kind"), "scheduling_notifications", ["event_kind"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_participant_id"), "scheduling_notifications", ["participant_id"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_recipient"), "scheduling_notifications", ["recipient"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_request_id"), "scheduling_notifications", ["request_id"], unique=False)
op.create_index("ix_scheduling_notifications_request_status", "scheduling_notifications", ["request_id", "status"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_status"), "scheduling_notifications", ["status"], unique=False)
op.create_index(op.f("ix_scheduling_notifications_tenant_id"), "scheduling_notifications", ["tenant_id"], unique=False)
op.create_index("ix_scheduling_notifications_tenant_status", "scheduling_notifications", ["tenant_id", "status"], unique=False)
def downgrade() -> None:
op.drop_table("scheduling_notifications")
op.drop_index(op.f("ix_scheduling_candidate_slots_tentative_hold_event_id"), table_name="scheduling_candidate_slots")
op.drop_index(op.f("ix_scheduling_candidate_slots_freebusy_status"), table_name="scheduling_candidate_slots")
op.drop_column("scheduling_candidate_slots", "tentative_hold_event_id")
op.drop_column("scheduling_candidate_slots", "freebusy_conflicts")
op.drop_column("scheduling_candidate_slots", "freebusy_status")
op.drop_column("scheduling_candidate_slots", "freebusy_checked_at")

View File

@@ -8,7 +8,11 @@ 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 (
SchedulingCalendarActionResponse,
SchedulingDecisionRequest,
SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse,
SchedulingNotificationResponse,
SchedulingRequestCreateRequest,
SchedulingRequestListResponse,
SchedulingRequestResponse,
@@ -20,11 +24,17 @@ from govoplan_scheduling.backend.service import (
SchedulingError,
cancel_scheduling_request,
close_scheduling_request,
create_final_calendar_event,
create_scheduling_notification_jobs,
create_scheduling_request,
create_tentative_calendar_holds,
decide_scheduling_request,
evaluate_calendar_freebusy,
get_scheduling_request,
list_scheduling_notifications,
list_scheduling_requests,
open_scheduling_request,
scheduling_notification_response,
scheduling_request_response,
scheduling_request_summary,
update_scheduling_request,
@@ -147,12 +157,83 @@ def api_decide_scheduling_request(
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request = decide_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
request = decide_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
payload=payload,
user_id=principal.account_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
@router.post("/requests/{request_id}/calendar/freebusy", response_model=SchedulingCalendarActionResponse)
def api_evaluate_calendar_freebusy(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
warnings=warnings,
)
@router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse)
def api_create_tentative_calendar_holds(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request, created_event_ids, warnings = create_tentative_calendar_holds(
session,
tenant_id=principal.tenant_id,
user_id=principal.account_id,
request_id=request_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
created_event_ids=created_event_ids,
updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids],
warnings=warnings,
)
@router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse)
def api_create_final_calendar_event(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request, event_id, warnings = create_final_calendar_event(
session,
tenant_id=principal.tenant_id,
user_id=principal.account_id,
request_id=request_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
created_event_ids=[event_id] if event_id else [],
warnings=warnings,
)
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
def api_cancel_scheduling_request(
request_id: str,
@@ -183,3 +264,52 @@ def api_scheduling_summary(
request=_request_response(request),
poll_summary=PollResultSummaryResponse.model_validate(summary),
)
@router.get("/notifications", response_model=SchedulingNotificationListResponse)
def api_list_scheduling_notifications(
request_id: str | None = None,
status_filter: str | None = Query(default=None, alias="status"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingNotificationListResponse:
_require_scope(principal, READ_SCOPE)
notifications = list_scheduling_notifications(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
status=status_filter,
)
return SchedulingNotificationListResponse(
notifications=[
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
for notification in notifications
]
)
@router.post("/requests/{request_id}/notifications", response_model=SchedulingNotificationListResponse)
def api_create_scheduling_notifications(
request_id: str,
payload: SchedulingNotificationCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingNotificationListResponse:
_require_scope(principal, WRITE_SCOPE)
try:
notifications = create_scheduling_notification_jobs(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
event_kind=payload.event_kind,
channel=payload.channel,
metadata=payload.metadata,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingNotificationListResponse(
notifications=[
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
for notification in notifications
]
)

View File

@@ -96,6 +96,10 @@ class SchedulingCandidateSlotResponse(BaseModel):
timezone: str
location: str | None = None
position: int
freebusy_checked_at: datetime | None = None
freebusy_status: str | None = None
freebusy_conflicts: list[dict[str, Any]] = Field(default_factory=list)
tentative_hold_event_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
@@ -163,3 +167,38 @@ class SchedulingDecisionRequest(BaseModel):
class SchedulingSummaryResponse(BaseModel):
request: SchedulingRequestResponse
poll_summary: PollResultSummaryResponse
class SchedulingCalendarActionResponse(BaseModel):
request: SchedulingRequestResponse
created_event_ids: list[str] = Field(default_factory=list)
updated_slot_ids: list[str] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
class SchedulingNotificationResponse(BaseModel):
id: str
request_id: str
participant_id: str | None = None
event_kind: str
channel: str
recipient: str | None = None
status: str
payload: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
sent_at: datetime | None = None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingNotificationListResponse(BaseModel):
notifications: list[SchedulingNotificationResponse] = Field(default_factory=list)
class SchedulingNotificationCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
event_kind: Literal["invitation", "reminder", "decision", "cancellation"]
channel: str = Field(default="mail", max_length=40)
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -18,7 +18,7 @@ from govoplan_poll.backend.service import (
open_poll,
poll_result_summary_by_id,
)
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.schemas import (
SchedulingDecisionRequest,
SchedulingRequestCreateRequest,
@@ -36,6 +36,7 @@ WORKFLOW_CLOSED = "availability_closed"
WORKFLOW_DECIDED = "slot_decided"
WORKFLOW_HANDED_OFF = "handed_off"
WORKFLOW_CANCELLED = "cancelled"
NOTIFICATION_KINDS = {"invitation", "reminder", "decision", "cancellation"}
def response_datetime(value: datetime | None) -> datetime | None:
@@ -46,6 +47,17 @@ def response_datetime(value: datetime | None) -> datetime | None:
return value.astimezone(timezone.utc)
def _jsonable(value: Any) -> Any:
if isinstance(value, datetime):
normalized = response_datetime(value)
return normalized.isoformat() if normalized else None
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, list):
return [_jsonable(item) for item in value]
return value
def _now() -> datetime:
return utcnow()
@@ -148,6 +160,238 @@ def _set_calendar_preferences(request: SchedulingRequest, payload) -> None:
request.create_calendar_event_on_decision = False
def _calendar_api():
try:
from govoplan_calendar.backend.schemas import CalendarEventCreateRequest
from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy
except ModuleNotFoundError as exc:
raise SchedulingError("Calendar module is not installed") from exc
return CalendarEventCreateRequest, CalendarError, create_event, list_freebusy
def _require_calendar_enabled(request: SchedulingRequest) -> None:
if not request.calendar_integration_enabled:
raise SchedulingError("Calendar integration is not enabled for this scheduling request")
if not request.calendar_id:
raise SchedulingError("Scheduling request has no target calendar")
def _attendees_for_calendar_event(request: SchedulingRequest) -> list[dict[str, Any]]:
attendees: list[dict[str, Any]] = []
for participant in _active_participants(request):
if not participant.email and not participant.display_name:
continue
attendees.append(
{
"email": participant.email,
"name": participant.display_name,
"required": participant.required,
"participant_id": participant.id,
"participant_type": participant.participant_type,
}
)
return attendees
def _calendar_event_payload(
request: SchedulingRequest,
slot: SchedulingCandidateSlot,
*,
status: str,
summary_prefix: str | None = None,
) -> Any:
CalendarEventCreateRequest, _CalendarError, _create_event, _list_freebusy = _calendar_api()
summary = f"{summary_prefix}: {request.title}" if summary_prefix else request.title
return CalendarEventCreateRequest(
calendar_id=request.calendar_id,
summary=summary,
description=request.description,
location=slot.location or request.location,
status=status,
transparency="OPAQUE",
classification="PRIVATE" if status == "TENTATIVE" else "PUBLIC",
start_at=slot.start_at,
end_at=slot.end_at,
timezone=slot.timezone,
attendees=_attendees_for_calendar_event(request),
categories=["GovOPlaN", "Scheduling"],
related_to=[{"reltype": "SIBLING", "uid": request.poll_id, "type": "poll"}] if request.poll_id else [],
metadata={
"scheduling_request_id": request.id,
"scheduling_slot_id": slot.id,
"poll_id": request.poll_id,
"kind": "tentative_hold" if status == "TENTATIVE" else "scheduled_meeting",
},
)
def evaluate_calendar_freebusy(
session: Session,
*,
tenant_id: str,
request_id: str,
) -> tuple[SchedulingRequest, list[str]]:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
_require_calendar_enabled(request)
if not request.calendar_freebusy_enabled:
raise SchedulingError("Calendar free/busy checks are not enabled for this scheduling request")
_CalendarEventCreateRequest, CalendarError, _create_event, list_freebusy = _calendar_api()
warnings: list[str] = []
for slot in _active_slots(request):
try:
conflicts = list_freebusy(
session,
tenant_id=tenant_id,
start_at=slot.start_at,
end_at=slot.end_at,
calendar_ids=[request.calendar_id] if request.calendar_id else None,
)
except CalendarError as exc:
slot.freebusy_checked_at = _now()
slot.freebusy_status = "error"
slot.freebusy_conflicts = [{"error": str(exc)}]
warnings.append(str(exc))
continue
slot.freebusy_checked_at = _now()
slot.freebusy_conflicts = _jsonable(conflicts)
slot.freebusy_status = "busy" if conflicts else "free"
session.flush()
return request, warnings
def create_tentative_calendar_holds(
session: Session,
*,
tenant_id: str,
user_id: str | None,
request_id: str,
) -> tuple[SchedulingRequest, list[str], list[str]]:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
_require_calendar_enabled(request)
if not request.calendar_hold_enabled:
raise SchedulingError("Tentative calendar holds are not enabled for this scheduling request")
_CalendarEventCreateRequest, CalendarError, create_event, _list_freebusy = _calendar_api()
created_event_ids: list[str] = []
warnings: list[str] = []
for slot in _active_slots(request):
if slot.tentative_hold_event_id:
continue
try:
event = create_event(
session,
tenant_id=tenant_id,
user_id=user_id,
payload=_calendar_event_payload(request, slot, status="TENTATIVE", summary_prefix="Tentative hold"),
)
except CalendarError as exc:
warnings.append(str(exc))
continue
slot.tentative_hold_event_id = event.id
created_event_ids.append(event.id)
session.flush()
return request, created_event_ids, warnings
def create_final_calendar_event(
session: Session,
*,
tenant_id: str,
user_id: str | None,
request_id: str,
) -> tuple[SchedulingRequest, str | None, list[str]]:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
_require_calendar_enabled(request)
if not request.create_calendar_event_on_decision:
raise SchedulingError("Final calendar event creation is not enabled for this scheduling request")
if request.calendar_event_id:
return request, request.calendar_event_id, []
if not request.selected_slot_id:
raise SchedulingError("Scheduling request has no selected slot")
slot = _selected_slot(request, slot_id=request.selected_slot_id)
_CalendarEventCreateRequest, CalendarError, create_event, _list_freebusy = _calendar_api()
try:
event = create_event(
session,
tenant_id=tenant_id,
user_id=user_id,
payload=_calendar_event_payload(request, slot, status="CONFIRMED"),
)
except CalendarError as exc:
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {"status": "error", "error": str(exc), "slot_id": slot.id, "calendar_id": request.calendar_id},
}
session.flush()
return request, None, [str(exc)]
request.calendar_event_id = event.id
request.handed_off_at = _now()
request.status = "handed_off"
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {"status": "created", "event_id": event.id, "slot_id": slot.id, "calendar_id": request.calendar_id},
}
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request, event.id, []
def create_scheduling_notification_jobs(
session: Session,
*,
tenant_id: str,
request_id: str,
event_kind: str,
channel: str = "mail",
metadata: dict[str, Any] | None = None,
) -> list[SchedulingNotification]:
if event_kind not in NOTIFICATION_KINDS:
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
notifications: list[SchedulingNotification] = []
for participant in _active_participants(request):
recipient = participant.email or participant.respondent_id
status = "pending" if recipient else "skipped"
notification = SchedulingNotification(
tenant_id=tenant_id,
request_id=request.id,
participant_id=participant.id,
event_kind=event_kind,
channel=channel,
recipient=recipient,
status=status,
payload={
"title": request.title,
"request_id": request.id,
"poll_id": request.poll_id,
"participant_id": participant.id,
"selected_slot_id": request.selected_slot_id,
"calendar_event_id": request.calendar_event_id,
},
metadata_=metadata or {},
)
if status == "skipped":
notification.error = "Participant has no deliverable recipient address or id"
session.add(notification)
notifications.append(notification)
session.flush()
return notifications
def list_scheduling_notifications(
session: Session,
*,
tenant_id: str,
request_id: str | None = None,
status: str | None = None,
) -> list[SchedulingNotification]:
query = session.query(SchedulingNotification).filter(SchedulingNotification.tenant_id == tenant_id)
if request_id:
query = query.filter(SchedulingNotification.request_id == request_id)
if status:
query = query.filter(SchedulingNotification.status == status)
return query.order_by(SchedulingNotification.created_at.desc(), SchedulingNotification.id.asc()).all()
def refresh_participant_response_state(session: Session, *, request: SchedulingRequest) -> None:
if request.poll_id is None:
return
@@ -290,6 +534,13 @@ def create_scheduling_request(
participant.last_invited_at = _now()
participant.status = "invited"
invitation_tokens[participant.id] = token
create_scheduling_notification_jobs(
session,
tenant_id=tenant_id,
request_id=request.id,
event_kind="invitation",
metadata={"created_with_request": True},
)
session.flush()
return request, invitation_tokens
@@ -368,6 +619,7 @@ def decide_scheduling_request(
tenant_id: str,
request_id: str,
payload: SchedulingDecisionRequest,
user_id: str | None = None,
) -> SchedulingRequest:
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.poll_id is None:
@@ -384,20 +636,25 @@ def decide_scheduling_request(
raise SchedulingError(str(exc)) from exc
request.selected_slot_id = slot.id
request.status = "decided"
if payload.handoff_to_calendar or (
should_handoff = 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,
},
}
)
if should_handoff:
if request.calendar_integration_enabled and request.create_calendar_event_on_decision:
create_final_calendar_event(session, tenant_id=tenant_id, user_id=user_id, request_id=request.id)
else:
request.handed_off_at = _now()
request.status = "handed_off"
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {
"status": "pending",
"reason": "Calendar event creation is not enabled or Calendar is unavailable.",
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="decision")
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request
@@ -415,6 +672,7 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
except PollError as exc:
raise SchedulingError(str(exc)) from exc
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="cancellation")
session.flush()
return request
@@ -456,6 +714,10 @@ def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]:
"timezone": slot.timezone,
"location": slot.location,
"position": slot.position,
"freebusy_checked_at": response_datetime(slot.freebusy_checked_at),
"freebusy_status": slot.freebusy_status,
"freebusy_conflicts": slot.freebusy_conflicts or [],
"tentative_hold_event_id": slot.tentative_hold_event_id,
"metadata": slot.metadata_ or {},
}
@@ -511,3 +773,21 @@ def scheduling_request_response(request: SchedulingRequest, *, invitation_tokens
for participant in _active_participants(request)
],
}
def scheduling_notification_response(notification: SchedulingNotification) -> dict[str, Any]:
return {
"id": notification.id,
"request_id": notification.request_id,
"participant_id": notification.participant_id,
"event_kind": notification.event_kind,
"channel": notification.channel,
"recipient": notification.recipient,
"status": notification.status,
"payload": notification.payload or {},
"error": notification.error,
"sent_at": response_datetime(notification.sent_at),
"created_at": response_datetime(notification.created_at),
"updated_at": response_datetime(notification.updated_at),
"metadata": notification.metadata_ or {},
}