feat(scheduling): add explicit invitation link lifecycle
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -158,7 +158,14 @@ class SchedulingRequestCreateRequest(BaseModel):
|
||||
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
|
||||
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
|
||||
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
|
||||
create_participant_invitations: bool = True
|
||||
create_participant_invitations: bool = Field(
|
||||
default=False,
|
||||
deprecated=True,
|
||||
description=(
|
||||
"Compatibility field; participant links are issued only through "
|
||||
"the explicit participant invitation action."
|
||||
),
|
||||
)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
||||
@@ -197,7 +204,14 @@ class SchedulingRequestUpdateRequest(BaseModel):
|
||||
min_length=1,
|
||||
)
|
||||
participants: list[SchedulingParticipantReconcileInput] | None = None
|
||||
create_participant_invitations: bool = True
|
||||
create_participant_invitations: bool = Field(
|
||||
default=False,
|
||||
deprecated=True,
|
||||
description=(
|
||||
"Compatibility field; participant links are issued only through "
|
||||
"the explicit participant invitation action."
|
||||
),
|
||||
)
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -483,6 +497,24 @@ class SchedulingNotificationCreateRequest(BaseModel):
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingInvitationActionRequest(BaseModel):
|
||||
"""Explicitly issue one fresh participant-specific participation link."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: Literal["copy", "send"]
|
||||
|
||||
|
||||
class SchedulingInvitationActionResponse(BaseModel):
|
||||
participant_id: str
|
||||
action: Literal["copy", "send", "revoke"]
|
||||
status: str
|
||||
action_url: str | None = None
|
||||
issued_at: datetime | None = None
|
||||
replayed: bool = False
|
||||
notification: SchedulingNotificationResponse | None = None
|
||||
|
||||
|
||||
class SchedulingAddressLookupCandidate(BaseModel):
|
||||
contact_id: str
|
||||
address_book_id: str
|
||||
|
||||
@@ -87,6 +87,18 @@ class SchedulingPublicParticipationError(SchedulingError):
|
||||
self.retry_after_seconds = max(0, retry_after_seconds)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SchedulingInvitationActionResult:
|
||||
request: SchedulingRequest
|
||||
participant: SchedulingParticipant
|
||||
action: str
|
||||
status: str
|
||||
action_url: str | None = None
|
||||
notification: SchedulingNotification | None = None
|
||||
replaced_existing: bool = False
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
WORKFLOW_DRAFT = "draft"
|
||||
WORKFLOW_COLLECTING = "collecting_availability"
|
||||
WORKFLOW_CLOSED = "availability_closed"
|
||||
@@ -626,13 +638,30 @@ def create_scheduling_notification_jobs(
|
||||
channel: str = "mail",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
invitation_tokens: dict[str, str] | None = None,
|
||||
participant_ids: frozenset[str] | None = None,
|
||||
) -> list[SchedulingNotification]:
|
||||
if event_kind not in NOTIFICATION_KINDS:
|
||||
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
|
||||
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||
participants = [
|
||||
participant
|
||||
for participant in _active_participants(request)
|
||||
if participant_ids is None or participant.id in participant_ids
|
||||
]
|
||||
if participant_ids is not None and {
|
||||
participant.id for participant in participants
|
||||
} != set(participant_ids):
|
||||
raise SchedulingError("Scheduling participant not found")
|
||||
if event_kind == "invitation" and (
|
||||
not invitation_tokens
|
||||
or any(participant.id not in invitation_tokens for participant in participants)
|
||||
):
|
||||
raise SchedulingError(
|
||||
"Invitation notifications require a freshly issued participation link"
|
||||
)
|
||||
notifications: list[SchedulingNotification] = []
|
||||
base_metadata = metadata or {}
|
||||
for participant in _active_participants(request):
|
||||
for participant in participants:
|
||||
recipient = participant.email or participant.respondent_id
|
||||
status = "pending" if recipient else "skipped"
|
||||
notification = SchedulingNotification(
|
||||
@@ -694,9 +723,12 @@ def _mirror_scheduling_notifications_to_center(
|
||||
),
|
||||
enqueue_delivery=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow.
|
||||
except Exception: # noqa: BLE001 - mirroring must not block scheduling workflow.
|
||||
notification.status = "failed"
|
||||
notification.error = f"Notification center enqueue failed: {exc}"
|
||||
# A provider exception can include a representation of the dispatch
|
||||
# request. Invitation requests contain a bearer link, so durable
|
||||
# error text must never copy exception details.
|
||||
notification.error = "Notification center enqueue failed"
|
||||
continue
|
||||
notification.metadata_ = {
|
||||
**(notification.metadata_ or {}),
|
||||
@@ -707,7 +739,7 @@ def _mirror_scheduling_notifications_to_center(
|
||||
notification.status = str(response["status"])
|
||||
elif response.get("status") == "skipped":
|
||||
notification.status = "skipped"
|
||||
notification.error = str(response.get("last_error") or "Notification center skipped delivery")
|
||||
notification.error = "Notification center skipped delivery"
|
||||
session.flush()
|
||||
|
||||
|
||||
@@ -1070,12 +1102,6 @@ def create_scheduling_request(
|
||||
user_id: str | None,
|
||||
payload: SchedulingRequestCreateRequest,
|
||||
) -> tuple[SchedulingRequest, dict[str, str]]:
|
||||
participation_provider = _poll_participation_provider()
|
||||
issue_public_invitations = (
|
||||
payload.create_participant_invitations and payload.status != "draft"
|
||||
)
|
||||
if issue_public_invitations and participation_provider is None:
|
||||
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
|
||||
if not payload.allow_external_participants:
|
||||
for participant_input in payload.participants:
|
||||
if participant_input.participant_type == "external":
|
||||
@@ -1135,7 +1161,7 @@ def create_scheduling_request(
|
||||
email=participant_input.email,
|
||||
participant_type=participant_input.participant_type,
|
||||
required=participant_input.required,
|
||||
status="invited" if issue_public_invitations else "draft",
|
||||
status="draft",
|
||||
metadata_=participant_input.metadata,
|
||||
)
|
||||
session.add(participant)
|
||||
@@ -1186,47 +1212,11 @@ def create_scheduling_request(
|
||||
for slot in _active_slots(request):
|
||||
slot.poll_option_id = options_by_position.get(slot.position)
|
||||
|
||||
invitation_tokens: dict[str, str] = {}
|
||||
if issue_public_invitations:
|
||||
if participation_provider is None: # Guarded above; narrows the type.
|
||||
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
|
||||
for participant in _active_participants(request):
|
||||
try:
|
||||
respondent_id = _stable_participant_respondent_id(participant)
|
||||
invitation = participation_provider.create_governed_invitation(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
poll_id=poll.id,
|
||||
command=PollGovernedInvitationCommand(
|
||||
gateway=_public_participation_gateway(request.id),
|
||||
policy=_public_participation_policy(request),
|
||||
respondent_id=respondent_id,
|
||||
respondent_label=participant.display_name,
|
||||
email=participant.email,
|
||||
expires_at=payload.deadline_at,
|
||||
metadata={
|
||||
"scheduling_request_id": request.id,
|
||||
"scheduling_participant_id": participant.id,
|
||||
},
|
||||
),
|
||||
)
|
||||
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
|
||||
except PollCapabilityError as exc:
|
||||
raise SchedulingError(str(exc)) from exc
|
||||
participant.poll_invitation_id = invitation.id
|
||||
participant.last_invited_at = _now()
|
||||
participant.status = "invited"
|
||||
invitation_tokens[participant.id] = invitation.token
|
||||
create_scheduling_notification_jobs(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request.id,
|
||||
event_kind="invitation",
|
||||
metadata={"created_with_request": True},
|
||||
invitation_tokens=invitation_tokens,
|
||||
)
|
||||
session.flush()
|
||||
return request, invitation_tokens
|
||||
# The deprecated create_participant_invitations field is deliberately a
|
||||
# no-op. Bearer credentials are minted only by the explicit participant
|
||||
# invitation action, where copy and delivery have distinct handling.
|
||||
return request, {}
|
||||
|
||||
|
||||
def list_scheduling_requests(session: Session, *, tenant_id: str, status: str | None = None) -> list[SchedulingRequest]:
|
||||
@@ -2134,10 +2124,6 @@ def _update_scheduling_request_with_invitation_tokens(
|
||||
session,
|
||||
request=request,
|
||||
supplied_participants=payload.participants,
|
||||
create_invitations=(
|
||||
payload.create_participant_invitations
|
||||
and request.status != "draft"
|
||||
),
|
||||
)
|
||||
if deadline_changed:
|
||||
for participant in _active_participants(request):
|
||||
@@ -2218,7 +2204,7 @@ def update_scheduling_request_with_invitation_tokens(
|
||||
request_id: str,
|
||||
payload: SchedulingRequestUpdateRequest,
|
||||
) -> tuple[SchedulingRequest, dict[str, str]]:
|
||||
"""Update a request and return any one-time tokens issued for new rows."""
|
||||
"""Compatibility wrapper; explicit invitation actions own raw tokens."""
|
||||
|
||||
return _update_scheduling_request_with_invitation_tokens(
|
||||
session,
|
||||
@@ -2612,19 +2598,102 @@ def _validate_participant_reconciliation(
|
||||
)
|
||||
|
||||
|
||||
def _create_reconciled_participant_invitation(
|
||||
def _participant_for_invitation_action(
|
||||
request: SchedulingRequest,
|
||||
*,
|
||||
participant_id: str,
|
||||
) -> SchedulingParticipant:
|
||||
participant = next(
|
||||
(
|
||||
item
|
||||
for item in _active_participants(request)
|
||||
if item.id == participant_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if participant is None:
|
||||
raise SchedulingError("Scheduling participant not found")
|
||||
return participant
|
||||
|
||||
|
||||
def _new_public_invitation_expiry(
|
||||
request: SchedulingRequest,
|
||||
) -> datetime | None:
|
||||
"""Keep collecting links deadline-bound without minting expired tokens.
|
||||
|
||||
Read-only links can still be issued after a response deadline or in a later
|
||||
lifecycle state; Scheduling independently rejects submissions whenever the
|
||||
request is not collecting. Cancellation can impose its own bounded notice
|
||||
expiry on top of this lifecycle helper.
|
||||
"""
|
||||
|
||||
deadline = response_datetime(request.deadline_at)
|
||||
return deadline if deadline is not None and deadline > _now() else None
|
||||
|
||||
|
||||
def _participant_has_delivery_target(
|
||||
participant: SchedulingParticipant,
|
||||
) -> bool:
|
||||
if participant.email:
|
||||
return True
|
||||
respondent_id = (participant.respondent_id or "").strip()
|
||||
return bool(
|
||||
respondent_id
|
||||
and not respondent_id.startswith("scheduling-participant:")
|
||||
)
|
||||
|
||||
|
||||
def issue_scheduling_participant_invitation(
|
||||
session: Session,
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
participant: SchedulingParticipant,
|
||||
) -> str:
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
participant_id: str,
|
||||
action: str,
|
||||
) -> SchedulingInvitationActionResult:
|
||||
"""Rotate one bearer link for explicit copy or immediate delivery.
|
||||
|
||||
The returned result contains the bearer URL only for ``copy``. ``send``
|
||||
passes it directly into the notification dispatch boundary and returns only
|
||||
the durable job projection.
|
||||
"""
|
||||
|
||||
if action not in {"copy", "send"}:
|
||||
raise SchedulingError("Unsupported scheduling invitation action")
|
||||
request = _lock_scheduling_request(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
lock_participants=True,
|
||||
)
|
||||
participant = _participant_for_invitation_action(
|
||||
request,
|
||||
participant_id=participant_id,
|
||||
)
|
||||
if request.poll_id is None:
|
||||
raise SchedulingError("Scheduling request has no backing poll")
|
||||
raise SchedulingError("Scheduling invitation action is unavailable")
|
||||
participation_provider = _poll_participation_provider()
|
||||
if participation_provider is None:
|
||||
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
|
||||
if action == "send":
|
||||
if not _participant_has_delivery_target(participant):
|
||||
raise SchedulingError(
|
||||
"Participant has no deliverable email address or account"
|
||||
)
|
||||
if notification_dispatch_provider(get_registry()) is None:
|
||||
raise SchedulingError(
|
||||
"Notification delivery is unavailable; copy the link instead"
|
||||
)
|
||||
|
||||
previous_invitation_id = participant.poll_invitation_id
|
||||
try:
|
||||
respondent_id = _stable_participant_respondent_id(participant)
|
||||
if previous_invitation_id is not None:
|
||||
participation_provider.revoke_invitation(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
poll_id=request.poll_id,
|
||||
invitation_id=previous_invitation_id,
|
||||
)
|
||||
invitation = participation_provider.create_governed_invitation(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
@@ -2632,23 +2701,110 @@ def _create_reconciled_participant_invitation(
|
||||
command=PollGovernedInvitationCommand(
|
||||
gateway=_public_participation_gateway(request.id),
|
||||
policy=_public_participation_policy(request),
|
||||
respondent_id=respondent_id,
|
||||
respondent_id=_stable_participant_respondent_id(participant),
|
||||
respondent_label=participant.display_name,
|
||||
email=participant.email,
|
||||
expires_at=request.deadline_at,
|
||||
expires_at=_new_public_invitation_expiry(request),
|
||||
metadata={
|
||||
"scheduling_request_id": request.id,
|
||||
"scheduling_participant_id": participant.id,
|
||||
"public_link_issued": True,
|
||||
"issue_action": action,
|
||||
},
|
||||
),
|
||||
)
|
||||
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
|
||||
except PollCapabilityError as exc:
|
||||
raise SchedulingError(str(exc)) from exc
|
||||
# Poll deliberately keeps its external participation errors generic;
|
||||
# retain that property at Scheduling's management boundary too.
|
||||
raise SchedulingError(
|
||||
"Scheduling invitation action could not be completed"
|
||||
) from exc
|
||||
|
||||
participant.poll_invitation_id = invitation.id
|
||||
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
|
||||
participant.last_invited_at = _now()
|
||||
participant.status = "invited"
|
||||
return invitation.token
|
||||
if participant.status in {"draft", "invited"}:
|
||||
participant.status = "invited"
|
||||
|
||||
action_url = f"/scheduling/public/{request.id}/{invitation.token}"
|
||||
notification: SchedulingNotification | None = None
|
||||
result_status = "issued"
|
||||
if action == "send":
|
||||
notifications = create_scheduling_notification_jobs(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
event_kind="invitation",
|
||||
metadata={"explicit_participant_delivery": True},
|
||||
invitation_tokens={participant.id: invitation.token},
|
||||
participant_ids=frozenset({participant.id}),
|
||||
)
|
||||
notification = notifications[0]
|
||||
result_status = notification.status
|
||||
action_url = None
|
||||
session.flush()
|
||||
return SchedulingInvitationActionResult(
|
||||
request=request,
|
||||
participant=participant,
|
||||
action=action,
|
||||
status=result_status,
|
||||
action_url=action_url,
|
||||
notification=notification,
|
||||
replaced_existing=previous_invitation_id is not None,
|
||||
)
|
||||
|
||||
|
||||
def revoke_scheduling_participant_invitation(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
participant_id: str,
|
||||
) -> SchedulingInvitationActionResult:
|
||||
"""Immediately revoke the current link; exact repeats are safe no-ops."""
|
||||
|
||||
request = _lock_scheduling_request(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
lock_participants=True,
|
||||
)
|
||||
participant = _participant_for_invitation_action(
|
||||
request,
|
||||
participant_id=participant_id,
|
||||
)
|
||||
invitation_id = participant.poll_invitation_id
|
||||
replayed = invitation_id is None
|
||||
if invitation_id is not None:
|
||||
if request.poll_id is None:
|
||||
raise SchedulingError("Scheduling invitation action is unavailable")
|
||||
participation_provider = _poll_participation_provider()
|
||||
if participation_provider is None:
|
||||
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
|
||||
try:
|
||||
revocation = participation_provider.revoke_invitation(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
poll_id=request.poll_id,
|
||||
invitation_id=invitation_id,
|
||||
)
|
||||
except PollCapabilityError as exc:
|
||||
raise SchedulingError(
|
||||
"Scheduling invitation action could not be completed"
|
||||
) from exc
|
||||
replayed = revocation.replayed
|
||||
participant.poll_invitation_id = None
|
||||
participant.participation_gateway = None
|
||||
if participant.status == "invited":
|
||||
participant.status = "draft"
|
||||
session.flush()
|
||||
return SchedulingInvitationActionResult(
|
||||
request=request,
|
||||
participant=participant,
|
||||
action="revoke",
|
||||
status="revoked",
|
||||
replayed=replayed,
|
||||
)
|
||||
|
||||
|
||||
def _update_participant_invitation_expiry(
|
||||
@@ -2695,7 +2851,6 @@ def _reconcile_scheduling_participants(
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
supplied_participants: list[SchedulingParticipantReconcileInput],
|
||||
create_invitations: bool,
|
||||
) -> dict[str, str]:
|
||||
"""Apply a complete participant collection and revoke removed links."""
|
||||
|
||||
@@ -2753,12 +2908,6 @@ def _reconcile_scheduling_participants(
|
||||
)
|
||||
session.add(participant)
|
||||
session.flush()
|
||||
if create_invitations:
|
||||
invitation_tokens[participant.id] = _create_reconciled_participant_invitation(
|
||||
session,
|
||||
request=request,
|
||||
participant=participant,
|
||||
)
|
||||
|
||||
participation_provider = _poll_participation_provider()
|
||||
for participant in removed_participants:
|
||||
|
||||
Reference in New Issue
Block a user