835 lines
30 KiB
Python
835 lines
30 KiB
Python
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
|