feat(scheduling): harden poll-backed request flows

This commit is contained in:
2026-07-20 17:34:13 +02:00
parent 2f1b7fb6b8
commit de7e68c97a
6 changed files with 1741 additions and 152 deletions

View File

@@ -7,10 +7,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
from govoplan_core.db.session import get_session
from govoplan_poll.backend.schemas import PollResultSummaryResponse
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
SchedulingAvailabilityResponseRequest,
SchedulingAddressLookupCandidate,
SchedulingAddressLookupResponse,
SchedulingCalendarActionResponse,
@@ -22,12 +23,14 @@ from govoplan_scheduling.backend.schemas import (
SchedulingRequestListResponse,
SchedulingRequestResponse,
SchedulingRequestUpdateRequest,
SchedulingPollSummaryResponse,
SchedulingStatusResponse,
SchedulingSummaryResponse,
)
from govoplan_scheduling.backend.runtime import get_registry
from govoplan_scheduling.backend.service import (
SchedulingError,
SchedulingPermissionError,
cancel_scheduling_request,
close_scheduling_request,
create_final_calendar_event,
@@ -36,13 +39,16 @@ from govoplan_scheduling.backend.service import (
create_tentative_calendar_holds,
decide_scheduling_request,
evaluate_calendar_freebusy,
get_scheduling_request,
list_scheduling_notifications,
list_scheduling_requests,
get_visible_scheduling_request,
list_visible_scheduling_notifications,
list_visible_scheduling_requests,
open_scheduling_request,
refresh_participant_response_state,
require_visible_scheduling_results,
scheduling_notification_response,
scheduling_request_response,
scheduling_request_summary,
submit_scheduling_availability,
update_scheduling_request,
)
@@ -88,15 +94,45 @@ def _require_scope(principal: ApiPrincipal, scope: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
candidates = (
principal.account_id,
principal.membership_id,
getattr(principal.user, "id", None),
principal.identity_id,
principal.principal.service_account_id,
principal.email,
)
return tuple(dict.fromkeys(str(value) for value in candidates if value))
def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE)
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
if isinstance(exc, SchedulingPermissionError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
if str(exc) == "Scheduling request not found":
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if str(exc) == "Scheduling results are not visible":
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _request_response(request, *, invitation_tokens: dict[str, str] | None = None) -> SchedulingRequestResponse:
def _request_response(
request,
*,
principal: ApiPrincipal,
invitation_tokens: dict[str, str] | None = None,
) -> SchedulingRequestResponse:
return SchedulingRequestResponse.model_validate(
scheduling_request_response(request, invitation_tokens=invitation_tokens)
scheduling_request_response(
request,
invitation_tokens=invitation_tokens,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
)
)
@@ -125,8 +161,27 @@ def api_list_scheduling_requests(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestListResponse:
_require_scope(principal, READ_SCOPE)
requests = list_scheduling_requests(session, tenant_id=principal.tenant_id, status=status_filter)
return SchedulingRequestListResponse(requests=[_request_response(request) for request in requests])
requests = list_visible_scheduling_requests(
session,
tenant_id=principal.tenant_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
status=status_filter,
)
actor_ids = _principal_actor_ids(principal)
for request in requests:
refresh_participant_response_state(
session,
request=request,
actor_ids=actor_ids,
)
response = SchedulingRequestListResponse(
requests=[_request_response(request, principal=principal) for request in requests]
)
# Poll responses are authoritative, while Scheduling keeps a durable
# participant projection used by its task-oriented list.
session.commit()
return response
@router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED)
@@ -145,7 +200,36 @@ def api_create_scheduling_request(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return _request_response(request, invitation_tokens=invitation_tokens)
response = _request_response(request, principal=principal, invitation_tokens=invitation_tokens)
session.commit()
return response
@router.post("/requests/{request_id}/responses", response_model=SchedulingStatusResponse)
def api_submit_scheduling_availability(
request_id: str,
payload: SchedulingAvailabilityResponseRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, RESPOND_SCOPE)
try:
request = submit_scheduling_availability(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
actor_ids=_principal_actor_ids(principal),
respondent_id=principal.account_id,
respondent_label=principal.display_name or principal.email,
payload=payload,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = SchedulingStatusResponse(
request=_request_response(request, principal=principal)
)
session.commit()
return response
@router.get("/requests/{request_id}", response_model=SchedulingRequestResponse)
@@ -156,9 +240,23 @@ def api_get_scheduling_request(
) -> SchedulingRequestResponse:
_require_scope(principal, READ_SCOPE)
try:
return _request_response(get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id))
request = get_visible_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
)
refresh_participant_response_state(
session,
request=request,
actor_ids=_principal_actor_ids(principal),
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal)
session.commit()
return response
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse)
@@ -170,11 +268,17 @@ def api_update_scheduling_request(
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
try:
return _request_response(
update_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
request = update_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
payload=payload,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal)
session.commit()
return response
@router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse)
@@ -188,7 +292,9 @@ def api_open_scheduling_request(
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
session.commit()
return response
@router.post("/requests/{request_id}/close", response_model=SchedulingStatusResponse)
@@ -202,7 +308,9 @@ def api_close_scheduling_request(
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
session.commit()
return response
@router.post("/requests/{request_id}/decide", response_model=SchedulingStatusResponse)
@@ -220,10 +328,16 @@ def api_decide_scheduling_request(
request_id=request_id,
payload=payload,
user_id=principal.account_id,
allow_calendar_handoff=has_scope(
principal,
CALENDAR_EVENT_WRITE_SCOPE,
),
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
session.commit()
return response
@router.post("/requests/{request_id}/calendar/freebusy", response_model=SchedulingCalendarActionResponse)
@@ -233,15 +347,18 @@ def api_evaluate_calendar_freebusy(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try:
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
response = SchedulingCalendarActionResponse(
request=_request_response(request, principal=principal),
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
warnings=warnings,
)
session.commit()
return response
@router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse)
@@ -251,6 +368,7 @@ def api_create_tentative_calendar_holds(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
request, created_event_ids, warnings = create_tentative_calendar_holds(
session,
@@ -260,12 +378,14 @@ def api_create_tentative_calendar_holds(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
response = SchedulingCalendarActionResponse(
request=_request_response(request, principal=principal),
created_event_ids=created_event_ids,
updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids],
warnings=warnings,
)
session.commit()
return response
@router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse)
@@ -275,6 +395,7 @@ def api_create_final_calendar_event(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
request, event_id, warnings = create_final_calendar_event(
session,
@@ -284,11 +405,13 @@ def api_create_final_calendar_event(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingCalendarActionResponse(
request=_request_response(request),
response = SchedulingCalendarActionResponse(
request=_request_response(request, principal=principal),
created_event_ids=[event_id] if event_id else [],
warnings=warnings,
)
session.commit()
return response
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
@@ -302,7 +425,9 @@ def api_cancel_scheduling_request(
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingStatusResponse(request=_request_response(request))
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
session.commit()
return response
@router.get("/requests/{request_id}/summary", response_model=SchedulingSummaryResponse)
@@ -313,14 +438,30 @@ def api_scheduling_summary(
) -> SchedulingSummaryResponse:
_require_scope(principal, READ_SCOPE)
try:
request = get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
request = get_visible_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
)
require_visible_scheduling_results(
session,
request=request,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
)
summary = scheduling_request_summary(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingSummaryResponse(
request=_request_response(request),
poll_summary=PollResultSummaryResponse.model_validate(summary),
response = SchedulingSummaryResponse(
request=_request_response(request, principal=principal),
poll_summary=SchedulingPollSummaryResponse.model_validate(summary),
)
# Refreshing the summary synchronizes participant response state and can
# enqueue response notifications, so persist that work before teardown.
session.commit()
return response
@router.get("/notifications", response_model=SchedulingNotificationListResponse)
@@ -331,9 +472,11 @@ def api_list_scheduling_notifications(
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingNotificationListResponse:
_require_scope(principal, READ_SCOPE)
notifications = list_scheduling_notifications(
notifications = list_visible_scheduling_notifications(
session,
tenant_id=principal.tenant_id,
actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal),
request_id=request_id,
status=status_filter,
)
@@ -364,9 +507,11 @@ def api_create_scheduling_notifications(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingNotificationListResponse(
response = SchedulingNotificationListResponse(
notifications=[
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
for notification in notifications
]
)
session.commit()
return response