3567 lines
129 KiB
Python
3567 lines
129 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from functools import lru_cache
|
|
from typing import Any, cast
|
|
|
|
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,
|
|
PollOptionOrderCommand,
|
|
PollOptionUpdateCommand,
|
|
PollOptionRequest,
|
|
PollResponseRef,
|
|
PollSchedulingProvider,
|
|
PollUpdateCommand,
|
|
poll_scheduling_provider,
|
|
)
|
|
from govoplan_core.core.poll_participation import (
|
|
ANONYMOUS_PASSWORD_REQUIREMENT,
|
|
PollGovernedInvitationCommand,
|
|
PollGovernedResponseCommand,
|
|
PollGovernedResponseRef,
|
|
PollParticipationContextRef,
|
|
PollParticipationGatewayProvider,
|
|
PollParticipationPolicy,
|
|
PollResponseGatewayRef,
|
|
participation_token_fingerprint,
|
|
poll_participation_gateway_provider,
|
|
)
|
|
from govoplan_core.core.policy import (
|
|
SchedulingParticipantPrivacyDecision,
|
|
SchedulingParticipantPrivacyRequest,
|
|
scheduling_participant_privacy_policy,
|
|
)
|
|
from govoplan_core.core.throttling import (
|
|
FixedWindowThrottle,
|
|
ThrottleDimension,
|
|
build_fixed_window_throttle,
|
|
)
|
|
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,
|
|
SchedulingCandidateSlotReconcileInput,
|
|
SchedulingCandidateSlotUpdateRequest,
|
|
SchedulingDecisionRequest,
|
|
SchedulingParticipantReconcileInput,
|
|
SchedulingPublicParticipationAccessRequest,
|
|
SchedulingPublicParticipationSubmitRequest,
|
|
SchedulingRequestCreateRequest,
|
|
SchedulingRequestUpdateRequest,
|
|
)
|
|
from govoplan_scheduling.backend.runtime import get_registry, get_settings
|
|
from govoplan_scheduling.backend.security import (
|
|
hash_participant_password,
|
|
verify_participant_password,
|
|
)
|
|
|
|
|
|
class SchedulingError(ValueError):
|
|
pass
|
|
|
|
|
|
class SchedulingPermissionError(SchedulingError):
|
|
pass
|
|
|
|
|
|
class SchedulingConflictError(SchedulingError):
|
|
pass
|
|
|
|
|
|
class SchedulingPublicParticipationError(SchedulingError):
|
|
def __init__(self, *, retry_after_seconds: int = 0) -> None:
|
|
super().__init__("Scheduling participation link or credentials are invalid")
|
|
self.retry_after_seconds = max(0, retry_after_seconds)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SchedulingInvitationActionResult:
|
|
request: SchedulingRequest
|
|
participant: SchedulingParticipant
|
|
action: str
|
|
status: str
|
|
action_url: str | None = None
|
|
notification: SchedulingNotification | None = None
|
|
replaced_existing: bool = False
|
|
replayed: bool = False
|
|
|
|
|
|
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"}
|
|
PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON = (
|
|
"Poll signed-participation policy enforcement is unavailable; restricted "
|
|
"public links are disabled until the Poll gateway contract is installed."
|
|
)
|
|
SCHEDULING_PARTICIPATION_GATEWAY = "scheduling"
|
|
PARTICIPATION_PASSWORD_ATTEMPT_LIMIT = 10
|
|
PARTICIPATION_PASSWORD_REQUEST_LIMIT = 100
|
|
PARTICIPATION_PASSWORD_WINDOW_SECONDS = 15 * 60
|
|
|
|
|
|
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 _cancellation_notice_days() -> int:
|
|
configured = getattr(
|
|
get_settings(),
|
|
"scheduling_cancellation_notice_days",
|
|
30,
|
|
)
|
|
try:
|
|
value = int(configured)
|
|
except (TypeError, ValueError):
|
|
return 30
|
|
return max(1, min(value, 90))
|
|
|
|
|
|
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 sorted(
|
|
(slot for slot in request.slots if slot.deleted_at is None),
|
|
key=lambda slot: (slot.position, slot.id),
|
|
)
|
|
|
|
|
|
def _active_participants(request: SchedulingRequest) -> list[SchedulingParticipant]:
|
|
return [participant for participant in request.participants if participant.deleted_at is None]
|
|
|
|
|
|
def _stable_participant_respondent_id(
|
|
participant: SchedulingParticipant,
|
|
) -> str:
|
|
if participant.respondent_id:
|
|
return participant.respondent_id
|
|
participant.respondent_id = f"scheduling-participant:{participant.id}"
|
|
return participant.respondent_id
|
|
|
|
|
|
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_matches_actor(participant, 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_participation_provider() -> PollParticipationGatewayProvider | None:
|
|
return poll_participation_gateway_provider(get_registry())
|
|
|
|
|
|
def _public_participation_gateway(request_id: str) -> PollResponseGatewayRef:
|
|
return PollResponseGatewayRef(
|
|
module_id="scheduling",
|
|
resource_type="scheduling_request",
|
|
resource_id=request_id,
|
|
)
|
|
|
|
|
|
def _public_participation_policy(request: SchedulingRequest) -> PollParticipationPolicy:
|
|
return PollParticipationPolicy(
|
|
single_choice=request.single_choice,
|
|
allow_maybe=request.allow_maybe,
|
|
max_participants_per_option=request.max_participants_per_option,
|
|
allow_comments=request.allow_comments,
|
|
participant_email_required=request.participant_email_required,
|
|
anonymous_password_required=request.anonymous_password_protection_enabled,
|
|
)
|
|
|
|
|
|
@lru_cache(maxsize=8)
|
|
def _configured_participation_password_throttle(
|
|
redis_url: str | None,
|
|
) -> FixedWindowThrottle:
|
|
return build_fixed_window_throttle(
|
|
redis_url=redis_url,
|
|
window_seconds=PARTICIPATION_PASSWORD_WINDOW_SECONDS,
|
|
key_prefix="govoplan:scheduling:participation-password:v1",
|
|
)
|
|
|
|
|
|
def _participation_password_throttle() -> FixedWindowThrottle:
|
|
settings = get_settings()
|
|
configured_url = getattr(settings, "redis_url", None)
|
|
redis_url = configured_url if isinstance(configured_url, str) else None
|
|
return _configured_participation_password_throttle(redis_url)
|
|
|
|
|
|
def _participation_password_dimensions(
|
|
request: SchedulingRequest,
|
|
*,
|
|
token: str,
|
|
client_address: str | None,
|
|
) -> tuple[ThrottleDimension, ThrottleDimension]:
|
|
token_subject = ":".join(
|
|
(
|
|
request.tenant_id,
|
|
request.id,
|
|
participation_token_fingerprint(token),
|
|
)
|
|
)
|
|
request_subject = ":".join(
|
|
(
|
|
request.tenant_id,
|
|
request.id,
|
|
client_address or "unknown-client",
|
|
)
|
|
)
|
|
return (
|
|
ThrottleDimension(
|
|
namespace="poll-participation-password",
|
|
subject=token_subject,
|
|
limit=PARTICIPATION_PASSWORD_ATTEMPT_LIMIT,
|
|
),
|
|
ThrottleDimension(
|
|
namespace="poll-participation-password-request",
|
|
subject=request_subject,
|
|
limit=PARTICIPATION_PASSWORD_REQUEST_LIMIT,
|
|
),
|
|
)
|
|
|
|
|
|
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,
|
|
participant_ids: frozenset[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)
|
|
participants = [
|
|
participant
|
|
for participant in _active_participants(request)
|
|
if participant_ids is None or participant.id in participant_ids
|
|
]
|
|
if participant_ids is not None and {
|
|
participant.id for participant in participants
|
|
} != set(participant_ids):
|
|
raise SchedulingError("Scheduling participant not found")
|
|
if event_kind == "invitation" and (
|
|
not invitation_tokens
|
|
or any(participant.id not in invitation_tokens for participant in participants)
|
|
):
|
|
raise SchedulingError(
|
|
"Invitation notifications require a freshly issued participation link"
|
|
)
|
|
notifications: list[SchedulingNotification] = []
|
|
base_metadata = metadata or {}
|
|
for participant in participants:
|
|
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: # noqa: BLE001 - mirroring must not block scheduling workflow.
|
|
notification.status = "failed"
|
|
# A provider exception can include a representation of the dispatch
|
|
# request. Invitation requests contain a bearer link, so durable
|
|
# error text must never copy exception details.
|
|
notification.error = "Notification center enqueue failed"
|
|
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 = "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"/scheduling/public/{request.id}/{invitation_token}"
|
|
if participant is not None
|
|
and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY
|
|
else 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 _identity_matches_actor(notification.recipient, ids) or (
|
|
participant is not None
|
|
and _participant_matches_actor(participant, ids)
|
|
):
|
|
visible.append(notification)
|
|
return visible
|
|
|
|
|
|
def _participant_response_indexes(
|
|
participants: list[SchedulingParticipant],
|
|
) -> tuple[dict[str, SchedulingParticipant], dict[str, SchedulingParticipant]]:
|
|
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
|
|
return by_invitation_id, by_respondent_id
|
|
|
|
|
|
def _response_participant(
|
|
response: PollResponseRef,
|
|
*,
|
|
by_invitation_id: dict[str, SchedulingParticipant],
|
|
by_respondent_id: dict[str, SchedulingParticipant],
|
|
actor_ids: set[str],
|
|
actor_participants: list[SchedulingParticipant],
|
|
) -> SchedulingParticipant | None:
|
|
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 to their unique Scheduling row.
|
|
if participant is None and response.respondent_id in actor_ids and len(actor_participants) == 1:
|
|
return actor_participants[0]
|
|
return participant
|
|
|
|
|
|
def _apply_participant_response(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
participant: SchedulingParticipant,
|
|
response: PollResponseRef,
|
|
) -> bool:
|
|
changed = False
|
|
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
|
|
submitted_at = response_datetime(response.submitted_at)
|
|
response_changed = (
|
|
participant.status != "responded"
|
|
or response_datetime(participant.responded_at) != submitted_at
|
|
)
|
|
if not response_changed:
|
|
return changed
|
|
participant.status = "responded"
|
|
participant.responded_at = submitted_at
|
|
if request.notify_on_answers:
|
|
_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}.",
|
|
)
|
|
return True
|
|
|
|
|
|
def _reset_missing_participant_responses(
|
|
participants: list[SchedulingParticipant],
|
|
*,
|
|
matched_participant_ids: set[str],
|
|
unmatched_response_count: int,
|
|
) -> bool:
|
|
changed = False
|
|
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:
|
|
# Preserve 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
|
|
participant.response_comment = 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
|
|
return changed
|
|
|
|
|
|
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, by_respondent_id = _participant_response_indexes(participants)
|
|
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_matches_actor(participant, ids)
|
|
]
|
|
matched_participant_ids: set[str] = set()
|
|
unmatched_response_count = 0
|
|
for response in responses:
|
|
participant = _response_participant(
|
|
response,
|
|
by_invitation_id=by_invitation_id,
|
|
by_respondent_id=by_respondent_id,
|
|
actor_ids=ids,
|
|
actor_participants=actor_participants,
|
|
)
|
|
if participant is None:
|
|
unmatched_response_count += 1
|
|
continue
|
|
matched_participant_ids.add(participant.id)
|
|
changed = _apply_participant_response(
|
|
session,
|
|
request=request,
|
|
participant=participant,
|
|
response=response,
|
|
) or changed
|
|
if reset_missing and _reset_missing_participant_responses(
|
|
participants,
|
|
matched_participant_ids=matched_participant_ids,
|
|
unmatched_response_count=unmatched_response_count,
|
|
):
|
|
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,
|
|
notify_on_answers=payload.notify_on_answers,
|
|
single_choice=payload.single_choice,
|
|
max_participants_per_option=payload.max_participants_per_option,
|
|
allow_maybe=payload.allow_maybe,
|
|
allow_comments=payload.allow_comments,
|
|
participant_email_required=payload.participant_email_required,
|
|
anonymous_password_protection_enabled=payload.anonymous_password_protection_enabled,
|
|
anonymous_password_hash=(
|
|
hash_participant_password(payload.anonymous_password.get_secret_value())
|
|
if payload.anonymous_password is not None
|
|
else None
|
|
),
|
|
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",
|
|
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(),
|
|
"scheduling_response_settings": {
|
|
"single_choice": payload.single_choice,
|
|
"max_participants_per_option": payload.max_participants_per_option,
|
|
"allow_maybe": payload.allow_maybe,
|
|
"allow_comments": payload.allow_comments,
|
|
"participant_email_required": payload.participant_email_required,
|
|
"anonymous_password_protection_enabled": payload.anonymous_password_protection_enabled,
|
|
},
|
|
},
|
|
),
|
|
)
|
|
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)
|
|
|
|
session.flush()
|
|
# The deprecated create_participant_invitations field is deliberately a
|
|
# no-op. Bearer credentials are minted only by the explicit participant
|
|
# invitation action, where copy and delivery have distinct handling.
|
|
return request, {}
|
|
|
|
|
|
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, ...]:
|
|
aliases: list[str] = []
|
|
for value in values:
|
|
if not value:
|
|
continue
|
|
identity = str(value)
|
|
aliases.append(identity)
|
|
if "@" in identity:
|
|
aliases.append(identity.strip().casefold())
|
|
return tuple(dict.fromkeys(aliases))
|
|
|
|
|
|
def _identity_matches_actor(
|
|
value: str | None,
|
|
actor_ids: set[str] | tuple[str, ...],
|
|
) -> bool:
|
|
if not value:
|
|
return False
|
|
if value in actor_ids:
|
|
return True
|
|
return "@" in value and value.strip().casefold() in actor_ids
|
|
|
|
|
|
def _participant_matches_actor(
|
|
participant: SchedulingParticipant,
|
|
actor_ids: set[str] | tuple[str, ...],
|
|
) -> bool:
|
|
return _identity_matches_actor(
|
|
participant.respondent_id,
|
|
actor_ids,
|
|
) or _identity_matches_actor(participant.email, actor_ids)
|
|
|
|
|
|
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_matches_actor(participant, 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,
|
|
lock_participants: 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()
|
|
)
|
|
if lock_participants:
|
|
(
|
|
session.query(SchedulingParticipant)
|
|
.filter(
|
|
SchedulingParticipant.tenant_id == tenant_id,
|
|
SchedulingParticipant.request_id == request.id,
|
|
)
|
|
.order_by(SchedulingParticipant.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 _require_scheduling_response_collection_open(
|
|
request: SchedulingRequest,
|
|
) -> None:
|
|
"""Enforce Scheduling's lifecycle independently of Poll's projection."""
|
|
|
|
if request.status != "collecting":
|
|
raise SchedulingError("Scheduling request is not collecting availability")
|
|
if (
|
|
request.deadline_at is not None
|
|
and response_datetime(request.deadline_at) <= _now()
|
|
):
|
|
raise SchedulingError("Scheduling response deadline has passed")
|
|
|
|
|
|
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 = _lock_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
)
|
|
if not scheduling_request_is_visible(request, actor_ids=actor_ids):
|
|
raise SchedulingError("Scheduling request not found")
|
|
_require_scheduling_response_collection_open(request)
|
|
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,
|
|
)
|
|
if not (
|
|
participant.poll_invitation_id is not None
|
|
and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY
|
|
):
|
|
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,
|
|
)
|
|
)
|
|
provider = _ensure_authenticated_participation_invitation(
|
|
session,
|
|
request=request,
|
|
participant=participant,
|
|
respondent_id=canonical_respondent_id,
|
|
)
|
|
gateway = _public_participation_gateway(request.id)
|
|
try:
|
|
provider.resolve_authenticated_participation(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
poll_id=request.poll_id,
|
|
invitation_id=cast(str, participant.poll_invitation_id),
|
|
gateway=gateway,
|
|
respondent_id=canonical_respondent_id,
|
|
)
|
|
response = provider.submit_authenticated_response(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
poll_id=request.poll_id,
|
|
invitation_id=cast(str, participant.poll_invitation_id),
|
|
gateway=gateway,
|
|
respondent_id=canonical_respondent_id,
|
|
command=PollGovernedResponseCommand(
|
|
respondent_id=canonical_respondent_id,
|
|
respondent_label=respondent_label,
|
|
participant_email=participant.email,
|
|
participant_is_authenticated=True,
|
|
answers=tuple(answers),
|
|
comment=payload.comment,
|
|
metadata={
|
|
"scheduling_participant_id": participant.id,
|
|
},
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
message = str(exc)
|
|
if "Participant limit reached" in message:
|
|
raise SchedulingConflictError(message) from exc
|
|
raise SchedulingError(message) from exc
|
|
participant.status = "responded"
|
|
participant.responded_at = response_datetime(response.response.submitted_at)
|
|
participant.response_comment = response.comment
|
|
if participant.respondent_id is None:
|
|
participant.respondent_id = canonical_respondent_id
|
|
if response.response.respondent_id:
|
|
participant.metadata_ = {
|
|
**(participant.metadata_ or {}),
|
|
"poll_response_respondent_id": response.response.respondent_id,
|
|
}
|
|
if request.notify_on_answers and not response.replayed:
|
|
_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 _ensure_authenticated_participation_invitation(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
participant: SchedulingParticipant,
|
|
respondent_id: str,
|
|
) -> PollParticipationGatewayProvider:
|
|
provider = _poll_participation_provider()
|
|
if provider is None:
|
|
raise SchedulingError(
|
|
"Poll governed participation capability is unavailable"
|
|
)
|
|
if (
|
|
participant.poll_invitation_id is not None
|
|
and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY
|
|
):
|
|
return provider
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling request has no backing poll")
|
|
try:
|
|
if participant.poll_invitation_id is not None:
|
|
provider.revoke_invitation(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
invitation_id=participant.poll_invitation_id,
|
|
)
|
|
invitation = provider.create_governed_invitation(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
command=PollGovernedInvitationCommand(
|
|
gateway=_public_participation_gateway(request.id),
|
|
policy=_public_participation_policy(request),
|
|
respondent_id=respondent_id,
|
|
respondent_label=participant.display_name,
|
|
email=participant.email,
|
|
expires_at=request.deadline_at,
|
|
metadata={
|
|
"scheduling_request_id": request.id,
|
|
"scheduling_participant_id": participant.id,
|
|
"public_link_issued": False,
|
|
},
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
# The token is deliberately discarded. Authenticated callers use the
|
|
# in-process invitation-id contract and no public link has been issued.
|
|
participant.poll_invitation_id = invitation.id
|
|
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
|
|
if participant.respondent_id is None:
|
|
participant.respondent_id = respondent_id
|
|
return provider
|
|
|
|
|
|
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: PollResponseRef | None = None
|
|
if not (
|
|
participant.poll_invitation_id is not None
|
|
and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY
|
|
):
|
|
response = _participant_poll_response(
|
|
session,
|
|
request=request,
|
|
participant=participant,
|
|
actor_ids=actor_ids,
|
|
canonical_respondent_id=canonical_respondent_id,
|
|
)
|
|
if response is not None and response.respondent_id:
|
|
canonical_respondent_id = response.respondent_id
|
|
if (
|
|
participant.poll_invitation_id is not None
|
|
and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY
|
|
):
|
|
provider = _poll_participation_provider()
|
|
if provider is None:
|
|
raise SchedulingError(
|
|
"Poll governed participation capability is unavailable"
|
|
)
|
|
try:
|
|
context = provider.resolve_authenticated_participation(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
invitation_id=participant.poll_invitation_id,
|
|
gateway=_public_participation_gateway(request.id),
|
|
respondent_id=canonical_respondent_id,
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
response = context.response.response if context.response is not None else None
|
|
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,
|
|
"comment": participant.response_comment if request.allow_comments else None,
|
|
}
|
|
|
|
|
|
def _resolve_public_scheduling_participation(
|
|
session: Session,
|
|
*,
|
|
request_id: str,
|
|
token: str,
|
|
payload: SchedulingPublicParticipationAccessRequest,
|
|
client_address: str | None,
|
|
lock_for_submission: bool = False,
|
|
) -> tuple[
|
|
SchedulingRequest,
|
|
SchedulingParticipant,
|
|
PollParticipationContextRef,
|
|
]:
|
|
request = (
|
|
session.query(SchedulingRequest)
|
|
.filter(
|
|
SchedulingRequest.id == request_id,
|
|
SchedulingRequest.deleted_at.is_(None),
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if request is None:
|
|
raise SchedulingPublicParticipationError()
|
|
|
|
if request.status == "cancelled":
|
|
notice_until = response_datetime(request.cancellation_notice_until)
|
|
if notice_until is None or notice_until <= _now():
|
|
raise SchedulingPublicParticipationError()
|
|
|
|
if lock_for_submission:
|
|
# Scheduling mutations acquire their request/participant locks before
|
|
# entering Poll. Public response submission must use the same order:
|
|
# otherwise a participant reconciliation (Scheduling -> Poll) can
|
|
# deadlock with a response (Poll -> Scheduling), and two first
|
|
# responses can both observe an unbound participant email.
|
|
try:
|
|
request = _lock_scheduling_request(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
request_id=request.id,
|
|
lock_participants=True,
|
|
)
|
|
except SchedulingError as exc:
|
|
raise SchedulingPublicParticipationError() from exc
|
|
try:
|
|
_require_scheduling_response_collection_open(request)
|
|
except SchedulingError as exc:
|
|
# Public participation deliberately does not disclose whether a
|
|
# token, lifecycle state, or deadline caused rejection.
|
|
raise SchedulingPublicParticipationError() from exc
|
|
|
|
password_dimensions: tuple[ThrottleDimension, ...] = ()
|
|
if request.anonymous_password_protection_enabled:
|
|
password_dimensions = _participation_password_dimensions(
|
|
request,
|
|
token=token,
|
|
client_address=client_address,
|
|
)
|
|
throttle = _participation_password_throttle()
|
|
decision = throttle.check(password_dimensions)
|
|
if not decision.allowed:
|
|
raise SchedulingPublicParticipationError(
|
|
retry_after_seconds=decision.retry_after_seconds,
|
|
)
|
|
if payload.password is None:
|
|
# The UI first probes whether the link needs credentials. Missing
|
|
# credentials are not a failed password attempt.
|
|
raise SchedulingPublicParticipationError()
|
|
supplied_password = payload.password.get_secret_value()
|
|
if not verify_participant_password(
|
|
supplied_password,
|
|
request.anonymous_password_hash,
|
|
):
|
|
decision = throttle.record(password_dimensions)
|
|
raise SchedulingPublicParticipationError(
|
|
retry_after_seconds=(
|
|
decision.retry_after_seconds
|
|
if not decision.allowed
|
|
else 0
|
|
),
|
|
)
|
|
|
|
provider = _poll_participation_provider()
|
|
if provider is None:
|
|
raise SchedulingPublicParticipationError()
|
|
gateway = _public_participation_gateway(request.id)
|
|
try:
|
|
context = provider.resolve_participation(
|
|
session,
|
|
token=token,
|
|
gateway=gateway,
|
|
participant_email=payload.participant_email,
|
|
participant_is_authenticated=False,
|
|
verified_requirements=(
|
|
frozenset({ANONYMOUS_PASSWORD_REQUIREMENT})
|
|
if request.anonymous_password_protection_enabled
|
|
else frozenset()
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingPublicParticipationError() from exc
|
|
if (
|
|
context.tenant_id != request.tenant_id
|
|
or context.poll_id != request.poll_id
|
|
or context.gateway != gateway
|
|
):
|
|
raise SchedulingPublicParticipationError()
|
|
participant = next(
|
|
(
|
|
item
|
|
for item in _active_participants(request)
|
|
if item.poll_invitation_id == context.invitation_id
|
|
and item.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY
|
|
),
|
|
None,
|
|
)
|
|
if participant is None:
|
|
raise SchedulingPublicParticipationError()
|
|
if (
|
|
participant.email is not None
|
|
and payload.participant_email is not None
|
|
and participant.email.strip().casefold()
|
|
!= payload.participant_email.strip().casefold()
|
|
):
|
|
raise SchedulingPublicParticipationError()
|
|
if password_dimensions:
|
|
# Clear only the token-specific bucket. The broader request/client
|
|
# dimension deliberately retains failures to resist token rotation.
|
|
_participation_password_throttle().reset(password_dimensions[:1])
|
|
return request, participant, context
|
|
|
|
|
|
def _public_scheduling_participation_response(
|
|
request: SchedulingRequest,
|
|
context: PollParticipationContextRef,
|
|
*,
|
|
response: PollGovernedResponseRef | None = None,
|
|
) -> dict[str, Any]:
|
|
if request.status == "cancelled":
|
|
# A cancelled link remains useful only as a bounded notice. Do not
|
|
# retain the description, location, candidate slots, or prior answer
|
|
# projection on this public surface.
|
|
return {
|
|
"request_id": request.id,
|
|
"title": request.title,
|
|
"timezone": request.timezone,
|
|
"status": request.status,
|
|
"cancelled_at": response_datetime(request.cancelled_at),
|
|
"cancellation_notice_until": response_datetime(
|
|
request.cancellation_notice_until
|
|
),
|
|
"cancellation_notice_only": True,
|
|
"participant_email_required": False,
|
|
"anonymous_password_required": False,
|
|
"single_choice": False,
|
|
"allow_maybe": False,
|
|
"allow_comments": False,
|
|
"allow_participant_updates": False,
|
|
}
|
|
current_response = response or context.response
|
|
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 current_response is not None:
|
|
for answer in current_response.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,
|
|
"title": request.title,
|
|
"description": request.description,
|
|
"location": request.location,
|
|
"timezone": request.timezone,
|
|
"status": request.status,
|
|
"deadline_at": response_datetime(request.deadline_at),
|
|
"cancelled_at": None,
|
|
"cancellation_notice_until": None,
|
|
"cancellation_notice_only": False,
|
|
"participant_email_required": context.policy.participant_email_required,
|
|
"anonymous_password_required": context.policy.anonymous_password_required,
|
|
"single_choice": context.policy.single_choice,
|
|
"max_participants_per_option": context.policy.max_participants_per_option,
|
|
"allow_maybe": context.policy.allow_maybe,
|
|
"allow_comments": context.policy.allow_comments,
|
|
"allow_participant_updates": request.allow_participant_updates,
|
|
"has_response": current_response is not None,
|
|
"submitted_at": (
|
|
response_datetime(current_response.response.submitted_at)
|
|
if current_response is not None
|
|
else None
|
|
),
|
|
"answers": answers,
|
|
"comment": current_response.comment if current_response is not None else None,
|
|
"replayed": current_response.replayed if current_response is not None else False,
|
|
"slots": [
|
|
{
|
|
"id": slot.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),
|
|
}
|
|
for slot in _active_slots(request)
|
|
],
|
|
}
|
|
|
|
|
|
def get_public_scheduling_participation(
|
|
session: Session,
|
|
*,
|
|
request_id: str,
|
|
token: str,
|
|
payload: SchedulingPublicParticipationAccessRequest,
|
|
client_address: str | None,
|
|
) -> dict[str, Any]:
|
|
request, _participant, context = _resolve_public_scheduling_participation(
|
|
session,
|
|
request_id=request_id,
|
|
token=token,
|
|
payload=payload,
|
|
client_address=client_address,
|
|
)
|
|
return _public_scheduling_participation_response(request, context)
|
|
|
|
|
|
def submit_public_scheduling_participation(
|
|
session: Session,
|
|
*,
|
|
request_id: str,
|
|
token: str,
|
|
payload: SchedulingPublicParticipationSubmitRequest,
|
|
client_address: str | None,
|
|
) -> dict[str, Any]:
|
|
access_payload = SchedulingPublicParticipationAccessRequest(
|
|
participant_email=payload.participant_email,
|
|
password=payload.password,
|
|
)
|
|
request, participant, context = _resolve_public_scheduling_participation(
|
|
session,
|
|
request_id=request_id,
|
|
token=token,
|
|
payload=access_payload,
|
|
client_address=client_address,
|
|
lock_for_submission=True,
|
|
)
|
|
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,
|
|
)
|
|
)
|
|
verified_requirements = (
|
|
frozenset({ANONYMOUS_PASSWORD_REQUIREMENT})
|
|
if request.anonymous_password_protection_enabled
|
|
else frozenset()
|
|
)
|
|
provider = _poll_participation_provider()
|
|
if provider is None:
|
|
raise SchedulingPublicParticipationError()
|
|
try:
|
|
governed_response = provider.submit_governed_response(
|
|
session,
|
|
token=token,
|
|
gateway=_public_participation_gateway(request.id),
|
|
command=PollGovernedResponseCommand(
|
|
respondent_label=participant.display_name,
|
|
participant_email=payload.participant_email,
|
|
participant_is_authenticated=False,
|
|
answers=tuple(answers),
|
|
comment=payload.comment,
|
|
verified_requirements=verified_requirements,
|
|
idempotency_key=payload.idempotency_key,
|
|
metadata={"scheduling_participant_id": participant.id},
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
message = str(exc)
|
|
if message == "Poll invitation not found":
|
|
raise SchedulingPublicParticipationError() from exc
|
|
if "Participant limit reached" in message or "Idempotency key" in message:
|
|
raise SchedulingConflictError(message) from exc
|
|
raise SchedulingError(message) from exc
|
|
|
|
participant.status = "responded"
|
|
participant.responded_at = response_datetime(
|
|
governed_response.response.submitted_at
|
|
)
|
|
participant.response_comment = governed_response.comment
|
|
if governed_response.response.respondent_id:
|
|
participant.metadata_ = {
|
|
**(participant.metadata_ or {}),
|
|
"poll_response_respondent_id": governed_response.response.respondent_id,
|
|
}
|
|
if participant.email is None and governed_response.participant_email is not None:
|
|
participant.email = governed_response.participant_email
|
|
if request.notify_on_answers and not governed_response.replayed:
|
|
_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 _public_scheduling_participation_response(
|
|
request,
|
|
context,
|
|
response=governed_response,
|
|
)
|
|
|
|
|
|
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_matches_actor(participant, ids)
|
|
for participant in _active_participants(request)
|
|
):
|
|
return
|
|
raise SchedulingError("Scheduling results are not visible")
|
|
|
|
|
|
def _update_scheduling_request_with_invitation_tokens(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
payload: SchedulingRequestUpdateRequest,
|
|
) -> tuple[SchedulingRequest, dict[str, str]]:
|
|
request = _lock_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
lock_slots=payload.slots is not None,
|
|
lock_participants=(
|
|
payload.participants is not None
|
|
or "deadline_at" in payload.model_fields_set
|
|
),
|
|
)
|
|
if request.status in {"decided", "handed_off", "cancelled", "archived"}:
|
|
raise SchedulingError("Decided, handed off, cancelled, or archived scheduling requests cannot be edited")
|
|
deadline_changed = (
|
|
"deadline_at" in payload.model_fields_set
|
|
and response_datetime(payload.deadline_at)
|
|
!= response_datetime(request.deadline_at)
|
|
)
|
|
invitation_ids_before_deadline_change = {
|
|
participant.id: participant.poll_invitation_id
|
|
for participant in _active_participants(request)
|
|
if participant.poll_invitation_id is not None
|
|
}
|
|
if (
|
|
deadline_changed
|
|
and invitation_ids_before_deadline_change
|
|
and _poll_participation_provider() is None
|
|
):
|
|
raise SchedulingError(
|
|
"Poll participation invitation rotation capability is unavailable"
|
|
)
|
|
policy_changed = any(
|
|
field in payload.model_fields_set
|
|
and getattr(payload, field) is not None
|
|
and getattr(payload, field) != getattr(request, field)
|
|
for field in (
|
|
"single_choice",
|
|
"allow_maybe",
|
|
"allow_comments",
|
|
"participant_email_required",
|
|
"anonymous_password_protection_enabled",
|
|
)
|
|
) or (
|
|
"max_participants_per_option" in payload.model_fields_set
|
|
and payload.max_participants_per_option
|
|
!= request.max_participants_per_option
|
|
)
|
|
if (
|
|
policy_changed
|
|
and any(participant.poll_invitation_id for participant in _active_participants(request))
|
|
):
|
|
raise SchedulingError(
|
|
"Public participation policy cannot change after invitation links are issued"
|
|
)
|
|
nullable_fields = {"description", "location", "deadline_at"}
|
|
for field in (
|
|
"title",
|
|
"description",
|
|
"location",
|
|
"deadline_at",
|
|
"allow_external_participants",
|
|
"allow_participant_updates",
|
|
"result_visibility",
|
|
"participant_visibility",
|
|
"notify_on_answers",
|
|
"single_choice",
|
|
"allow_maybe",
|
|
"allow_comments",
|
|
"participant_email_required",
|
|
):
|
|
if field not in payload.model_fields_set:
|
|
continue
|
|
value = getattr(payload, field)
|
|
if value is None and field not in nullable_fields:
|
|
raise SchedulingError(f"{field} cannot be null")
|
|
setattr(request, field, value)
|
|
if "max_participants_per_option" in payload.model_fields_set:
|
|
request.max_participants_per_option = payload.max_participants_per_option
|
|
if (
|
|
"anonymous_password_protection_enabled" in payload.model_fields_set
|
|
and payload.anonymous_password_protection_enabled is not None
|
|
):
|
|
if payload.anonymous_password_protection_enabled:
|
|
request.anonymous_password_protection_enabled = True
|
|
else:
|
|
request.anonymous_password_protection_enabled = False
|
|
request.anonymous_password_hash = None
|
|
if payload.anonymous_password is not None:
|
|
if not request.anonymous_password_protection_enabled:
|
|
raise SchedulingError(
|
|
"Password protection must be enabled before setting an anonymous participant password"
|
|
)
|
|
request.anonymous_password_hash = hash_participant_password(
|
|
payload.anonymous_password.get_secret_value()
|
|
)
|
|
if request.anonymous_password_protection_enabled and not request.anonymous_password_hash:
|
|
raise SchedulingError(
|
|
"anonymous_password is required when password protection is enabled"
|
|
)
|
|
if payload.calendar is not None:
|
|
_set_calendar_preferences(request, payload)
|
|
if payload.metadata is not None:
|
|
request.metadata_ = payload.metadata
|
|
invitation_tokens: dict[str, str] = {}
|
|
slots_changed = False
|
|
if payload.slots is not None:
|
|
slots_changed = _reconcile_scheduling_slots(
|
|
session,
|
|
request=request,
|
|
supplied_slots=payload.slots,
|
|
)
|
|
if payload.participants is not None:
|
|
invitation_tokens = _reconcile_scheduling_participants(
|
|
session,
|
|
request=request,
|
|
supplied_participants=payload.participants,
|
|
)
|
|
if deadline_changed:
|
|
for participant in _active_participants(request):
|
|
previous_invitation_id = invitation_ids_before_deadline_change.get(
|
|
participant.id
|
|
)
|
|
if (
|
|
previous_invitation_id is None
|
|
or participant.poll_invitation_id != previous_invitation_id
|
|
):
|
|
# Removed participants were revoked by reconciliation, while
|
|
# newly added participants already use the new deadline.
|
|
continue
|
|
_update_participant_invitation_expiry(
|
|
session,
|
|
request=request,
|
|
participant=participant,
|
|
invitation_id=previous_invitation_id,
|
|
expires_at=request.deadline_at,
|
|
)
|
|
if (
|
|
not request.allow_external_participants
|
|
and any(
|
|
participant.participant_type == "external"
|
|
for participant in _active_participants(request)
|
|
)
|
|
):
|
|
raise SchedulingError(
|
|
"External participants are not allowed for this scheduling request"
|
|
)
|
|
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
|
|
if slots_changed:
|
|
refresh_participant_response_state(
|
|
session,
|
|
request=request,
|
|
reset_missing=True,
|
|
)
|
|
session.flush()
|
|
return request, invitation_tokens
|
|
|
|
|
|
def update_scheduling_request(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
payload: SchedulingRequestUpdateRequest,
|
|
) -> SchedulingRequest:
|
|
request, _invitation_tokens = _update_scheduling_request_with_invitation_tokens(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
payload=payload,
|
|
)
|
|
return request
|
|
|
|
|
|
def update_scheduling_request_with_invitation_tokens(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
payload: SchedulingRequestUpdateRequest,
|
|
) -> tuple[SchedulingRequest, dict[str, str]]:
|
|
"""Compatibility wrapper; explicit invitation actions own raw tokens."""
|
|
|
|
return _update_scheduling_request_with_invitation_tokens(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
payload=payload,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _CandidateSlotChanges:
|
|
label: str
|
|
description: str | None
|
|
start_at: datetime
|
|
end_at: datetime
|
|
timezone: str
|
|
location: str | None
|
|
metadata: dict[str, Any]
|
|
normalized_start: datetime
|
|
normalized_end: datetime
|
|
|
|
|
|
def _candidate_slot_changes(
|
|
slot: SchedulingCandidateSlot,
|
|
payload: SchedulingCandidateSlotUpdateRequest,
|
|
) -> _CandidateSlotChanges:
|
|
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 = cast(str, payload.label if "label" in supplied else slot.label)
|
|
description = payload.description if "description" in supplied else slot.description
|
|
start_at = cast(datetime, payload.start_at if "start_at" in supplied else slot.start_at)
|
|
end_at = cast(datetime, payload.end_at if "end_at" in supplied else slot.end_at)
|
|
timezone_name = cast(str, 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 = cast(datetime, response_datetime(start_at))
|
|
normalized_end = cast(datetime, response_datetime(end_at))
|
|
if normalized_end <= normalized_start:
|
|
raise SchedulingError("end_at must be after start_at")
|
|
return _CandidateSlotChanges(
|
|
label=label,
|
|
description=description,
|
|
start_at=start_at,
|
|
end_at=end_at,
|
|
timezone=timezone_name,
|
|
location=location,
|
|
metadata=metadata,
|
|
normalized_start=normalized_start,
|
|
normalized_end=normalized_end,
|
|
)
|
|
|
|
|
|
def _candidate_slot_change_flags(
|
|
slot: SchedulingCandidateSlot,
|
|
changes: _CandidateSlotChanges,
|
|
) -> tuple[bool, bool]:
|
|
temporal_or_location_changed = (
|
|
changes.normalized_start != response_datetime(slot.start_at)
|
|
or changes.normalized_end != response_datetime(slot.end_at)
|
|
or changes.timezone != slot.timezone
|
|
or changes.location != slot.location
|
|
)
|
|
changed = (
|
|
changes.label != slot.label
|
|
or changes.description != slot.description
|
|
or temporal_or_location_changed
|
|
or changes.metadata != (slot.metadata_ or {})
|
|
)
|
|
return changed, temporal_or_location_changed
|
|
|
|
|
|
def _update_candidate_poll_option(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request: SchedulingRequest,
|
|
slot: SchedulingCandidateSlot,
|
|
changes: _CandidateSlotChanges,
|
|
) -> None:
|
|
try:
|
|
_poll_provider().update_option(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
poll_id=cast(str, request.poll_id),
|
|
option_id=cast(str, slot.poll_option_id),
|
|
command=PollOptionUpdateCommand(
|
|
label=changes.label,
|
|
description=changes.description,
|
|
value={
|
|
"slot_id": slot.id,
|
|
"start_at": changes.normalized_start.isoformat(),
|
|
"end_at": changes.normalized_end.isoformat(),
|
|
"timezone": changes.timezone,
|
|
"location": changes.location,
|
|
},
|
|
metadata=changes.metadata,
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
|
|
|
|
def _apply_candidate_slot_changes(
|
|
slot: SchedulingCandidateSlot,
|
|
changes: _CandidateSlotChanges,
|
|
*,
|
|
temporal_or_location_changed: bool,
|
|
) -> None:
|
|
slot.label = changes.label
|
|
slot.description = changes.description
|
|
slot.start_at = changes.start_at
|
|
slot.end_at = changes.end_at
|
|
slot.timezone = changes.timezone
|
|
slot.location = changes.location
|
|
slot.metadata_ = changes.metadata
|
|
if temporal_or_location_changed:
|
|
slot.freebusy_checked_at = None
|
|
slot.freebusy_status = None
|
|
slot.freebusy_conflicts = []
|
|
|
|
|
|
def _candidate_slot_changes_from_reconcile(
|
|
request: SchedulingRequest,
|
|
payload: SchedulingCandidateSlotReconcileInput,
|
|
) -> _CandidateSlotChanges:
|
|
timezone_name = payload.timezone or request.timezone
|
|
normalized_start = cast(datetime, response_datetime(payload.start_at))
|
|
normalized_end = cast(datetime, response_datetime(payload.end_at))
|
|
label = payload.label or _slot_label(
|
|
payload.start_at,
|
|
payload.end_at,
|
|
timezone_name=timezone_name,
|
|
)
|
|
return _CandidateSlotChanges(
|
|
label=label,
|
|
description=payload.description,
|
|
start_at=payload.start_at,
|
|
end_at=payload.end_at,
|
|
timezone=timezone_name,
|
|
location=payload.location if payload.location is not None else request.location,
|
|
metadata=dict(payload.metadata),
|
|
normalized_start=normalized_start,
|
|
normalized_end=normalized_end,
|
|
)
|
|
|
|
|
|
def _reconcile_scheduling_slots(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
supplied_slots: list[SchedulingCandidateSlotReconcileInput],
|
|
) -> bool:
|
|
"""Apply a complete slot collection while keeping Poll answers coherent."""
|
|
|
|
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")
|
|
current_slots = _active_slots(request)
|
|
current_by_id = {slot.id: slot for slot in current_slots}
|
|
unknown_ids = sorted(
|
|
item.id
|
|
for item in supplied_slots
|
|
if item.id is not None and item.id not in current_by_id
|
|
)
|
|
if unknown_ids:
|
|
raise SchedulingError("Unknown scheduling candidate slot")
|
|
|
|
supplied_ids = {
|
|
item.id
|
|
for item in supplied_slots
|
|
if item.id is not None
|
|
}
|
|
removed_slots = [slot for slot in current_slots if slot.id not in supplied_ids]
|
|
new_inputs = [item for item in supplied_slots if item.id is None]
|
|
if (removed_slots or new_inputs) and _poll_participation_provider() is None:
|
|
raise SchedulingError(
|
|
"Poll participation option mutation capability is unavailable"
|
|
)
|
|
for slot in removed_slots:
|
|
if slot.tentative_hold_event_id:
|
|
raise SchedulingError(
|
|
"A candidate slot with a tentative calendar hold cannot be removed"
|
|
)
|
|
if slot.poll_option_id is None:
|
|
raise SchedulingError("Scheduling slot has no backing poll option")
|
|
|
|
planned_updates: list[
|
|
tuple[SchedulingCandidateSlot, _CandidateSlotChanges, bool]
|
|
] = []
|
|
for item in supplied_slots:
|
|
if item.id is None:
|
|
continue
|
|
slot = current_by_id[item.id]
|
|
if item.revision != scheduling_slot_revision(slot):
|
|
raise SchedulingConflictError(
|
|
"Scheduling options changed after this edit form was loaded; reload before saving"
|
|
)
|
|
changes = _candidate_slot_changes_from_reconcile(request, item)
|
|
changed, temporal_or_location_changed = _candidate_slot_change_flags(
|
|
slot,
|
|
changes,
|
|
)
|
|
if (
|
|
changed
|
|
and 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"
|
|
)
|
|
if changed:
|
|
if slot.poll_option_id is None:
|
|
raise SchedulingError("Scheduling slot has no backing poll option")
|
|
planned_updates.append((slot, changes, temporal_or_location_changed))
|
|
|
|
for slot, changes, temporal_or_location_changed in planned_updates:
|
|
_update_candidate_poll_option(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
request=request,
|
|
slot=slot,
|
|
changes=changes,
|
|
)
|
|
_apply_candidate_slot_changes(
|
|
slot,
|
|
changes,
|
|
temporal_or_location_changed=temporal_or_location_changed,
|
|
)
|
|
|
|
participation_provider = _poll_participation_provider()
|
|
created_slots: list[SchedulingCandidateSlot] = []
|
|
for item in new_inputs:
|
|
changes = _candidate_slot_changes_from_reconcile(request, item)
|
|
slot = SchedulingCandidateSlot(
|
|
tenant_id=request.tenant_id,
|
|
request=request,
|
|
label=changes.label,
|
|
description=changes.description,
|
|
start_at=changes.start_at,
|
|
end_at=changes.end_at,
|
|
timezone=changes.timezone,
|
|
location=changes.location,
|
|
position=max((stored.position for stored in request.slots), default=-1) + 1,
|
|
metadata_=changes.metadata,
|
|
)
|
|
session.add(slot)
|
|
session.flush()
|
|
if participation_provider is None: # Guarded above; keeps type narrowing explicit.
|
|
raise SchedulingError(
|
|
"Poll participation option mutation capability is unavailable"
|
|
)
|
|
try:
|
|
created = participation_provider.add_option(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
command=PollOptionRequest(
|
|
key=f"slot-{slot.id}",
|
|
label=slot.label,
|
|
description=slot.description,
|
|
value={
|
|
"slot_id": slot.id,
|
|
"start_at": changes.normalized_start.isoformat(),
|
|
"end_at": changes.normalized_end.isoformat(),
|
|
"timezone": slot.timezone,
|
|
"location": slot.location,
|
|
},
|
|
metadata=slot.metadata_ or {},
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
slot.poll_option_id = created.id
|
|
slot.position = created.position
|
|
created_slots.append(slot)
|
|
|
|
for slot in removed_slots:
|
|
if participation_provider is None: # Guarded above; keeps type narrowing explicit.
|
|
raise SchedulingError(
|
|
"Poll participation option mutation capability is unavailable"
|
|
)
|
|
try:
|
|
participation_provider.remove_option(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
option_id=cast(str, slot.poll_option_id),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
slot.deleted_at = _now()
|
|
|
|
created_iter = iter(created_slots)
|
|
ordered_slots = [
|
|
current_by_id[item.id] if item.id is not None else next(created_iter)
|
|
for item in supplied_slots
|
|
]
|
|
if any(slot.poll_option_id is None for slot in ordered_slots):
|
|
raise SchedulingError("Scheduling slot has no backing poll option")
|
|
position_changed = any(
|
|
slot.position != position
|
|
for position, slot in enumerate(ordered_slots)
|
|
)
|
|
try:
|
|
_poll_provider().reorder_options(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
command=PollOptionOrderCommand(
|
|
option_ids=tuple(
|
|
cast(str, slot.poll_option_id)
|
|
for slot in ordered_slots
|
|
),
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
for position, slot in enumerate(ordered_slots):
|
|
slot.position = position
|
|
|
|
return bool(
|
|
planned_updates
|
|
or new_inputs
|
|
or removed_slots
|
|
or position_changed
|
|
)
|
|
|
|
|
|
def _normalized_participant_identity(value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
normalized = value.strip()
|
|
if not normalized:
|
|
return None
|
|
return normalized.casefold() if "@" in normalized else normalized
|
|
|
|
|
|
def _participant_identity_values(
|
|
participant: SchedulingParticipant | SchedulingParticipantReconcileInput,
|
|
) -> tuple[str, ...]:
|
|
identities = (
|
|
_normalized_participant_identity(participant.respondent_id),
|
|
_normalized_participant_identity(participant.email),
|
|
)
|
|
return tuple(dict.fromkeys(value for value in identities if value is not None))
|
|
|
|
|
|
def _validate_participant_reconciliation(
|
|
request: SchedulingRequest,
|
|
current_by_id: dict[str, SchedulingParticipant],
|
|
supplied_participants: list[SchedulingParticipantReconcileInput],
|
|
) -> None:
|
|
unknown_ids = sorted(
|
|
item.id
|
|
for item in supplied_participants
|
|
if item.id is not None and item.id not in current_by_id
|
|
)
|
|
if unknown_ids:
|
|
raise SchedulingError("Unknown scheduling participant")
|
|
if not request.allow_external_participants and any(
|
|
item.participant_type == "external" for item in supplied_participants
|
|
):
|
|
raise SchedulingError(
|
|
"External participants are not allowed for this scheduling request"
|
|
)
|
|
assigned_identities: dict[str, int] = {}
|
|
for index, item in enumerate(supplied_participants):
|
|
for identity in _participant_identity_values(item):
|
|
previous = assigned_identities.setdefault(identity, index)
|
|
if previous != index:
|
|
raise SchedulingError(
|
|
"A scheduling participant identity or email can occur only once"
|
|
)
|
|
for item in supplied_participants:
|
|
if item.id is None:
|
|
continue
|
|
participant = current_by_id[item.id]
|
|
if participant.poll_invitation_id is None:
|
|
continue
|
|
identity_changed = (
|
|
_normalized_participant_identity(item.respondent_id)
|
|
!= _normalized_participant_identity(participant.respondent_id)
|
|
or _normalized_participant_identity(item.email)
|
|
!= _normalized_participant_identity(participant.email)
|
|
or item.display_name != participant.display_name
|
|
)
|
|
if identity_changed:
|
|
raise SchedulingError(
|
|
"An invited participant's identity, name, and email cannot be changed; remove and add the participant instead"
|
|
)
|
|
|
|
|
|
def _participant_for_invitation_action(
|
|
request: SchedulingRequest,
|
|
*,
|
|
participant_id: str,
|
|
) -> SchedulingParticipant:
|
|
participant = next(
|
|
(
|
|
item
|
|
for item in _active_participants(request)
|
|
if item.id == participant_id
|
|
),
|
|
None,
|
|
)
|
|
if participant is None:
|
|
raise SchedulingError("Scheduling participant not found")
|
|
return participant
|
|
|
|
|
|
def _new_public_invitation_expiry(
|
|
request: SchedulingRequest,
|
|
) -> datetime | None:
|
|
"""Keep collecting links deadline-bound without minting expired tokens.
|
|
|
|
Read-only links can still be issued after a response deadline or in a later
|
|
lifecycle state; Scheduling independently rejects submissions whenever the
|
|
request is not collecting. Cancellation can impose its own bounded notice
|
|
expiry on top of this lifecycle helper.
|
|
"""
|
|
|
|
if request.status == "cancelled":
|
|
notice_until = response_datetime(request.cancellation_notice_until)
|
|
if notice_until is None or notice_until <= _now():
|
|
raise SchedulingError("Scheduling cancellation notice has expired")
|
|
return notice_until
|
|
deadline = response_datetime(request.deadline_at)
|
|
return deadline if deadline is not None and deadline > _now() else None
|
|
|
|
|
|
def _participant_has_delivery_target(
|
|
participant: SchedulingParticipant,
|
|
) -> bool:
|
|
if participant.email:
|
|
return True
|
|
respondent_id = (participant.respondent_id or "").strip()
|
|
return bool(
|
|
respondent_id
|
|
and not respondent_id.startswith("scheduling-participant:")
|
|
)
|
|
|
|
|
|
def issue_scheduling_participant_invitation(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
participant_id: str,
|
|
action: str,
|
|
) -> SchedulingInvitationActionResult:
|
|
"""Rotate one bearer link for explicit copy or immediate delivery.
|
|
|
|
The returned result contains the bearer URL only for ``copy``. ``send``
|
|
passes it directly into the notification dispatch boundary and returns only
|
|
the durable job projection.
|
|
"""
|
|
|
|
if action not in {"copy", "send"}:
|
|
raise SchedulingError("Unsupported scheduling invitation action")
|
|
request = _lock_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
lock_participants=True,
|
|
)
|
|
participant = _participant_for_invitation_action(
|
|
request,
|
|
participant_id=participant_id,
|
|
)
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling invitation action is unavailable")
|
|
participation_provider = _poll_participation_provider()
|
|
if participation_provider is None:
|
|
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
|
|
if action == "send":
|
|
if not _participant_has_delivery_target(participant):
|
|
raise SchedulingError(
|
|
"Participant has no deliverable email address or account"
|
|
)
|
|
if notification_dispatch_provider(get_registry()) is None:
|
|
raise SchedulingError(
|
|
"Notification delivery is unavailable; copy the link instead"
|
|
)
|
|
|
|
invitation_expiry = _new_public_invitation_expiry(request)
|
|
previous_invitation_id = participant.poll_invitation_id
|
|
try:
|
|
if previous_invitation_id is not None:
|
|
participation_provider.revoke_invitation(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
invitation_id=previous_invitation_id,
|
|
)
|
|
invitation = participation_provider.create_governed_invitation(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
command=PollGovernedInvitationCommand(
|
|
gateway=_public_participation_gateway(request.id),
|
|
policy=_public_participation_policy(request),
|
|
respondent_id=_stable_participant_respondent_id(participant),
|
|
respondent_label=participant.display_name,
|
|
email=participant.email,
|
|
expires_at=invitation_expiry,
|
|
metadata={
|
|
"scheduling_request_id": request.id,
|
|
"scheduling_participant_id": participant.id,
|
|
"public_link_issued": True,
|
|
"issue_action": action,
|
|
},
|
|
),
|
|
)
|
|
except PollCapabilityError as exc:
|
|
# Poll deliberately keeps its external participation errors generic;
|
|
# retain that property at Scheduling's management boundary too.
|
|
raise SchedulingError(
|
|
"Scheduling invitation action could not be completed"
|
|
) from exc
|
|
|
|
participant.poll_invitation_id = invitation.id
|
|
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
|
|
participant.last_invited_at = _now()
|
|
if participant.status in {"draft", "invited"}:
|
|
participant.status = "invited"
|
|
|
|
action_url = f"/scheduling/public/{request.id}/{invitation.token}"
|
|
notification: SchedulingNotification | None = None
|
|
result_status = "issued"
|
|
if action == "send":
|
|
notifications = create_scheduling_notification_jobs(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
request_id=request.id,
|
|
event_kind="invitation",
|
|
metadata={"explicit_participant_delivery": True},
|
|
invitation_tokens={participant.id: invitation.token},
|
|
participant_ids=frozenset({participant.id}),
|
|
)
|
|
notification = notifications[0]
|
|
result_status = notification.status
|
|
action_url = None
|
|
session.flush()
|
|
return SchedulingInvitationActionResult(
|
|
request=request,
|
|
participant=participant,
|
|
action=action,
|
|
status=result_status,
|
|
action_url=action_url,
|
|
notification=notification,
|
|
replaced_existing=previous_invitation_id is not None,
|
|
)
|
|
|
|
|
|
def revoke_scheduling_participant_invitation(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
request_id: str,
|
|
participant_id: str,
|
|
) -> SchedulingInvitationActionResult:
|
|
"""Immediately revoke the current link; exact repeats are safe no-ops."""
|
|
|
|
request = _lock_scheduling_request(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request_id=request_id,
|
|
lock_participants=True,
|
|
)
|
|
participant = _participant_for_invitation_action(
|
|
request,
|
|
participant_id=participant_id,
|
|
)
|
|
invitation_id = participant.poll_invitation_id
|
|
replayed = invitation_id is None
|
|
if invitation_id is not None:
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling invitation action is unavailable")
|
|
participation_provider = _poll_participation_provider()
|
|
if participation_provider is None:
|
|
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
|
|
try:
|
|
revocation = participation_provider.revoke_invitation(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
invitation_id=invitation_id,
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(
|
|
"Scheduling invitation action could not be completed"
|
|
) from exc
|
|
replayed = revocation.replayed
|
|
participant.poll_invitation_id = None
|
|
participant.participation_gateway = None
|
|
if participant.status == "invited":
|
|
participant.status = "draft"
|
|
session.flush()
|
|
return SchedulingInvitationActionResult(
|
|
request=request,
|
|
participant=participant,
|
|
action="revoke",
|
|
status="revoked",
|
|
replayed=replayed,
|
|
)
|
|
|
|
|
|
def _update_participant_invitation_expiry(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
participant: SchedulingParticipant,
|
|
invitation_id: str,
|
|
expires_at: datetime | None,
|
|
) -> None:
|
|
"""Align one retained participant link with its lifecycle expiry.
|
|
|
|
Poll owns invitation rows and performs the owner- and gateway-bound expiry
|
|
mutation under Poll -> invitation row locks. The invitation identity and
|
|
token remain unchanged across future, past, extended, and cleared
|
|
deadlines, so no raw replacement token has to cross the API boundary.
|
|
"""
|
|
|
|
if request.poll_id is None:
|
|
raise SchedulingError("Scheduling request has no backing poll")
|
|
participation_provider = _poll_participation_provider()
|
|
if participation_provider is None:
|
|
raise SchedulingError(
|
|
"Poll participation invitation rotation capability is unavailable"
|
|
)
|
|
try:
|
|
result = participation_provider.update_invitation_expiry(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=request.poll_id,
|
|
invitation_id=invitation_id,
|
|
gateway=_public_participation_gateway(request.id),
|
|
expires_at=expires_at,
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
if result.id != invitation_id:
|
|
raise SchedulingError("Poll invitation expiry update returned a different invitation")
|
|
participant.poll_invitation_id = invitation_id
|
|
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
|
|
|
|
|
|
def _reconcile_scheduling_participants(
|
|
session: Session,
|
|
*,
|
|
request: SchedulingRequest,
|
|
supplied_participants: list[SchedulingParticipantReconcileInput],
|
|
) -> dict[str, str]:
|
|
"""Apply a complete participant collection and revoke removed links."""
|
|
|
|
current_participants = _active_participants(request)
|
|
current_by_id = {participant.id: participant for participant in current_participants}
|
|
_validate_participant_reconciliation(
|
|
request,
|
|
current_by_id,
|
|
supplied_participants,
|
|
)
|
|
supplied_ids = {
|
|
item.id
|
|
for item in supplied_participants
|
|
if item.id is not None
|
|
}
|
|
removed_participants = [
|
|
participant
|
|
for participant in current_participants
|
|
if participant.id not in supplied_ids
|
|
]
|
|
if (
|
|
any(participant.poll_invitation_id for participant in removed_participants)
|
|
and _poll_participation_provider() is None
|
|
):
|
|
raise SchedulingError(
|
|
"Poll participation invitation revocation capability is unavailable"
|
|
)
|
|
|
|
for item in supplied_participants:
|
|
if item.id is None:
|
|
continue
|
|
participant = current_by_id[item.id]
|
|
if participant.poll_invitation_id is None:
|
|
participant.respondent_id = item.respondent_id
|
|
participant.display_name = item.display_name
|
|
participant.email = item.email
|
|
participant.participant_type = item.participant_type
|
|
participant.required = item.required
|
|
participant.metadata_ = dict(item.metadata)
|
|
|
|
invitation_tokens: dict[str, str] = {}
|
|
for item in supplied_participants:
|
|
if item.id is not None:
|
|
continue
|
|
participant = SchedulingParticipant(
|
|
tenant_id=request.tenant_id,
|
|
request=request,
|
|
respondent_id=item.respondent_id,
|
|
display_name=item.display_name,
|
|
email=item.email,
|
|
participant_type=item.participant_type,
|
|
required=item.required,
|
|
status="draft",
|
|
metadata_=dict(item.metadata),
|
|
)
|
|
session.add(participant)
|
|
session.flush()
|
|
|
|
participation_provider = _poll_participation_provider()
|
|
for participant in removed_participants:
|
|
if participant.poll_invitation_id is not None:
|
|
if participation_provider is None: # Guarded above.
|
|
raise SchedulingError(
|
|
"Poll participation invitation revocation capability is unavailable"
|
|
)
|
|
try:
|
|
participation_provider.revoke_invitation(
|
|
session,
|
|
tenant_id=request.tenant_id,
|
|
poll_id=cast(str, request.poll_id),
|
|
invitation_id=participant.poll_invitation_id,
|
|
)
|
|
except PollCapabilityError as exc:
|
|
raise SchedulingError(str(exc)) from exc
|
|
participant.status = "removed"
|
|
participant.deleted_at = _now()
|
|
|
|
return invitation_tokens
|
|
|
|
|
|
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")
|
|
|
|
changes = _candidate_slot_changes(slot, payload)
|
|
changed, temporal_or_location_changed = _candidate_slot_change_flags(slot, changes)
|
|
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"
|
|
)
|
|
|
|
_update_candidate_poll_option(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
request=request,
|
|
slot=slot,
|
|
changes=changes,
|
|
)
|
|
|
|
refresh_participant_response_state(
|
|
session,
|
|
request=request,
|
|
reset_missing=True,
|
|
)
|
|
|
|
_apply_candidate_slot_changes(
|
|
slot,
|
|
changes,
|
|
temporal_or_location_changed=temporal_or_location_changed,
|
|
)
|
|
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"
|
|
)
|
|
cancelled_at = _now()
|
|
request.status = "cancelled"
|
|
request.cancelled_at = cancelled_at
|
|
request.cancellation_notice_until = cancelled_at + timedelta(
|
|
days=_cancellation_notice_days()
|
|
)
|
|
for participant in _active_participants(request):
|
|
if participant.poll_invitation_id is None:
|
|
continue
|
|
_update_participant_invitation_expiry(
|
|
session,
|
|
request=request,
|
|
participant=participant,
|
|
invitation_id=participant.poll_invitation_id,
|
|
expires_at=request.cancellation_notice_until,
|
|
)
|
|
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)
|
|
|
|
|
|
_PARTICIPANT_FREEBUSY_FIELDS = frozenset(
|
|
{
|
|
"start_at",
|
|
"end_at",
|
|
"all_day",
|
|
"transparency",
|
|
"status",
|
|
}
|
|
)
|
|
|
|
|
|
def _participant_freebusy_conflicts(
|
|
conflicts: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Return useful busy intervals without connector or event identities."""
|
|
|
|
return [
|
|
{
|
|
key: value
|
|
for key, value in conflict.items()
|
|
if key in _PARTICIPANT_FREEBUSY_FIELDS
|
|
}
|
|
for conflict in conflicts
|
|
if any(key in _PARTICIPANT_FREEBUSY_FIELDS for key in conflict)
|
|
]
|
|
|
|
|
|
def scheduling_slot_response(
|
|
slot: SchedulingCandidateSlot,
|
|
*,
|
|
include_management_fields: bool = True,
|
|
) -> dict[str, Any]:
|
|
conflicts = slot.freebusy_conflicts or []
|
|
return {
|
|
"id": slot.id,
|
|
"poll_option_id": (
|
|
slot.poll_option_id if include_management_fields else None
|
|
),
|
|
"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": (
|
|
conflicts
|
|
if include_management_fields
|
|
else _participant_freebusy_conflicts(conflicts)
|
|
),
|
|
"tentative_hold_event_id": (
|
|
slot.tentative_hold_event_id if include_management_fields else None
|
|
),
|
|
"metadata": (slot.metadata_ or {}) if include_management_fields else {},
|
|
}
|
|
|
|
|
|
def scheduling_participant_response(
|
|
participant: SchedulingParticipant,
|
|
*,
|
|
invitation_token: str | None = None,
|
|
redact_sensitive: bool = False,
|
|
include_management_fields: bool = True,
|
|
is_current_participant: bool = False,
|
|
) -> dict[str, Any]:
|
|
expose_email = include_management_fields or is_current_participant
|
|
return {
|
|
"id": participant.id,
|
|
"is_current_participant": is_current_participant,
|
|
"respondent_id": (
|
|
participant.respondent_id
|
|
if include_management_fields and not redact_sensitive
|
|
else None
|
|
),
|
|
"display_name": participant.display_name,
|
|
"email": participant.email if expose_email and not redact_sensitive else None,
|
|
"participant_type": (
|
|
participant.participant_type if include_management_fields else None
|
|
),
|
|
"required": participant.required if include_management_fields else None,
|
|
"status": participant.status,
|
|
"poll_invitation_id": (
|
|
participant.poll_invitation_id
|
|
if include_management_fields and not redact_sensitive
|
|
else None
|
|
),
|
|
"invitation_token": (
|
|
invitation_token
|
|
if include_management_fields and not redact_sensitive
|
|
else None
|
|
),
|
|
"last_invited_at": (
|
|
response_datetime(participant.last_invited_at)
|
|
if include_management_fields and not redact_sensitive
|
|
else None
|
|
),
|
|
"responded_at": (
|
|
response_datetime(participant.responded_at)
|
|
if (include_management_fields or is_current_participant)
|
|
and not redact_sensitive
|
|
else None
|
|
),
|
|
"metadata": (
|
|
(participant.metadata_ or {})
|
|
if include_management_fields and not redact_sensitive
|
|
else {}
|
|
),
|
|
}
|
|
|
|
|
|
_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_matches_actor(participant, 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 = {}
|
|
visibility_decision = {
|
|
**visibility_decision,
|
|
# Policy provenance may contain tenant, organisational-unit, or
|
|
# provider internals. Participants need the effective outcome and
|
|
# its user-facing reason, not the enforcement topology.
|
|
"source_path": [],
|
|
"details": {},
|
|
}
|
|
public_policy_available = _poll_participation_provider() is not None
|
|
return {
|
|
"id": request.id,
|
|
"tenant_id": request.tenant_id if full_roster else None,
|
|
"title": request.title,
|
|
"description": request.description,
|
|
"location": request.location,
|
|
"timezone": request.timezone,
|
|
"status": request.status,
|
|
"poll_id": request.poll_id if full_roster else None,
|
|
"selected_slot_id": request.selected_slot_id,
|
|
"organizer_user_id": request.organizer_user_id if full_roster else None,
|
|
"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,
|
|
"notify_on_answers": request.notify_on_answers,
|
|
"single_choice": request.single_choice,
|
|
"max_participants_per_option": request.max_participants_per_option,
|
|
"allow_maybe": request.allow_maybe,
|
|
"allow_comments": request.allow_comments,
|
|
"participant_email_required": request.participant_email_required,
|
|
"anonymous_password_protection_enabled": request.anonymous_password_protection_enabled,
|
|
"public_participation_policy_enforcement_available": (
|
|
public_policy_available if full_roster else None
|
|
),
|
|
"public_participation_policy_enforcement_reason": (
|
|
None
|
|
if public_policy_available or not full_roster
|
|
else PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON
|
|
),
|
|
"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 if full_roster else None
|
|
),
|
|
"calendar_id": request.calendar_id if full_roster else None,
|
|
"calendar_freebusy_enabled": (
|
|
request.calendar_freebusy_enabled if full_roster else None
|
|
),
|
|
"calendar_hold_enabled": (
|
|
request.calendar_hold_enabled if full_roster else None
|
|
),
|
|
"create_calendar_event_on_decision": (
|
|
request.create_calendar_event_on_decision if full_roster else None
|
|
),
|
|
"calendar_event_id": request.calendar_event_id if full_roster else None,
|
|
"handed_off_at": response_datetime(request.handed_off_at),
|
|
"cancelled_at": response_datetime(request.cancelled_at),
|
|
"cancellation_notice_until": response_datetime(
|
|
request.cancellation_notice_until
|
|
),
|
|
"created_at": response_datetime(request.created_at),
|
|
"updated_at": response_datetime(request.updated_at),
|
|
"metadata": (request.metadata_ or {}) if full_roster else {},
|
|
"slots": [
|
|
scheduling_slot_response(
|
|
slot,
|
|
include_management_fields=full_roster,
|
|
)
|
|
for slot in _active_slots(request)
|
|
],
|
|
"participants": [
|
|
scheduling_participant_response(
|
|
participant,
|
|
invitation_token=invitation_tokens.get(participant.id),
|
|
include_management_fields=full_roster,
|
|
is_current_participant=_participant_matches_actor(
|
|
participant,
|
|
ids,
|
|
),
|
|
redact_sensitive=(
|
|
not full_roster
|
|
and not _participant_matches_actor(participant, 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 {},
|
|
}
|