feat: reconcile tentative calendar holds
This commit is contained in:
@@ -147,6 +147,9 @@ DOCUMENTATION = (
|
|||||||
body=(
|
body=(
|
||||||
"Calendar coordination remains optional. It is available only when Calendar contributes its picker capability and the actor can read calendars and availability and write events. "
|
"Calendar coordination remains optional. It is available only when Calendar contributes its picker capability and the actor can read calendars and availability and write events. "
|
||||||
"A disabled Calendar control therefore names the missing integration or authority instead of silently accepting a configuration that cannot run. "
|
"A disabled Calendar control therefore names the missing integration or authority instead of silently accepting a configuration that cannot run. "
|
||||||
|
"At decision time, a selected tentative hold is promoted in place while every unused hold is submitted to Calendar for durable release. Cancellation likewise waits until Calendar accepts every release. "
|
||||||
|
"Partial or unavailable Calendar effects keep the Scheduling lifecycle incomplete and expose retry-required cleanup state; an exact retry reuses the recorded event and operation identities instead of duplicating effects. "
|
||||||
|
"Administrators recover synchronized failures through Calendar's outbound-change reconciliation and then repeat the Scheduling action. Scheduling stores only operation identifiers, last-known state, and pending slot references; Calendar remains authoritative for event and outbox evidence. "
|
||||||
"Administrators should enable the Calendar module and grant the bounded calendar, availability, and event permissions needed by the organizer; Scheduling never imports Calendar internals."
|
"Administrators should enable the Calendar module and grant the bounded calendar, availability, and event permissions needed by the organizer; Scheduling never imports Calendar internals."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
@@ -290,7 +293,7 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||||
ModuleInterfaceRequirement(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
ModuleInterfaceRequirement(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.9", version_max_exclusive="0.2.0", optional=True),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
|||||||
@@ -739,7 +739,13 @@ def api_cancel_scheduling_request(
|
|||||||
) -> SchedulingStatusResponse:
|
) -> SchedulingStatusResponse:
|
||||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||||
try:
|
try:
|
||||||
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
request = cancel_scheduling_request(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=principal.account_id,
|
||||||
|
allow_calendar_cleanup=has_scope(principal, CALENDAR_EVENT_WRITE_SCOPE),
|
||||||
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
||||||
|
|||||||
@@ -623,7 +623,6 @@ def create_tentative_calendar_holds(
|
|||||||
slot.metadata_ = {
|
slot.metadata_ = {
|
||||||
**(slot.metadata_ or {}),
|
**(slot.metadata_ or {}),
|
||||||
"calendar_hold": {
|
"calendar_hold": {
|
||||||
"event_id": event.id,
|
|
||||||
# This is a creation-time snapshot. Calendar remains the
|
# This is a creation-time snapshot. Calendar remains the
|
||||||
# authority for later outbox delivery transitions.
|
# authority for later outbox delivery transitions.
|
||||||
"last_known_external_state": event.external_state,
|
"last_known_external_state": event.external_state,
|
||||||
@@ -635,6 +634,104 @@ def create_tentative_calendar_holds(
|
|||||||
return request, created_event_ids, warnings
|
return request, created_event_ids, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_cleanup_metadata(
|
||||||
|
slot: SchedulingCandidateSlot,
|
||||||
|
*,
|
||||||
|
state: str,
|
||||||
|
operation_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
slot.metadata_ = {
|
||||||
|
**(slot.metadata_ or {}),
|
||||||
|
"calendar_hold": {
|
||||||
|
"last_known_external_state": state,
|
||||||
|
"outbox_operation_id": operation_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _release_tentative_calendar_holds(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
user_id: str | None,
|
||||||
|
include_selected: bool,
|
||||||
|
) -> list[str]:
|
||||||
|
candidates = [
|
||||||
|
slot
|
||||||
|
for slot in _active_slots(request)
|
||||||
|
if slot.tentative_hold_event_id
|
||||||
|
and (include_selected or slot.id != request.selected_slot_id)
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
provider = _calendar_provider()
|
||||||
|
except SchedulingError as exc:
|
||||||
|
for slot in candidates:
|
||||||
|
_calendar_cleanup_metadata(slot, state="retry_required")
|
||||||
|
return [str(exc)]
|
||||||
|
warnings: list[str] = []
|
||||||
|
for slot in candidates:
|
||||||
|
event_id = slot.tentative_hold_event_id
|
||||||
|
if not event_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
released = provider.release_event(
|
||||||
|
session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
event_id=event_id,
|
||||||
|
)
|
||||||
|
except CalendarCapabilityError as exc:
|
||||||
|
_calendar_cleanup_metadata(slot, state="retry_required")
|
||||||
|
warnings.append(f"Slot {slot.id}: {exc}")
|
||||||
|
continue
|
||||||
|
if not released.accepted:
|
||||||
|
_calendar_cleanup_metadata(
|
||||||
|
slot,
|
||||||
|
state=released.external_state or "retry_required",
|
||||||
|
operation_id=released.outbox_operation_id,
|
||||||
|
)
|
||||||
|
warnings.append(f"Slot {slot.id}: Calendar did not accept the release operation")
|
||||||
|
continue
|
||||||
|
_calendar_cleanup_metadata(
|
||||||
|
slot,
|
||||||
|
state=released.external_state,
|
||||||
|
operation_id=released.outbox_operation_id,
|
||||||
|
)
|
||||||
|
slot.tentative_hold_event_id = None
|
||||||
|
session.flush()
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _record_calendar_cleanup_state(
|
||||||
|
request: SchedulingRequest,
|
||||||
|
*,
|
||||||
|
warnings: list[str],
|
||||||
|
target_state: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
current_cleanup = (request.metadata_ or {}).get("calendar_cleanup")
|
||||||
|
inherited_target = (
|
||||||
|
current_cleanup.get("target_state")
|
||||||
|
if isinstance(current_cleanup, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
pending_slot_ids = [
|
||||||
|
slot.id
|
||||||
|
for slot in _active_slots(request)
|
||||||
|
if slot.tentative_hold_event_id
|
||||||
|
]
|
||||||
|
request.metadata_ = {
|
||||||
|
**(request.metadata_ or {}),
|
||||||
|
"calendar_cleanup": {
|
||||||
|
"status": "retry_required" if warnings or pending_slot_ids else "accepted",
|
||||||
|
"target_state": target_state or inherited_target,
|
||||||
|
"pending_slot_ids": pending_slot_ids,
|
||||||
|
"warnings": warnings[:20],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_final_calendar_event(
|
def create_final_calendar_event(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -646,53 +743,120 @@ def create_final_calendar_event(
|
|||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
lock_slots=True,
|
||||||
)
|
)
|
||||||
_require_calendar_enabled(request)
|
_require_calendar_enabled(request)
|
||||||
if not request.create_calendar_event_on_decision:
|
if not request.create_calendar_event_on_decision:
|
||||||
raise SchedulingError("Final calendar event creation is not enabled for this scheduling request")
|
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:
|
if (
|
||||||
|
request.status == "handed_off"
|
||||||
|
and request.selected_slot_id
|
||||||
|
and request.calendar_event_id
|
||||||
|
and not any(slot.tentative_hold_event_id for slot in _active_slots(request))
|
||||||
|
):
|
||||||
return request, request.calendar_event_id, []
|
return request, request.calendar_event_id, []
|
||||||
if request.status != "decided" or not request.selected_slot_id:
|
if request.status not in {"decided", "handed_off"} or not request.selected_slot_id:
|
||||||
raise SchedulingError(
|
raise SchedulingError(
|
||||||
"A final calendar event can only be created for a decided scheduling request with a selected slot"
|
"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)
|
slot = _selected_slot(request, slot_id=request.selected_slot_id)
|
||||||
provider = _calendar_provider()
|
provider = _calendar_provider()
|
||||||
|
warnings: list[str] = []
|
||||||
|
event = None
|
||||||
|
if not request.calendar_event_id:
|
||||||
try:
|
try:
|
||||||
|
if slot.tentative_hold_event_id:
|
||||||
|
event = provider.promote_event(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
event_id=slot.tentative_hold_event_id,
|
||||||
|
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
|
||||||
|
)
|
||||||
|
_calendar_cleanup_metadata(
|
||||||
|
slot,
|
||||||
|
state=event.external_state,
|
||||||
|
operation_id=event.outbox_operation_id,
|
||||||
|
)
|
||||||
|
slot.tentative_hold_event_id = None
|
||||||
|
else:
|
||||||
event = provider.create_event(
|
event = provider.create_event(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
|
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
|
||||||
)
|
)
|
||||||
|
request.calendar_event_id = event.id
|
||||||
except CalendarCapabilityError as exc:
|
except CalendarCapabilityError as exc:
|
||||||
|
_calendar_cleanup_metadata(slot, state="retry_required")
|
||||||
|
warnings.append(str(exc))
|
||||||
|
warnings.extend(
|
||||||
|
_release_tentative_calendar_holds(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
user_id=user_id,
|
||||||
|
include_selected=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_record_calendar_cleanup_state(
|
||||||
|
request,
|
||||||
|
warnings=warnings,
|
||||||
|
target_state="handed_off",
|
||||||
|
)
|
||||||
|
if warnings or not request.calendar_event_id:
|
||||||
|
previous_handoff = (request.metadata_ or {}).get("calendar_handoff")
|
||||||
|
previous_handoff = (
|
||||||
|
previous_handoff if isinstance(previous_handoff, dict) else {}
|
||||||
|
)
|
||||||
request.metadata_ = {
|
request.metadata_ = {
|
||||||
**(request.metadata_ or {}),
|
**(request.metadata_ or {}),
|
||||||
"calendar_handoff": {"status": "error", "error": str(exc), "slot_id": slot.id, "calendar_id": request.calendar_id},
|
"calendar_handoff": {
|
||||||
|
"status": "retry_required",
|
||||||
|
"last_known_external_state": (
|
||||||
|
event.external_state
|
||||||
|
if event
|
||||||
|
else previous_handoff.get("last_known_external_state", "retry_required")
|
||||||
|
),
|
||||||
|
"outbox_operation_id": (
|
||||||
|
event.outbox_operation_id
|
||||||
|
if event
|
||||||
|
else previous_handoff.get("outbox_operation_id")
|
||||||
|
),
|
||||||
|
"event_id": request.calendar_event_id,
|
||||||
|
"slot_id": slot.id,
|
||||||
|
"calendar_id": request.calendar_id,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
session.flush()
|
session.flush()
|
||||||
return request, None, [str(exc)]
|
return request, request.calendar_event_id, warnings
|
||||||
request.calendar_event_id = event.id
|
|
||||||
request.handed_off_at = _now()
|
request.handed_off_at = _now()
|
||||||
request.status = "handed_off"
|
request.status = "handed_off"
|
||||||
|
previous_handoff = (request.metadata_ or {}).get("calendar_handoff")
|
||||||
|
previous_handoff = previous_handoff if isinstance(previous_handoff, dict) else {}
|
||||||
request.metadata_ = {
|
request.metadata_ = {
|
||||||
**(request.metadata_ or {}),
|
**(request.metadata_ or {}),
|
||||||
"calendar_handoff": {
|
"calendar_handoff": {
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
# Scheduling stores identifiers plus an explicitly named snapshot;
|
# Scheduling stores identifiers plus an explicitly named snapshot;
|
||||||
# consumers must resolve the Calendar event/outbox for live state.
|
# consumers must resolve the Calendar event/outbox for live state.
|
||||||
"last_known_external_state": event.external_state,
|
"last_known_external_state": (
|
||||||
"outbox_operation_id": event.outbox_operation_id,
|
event.external_state
|
||||||
"event_id": event.id,
|
if event
|
||||||
|
else previous_handoff.get("last_known_external_state", "accepted")
|
||||||
|
),
|
||||||
|
"outbox_operation_id": (
|
||||||
|
event.outbox_operation_id
|
||||||
|
if event
|
||||||
|
else previous_handoff.get("outbox_operation_id")
|
||||||
|
),
|
||||||
|
"event_id": request.calendar_event_id,
|
||||||
"slot_id": slot.id,
|
"slot_id": slot.id,
|
||||||
"calendar_id": request.calendar_id,
|
"calendar_id": request.calendar_id,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
|
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
|
||||||
session.flush()
|
session.flush()
|
||||||
return request, event.id, []
|
return request, request.calendar_event_id, []
|
||||||
|
|
||||||
|
|
||||||
def create_scheduling_notification_jobs(
|
def create_scheduling_notification_jobs(
|
||||||
@@ -3917,6 +4081,7 @@ def decide_scheduling_request(
|
|||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
lock_slots=True,
|
||||||
)
|
)
|
||||||
if request.poll_id is None:
|
if request.poll_id is None:
|
||||||
raise SchedulingError("Scheduling request has no backing poll")
|
raise SchedulingError("Scheduling request has no backing poll")
|
||||||
@@ -3927,6 +4092,44 @@ def decide_scheduling_request(
|
|||||||
"A different slot cannot be selected after a scheduling decision; "
|
"A different slot cannot be selected after a scheduling decision; "
|
||||||
"explicit rescheduling semantics are required"
|
"explicit rescheduling semantics are required"
|
||||||
)
|
)
|
||||||
|
handoff_state = (request.metadata_ or {}).get("calendar_handoff")
|
||||||
|
cleanup_state = (request.metadata_ or {}).get("calendar_cleanup")
|
||||||
|
if (
|
||||||
|
request.status == "decided"
|
||||||
|
and isinstance(handoff_state, dict)
|
||||||
|
and handoff_state.get("status") == "retry_required"
|
||||||
|
):
|
||||||
|
if not allow_calendar_handoff:
|
||||||
|
raise SchedulingPermissionError(
|
||||||
|
"Calendar event promotion or release requires calendar:event:write"
|
||||||
|
)
|
||||||
|
return create_final_calendar_event(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
request_id=request.id,
|
||||||
|
)[0]
|
||||||
|
if (
|
||||||
|
request.status == "decided"
|
||||||
|
and isinstance(cleanup_state, dict)
|
||||||
|
and cleanup_state.get("status") == "retry_required"
|
||||||
|
):
|
||||||
|
if not allow_calendar_handoff:
|
||||||
|
raise SchedulingPermissionError(
|
||||||
|
"Calendar event release requires calendar:event:write"
|
||||||
|
)
|
||||||
|
cleanup_warnings = _release_tentative_calendar_holds(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
user_id=user_id,
|
||||||
|
include_selected=True,
|
||||||
|
)
|
||||||
|
_record_calendar_cleanup_state(
|
||||||
|
request,
|
||||||
|
warnings=cleanup_warnings,
|
||||||
|
target_state="decided",
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
return request
|
return request
|
||||||
if request.status != "closed":
|
if request.status != "closed":
|
||||||
raise SchedulingError("Only closed scheduling requests can be decided")
|
raise SchedulingError("Only closed scheduling requests can be decided")
|
||||||
@@ -3939,9 +4142,12 @@ def decide_scheduling_request(
|
|||||||
and request.calendar_integration_enabled
|
and request.calendar_integration_enabled
|
||||||
and request.create_calendar_event_on_decision
|
and request.create_calendar_event_on_decision
|
||||||
)
|
)
|
||||||
if creates_calendar_event and not allow_calendar_handoff:
|
has_calendar_holds = any(
|
||||||
|
slot.tentative_hold_event_id for slot in _active_slots(request)
|
||||||
|
)
|
||||||
|
if (creates_calendar_event or has_calendar_holds) and not allow_calendar_handoff:
|
||||||
raise SchedulingPermissionError(
|
raise SchedulingPermissionError(
|
||||||
"Calendar event creation requires calendar:event:write"
|
"Calendar event promotion or release requires calendar:event:write"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
_poll_provider().decide_poll(
|
_poll_provider().decide_poll(
|
||||||
@@ -3958,6 +4164,28 @@ def decide_scheduling_request(
|
|||||||
if request.calendar_integration_enabled and request.create_calendar_event_on_decision:
|
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)
|
create_final_calendar_event(session, tenant_id=tenant_id, user_id=user_id, request_id=request.id)
|
||||||
else:
|
else:
|
||||||
|
cleanup_warnings = _release_tentative_calendar_holds(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
user_id=user_id,
|
||||||
|
include_selected=True,
|
||||||
|
)
|
||||||
|
_record_calendar_cleanup_state(
|
||||||
|
request,
|
||||||
|
warnings=cleanup_warnings,
|
||||||
|
target_state="handed_off",
|
||||||
|
)
|
||||||
|
if cleanup_warnings:
|
||||||
|
request.metadata_ = {
|
||||||
|
**(request.metadata_ or {}),
|
||||||
|
"calendar_handoff": {
|
||||||
|
"status": "retry_required",
|
||||||
|
"slot_id": slot.id,
|
||||||
|
"calendar_id": request.calendar_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
session.flush()
|
||||||
|
return request
|
||||||
request.handed_off_at = _now()
|
request.handed_off_at = _now()
|
||||||
request.status = "handed_off"
|
request.status = "handed_off"
|
||||||
request.metadata_ = {
|
request.metadata_ = {
|
||||||
@@ -3969,17 +4197,37 @@ def decide_scheduling_request(
|
|||||||
"calendar_id": request.calendar_id,
|
"calendar_id": request.calendar_id,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
elif has_calendar_holds:
|
||||||
|
cleanup_warnings = _release_tentative_calendar_holds(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
user_id=user_id,
|
||||||
|
include_selected=True,
|
||||||
|
)
|
||||||
|
_record_calendar_cleanup_state(
|
||||||
|
request,
|
||||||
|
warnings=cleanup_warnings,
|
||||||
|
target_state="decided",
|
||||||
|
)
|
||||||
create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="decision")
|
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)
|
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
|
||||||
session.flush()
|
session.flush()
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
|
||||||
def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
|
def cancel_scheduling_request(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
request_id: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
allow_calendar_cleanup: bool = False,
|
||||||
|
) -> SchedulingRequest:
|
||||||
request = _lock_scheduling_request(
|
request = _lock_scheduling_request(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
lock_slots=True,
|
||||||
)
|
)
|
||||||
if request.status == "cancelled":
|
if request.status == "cancelled":
|
||||||
return request
|
return request
|
||||||
@@ -3987,6 +4235,27 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
|
|||||||
raise SchedulingError(
|
raise SchedulingError(
|
||||||
"Only draft, collecting, or closed scheduling requests can be cancelled"
|
"Only draft, collecting, or closed scheduling requests can be cancelled"
|
||||||
)
|
)
|
||||||
|
has_calendar_holds = any(
|
||||||
|
slot.tentative_hold_event_id for slot in _active_slots(request)
|
||||||
|
)
|
||||||
|
if has_calendar_holds and not allow_calendar_cleanup:
|
||||||
|
raise SchedulingPermissionError(
|
||||||
|
"Calendar event release requires calendar:event:write"
|
||||||
|
)
|
||||||
|
cleanup_warnings = _release_tentative_calendar_holds(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
user_id=user_id,
|
||||||
|
include_selected=True,
|
||||||
|
)
|
||||||
|
_record_calendar_cleanup_state(
|
||||||
|
request,
|
||||||
|
warnings=cleanup_warnings,
|
||||||
|
target_state="cancelled",
|
||||||
|
)
|
||||||
|
if cleanup_warnings:
|
||||||
|
session.flush()
|
||||||
|
return request
|
||||||
cancelled_at = _now()
|
cancelled_at = _now()
|
||||||
request.status = "cancelled"
|
request.status = "cancelled"
|
||||||
request.cancelled_at = cancelled_at
|
request.cancelled_at = cancelled_at
|
||||||
|
|||||||
+180
-2
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal
|
from govoplan_core.auth import ApiPrincipal
|
||||||
from govoplan_core.core.access import PrincipalRef
|
from govoplan_core.core.access import PrincipalRef
|
||||||
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
|
from govoplan_core.core.calendar import CalendarCapabilityError, CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
@@ -25,6 +25,7 @@ from govoplan_calendar.backend.db.models import (
|
|||||||
CalendarSyncSource,
|
CalendarSyncSource,
|
||||||
)
|
)
|
||||||
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
|
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
|
||||||
|
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
|
||||||
from govoplan_poll.backend.db.models import (
|
from govoplan_poll.backend.db.models import (
|
||||||
Poll,
|
Poll,
|
||||||
PollInvitation,
|
PollInvitation,
|
||||||
@@ -533,6 +534,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
request_id=request.id,
|
request_id=request.id,
|
||||||
payload=SchedulingDecisionRequest(slot_id=request.slots[1].id, handoff_to_calendar=False),
|
payload=SchedulingDecisionRequest(slot_id=request.slots[1].id, handoff_to_calendar=False),
|
||||||
user_id="user-1",
|
user_id="user-1",
|
||||||
|
allow_calendar_handoff=True,
|
||||||
)
|
)
|
||||||
with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"):
|
with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"):
|
||||||
evaluate_calendar_freebusy(
|
evaluate_calendar_freebusy(
|
||||||
@@ -579,12 +581,17 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
user_id="user-1",
|
user_id="user-1",
|
||||||
request_id=decided.id,
|
request_id=decided.id,
|
||||||
)
|
)
|
||||||
|
self.assertTrue(all(slot.tentative_hold_event_id is None for slot in request.slots))
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
all(
|
all(
|
||||||
slot.metadata_["calendar_hold"]["last_known_external_state"] == "queued"
|
set(slot.metadata_["calendar_hold"]) == {
|
||||||
|
"last_known_external_state",
|
||||||
|
"outbox_operation_id",
|
||||||
|
}
|
||||||
for slot in request.slots
|
for slot in request.slots
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.assertEqual(request.metadata_["calendar_cleanup"]["status"], "accepted")
|
||||||
generated_events = (
|
generated_events = (
|
||||||
self.session.query(CalendarEvent)
|
self.session.query(CalendarEvent)
|
||||||
.filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id)
|
.filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id)
|
||||||
@@ -632,6 +639,177 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
user_id="user-1",
|
user_id="user-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_decision_promotes_selected_hold_and_releases_the_rest(self) -> None:
|
||||||
|
self._calendar()
|
||||||
|
self.session.add(
|
||||||
|
CalendarSyncSource(
|
||||||
|
id="source-decision-cleanup",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
calendar_id="calendar-1",
|
||||||
|
source_kind="caldav",
|
||||||
|
collection_url="https://dav.example.test/cal/",
|
||||||
|
auth_type="none",
|
||||||
|
sync_enabled=True,
|
||||||
|
sync_interval_seconds=900,
|
||||||
|
sync_direction="two_way",
|
||||||
|
conflict_policy="etag",
|
||||||
|
metadata_={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
request, _tokens = create_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=self._payload(),
|
||||||
|
)
|
||||||
|
request, hold_ids, warnings = create_tentative_calendar_holds(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
request_id=request.id,
|
||||||
|
)
|
||||||
|
selected_slot = request.slots[0]
|
||||||
|
released_slot = request.slots[1]
|
||||||
|
selected_hold_id = selected_slot.tentative_hold_event_id
|
||||||
|
released_hold_id = released_slot.tentative_hold_event_id
|
||||||
|
self.assertEqual(len(hold_ids), 2)
|
||||||
|
self.assertEqual(warnings, [])
|
||||||
|
|
||||||
|
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
|
||||||
|
decided = decide_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
request_id=request.id,
|
||||||
|
payload=SchedulingDecisionRequest(slot_id=selected_slot.id, handoff_to_calendar=True),
|
||||||
|
user_id="user-1",
|
||||||
|
allow_calendar_handoff=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(decided.status, "handed_off")
|
||||||
|
self.assertEqual(decided.calendar_event_id, selected_hold_id)
|
||||||
|
self.assertEqual(decided.metadata_["calendar_cleanup"]["status"], "accepted")
|
||||||
|
self.assertIsNone(selected_slot.tentative_hold_event_id)
|
||||||
|
self.assertIsNone(released_slot.tentative_hold_event_id)
|
||||||
|
self.assertEqual(
|
||||||
|
set(selected_slot.metadata_["calendar_hold"]),
|
||||||
|
{"last_known_external_state", "outbox_operation_id"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
set(released_slot.metadata_["calendar_hold"]),
|
||||||
|
{"last_known_external_state", "outbox_operation_id"},
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(selected_slot.metadata_["calendar_hold"]["outbox_operation_id"])
|
||||||
|
self.assertIsNotNone(released_slot.metadata_["calendar_hold"]["outbox_operation_id"])
|
||||||
|
promoted = self.session.get(CalendarEvent, selected_hold_id)
|
||||||
|
released = self.session.get(CalendarEvent, released_hold_id)
|
||||||
|
self.assertEqual(promoted.status, "CONFIRMED")
|
||||||
|
self.assertIsNone(promoted.deleted_at)
|
||||||
|
self.assertIsNotNone(released.deleted_at)
|
||||||
|
|
||||||
|
replayed = decide_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
request_id=request.id,
|
||||||
|
payload=SchedulingDecisionRequest(slot_id=selected_slot.id, handoff_to_calendar=True),
|
||||||
|
user_id="user-1",
|
||||||
|
allow_calendar_handoff=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(replayed.calendar_event_id, selected_hold_id)
|
||||||
|
self.assertEqual(
|
||||||
|
self.session.query(CalendarEvent)
|
||||||
|
.filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id)
|
||||||
|
.count(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cancellation_keeps_partial_calendar_cleanup_retryable(self) -> None:
|
||||||
|
self._calendar()
|
||||||
|
request, _tokens = create_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=self._payload(),
|
||||||
|
)
|
||||||
|
request, _hold_ids, _warnings = create_tentative_calendar_holds(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
request_id=request.id,
|
||||||
|
)
|
||||||
|
failed_event_id = request.slots[1].tentative_hold_event_id
|
||||||
|
original_release = SqlCalendarSchedulingProvider.release_event
|
||||||
|
|
||||||
|
def flaky_release(provider, session, *, tenant_id, user_id, event_id):
|
||||||
|
if event_id == failed_event_id:
|
||||||
|
raise CalendarCapabilityError("temporary Calendar failure")
|
||||||
|
return original_release(
|
||||||
|
provider,
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
event_id=event_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(SqlCalendarSchedulingProvider, "release_event", new=flaky_release):
|
||||||
|
pending = cancel_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
request_id=request.id,
|
||||||
|
user_id="user-1",
|
||||||
|
allow_calendar_cleanup=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(pending.status, "collecting")
|
||||||
|
self.assertEqual(pending.metadata_["calendar_cleanup"]["status"], "retry_required")
|
||||||
|
self.assertEqual(
|
||||||
|
[slot.tentative_hold_event_id for slot in request.slots],
|
||||||
|
[None, failed_event_id],
|
||||||
|
)
|
||||||
|
|
||||||
|
cancelled = cancel_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
request_id=request.id,
|
||||||
|
user_id="user-1",
|
||||||
|
allow_calendar_cleanup=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(cancelled.status, "cancelled")
|
||||||
|
self.assertEqual(cancelled.metadata_["calendar_cleanup"]["status"], "accepted")
|
||||||
|
self.assertTrue(all(slot.tentative_hold_event_id is None for slot in request.slots))
|
||||||
|
|
||||||
|
def test_cancellation_waits_when_calendar_capability_is_unavailable(self) -> None:
|
||||||
|
self._calendar()
|
||||||
|
request, _tokens = create_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=self._payload(),
|
||||||
|
)
|
||||||
|
request, hold_ids, _warnings = create_tentative_calendar_holds(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
request_id=request.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_scheduling.backend.service._calendar_provider",
|
||||||
|
side_effect=SchedulingError("Calendar scheduling capability is unavailable"),
|
||||||
|
):
|
||||||
|
pending = cancel_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
request_id=request.id,
|
||||||
|
user_id="user-1",
|
||||||
|
allow_calendar_cleanup=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(pending.status, "collecting")
|
||||||
|
self.assertEqual(pending.metadata_["calendar_cleanup"]["status"], "retry_required")
|
||||||
|
self.assertEqual(
|
||||||
|
[slot.tentative_hold_event_id for slot in request.slots],
|
||||||
|
hold_ids,
|
||||||
|
)
|
||||||
|
|
||||||
def test_notification_outbox_jobs_are_created_and_listed(self) -> None:
|
def test_notification_outbox_jobs_are_created_and_listed(self) -> None:
|
||||||
request, _tokens = create_scheduling_request(
|
request, _tokens = create_scheduling_request(
|
||||||
self.session,
|
self.session,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const editorStart = page.indexOf('<div className="scheduling-editor-surface">');
|
|||||||
const editorEnd = page.indexOf('</form>', editorStart);
|
const editorEnd = page.indexOf('</form>', editorStart);
|
||||||
const editor = page.slice(editorStart, editorEnd);
|
const editor = page.slice(editorStart, editorEnd);
|
||||||
assert.ok(editor.indexOf("I18N.discard") < editor.indexOf("I18N.save"));
|
assert.ok(editor.indexOf("I18N.discard") < editor.indexOf("I18N.save"));
|
||||||
assert.match(editor, /form="scheduling-editor-form"/);
|
assert.match(editor, /form:\s*"scheduling-editor-form"/);
|
||||||
assert.match(editor, /<Card title=\{I18N\.basicInformation\}>/);
|
assert.match(editor, /<Card title=\{I18N\.basicInformation\}>/);
|
||||||
assert.match(editor, /<Card title=\{I18N\.calendarIntegration\}>/);
|
assert.match(editor, /<Card title=\{I18N\.calendarIntegration\}>/);
|
||||||
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
|
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
|
||||||
@@ -55,6 +55,7 @@ assert.match(editor, /<FormField label=\{I18N\.title\}>/);
|
|||||||
assert.match(page, /useUnsavedDraftGuard\(/);
|
assert.match(page, /useUnsavedDraftGuard\(/);
|
||||||
assert.match(page, /requestDiscard\(exitEditor\)/);
|
assert.match(page, /requestDiscard\(exitEditor\)/);
|
||||||
assert.match(page, /dirty: responseDirty,[\s\S]*onSave: persistAvailability,[\s\S]*onDiscard: resetResponseDraft/);
|
assert.match(page, /dirty: responseDirty,[\s\S]*onSave: persistAvailability,[\s\S]*onDiscard: resetResponseDraft/);
|
||||||
|
assert.match(page, /calendarCleanup\?\.status === "retry_required"[\s\S]*I18N\.calendarCleanupRetryTitle/);
|
||||||
assert.doesNotMatch(page, /window\.(?:alert|confirm)\(/);
|
assert.doesNotMatch(page, /window\.(?:alert|confirm)\(/);
|
||||||
|
|
||||||
for (const setting of [
|
for (const setting of [
|
||||||
@@ -92,7 +93,7 @@ assert.match(page, /search=\{participantSearch\}/);
|
|||||||
assert.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
|
assert.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
|
||||||
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
|
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
|
||||||
assert.match(page, /showDecisionAction=\{canManageSelected\}/);
|
assert.match(page, /showDecisionAction=\{canManageSelected\}/);
|
||||||
assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/);
|
assert.match(page, /<WorkspaceActionBar[\s\S]*refreshable[\s\S]*reloadAction=\{\{[\s\S]*label: I18N\.refresh/);
|
||||||
assert.doesNotMatch(page, /AdminIconButton/);
|
assert.doesNotMatch(page, /AdminIconButton/);
|
||||||
|
|
||||||
const participantGridStart = page.indexOf("function ParticipantsGrid(");
|
const participantGridStart = page.indexOf("function ParticipantsGrid(");
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { ContentGrid, FormGrid,
|
|||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
FormField,
|
FormField,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
IconButton,
|
|
||||||
PageTitle,
|
PageTitle,
|
||||||
PasswordField,
|
PasswordField,
|
||||||
PeoplePicker,
|
PeoplePicker,
|
||||||
@@ -127,6 +126,11 @@ type DecisionTarget = {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
slot: SchedulingCandidateSlot;
|
slot: SchedulingCandidateSlot;
|
||||||
};
|
};
|
||||||
|
type SchedulingCalendarCleanup = {
|
||||||
|
status: "accepted" | "retry_required";
|
||||||
|
targetState: string | null;
|
||||||
|
pendingCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
const I18N = {
|
const I18N = {
|
||||||
actions: "i18n:govoplan-core.actions.c3cd636a",
|
actions: "i18n:govoplan-core.actions.c3cd636a",
|
||||||
@@ -148,6 +152,8 @@ const I18N = {
|
|||||||
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
|
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
|
||||||
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
|
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
|
||||||
calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106",
|
calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106",
|
||||||
|
calendarCleanupRetryTitle: "i18n:govoplan-scheduling.calendar_cleanup_retry_title",
|
||||||
|
calendarCleanupRetryMessage: "i18n:govoplan-scheduling.calendar_cleanup_retry_message",
|
||||||
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
|
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
|
||||||
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
|
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
|
||||||
cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c",
|
cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c",
|
||||||
@@ -342,6 +348,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
() => requests.find((item) => item.id === selectedId) ?? firstRequest(groups, canAdminister),
|
() => requests.find((item) => item.id === selectedId) ?? firstRequest(groups, canAdminister),
|
||||||
[canAdminister, groups, requests, selectedId]
|
[canAdminister, groups, requests, selectedId]
|
||||||
);
|
);
|
||||||
|
const calendarCleanup = selected ? schedulingCalendarCleanup(selected) : null;
|
||||||
const selectedParticipant = useMemo(
|
const selectedParticipant = useMemo(
|
||||||
() => selected ? schedulingParticipantForActor(selected, actor) : null,
|
() => selected ? schedulingParticipantForActor(selected, actor) : null,
|
||||||
[actor, selected]
|
[actor, selected]
|
||||||
@@ -368,7 +375,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
selectedPhase !== "past" &&
|
selectedPhase !== "past" &&
|
||||||
selected.status === "decided" &&
|
selected.status === "decided" &&
|
||||||
selected.selected_slot_id &&
|
selected.selected_slot_id &&
|
||||||
!selected.calendar_event_id &&
|
(!selected.calendar_event_id || (
|
||||||
|
calendarCleanup?.status === "retry_required"
|
||||||
|
&& calendarCleanup.targetState === "handed_off"
|
||||||
|
)) &&
|
||||||
selected.create_calendar_event_on_decision
|
selected.create_calendar_event_on_decision
|
||||||
);
|
);
|
||||||
const optionResultById = useMemo(() => {
|
const optionResultById = useMemo(() => {
|
||||||
@@ -1298,7 +1308,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
|
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id) ? (
|
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id || calendarCleanup?.status === "retry_required") ? (
|
||||||
<Card
|
<Card
|
||||||
title={I18N.calendarCoordination}
|
title={I18N.calendarCoordination}
|
||||||
actions={(
|
actions={(
|
||||||
@@ -1342,6 +1352,14 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
label={I18N.configuredCalendar}
|
label={I18N.configuredCalendar}
|
||||||
disabled />
|
disabled />
|
||||||
) : <p>{I18N.configuredCalendar}</p>}
|
) : <p>{I18N.configuredCalendar}</p>}
|
||||||
|
{calendarCleanup?.status === "retry_required" ? (
|
||||||
|
<DismissibleAlert tone="warning" dismissible={false} compact>
|
||||||
|
<strong>{I18N.calendarCleanupRetryTitle}</strong>{" "}
|
||||||
|
{i18nMessage(I18N.calendarCleanupRetryMessage, {
|
||||||
|
value0: calendarCleanup.pendingCount
|
||||||
|
})}
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1714,6 +1732,20 @@ function ParticipationStats({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function schedulingCalendarCleanup(request: SchedulingRequest): SchedulingCalendarCleanup | null {
|
||||||
|
const value = request.metadata?.calendar_cleanup;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const status = record.status === "retry_required" ? "retry_required" : record.status === "accepted" ? "accepted" : null;
|
||||||
|
if (!status) return null;
|
||||||
|
const pending = Array.isArray(record.pending_slot_ids) ? record.pending_slot_ids : [];
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
targetState: typeof record.target_state === "string" ? record.target_state : null,
|
||||||
|
pendingCount: pending.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function schedulingActionConfirmation(kind: ConsequentialAction["kind"]): {
|
function schedulingActionConfirmation(kind: ConsequentialAction["kind"]): {
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export const generatedTranslations = {
|
export const generatedTranslations = {
|
||||||
en: {
|
en: {
|
||||||
|
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Calendar cleanup requires attention.",
|
||||||
|
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} tentative hold operations remain. Reconcile failed Calendar outbound changes if necessary, then repeat the original decision or cancellation action.",
|
||||||
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
|
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
|
||||||
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Automatic invitation delivery is unavailable; copy the link instead.",
|
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Automatic invitation delivery is unavailable; copy the link instead.",
|
||||||
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Cancellation notice available until",
|
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Cancellation notice available until",
|
||||||
@@ -180,6 +182,8 @@ export const generatedTranslations = {
|
|||||||
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
|
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
|
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Die Kalenderbereinigung erfordert Aufmerksamkeit.",
|
||||||
|
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} Vorgänge für vorläufige Reservierungen stehen noch aus. Gleichen Sie fehlgeschlagene ausgehende Kalenderänderungen bei Bedarf ab und wiederholen Sie anschließend die ursprüngliche Entscheidungs- oder Abbruchaktion.",
|
||||||
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
|
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
|
||||||
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Die automatische Einladungszustellung ist nicht verfügbar; kopieren Sie stattdessen den Link.",
|
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Die automatische Einladungszustellung ist nicht verfügbar; kopieren Sie stattdessen den Link.",
|
||||||
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",
|
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",
|
||||||
|
|||||||
Reference in New Issue
Block a user