Add scheduling calendar integration and WebUI
This commit is contained in:
@@ -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 {},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user