1856 lines
68 KiB
Python
1856 lines
68 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session, object_session
|
|
|
|
from govoplan_core.core.calendar import (
|
|
CalendarCapabilityError,
|
|
CalendarEventRequest,
|
|
CalendarSchedulingProvider,
|
|
calendar_scheduling_provider,
|
|
)
|
|
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
|
from govoplan_core.core.poll import (
|
|
PollAnswerRequest,
|
|
PollCapabilityError,
|
|
PollCreateCommand,
|
|
PollInvitationCommand,
|
|
PollOptionUpdateCommand,
|
|
PollOptionRequest,
|
|
PollResponseRef,
|
|
PollResponseSubmissionProvider,
|
|
PollSchedulingProvider,
|
|
PollSubmitResponseCommand,
|
|
PollUpdateCommand,
|
|
poll_response_submission_provider,
|
|
poll_scheduling_provider,
|
|
)
|
|
from govoplan_core.core.policy import (
|
|
SchedulingParticipantPrivacyDecision,
|
|
SchedulingParticipantPrivacyRequest,
|
|
scheduling_participant_privacy_policy,
|
|
)
|
|
from govoplan_core.db.base import utcnow
|
|
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
|
|
from govoplan_scheduling.backend.schemas import (
|
|
SchedulingAvailabilityResponseRequest,
|
|
SchedulingCandidateSlotUpdateRequest,
|
|
SchedulingDecisionRequest,
|
|
SchedulingRequestCreateRequest,
|
|
SchedulingRequestUpdateRequest,
|
|
)
|
|
from govoplan_scheduling.backend.runtime import get_registry
|
|
|
|
|
|
class SchedulingError(ValueError):
|
|
pass
|
|
|
|
|
|
class SchedulingPermissionError(SchedulingError):
|
|
pass
|
|
|
|
|
|
class SchedulingConflictError(SchedulingError):
|
|
pass
|
|
|
|
|
|
WORKFLOW_DRAFT = "draft"
|
|
WORKFLOW_COLLECTING = "collecting_availability"
|
|
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:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
return value.replace(tzinfo=timezone.utc)
|
|
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, tuple)):
|
|
return [_jsonable(item) for item in value]
|
|
return value
|
|
|
|
|
|
def scheduling_slot_revision(slot: SchedulingCandidateSlot) -> str:
|
|
"""Stable semantic revision used to reject responses from stale forms."""
|
|
|
|
snapshot = {
|
|
"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,
|
|
"metadata": slot.metadata_ or {},
|
|
}
|
|
encoded = json.dumps(
|
|
_jsonable(snapshot),
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
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 _participant_for_actor(
|
|
request: SchedulingRequest,
|
|
*,
|
|
actor_ids: tuple[str, ...],
|
|
) -> SchedulingParticipant:
|
|
ids = _actor_ids(actor_ids)
|
|
participants = [
|
|
participant
|
|
for participant in _active_participants(request)
|
|
if participant.respondent_id in ids or participant.email in ids
|
|
]
|
|
if len(participants) != 1:
|
|
raise SchedulingPermissionError(
|
|
"A unique participant assignment is required to respond"
|
|
)
|
|
return participants[0]
|
|
|
|
|
|
def _participant_respondent_id(
|
|
participant: SchedulingParticipant,
|
|
*,
|
|
fallback_respondent_id: str | None,
|
|
) -> str:
|
|
if participant.respondent_id:
|
|
return participant.respondent_id
|
|
if participant.poll_invitation_id:
|
|
return f"invitation:{participant.poll_invitation_id}"
|
|
if fallback_respondent_id:
|
|
return fallback_respondent_id
|
|
raise SchedulingPermissionError("An authenticated account is required to respond")
|
|
|
|
|
|
def _participant_poll_response(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
participant: SchedulingParticipant,
|
|
actor_ids: tuple[str, ...],
|
|
canonical_respondent_id: str,
|
|
) -> PollResponseRef | None:
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling request has no backing poll")
|
|
try:
|
|
return _poll_provider().get_response(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
respondent_ids=_actor_ids((*actor_ids, canonical_respondent_id)),
|
|
invitation_id=participant.poll_invitation_id,
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
|
|
|
|
def _poll_option_inputs(request: SchedulingRequest) -> list[PollOptionRequest]:
|
|
options: list[PollOptionRequest] = []
|
|
for slot in _active_slots(request):
|
|
options.append(
|
|
PollOptionRequest(
|
|
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
|
|
state = _poll_workflow_state(request)
|
|
try:
|
|
_poll_provider().set_workflow_context(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
poll_id=request.poll_id,
|
|
workflow_state=state,
|
|
workflow_steps=_workflow_steps(state),
|
|
context_module="scheduling",
|
|
context_resource_type="scheduling_request",
|
|
context_resource_id=request.id,
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
|
|
|
|
def _poll_provider() -> PollSchedulingProvider:
|
|
provider = poll_scheduling_provider(get_registry())
|
|
if provider is None:
|
|
raise SchedulingError("Poll scheduling capability is unavailable")
|
|
return provider
|
|
|
|
|
|
def _poll_response_provider() -> PollResponseSubmissionProvider:
|
|
provider = poll_response_submission_provider(get_registry())
|
|
if provider is None:
|
|
raise SchedulingError("Poll response submission capability is unavailable")
|
|
return provider
|
|
|
|
|
|
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 _calendar_provider() -> CalendarSchedulingProvider:
|
|
provider = calendar_scheduling_provider(get_registry())
|
|
if provider is None:
|
|
raise SchedulingError("Calendar scheduling capability is unavailable")
|
|
return provider
|
|
|
|
|
|
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 _require_calendar_planning_state(request: SchedulingRequest) -> None:
|
|
if request.status not in {"draft", "collecting", "closed"} or request.selected_slot_id:
|
|
raise SchedulingError(
|
|
"Calendar availability checks and tentative holds are only available before a scheduling decision"
|
|
)
|
|
|
|
|
|
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:
|
|
summary = f"{summary_prefix}: {request.title}" if summary_prefix else request.title
|
|
summary = summary[:500]
|
|
return CalendarEventRequest(
|
|
calendar_id=request.calendar_id,
|
|
summary=summary,
|
|
description=request.description,
|
|
location=slot.location or request.location,
|
|
status=status,
|
|
transparency="OPAQUE",
|
|
classification="PRIVATE",
|
|
start_at=slot.start_at,
|
|
end_at=slot.end_at,
|
|
timezone=slot.timezone,
|
|
attendees=tuple(_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 = _lock_scheduling_calendar_handoff(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
lock_slots=True,
|
|
)
|
|
_require_calendar_planning_state(request)
|
|
_require_calendar_enabled(request)
|
|
if not request.calendar_freebusy_enabled:
|
|
raise SchedulingError("Calendar free/busy checks are not enabled for this scheduling request")
|
|
provider = _calendar_provider()
|
|
warnings: list[str] = []
|
|
for slot in _active_slots(request):
|
|
try:
|
|
conflicts = provider.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 CalendarCapabilityError 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 = _lock_scheduling_calendar_handoff(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
lock_slots=True,
|
|
)
|
|
_require_calendar_planning_state(request)
|
|
_require_calendar_enabled(request)
|
|
if not request.calendar_hold_enabled:
|
|
raise SchedulingError("Tentative calendar holds are not enabled for this scheduling request")
|
|
provider = _calendar_provider()
|
|
created_event_ids: list[str] = []
|
|
warnings: list[str] = []
|
|
for slot in _active_slots(request):
|
|
if slot.tentative_hold_event_id:
|
|
continue
|
|
try:
|
|
event = provider.create_event(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
request=_calendar_event_payload(request, slot, status="TENTATIVE", summary_prefix="Tentative hold"),
|
|
)
|
|
except CalendarCapabilityError as exc:
|
|
warnings.append(str(exc))
|
|
continue
|
|
slot.tentative_hold_event_id = event.id
|
|
slot.metadata_ = {
|
|
**(slot.metadata_ or {}),
|
|
"calendar_hold": {
|
|
"event_id": event.id,
|
|
# This is a creation-time snapshot. Calendar remains the
|
|
# authority for later outbox delivery transitions.
|
|
"last_known_external_state": event.external_state,
|
|
"outbox_operation_id": event.outbox_operation_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 = _lock_scheduling_calendar_handoff(
|
|
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.status == "handed_off" and request.selected_slot_id and request.calendar_event_id:
|
|
return request, request.calendar_event_id, []
|
|
if request.status != "decided" or not request.selected_slot_id:
|
|
raise SchedulingError(
|
|
"A final calendar event can only be created for a decided scheduling request with a selected slot"
|
|
)
|
|
if request.calendar_event_id:
|
|
return request, request.calendar_event_id, []
|
|
slot = _selected_slot(request, slot_id=request.selected_slot_id)
|
|
provider = _calendar_provider()
|
|
try:
|
|
event = provider.create_event(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
|
|
)
|
|
except CalendarCapabilityError 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": "accepted",
|
|
# Scheduling stores identifiers plus an explicitly named snapshot;
|
|
# consumers must resolve the Calendar event/outbox for live state.
|
|
"last_known_external_state": event.external_state,
|
|
"outbox_operation_id": event.outbox_operation_id,
|
|
"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,
|
|
invitation_tokens: dict[str, str] | 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] = []
|
|
base_metadata = metadata or {}
|
|
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_=dict(base_metadata),
|
|
)
|
|
if status == "skipped":
|
|
notification.error = "Participant has no deliverable recipient address or id"
|
|
session.add(notification)
|
|
notifications.append(notification)
|
|
session.flush()
|
|
_mirror_scheduling_notifications_to_center(
|
|
session,
|
|
request=request,
|
|
notifications=notifications,
|
|
invitation_tokens=invitation_tokens,
|
|
)
|
|
return notifications
|
|
|
|
|
|
def _mirror_scheduling_notifications_to_center(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
notifications: list[SchedulingNotification],
|
|
invitation_tokens: dict[str, str] | None = None,
|
|
) -> None:
|
|
provider = notification_dispatch_provider(get_registry())
|
|
if provider is None:
|
|
return
|
|
participants_by_id = {participant.id: participant for participant in _active_participants(request)}
|
|
for notification in notifications:
|
|
if notification.status == "skipped":
|
|
continue
|
|
participant = participants_by_id.get(notification.participant_id or "")
|
|
invitation_token = invitation_tokens.get(participant.id) if invitation_tokens and participant else None
|
|
try:
|
|
response = provider.enqueue_notification(
|
|
session,
|
|
_notification_dispatch_request(
|
|
request=request,
|
|
participant=participant,
|
|
notification=notification,
|
|
invitation_token=invitation_token,
|
|
),
|
|
enqueue_delivery=True,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow.
|
|
notification.status = "failed"
|
|
notification.error = f"Notification center enqueue failed: {exc}"
|
|
continue
|
|
notification.metadata_ = {
|
|
**(notification.metadata_ or {}),
|
|
"notification_id": response.get("id"),
|
|
"notification_status": response.get("status"),
|
|
}
|
|
if response.get("status") in {"queued", "sending", "sent"}:
|
|
notification.status = str(response["status"])
|
|
elif response.get("status") == "skipped":
|
|
notification.status = "skipped"
|
|
notification.error = str(response.get("last_error") or "Notification center skipped delivery")
|
|
session.flush()
|
|
|
|
|
|
def _notification_dispatch_request(
|
|
*,
|
|
request: SchedulingRequest,
|
|
participant: SchedulingParticipant | None,
|
|
notification: SchedulingNotification,
|
|
invitation_token: str | None = None,
|
|
) -> NotificationDispatchRequest:
|
|
payload = {
|
|
**(notification.payload or {}),
|
|
"scheduling_notification_id": notification.id,
|
|
"participant": {
|
|
"id": participant.id,
|
|
"display_name": participant.display_name,
|
|
"email": participant.email,
|
|
"required": participant.required,
|
|
"participant_type": participant.participant_type,
|
|
}
|
|
if participant
|
|
else None,
|
|
}
|
|
return NotificationDispatchRequest(
|
|
tenant_id=request.tenant_id,
|
|
source_module="scheduling",
|
|
source_resource_type="request",
|
|
source_resource_id=request.id,
|
|
event_kind=notification.event_kind,
|
|
channel=notification.channel,
|
|
recipient=notification.recipient,
|
|
recipient_type="scheduling_participant",
|
|
recipient_id=participant.respondent_id if participant else None,
|
|
recipient_label=participant.display_name if participant else None,
|
|
subject=_notification_subject(request, notification.event_kind),
|
|
body_text=_notification_body_text(request, participant, notification.event_kind),
|
|
action_url=(
|
|
f"/poll/public/{invitation_token}"
|
|
if notification.event_kind == "invitation" and invitation_token
|
|
else f"/scheduling?request_id={request.id}"
|
|
),
|
|
payload=_jsonable(payload),
|
|
metadata={
|
|
**(notification.metadata_ or {}),
|
|
"scheduling_notification_id": notification.id,
|
|
"scheduling_request_id": request.id,
|
|
},
|
|
)
|
|
|
|
|
|
def _notification_subject(request: SchedulingRequest, event_kind: str) -> str:
|
|
labels = {
|
|
"invitation": "Scheduling invitation",
|
|
"reminder": "Scheduling reminder",
|
|
"decision": "Scheduling decision",
|
|
"cancellation": "Scheduling cancelled",
|
|
}
|
|
return f"{labels.get(event_kind, 'Scheduling update')}: {request.title}"
|
|
|
|
|
|
def _notification_body_text(
|
|
request: SchedulingRequest,
|
|
participant: SchedulingParticipant | None,
|
|
event_kind: str,
|
|
) -> str:
|
|
greeting = f"Hello {participant.display_name}," if participant and participant.display_name else "Hello,"
|
|
lines = [greeting, "", _notification_body_intro(request, event_kind)]
|
|
selected_slot = next((slot for slot in _active_slots(request) if slot.id == request.selected_slot_id), None)
|
|
if selected_slot is not None:
|
|
lines.append(f"Selected slot: {_slot_label(selected_slot.start_at, selected_slot.end_at, timezone_name=selected_slot.timezone)}")
|
|
if request.location:
|
|
lines.append(f"Location: {request.location}")
|
|
lines.append(f"Scheduling request: {request.title}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _notification_body_intro(request: SchedulingRequest, event_kind: str) -> str:
|
|
if event_kind == "invitation":
|
|
return f"You are invited to respond to the scheduling poll for {request.title}."
|
|
if event_kind == "reminder":
|
|
return f"This is a reminder to respond to the scheduling poll for {request.title}."
|
|
if event_kind == "decision":
|
|
return f"A final slot was selected for {request.title}."
|
|
if event_kind == "cancellation":
|
|
return f"The scheduling request {request.title} was cancelled."
|
|
return f"There is an update for {request.title}."
|
|
|
|
|
|
def _emit_scheduling_center_notification(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
event_kind: str,
|
|
subject: str,
|
|
body_text: str,
|
|
participant: SchedulingParticipant | None = None,
|
|
priority: int = 0,
|
|
) -> None:
|
|
provider = notification_dispatch_provider(get_registry())
|
|
if provider is None:
|
|
return
|
|
try:
|
|
provider.enqueue_notification(
|
|
session,
|
|
NotificationDispatchRequest(
|
|
tenant_id=request.tenant_id,
|
|
source_module="scheduling",
|
|
source_resource_type="request",
|
|
source_resource_id=request.id,
|
|
event_kind=event_kind,
|
|
channel="inbox",
|
|
recipient_type="user" if request.organizer_user_id else None,
|
|
recipient_id=request.organizer_user_id,
|
|
subject=subject,
|
|
body_text=body_text,
|
|
action_url=f"/scheduling?request_id={request.id}",
|
|
priority=priority,
|
|
payload=_jsonable(
|
|
{
|
|
"request_id": request.id,
|
|
"poll_id": request.poll_id,
|
|
"participant_id": participant.id if participant else None,
|
|
}
|
|
),
|
|
metadata={"scheduling_request_id": request.id},
|
|
),
|
|
enqueue_delivery=False,
|
|
)
|
|
except Exception:
|
|
return
|
|
|
|
|
|
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 list_visible_scheduling_notifications(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
actor_ids: tuple[str, ...],
|
|
can_manage: bool = False,
|
|
request_id: str | None = None,
|
|
status: str | None = None,
|
|
) -> list[SchedulingNotification]:
|
|
notifications = list_scheduling_notifications(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
status=status,
|
|
)
|
|
if can_manage:
|
|
return notifications
|
|
ids = _actor_ids(actor_ids)
|
|
if not ids:
|
|
return []
|
|
visible: list[SchedulingNotification] = []
|
|
requests_by_id: dict[str, SchedulingRequest] = {}
|
|
for notification in notifications:
|
|
request = requests_by_id.get(notification.request_id)
|
|
if request is None:
|
|
request = get_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=notification.request_id,
|
|
)
|
|
requests_by_id[notification.request_id] = request
|
|
if request.organizer_user_id in ids:
|
|
visible.append(notification)
|
|
continue
|
|
participant = next(
|
|
(item for item in _active_participants(request) if item.id == notification.participant_id),
|
|
None,
|
|
)
|
|
if notification.recipient in ids or (
|
|
participant is not None
|
|
and (participant.respondent_id in ids or participant.email in ids)
|
|
):
|
|
visible.append(notification)
|
|
return visible
|
|
|
|
|
|
def refresh_participant_response_state(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
actor_ids: tuple[str, ...] = (),
|
|
reset_missing: bool = False,
|
|
) -> None:
|
|
if request.poll_id is None:
|
|
return
|
|
participants = _active_participants(request)
|
|
by_invitation_id = {
|
|
participant.poll_invitation_id: participant
|
|
for participant in participants
|
|
if participant.poll_invitation_id is not None
|
|
}
|
|
by_respondent_id: dict[str, SchedulingParticipant] = {}
|
|
for participant in participants:
|
|
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
|
|
for identity in (participant.respondent_id, stored_identity):
|
|
if isinstance(identity, str) and identity:
|
|
by_respondent_id[identity] = participant
|
|
try:
|
|
responses = _poll_provider().list_responses(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
changed = False
|
|
ids = _actor_ids(actor_ids)
|
|
actor_participants = [
|
|
participant
|
|
for participant in participants
|
|
if participant.respondent_id in ids or participant.email in ids
|
|
]
|
|
matched_participant_ids: set[str] = set()
|
|
unmatched_response_count = 0
|
|
for response in responses:
|
|
participant = by_invitation_id.get(response.invitation_id)
|
|
if participant is None and response.respondent_id is not None:
|
|
participant = by_respondent_id.get(response.respondent_id)
|
|
# Poll deliberately ignores client-supplied invitation identifiers on
|
|
# authenticated submissions. While reconciling for that same actor,
|
|
# map the server-authenticated respondent id to their unique Scheduling
|
|
# participant row (which may have been invited by email only).
|
|
if (
|
|
participant is None
|
|
and response.respondent_id in ids
|
|
and len(actor_participants) == 1
|
|
):
|
|
participant = actor_participants[0]
|
|
if participant is None:
|
|
unmatched_response_count += 1
|
|
continue
|
|
matched_participant_ids.add(participant.id)
|
|
if response.respondent_id:
|
|
metadata = participant.metadata_ or {}
|
|
if metadata.get("poll_response_respondent_id") != response.respondent_id:
|
|
participant.metadata_ = {
|
|
**metadata,
|
|
"poll_response_respondent_id": response.respondent_id,
|
|
}
|
|
changed = True
|
|
if participant.status != "responded":
|
|
participant.status = "responded"
|
|
participant.responded_at = response_datetime(response.submitted_at)
|
|
_emit_scheduling_center_notification(
|
|
session,
|
|
request=request,
|
|
participant=participant,
|
|
event_kind="scheduling.participant_responded",
|
|
subject=f"Scheduling response: {request.title}",
|
|
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.",
|
|
)
|
|
changed = True
|
|
if reset_missing:
|
|
for participant in participants:
|
|
if participant.status != "responded" or participant.id in matched_participant_ids:
|
|
continue
|
|
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
|
|
if not isinstance(stored_identity, str) and unmatched_response_count:
|
|
# Do not reset an older projection while an active response
|
|
# exists that cannot yet be mapped safely to a participant.
|
|
continue
|
|
participant.status = "invited" if participant.poll_invitation_id else "draft"
|
|
participant.responded_at = None
|
|
if isinstance(stored_identity, str):
|
|
participant.metadata_ = {
|
|
key: value
|
|
for key, value in (participant.metadata_ or {}).items()
|
|
if key != "poll_response_respondent_id"
|
|
}
|
|
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,
|
|
participant_visibility=payload.participant_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 = _poll_provider().create_poll(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
command=PollCreateCommand(
|
|
title=payload.title,
|
|
description=payload.description,
|
|
kind="availability",
|
|
status=_poll_status_for_request(payload.status),
|
|
visibility="private",
|
|
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=tuple(_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=tuple(_poll_option_inputs(request)),
|
|
metadata={"scheduling_request_id": request.id, "calendar": payload.calendar.model_dump()},
|
|
),
|
|
)
|
|
except PollCapabilityError 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 = _poll_provider().create_invitation(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
poll_id=poll.id,
|
|
command=PollInvitationCommand(
|
|
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 PollCapabilityError 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] = invitation.token
|
|
create_scheduling_notification_jobs(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request.id,
|
|
event_kind="invitation",
|
|
metadata={"created_with_request": True},
|
|
invitation_tokens=invitation_tokens,
|
|
)
|
|
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 _actor_ids(values: tuple[str, ...]) -> tuple[str, ...]:
|
|
return tuple(dict.fromkeys(str(value) for value in values if value))
|
|
|
|
|
|
def scheduling_request_is_visible(
|
|
request: SchedulingRequest,
|
|
*,
|
|
actor_ids: tuple[str, ...],
|
|
can_manage: bool = False,
|
|
) -> bool:
|
|
if can_manage:
|
|
return True
|
|
ids = _actor_ids(actor_ids)
|
|
if not ids:
|
|
return False
|
|
if request.organizer_user_id in ids:
|
|
return True
|
|
return any(
|
|
participant.respondent_id in ids or participant.email in ids
|
|
for participant in _active_participants(request)
|
|
)
|
|
|
|
|
|
def list_visible_scheduling_requests(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
actor_ids: tuple[str, ...],
|
|
can_manage: bool = False,
|
|
status: str | None = None,
|
|
) -> list[SchedulingRequest]:
|
|
return [
|
|
request
|
|
for request in list_scheduling_requests(session, tenant_id=tenant_id, status=status)
|
|
if scheduling_request_is_visible(request, actor_ids=actor_ids, can_manage=can_manage)
|
|
]
|
|
|
|
|
|
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 _lock_scheduling_request(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
lock_slots: bool = False,
|
|
) -> SchedulingRequest:
|
|
"""Serialize lifecycle and Calendar handoff checks for one request."""
|
|
|
|
request = (
|
|
session.query(SchedulingRequest)
|
|
.filter(
|
|
SchedulingRequest.tenant_id == tenant_id,
|
|
SchedulingRequest.id == request_id,
|
|
SchedulingRequest.deleted_at.is_(None),
|
|
)
|
|
.populate_existing()
|
|
.with_for_update()
|
|
.first()
|
|
)
|
|
if request is None:
|
|
raise SchedulingError("Scheduling request not found")
|
|
if lock_slots:
|
|
(
|
|
session.query(SchedulingCandidateSlot)
|
|
.filter(
|
|
SchedulingCandidateSlot.tenant_id == tenant_id,
|
|
SchedulingCandidateSlot.request_id == request.id,
|
|
)
|
|
.order_by(SchedulingCandidateSlot.id.asc())
|
|
.populate_existing()
|
|
.with_for_update()
|
|
.all()
|
|
)
|
|
return request
|
|
|
|
|
|
def _lock_scheduling_calendar_handoff(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
lock_slots: bool = False,
|
|
) -> SchedulingRequest:
|
|
return _lock_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
lock_slots=lock_slots,
|
|
)
|
|
|
|
|
|
def get_visible_scheduling_request(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
actor_ids: tuple[str, ...],
|
|
can_manage: bool = False,
|
|
) -> SchedulingRequest:
|
|
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
|
if not scheduling_request_is_visible(request, actor_ids=actor_ids, can_manage=can_manage):
|
|
raise SchedulingError("Scheduling request not found")
|
|
return request
|
|
|
|
|
|
def submit_scheduling_availability(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
actor_ids: tuple[str, ...],
|
|
respondent_id: str | None,
|
|
respondent_label: str | None,
|
|
payload: SchedulingAvailabilityResponseRequest,
|
|
) -> SchedulingRequest:
|
|
request = get_visible_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
actor_ids=actor_ids,
|
|
)
|
|
if request.status != "collecting":
|
|
raise SchedulingError("Scheduling request is not collecting availability")
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling request has no backing poll")
|
|
if respondent_id is None:
|
|
raise SchedulingPermissionError("An authenticated account is required to respond")
|
|
participant = _participant_for_actor(request, actor_ids=actor_ids)
|
|
canonical_respondent_id = _participant_respondent_id(
|
|
participant,
|
|
fallback_respondent_id=respondent_id,
|
|
)
|
|
existing_response = _participant_poll_response(
|
|
session,
|
|
request=request,
|
|
participant=participant,
|
|
actor_ids=actor_ids,
|
|
canonical_respondent_id=canonical_respondent_id,
|
|
)
|
|
if existing_response is not None and existing_response.respondent_id:
|
|
canonical_respondent_id = existing_response.respondent_id
|
|
answers: list[PollAnswerRequest] = []
|
|
for answer in payload.answers:
|
|
slot = _selected_slot(request, slot_id=answer.slot_id)
|
|
if slot.poll_option_id is None:
|
|
raise SchedulingError("Scheduling slot has no backing poll option")
|
|
if answer.option_revision != scheduling_slot_revision(slot):
|
|
raise SchedulingConflictError(
|
|
"Scheduling options changed after this response form was loaded; reload before responding"
|
|
)
|
|
answers.append(
|
|
PollAnswerRequest(
|
|
option_id=slot.poll_option_id,
|
|
value=answer.value,
|
|
)
|
|
)
|
|
try:
|
|
response = _poll_response_provider().submit_response(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
poll_id=request.poll_id,
|
|
command=PollSubmitResponseCommand(
|
|
respondent_id=canonical_respondent_id,
|
|
respondent_label=respondent_label,
|
|
answers=tuple(answers),
|
|
metadata={
|
|
"scheduling_participant_id": participant.id,
|
|
**(
|
|
{"invitation_id": participant.poll_invitation_id}
|
|
if participant.poll_invitation_id
|
|
else {}
|
|
),
|
|
},
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
first_response = participant.status != "responded"
|
|
participant.status = "responded"
|
|
participant.responded_at = response_datetime(response.submitted_at)
|
|
if response.respondent_id:
|
|
participant.metadata_ = {
|
|
**(participant.metadata_ or {}),
|
|
"poll_response_respondent_id": response.respondent_id,
|
|
}
|
|
if first_response:
|
|
_emit_scheduling_center_notification(
|
|
session,
|
|
request=request,
|
|
participant=participant,
|
|
event_kind="scheduling.participant_responded",
|
|
subject=f"Scheduling response: {request.title}",
|
|
body_text=(
|
|
f"{participant.display_name or participant.email or 'A participant'} "
|
|
f"responded to {request.title}."
|
|
),
|
|
)
|
|
session.flush()
|
|
return request
|
|
|
|
|
|
def get_scheduling_availability_response(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
actor_ids: tuple[str, ...],
|
|
respondent_id: str | None,
|
|
) -> dict[str, Any]:
|
|
"""Return only the caller's still-valid availability answers."""
|
|
|
|
request = get_visible_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
actor_ids=actor_ids,
|
|
)
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling request has no backing poll")
|
|
participant = _participant_for_actor(request, actor_ids=actor_ids)
|
|
canonical_respondent_id = _participant_respondent_id(
|
|
participant,
|
|
fallback_respondent_id=respondent_id,
|
|
)
|
|
response = _participant_poll_response(
|
|
session,
|
|
request=request,
|
|
participant=participant,
|
|
actor_ids=actor_ids,
|
|
canonical_respondent_id=canonical_respondent_id,
|
|
)
|
|
slot_by_option_id = {
|
|
slot.poll_option_id: slot
|
|
for slot in _active_slots(request)
|
|
if slot.poll_option_id is not None
|
|
}
|
|
slot_by_option_key = {
|
|
f"slot-{slot.position + 1}": slot
|
|
for slot in _active_slots(request)
|
|
}
|
|
answers: list[dict[str, str]] = []
|
|
if response is not None:
|
|
for answer in response.answers:
|
|
slot = slot_by_option_id.get(answer.option_id) or slot_by_option_key.get(answer.option_key)
|
|
if slot is None or answer.value not in {"available", "maybe", "unavailable"}:
|
|
continue
|
|
answers.append({"slot_id": slot.id, "value": str(answer.value)})
|
|
return {
|
|
"request_id": request.id,
|
|
"participant_id": participant.id,
|
|
"has_response": response is not None,
|
|
"submitted_at": response_datetime(response.submitted_at) if response is not None else None,
|
|
"answers": answers,
|
|
}
|
|
|
|
|
|
def require_visible_scheduling_results(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
actor_ids: tuple[str, ...],
|
|
can_manage: bool = False,
|
|
) -> None:
|
|
ids = _actor_ids(actor_ids)
|
|
if can_manage or request.organizer_user_id in ids or request.result_visibility == "public":
|
|
return
|
|
if request.result_visibility == "after_close" and request.status in {
|
|
"closed",
|
|
"decided",
|
|
"handed_off",
|
|
"cancelled",
|
|
"archived",
|
|
}:
|
|
return
|
|
if request.result_visibility == "after_response":
|
|
refresh_participant_response_state(session, request=request)
|
|
if any(
|
|
participant.status == "responded"
|
|
and (participant.respondent_id in ids or participant.email in ids)
|
|
for participant in _active_participants(request)
|
|
):
|
|
return
|
|
raise SchedulingError("Scheduling results are not visible")
|
|
|
|
|
|
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",
|
|
"participant_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
|
|
if request.poll_id is not None:
|
|
try:
|
|
_poll_provider().update_poll(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
poll_id=request.poll_id,
|
|
command=PollUpdateCommand(
|
|
title=request.title,
|
|
description=request.description,
|
|
visibility="private",
|
|
result_visibility=request.result_visibility,
|
|
allow_anonymous=False,
|
|
allow_response_update=request.allow_participant_updates,
|
|
closes_at=request.deadline_at,
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
session.flush()
|
|
return request
|
|
|
|
|
|
def update_scheduling_candidate_slot(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
slot_id: str,
|
|
payload: SchedulingCandidateSlotUpdateRequest,
|
|
) -> SchedulingRequest:
|
|
"""Update one slot and invalidate only its Poll answers atomically."""
|
|
|
|
request = _lock_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
lock_slots=True,
|
|
)
|
|
if request.status not in {"draft", "collecting"}:
|
|
raise SchedulingError("Only draft or collecting scheduling request slots can be edited")
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling request has no backing poll")
|
|
slot = _selected_slot(request, slot_id=slot_id)
|
|
if slot.poll_option_id is None:
|
|
raise SchedulingError("Scheduling slot has no backing poll option")
|
|
|
|
supplied = payload.model_fields_set
|
|
for field in ("label", "start_at", "end_at", "timezone"):
|
|
if field in supplied and getattr(payload, field) is None:
|
|
raise SchedulingError(f"{field} cannot be null")
|
|
|
|
label = payload.label if "label" in supplied else slot.label
|
|
description = payload.description if "description" in supplied else slot.description
|
|
start_at = payload.start_at if "start_at" in supplied else slot.start_at
|
|
end_at = payload.end_at if "end_at" in supplied else slot.end_at
|
|
timezone_name = payload.timezone if "timezone" in supplied else slot.timezone
|
|
location = payload.location if "location" in supplied else slot.location
|
|
metadata = (payload.metadata or {}) if "metadata" in supplied else (slot.metadata_ or {})
|
|
normalized_start = response_datetime(start_at)
|
|
normalized_end = response_datetime(end_at)
|
|
if normalized_end <= normalized_start:
|
|
raise SchedulingError("end_at must be after start_at")
|
|
|
|
temporal_or_location_changed = (
|
|
normalized_start != response_datetime(slot.start_at)
|
|
or normalized_end != response_datetime(slot.end_at)
|
|
or timezone_name != slot.timezone
|
|
or location != slot.location
|
|
)
|
|
changed = (
|
|
label != slot.label
|
|
or description != slot.description
|
|
or temporal_or_location_changed
|
|
or metadata != (slot.metadata_ or {})
|
|
)
|
|
if not changed:
|
|
return request
|
|
if temporal_or_location_changed and slot.tentative_hold_event_id:
|
|
raise SchedulingError(
|
|
"A slot with a tentative calendar hold cannot change time, timezone, or location"
|
|
)
|
|
|
|
try:
|
|
_poll_provider().update_option(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
poll_id=request.poll_id,
|
|
option_id=slot.poll_option_id,
|
|
command=PollOptionUpdateCommand(
|
|
label=label,
|
|
description=description,
|
|
value={
|
|
"slot_id": slot.id,
|
|
"start_at": normalized_start.isoformat(),
|
|
"end_at": normalized_end.isoformat(),
|
|
"timezone": timezone_name,
|
|
"location": location,
|
|
},
|
|
metadata=metadata,
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
|
|
refresh_participant_response_state(
|
|
session,
|
|
request=request,
|
|
reset_missing=True,
|
|
)
|
|
|
|
slot.label = label
|
|
slot.description = description
|
|
slot.start_at = start_at
|
|
slot.end_at = end_at
|
|
slot.timezone = timezone_name
|
|
slot.location = location
|
|
slot.metadata_ = metadata
|
|
if temporal_or_location_changed:
|
|
slot.freebusy_checked_at = None
|
|
slot.freebusy_status = None
|
|
slot.freebusy_conflicts = []
|
|
session.flush()
|
|
return request
|
|
|
|
|
|
def open_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
|
|
request = _lock_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
)
|
|
if request.status == "collecting":
|
|
return request
|
|
if request.status != "draft":
|
|
raise SchedulingError("Only draft scheduling requests can be opened")
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling request has no backing poll")
|
|
try:
|
|
_poll_provider().open_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
|
|
except PollCapabilityError 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 = _lock_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
)
|
|
if request.status == "closed":
|
|
return request
|
|
if request.status != "collecting":
|
|
raise SchedulingError("Only collecting scheduling requests can be closed")
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling request has no backing poll")
|
|
try:
|
|
_poll_provider().close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
|
|
except PollCapabilityError 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,
|
|
user_id: str | None = None,
|
|
allow_calendar_handoff: bool = False,
|
|
) -> SchedulingRequest:
|
|
request = _lock_scheduling_calendar_handoff(
|
|
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)
|
|
if request.status in {"decided", "handed_off"} and request.selected_slot_id:
|
|
if request.selected_slot_id != slot.id:
|
|
raise SchedulingError(
|
|
"A different slot cannot be selected after a scheduling decision; "
|
|
"explicit rescheduling semantics are required"
|
|
)
|
|
return request
|
|
if request.status != "closed":
|
|
raise SchedulingError("Only closed scheduling requests can be decided")
|
|
should_handoff = payload.handoff_to_calendar or (
|
|
payload.handoff_to_calendar is None
|
|
and request.create_calendar_event_on_decision
|
|
)
|
|
creates_calendar_event = bool(
|
|
should_handoff
|
|
and request.calendar_integration_enabled
|
|
and request.create_calendar_event_on_decision
|
|
)
|
|
if creates_calendar_event and not allow_calendar_handoff:
|
|
raise SchedulingPermissionError(
|
|
"Calendar event creation requires calendar:event:write"
|
|
)
|
|
try:
|
|
_poll_provider().decide_poll(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
poll_id=request.poll_id,
|
|
option_id=slot.poll_option_id,
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
request.selected_slot_id = slot.id
|
|
request.status = "decided"
|
|
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
|
|
|
|
|
|
def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
|
|
request = _lock_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
)
|
|
if request.status == "cancelled":
|
|
return request
|
|
if request.status not in {"draft", "collecting", "closed"}:
|
|
raise SchedulingError(
|
|
"Only draft, collecting, or closed scheduling requests can be cancelled"
|
|
)
|
|
request.status = "cancelled"
|
|
request.cancelled_at = _now()
|
|
if request.poll_id is not None:
|
|
try:
|
|
provider = _poll_provider()
|
|
poll = provider.get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
|
|
if poll.status == "open":
|
|
provider.close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
|
|
except PollCapabilityError 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
|
|
|
|
|
|
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_provider().result_summary(session, tenant_id=tenant_id, poll_id=request.poll_id)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
refresh_participant_response_state(session, request=request)
|
|
return dict(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,
|
|
"revision": scheduling_slot_revision(slot),
|
|
"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 {},
|
|
}
|
|
|
|
|
|
def scheduling_participant_response(
|
|
participant: SchedulingParticipant,
|
|
*,
|
|
invitation_token: str | None = None,
|
|
redact_sensitive: bool = False,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"id": participant.id,
|
|
"respondent_id": None if redact_sensitive else participant.respondent_id,
|
|
"display_name": participant.display_name,
|
|
"email": None if redact_sensitive else participant.email,
|
|
"participant_type": participant.participant_type,
|
|
"required": participant.required,
|
|
"status": participant.status,
|
|
"poll_invitation_id": None if redact_sensitive else participant.poll_invitation_id,
|
|
"invitation_token": None if redact_sensitive else invitation_token,
|
|
"last_invited_at": None if redact_sensitive else response_datetime(participant.last_invited_at),
|
|
"responded_at": None if redact_sensitive else response_datetime(participant.responded_at),
|
|
"metadata": {} if redact_sensitive else participant.metadata_ or {},
|
|
}
|
|
|
|
|
|
_PARTICIPANT_STATUSES = ("draft", "invited", "responded", "declined", "removed")
|
|
|
|
|
|
def _participant_aggregate(participants: list[SchedulingParticipant]) -> dict[str, Any]:
|
|
status_counts = {status: 0 for status in _PARTICIPANT_STATUSES}
|
|
for participant in participants:
|
|
status_counts[participant.status] = status_counts.get(participant.status, 0) + 1
|
|
return {"total": len(participants), "status_counts": status_counts}
|
|
|
|
|
|
def _participant_visibility_decision(
|
|
request: SchedulingRequest,
|
|
*,
|
|
participant: SchedulingParticipant | None,
|
|
actor_user_id: str | None,
|
|
) -> dict[str, Any]:
|
|
requested = request.participant_visibility
|
|
payload: dict[str, Any] = {
|
|
"requested_visibility": requested,
|
|
"effective_visibility": requested,
|
|
"policy_applied": False,
|
|
"reason": None,
|
|
"source_path": [],
|
|
"details": {},
|
|
}
|
|
if participant is None:
|
|
payload["effective_visibility"] = "aggregates_only"
|
|
payload["reason"] = "A unique participant context is required for roster visibility."
|
|
return payload
|
|
|
|
provider = scheduling_participant_privacy_policy(get_registry())
|
|
if provider is None:
|
|
return payload
|
|
|
|
payload["policy_applied"] = True
|
|
try:
|
|
decision = provider.resolve_scheduling_participant_visibility(
|
|
object_session(request),
|
|
request=SchedulingParticipantPrivacyRequest(
|
|
tenant_id=request.tenant_id,
|
|
scheduling_request_id=request.id,
|
|
participant_id=participant.id,
|
|
actor_user_id=actor_user_id,
|
|
requested_visibility=requested,
|
|
context={
|
|
"request_status": request.status,
|
|
"organizer_user_id": request.organizer_user_id,
|
|
},
|
|
),
|
|
)
|
|
except Exception:
|
|
payload["effective_visibility"] = "aggregates_only"
|
|
payload["reason"] = "The participant privacy policy could not be evaluated."
|
|
payload["details"] = {"policy_error": True}
|
|
return payload
|
|
|
|
if not isinstance(decision, SchedulingParticipantPrivacyDecision):
|
|
payload["effective_visibility"] = "aggregates_only"
|
|
payload["reason"] = "The participant privacy policy returned an invalid decision."
|
|
payload["details"] = {"invalid_policy_decision": True}
|
|
return payload
|
|
|
|
decision_payload = decision.to_dict()
|
|
resolved = decision.effective_visibility
|
|
if requested == "aggregates_only" or resolved not in {"aggregates_only", "names_and_statuses"}:
|
|
resolved = "aggregates_only"
|
|
payload.update(
|
|
effective_visibility=resolved,
|
|
reason=decision.reason,
|
|
source_path=decision_payload["source_path"],
|
|
details=decision_payload["details"],
|
|
)
|
|
return payload
|
|
|
|
|
|
def scheduling_request_response(
|
|
request: SchedulingRequest,
|
|
*,
|
|
invitation_tokens: dict[str, str] | None = None,
|
|
actor_ids: tuple[str, ...] | None = None,
|
|
actor_user_id: str | None = None,
|
|
can_manage: bool = False,
|
|
) -> dict[str, Any]:
|
|
invitation_tokens = invitation_tokens or {}
|
|
ids = _actor_ids(actor_ids or ())
|
|
full_roster = actor_ids is None or can_manage or request.organizer_user_id in ids
|
|
active_participants = _active_participants(request)
|
|
own_participants = [
|
|
participant
|
|
for participant in active_participants
|
|
if participant.respondent_id in ids or participant.email in ids
|
|
]
|
|
if full_roster:
|
|
visibility_decision = {
|
|
"requested_visibility": request.participant_visibility,
|
|
"effective_visibility": "names_and_statuses",
|
|
"policy_applied": False,
|
|
"reason": "Full participant roster access is granted to organizers and scheduling managers.",
|
|
"source_path": [],
|
|
"details": {"management_access": True},
|
|
}
|
|
projected_participants = active_participants
|
|
else:
|
|
visibility_decision = _participant_visibility_decision(
|
|
request,
|
|
participant=own_participants[0] if len(own_participants) == 1 else None,
|
|
actor_user_id=actor_user_id,
|
|
)
|
|
projected_participants = (
|
|
active_participants
|
|
if visibility_decision["effective_visibility"] == "names_and_statuses"
|
|
else own_participants
|
|
)
|
|
if not full_roster:
|
|
invitation_tokens = {}
|
|
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,
|
|
"participant_visibility": request.participant_visibility,
|
|
"effective_participant_visibility": visibility_decision["effective_visibility"],
|
|
"participant_aggregate": _participant_aggregate(active_participants),
|
|
"participant_visibility_decision": visibility_decision,
|
|
"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),
|
|
redact_sensitive=not full_roster
|
|
and participant.respondent_id not in ids
|
|
and participant.email not in ids,
|
|
)
|
|
for participant in projected_participants
|
|
],
|
|
}
|
|
|
|
|
|
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 {},
|
|
}
|