Add scheduling calendar integration and WebUI

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

View File

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