feat(scheduling): add explicit invitation link lifecycle

This commit is contained in:
2026-07-22 03:08:56 +02:00
parent ed828685f6
commit 2cb86c90dc
6 changed files with 785 additions and 122 deletions

View File

@@ -3,9 +3,10 @@ from __future__ import annotations
import dataclasses
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
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.db.session import get_session
@@ -18,6 +19,8 @@ from govoplan_scheduling.backend.schemas import (
SchedulingCalendarActionResponse,
SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest,
SchedulingInvitationActionRequest,
SchedulingInvitationActionResponse,
SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse,
SchedulingNotificationResponse,
@@ -52,9 +55,11 @@ from govoplan_scheduling.backend.service import (
get_visible_scheduling_request,
list_visible_scheduling_notifications,
list_visible_scheduling_requests,
issue_scheduling_participant_invitation,
open_scheduling_request,
refresh_participant_response_state,
require_visible_scheduling_results,
revoke_scheduling_participant_invitation,
scheduling_notification_response,
scheduling_request_response,
scheduling_request_summary,
@@ -186,16 +191,41 @@ 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,
invitation_tokens: dict[str, str] | None = None,
) -> SchedulingRequestResponse:
return SchedulingRequestResponse.model_validate(
scheduling_request_response(
request,
invitation_tokens=invitation_tokens,
actor_ids=_principal_actor_ids(principal),
actor_user_id=principal.account_id,
can_manage=_can_manage_scheduling(principal),
@@ -311,7 +341,7 @@ def api_create_scheduling_request(
) -> SchedulingRequestResponse:
_require_scheduling_writer(principal)
try:
request, invitation_tokens = create_scheduling_request(
request, _invitation_tokens = create_scheduling_request(
session,
tenant_id=principal.tenant_id,
user_id=principal.account_id,
@@ -319,7 +349,7 @@ def api_create_scheduling_request(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal, invitation_tokens=invitation_tokens)
response = _request_response(request, principal=principal)
session.commit()
return response
@@ -411,7 +441,7 @@ def api_update_scheduling_request(
request_id=request_id,
)
try:
request, invitation_tokens = update_scheduling_request_with_invitation_tokens(
request, _invitation_tokens = update_scheduling_request_with_invitation_tokens(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
@@ -419,15 +449,135 @@ def api_update_scheduling_request(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(
request,
principal=principal,
invitation_tokens=invitation_tokens,
)
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,
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,
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,
)
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,