from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from sqlalchemy.orm import Session from govoplan_core.audit.logging import audit_event 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.core.people import search_visible_people from govoplan_core.db.session import get_session from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE from govoplan_scheduling.backend.schemas import ( SchedulingAvailabilityResponse, SchedulingAvailabilityResponseRequest, SchedulingCalendarActionResponse, SchedulingCandidateSlotUpdateRequest, SchedulingDecisionRequest, SchedulingInvitationActionRequest, SchedulingInvitationActionResponse, SchedulingInvitationRevokeRequest, SchedulingNotificationCreateRequest, SchedulingNotificationListResponse, SchedulingNotificationResponse, SchedulingPeopleSearchCandidate, SchedulingPeopleSearchGroup, SchedulingPeopleSearchResponse, SchedulingRequestCreateRequest, SchedulingRequestListResponse, SchedulingRequestResponse, SchedulingRequestUpdateRequest, SchedulingPollSummaryResponse, SchedulingPublicParticipationAccessRequest, SchedulingPublicParticipationResponse, SchedulingPublicParticipationSubmitRequest, SchedulingStatusResponse, SchedulingSummaryResponse, ) from govoplan_scheduling.backend.runtime import get_registry from govoplan_scheduling.backend.service import ( SchedulingConflictError, SchedulingError, SchedulingPermissionError, SchedulingPublicParticipationError, cancel_scheduling_request, close_scheduling_request, create_final_calendar_event, create_scheduling_notification_jobs, create_scheduling_request, create_tentative_calendar_holds, decide_scheduling_request, evaluate_calendar_freebusy, get_scheduling_request, get_scheduling_availability_response, get_public_scheduling_participation, get_visible_scheduling_request, list_visible_scheduling_notifications, list_visible_scheduling_requests, issue_scheduling_participant_invitation, open_scheduling_request, require_visible_scheduling_results, revoke_scheduling_participant_invitation, scheduling_notification_response, scheduling_request_response, scheduling_request_summary, submit_scheduling_availability, submit_public_scheduling_participation, update_scheduling_candidate_slot, update_scheduling_request_with_change_log, ) router = APIRouter(prefix="/scheduling", tags=["scheduling"]) def _require_scope(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): 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, ADMIN_SCOPE) def _require_scheduling_writer(principal: ApiPrincipal) -> None: if has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE): return raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {WRITE_SCOPE} or {ADMIN_SCOPE}", ) def _require_request_editor( session: Session, *, principal: ApiPrincipal, request_id: str, ) -> None: _require_scheduling_writer(principal) if has_scope(principal, ADMIN_SCOPE): return try: request = get_scheduling_request( session, tenant_id=principal.tenant_id, request_id=request_id, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc if request.organizer_user_id not in _principal_actor_ids(principal): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the organizer or a scheduling administrator can edit this request", ) def _scheduling_http_error(exc: SchedulingError) -> HTTPException: if isinstance(exc, SchedulingConflictError): return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) 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 _public_participation_http_error( exc: SchedulingPublicParticipationError, ) -> HTTPException: if exc.retry_after_seconds: return HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc), headers={"Retry-After": str(exc.retry_after_seconds)}, ) return HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc), ) def _client_address(request: Request) -> str | None: return request.client.host if request.client is not None else None def _set_sensitive_response_headers(response: Response) -> None: response.headers["Cache-Control"] = "no-store, private" response.headers["Pragma"] = "no-cache" response.headers["Referrer-Policy"] = "no-referrer" def _audit_invitation_action( session: Session, *, principal: ApiPrincipal, request_id: str, participant_id: str, action: str, details: dict[str, Any], ) -> None: audit_event( session, tenant_id=principal.tenant_id, user_id=(getattr(principal.user, "id", None) or principal.account_id), api_key_id=principal.api_key_id, action=action, object_type="scheduling_request", object_id=request_id, details={"participant_id": participant_id, **details}, ) def _request_response( request, *, principal: ApiPrincipal, ) -> SchedulingRequestResponse: return SchedulingRequestResponse.model_validate( scheduling_request_response( request, actor_ids=_principal_actor_ids(principal), actor_user_id=principal.account_id, can_manage=_can_manage_scheduling(principal), ) ) @router.post( "/public/{request_id}/{token}", response_model=SchedulingPublicParticipationResponse, ) def api_get_public_scheduling_participation( request_id: str, token: str, payload: SchedulingPublicParticipationAccessRequest, request: Request, session: Session = Depends(get_session), ) -> SchedulingPublicParticipationResponse: try: response = get_public_scheduling_participation( session, request_id=request_id, token=token, payload=payload, client_address=_client_address(request), ) except SchedulingPublicParticipationError as exc: raise _public_participation_http_error(exc) from exc return SchedulingPublicParticipationResponse.model_validate(response) @router.post( "/public/{request_id}/{token}/responses", response_model=SchedulingPublicParticipationResponse, ) def api_submit_public_scheduling_participation( request_id: str, token: str, payload: SchedulingPublicParticipationSubmitRequest, request: Request, session: Session = Depends(get_session), ) -> SchedulingPublicParticipationResponse: try: response = submit_public_scheduling_participation( session, request_id=request_id, token=token, payload=payload, client_address=_client_address(request), ) except SchedulingPublicParticipationError as exc: raise _public_participation_http_error(exc) from exc except SchedulingError as exc: raise _scheduling_http_error(exc) from exc validated = SchedulingPublicParticipationResponse.model_validate(response) session.commit() return validated @router.get("/people", response_model=SchedulingPeopleSearchResponse) def api_search_scheduling_people( query: str = Query(min_length=1), limit: int = Query(default=25, ge=1, le=100), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingPeopleSearchResponse: _require_scheduling_writer(principal) groups = search_visible_people( get_registry(), session, principal, query=query, limit=limit, ) return SchedulingPeopleSearchResponse( groups=[ SchedulingPeopleSearchGroup( key=group.key, label=group.label, candidates=[ SchedulingPeopleSearchCandidate( selection_key=candidate.selection_key, kind=candidate.kind, reference_id=candidate.reference_id, display_name=candidate.display_name, email=candidate.email, source_module=candidate.source_module, source_label=candidate.source_label, source_revision=candidate.source_revision, description=candidate.description, ) for candidate in group.candidates ], ) for group in groups ] ) @router.get("/requests", response_model=SchedulingRequestListResponse) def api_list_scheduling_requests( status_filter: str | None = Query(default=None, alias="status"), limit: int = 100, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestListResponse: _require_scope(principal, READ_SCOPE) 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, limit=limit, ) return SchedulingRequestListResponse( requests=[_request_response(request, principal=principal) for request in requests] ) @router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED) def api_create_scheduling_request( payload: SchedulingRequestCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestResponse: _require_scheduling_writer(principal) try: request, _invitation_tokens = create_scheduling_request( session, tenant_id=principal.tenant_id, user_id=principal.account_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}/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}/responses/me", response_model=SchedulingAvailabilityResponse) def api_get_my_scheduling_availability( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingAvailabilityResponse: _require_scope(principal, RESPOND_SCOPE) try: response = get_scheduling_availability_response( session, tenant_id=principal.tenant_id, request_id=request_id, actor_ids=_principal_actor_ids(principal), respondent_id=principal.account_id, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc return SchedulingAvailabilityResponse.model_validate(response) @router.get("/requests/{request_id}", response_model=SchedulingRequestResponse) def api_get_scheduling_request( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestResponse: _require_scope(principal, READ_SCOPE) try: 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), ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc return _request_response(request, principal=principal) @router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse) def api_update_scheduling_request( request_id: str, payload: SchedulingRequestUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestResponse: _require_request_editor( session, principal=principal, request_id=request_id, ) try: request, participant_mutations = update_scheduling_request_with_change_log( session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc for mutation in participant_mutations: _audit_invitation_action( session, principal=principal, request_id=request_id, participant_id=mutation.participant_id, action=mutation.action, details={ "replacement_participant_id": mutation.replacement_participant_id, "changed_fields": list(mutation.changed_fields), "invitation_revoked": mutation.invitation_revoked, "retired_response_count": mutation.retired_response_count, "notification_id": mutation.notification_id, }, ) response = _request_response(request, principal=principal) session.commit() return response @router.post( "/requests/{request_id}/participants/{participant_id}/invitation", response_model=SchedulingInvitationActionResponse, ) def api_issue_scheduling_participant_invitation( request_id: str, participant_id: str, payload: SchedulingInvitationActionRequest, response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingInvitationActionResponse: _require_request_editor( session, principal=principal, request_id=request_id, ) try: result = issue_scheduling_participant_invitation( session, tenant_id=principal.tenant_id, request_id=request_id, participant_id=participant_id, participant_revision=payload.participant_revision, action=payload.action, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc audit_details = { "request_status": result.request.status, "delivery_action": payload.action, "replaced_existing": result.replaced_existing, } _audit_invitation_action( session, principal=principal, request_id=request_id, participant_id=participant_id, action="scheduling.invitation_issued", details=audit_details, ) _audit_invitation_action( session, principal=principal, request_id=request_id, participant_id=participant_id, action=( "scheduling.invitation_copied" if payload.action == "copy" else "scheduling.invitation_send_requested" ), details={ **audit_details, "notification_id": ( result.notification.id if result.notification is not None else None ), "notification_status": result.status, }, ) validated = SchedulingInvitationActionResponse( participant_id=result.participant.id, action=payload.action, status=result.status, action_url=result.action_url, issued_at=result.participant.last_invited_at, notification=( SchedulingNotificationResponse.model_validate( scheduling_notification_response(result.notification) ) if result.notification is not None else None ), ) _set_sensitive_response_headers(response) session.commit() return validated @router.delete( "/requests/{request_id}/participants/{participant_id}/invitation", response_model=SchedulingInvitationActionResponse, ) def api_revoke_scheduling_participant_invitation( request_id: str, participant_id: str, payload: SchedulingInvitationRevokeRequest, response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingInvitationActionResponse: _require_request_editor( session, principal=principal, request_id=request_id, ) try: result = revoke_scheduling_participant_invitation( session, tenant_id=principal.tenant_id, request_id=request_id, participant_id=participant_id, participant_revision=payload.participant_revision, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc _audit_invitation_action( session, principal=principal, request_id=request_id, participant_id=participant_id, action="scheduling.invitation_revoked", details={ "request_status": result.request.status, "replayed": result.replayed, }, ) validated = SchedulingInvitationActionResponse( participant_id=result.participant.id, action="revoke", status=result.status, replayed=result.replayed, ) _set_sensitive_response_headers(response) session.commit() return validated @router.patch("/requests/{request_id}/slots/{slot_id}", response_model=SchedulingRequestResponse) def api_update_scheduling_candidate_slot( request_id: str, slot_id: str, payload: SchedulingCandidateSlotUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestResponse: _require_request_editor( session, principal=principal, request_id=request_id, ) try: request = update_scheduling_candidate_slot( session, tenant_id=principal.tenant_id, request_id=request_id, slot_id=slot_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) def api_open_scheduling_request( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingStatusResponse: _require_request_editor(session, principal=principal, request_id=request_id) try: 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 response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) session.commit() return response @router.post("/requests/{request_id}/close", response_model=SchedulingStatusResponse) def api_close_scheduling_request( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingStatusResponse: _require_request_editor(session, principal=principal, request_id=request_id) try: 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 response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) session.commit() return response @router.post("/requests/{request_id}/decide", response_model=SchedulingStatusResponse) def api_decide_scheduling_request( request_id: str, payload: SchedulingDecisionRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingStatusResponse: _require_request_editor(session, principal=principal, request_id=request_id) try: request = decide_scheduling_request( session, tenant_id=principal.tenant_id, 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 response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) session.commit() return response @router.post("/requests/{request_id}/calendar/freebusy", response_model=SchedulingCalendarActionResponse) def api_evaluate_calendar_freebusy( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingCalendarActionResponse: _require_request_editor(session, principal=principal, request_id=request_id) _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 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) def api_create_tentative_calendar_holds( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingCalendarActionResponse: _require_request_editor(session, principal=principal, request_id=request_id) _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE) try: request, created_event_ids, warnings = create_tentative_calendar_holds( session, tenant_id=principal.tenant_id, user_id=principal.account_id, request_id=request_id, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc 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) def api_create_final_calendar_event( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingCalendarActionResponse: _require_request_editor(session, principal=principal, request_id=request_id) _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE) try: request, event_id, warnings = create_final_calendar_event( session, tenant_id=principal.tenant_id, user_id=principal.account_id, request_id=request_id, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc 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) def api_cancel_scheduling_request( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingStatusResponse: _require_request_editor(session, principal=principal, request_id=request_id) try: 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 response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) session.commit() return response @router.get("/requests/{request_id}/summary", response_model=SchedulingSummaryResponse) def api_scheduling_summary( request_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingSummaryResponse: _require_scope(principal, READ_SCOPE) try: 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 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) def api_list_scheduling_notifications( request_id: str | None = None, status_filter: str | None = Query(default=None, alias="status"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingNotificationListResponse: _require_scope(principal, READ_SCOPE) 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, ) return SchedulingNotificationListResponse( notifications=[ SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification)) for notification in notifications ] ) @router.post("/requests/{request_id}/notifications", response_model=SchedulingNotificationListResponse) def api_create_scheduling_notifications( request_id: str, payload: SchedulingNotificationCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingNotificationListResponse: _require_request_editor(session, principal=principal, request_id=request_id) try: notifications = create_scheduling_notification_jobs( session, tenant_id=principal.tenant_id, request_id=request_id, event_kind=payload.event_kind, channel=payload.channel, metadata=payload.metadata, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc response = SchedulingNotificationListResponse( notifications=[ SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification)) for notification in notifications ] ) session.commit() return response