feat(scheduling): add explicit invitation link lifecycle
This commit is contained in:
36
README.md
36
README.md
@@ -179,16 +179,32 @@ frontend handoff rather than a working guest page. The token is never moved into
|
|||||||
query parameters, browser storage, or an authenticated API contract while that
|
query parameters, browser storage, or an authenticated API contract while that
|
||||||
shell boundary is unresolved.
|
shell boundary is unresolved.
|
||||||
|
|
||||||
Draft saves never issue public tokens or enqueue invitation delivery, even when
|
Creating, editing, and opening a request never issue public tokens or enqueue
|
||||||
`create_participant_invitations` is left at its compatibility default. A
|
invitation delivery. The deprecated `create_participant_invitations` request
|
||||||
collecting request may explicitly issue invitations; authenticated in-module
|
field remains accepted for compatibility, defaults to `false`, and has no side
|
||||||
responses lazily create a gateway-bound invitation and discard its token. The
|
effect. Authenticated in-module responses can still lazily create a
|
||||||
draft-to-open transition therefore supports authenticated lazy responses, but
|
gateway-bound invitation whose token is discarded.
|
||||||
does not make a guest link available from the current UI. The product decision
|
|
||||||
still open is the explicit organizer workflow for issuing or reissuing public
|
Organizers and Scheduling administrators use the participant-specific
|
||||||
links after a draft is opened and, when Mail is installed, whether that action
|
invitation action instead:
|
||||||
should also enqueue delivery or return links for separate distribution. Until
|
|
||||||
that workflow is agreed, opening a draft does not silently send anything.
|
- `POST /scheduling/requests/{request_id}/participants/{participant_id}/invitation`
|
||||||
|
with `{"action":"copy"}` rotates the previous invitation and returns the new
|
||||||
|
relative action URL once. The response is marked `no-store`.
|
||||||
|
- The same endpoint with `{"action":"send"}` rotates the invitation and passes
|
||||||
|
its URL directly to the notification dispatch job. Neither the API response
|
||||||
|
nor Scheduling's durable notification projection contains the token.
|
||||||
|
- `DELETE` on the same resource revokes the active link immediately; exact
|
||||||
|
repeats are idempotent.
|
||||||
|
|
||||||
|
Links can be issued in any request state. Collection state and deadline checks
|
||||||
|
remain independent submission requirements, so a link to a draft, closed, or
|
||||||
|
decided request is read-only. Issue, copy, send-request, and revoke actions are
|
||||||
|
audited with request and participant identifiers but never a bearer token.
|
||||||
|
Only an organizer (under the ordinary Scheduling write policy) or a tenant-wide
|
||||||
|
Scheduling administrator can use these actions. If notification delivery is
|
||||||
|
not installed, `send` fails before rotating the current link and the organizer
|
||||||
|
can use `copy` for separate distribution.
|
||||||
|
|
||||||
## FieldLabel omission register
|
## FieldLabel omission register
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
from typing import Any
|
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 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.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.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
@@ -18,6 +19,8 @@ from govoplan_scheduling.backend.schemas import (
|
|||||||
SchedulingCalendarActionResponse,
|
SchedulingCalendarActionResponse,
|
||||||
SchedulingCandidateSlotUpdateRequest,
|
SchedulingCandidateSlotUpdateRequest,
|
||||||
SchedulingDecisionRequest,
|
SchedulingDecisionRequest,
|
||||||
|
SchedulingInvitationActionRequest,
|
||||||
|
SchedulingInvitationActionResponse,
|
||||||
SchedulingNotificationCreateRequest,
|
SchedulingNotificationCreateRequest,
|
||||||
SchedulingNotificationListResponse,
|
SchedulingNotificationListResponse,
|
||||||
SchedulingNotificationResponse,
|
SchedulingNotificationResponse,
|
||||||
@@ -52,9 +55,11 @@ from govoplan_scheduling.backend.service import (
|
|||||||
get_visible_scheduling_request,
|
get_visible_scheduling_request,
|
||||||
list_visible_scheduling_notifications,
|
list_visible_scheduling_notifications,
|
||||||
list_visible_scheduling_requests,
|
list_visible_scheduling_requests,
|
||||||
|
issue_scheduling_participant_invitation,
|
||||||
open_scheduling_request,
|
open_scheduling_request,
|
||||||
refresh_participant_response_state,
|
refresh_participant_response_state,
|
||||||
require_visible_scheduling_results,
|
require_visible_scheduling_results,
|
||||||
|
revoke_scheduling_participant_invitation,
|
||||||
scheduling_notification_response,
|
scheduling_notification_response,
|
||||||
scheduling_request_response,
|
scheduling_request_response,
|
||||||
scheduling_request_summary,
|
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
|
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(
|
def _request_response(
|
||||||
request,
|
request,
|
||||||
*,
|
*,
|
||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
invitation_tokens: dict[str, str] | None = None,
|
|
||||||
) -> SchedulingRequestResponse:
|
) -> SchedulingRequestResponse:
|
||||||
return SchedulingRequestResponse.model_validate(
|
return SchedulingRequestResponse.model_validate(
|
||||||
scheduling_request_response(
|
scheduling_request_response(
|
||||||
request,
|
request,
|
||||||
invitation_tokens=invitation_tokens,
|
|
||||||
actor_ids=_principal_actor_ids(principal),
|
actor_ids=_principal_actor_ids(principal),
|
||||||
actor_user_id=principal.account_id,
|
actor_user_id=principal.account_id,
|
||||||
can_manage=_can_manage_scheduling(principal),
|
can_manage=_can_manage_scheduling(principal),
|
||||||
@@ -311,7 +341,7 @@ def api_create_scheduling_request(
|
|||||||
) -> SchedulingRequestResponse:
|
) -> SchedulingRequestResponse:
|
||||||
_require_scheduling_writer(principal)
|
_require_scheduling_writer(principal)
|
||||||
try:
|
try:
|
||||||
request, invitation_tokens = create_scheduling_request(
|
request, _invitation_tokens = create_scheduling_request(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
user_id=principal.account_id,
|
user_id=principal.account_id,
|
||||||
@@ -319,7 +349,7 @@ def api_create_scheduling_request(
|
|||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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()
|
session.commit()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -411,7 +441,7 @@ def api_update_scheduling_request(
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
request, invitation_tokens = update_scheduling_request_with_invitation_tokens(
|
request, _invitation_tokens = update_scheduling_request_with_invitation_tokens(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@@ -419,15 +449,135 @@ def api_update_scheduling_request(
|
|||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
response = _request_response(
|
response = _request_response(request, principal=principal)
|
||||||
request,
|
|
||||||
principal=principal,
|
|
||||||
invitation_tokens=invitation_tokens,
|
|
||||||
)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
return response
|
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)
|
@router.patch("/requests/{request_id}/slots/{slot_id}", response_model=SchedulingRequestResponse)
|
||||||
def api_update_scheduling_candidate_slot(
|
def api_update_scheduling_candidate_slot(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
|||||||
@@ -158,7 +158,14 @@ class SchedulingRequestCreateRequest(BaseModel):
|
|||||||
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
|
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
|
||||||
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
|
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
|
||||||
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
|
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)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
_validate_timezone = field_validator("timezone")(_known_timezone)
|
||||||
@@ -197,7 +204,14 @@ class SchedulingRequestUpdateRequest(BaseModel):
|
|||||||
min_length=1,
|
min_length=1,
|
||||||
)
|
)
|
||||||
participants: list[SchedulingParticipantReconcileInput] | None = None
|
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
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@@ -483,6 +497,24 @@ class SchedulingNotificationCreateRequest(BaseModel):
|
|||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class SchedulingAddressLookupCandidate(BaseModel):
|
||||||
contact_id: str
|
contact_id: str
|
||||||
address_book_id: str
|
address_book_id: str
|
||||||
|
|||||||
@@ -87,6 +87,18 @@ class SchedulingPublicParticipationError(SchedulingError):
|
|||||||
self.retry_after_seconds = max(0, retry_after_seconds)
|
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_DRAFT = "draft"
|
||||||
WORKFLOW_COLLECTING = "collecting_availability"
|
WORKFLOW_COLLECTING = "collecting_availability"
|
||||||
WORKFLOW_CLOSED = "availability_closed"
|
WORKFLOW_CLOSED = "availability_closed"
|
||||||
@@ -626,13 +638,30 @@ def create_scheduling_notification_jobs(
|
|||||||
channel: str = "mail",
|
channel: str = "mail",
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
invitation_tokens: dict[str, str] | None = None,
|
invitation_tokens: dict[str, str] | None = None,
|
||||||
|
participant_ids: frozenset[str] | None = None,
|
||||||
) -> list[SchedulingNotification]:
|
) -> list[SchedulingNotification]:
|
||||||
if event_kind not in NOTIFICATION_KINDS:
|
if event_kind not in NOTIFICATION_KINDS:
|
||||||
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
|
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
|
||||||
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
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] = []
|
notifications: list[SchedulingNotification] = []
|
||||||
base_metadata = metadata or {}
|
base_metadata = metadata or {}
|
||||||
for participant in _active_participants(request):
|
for participant in participants:
|
||||||
recipient = participant.email or participant.respondent_id
|
recipient = participant.email or participant.respondent_id
|
||||||
status = "pending" if recipient else "skipped"
|
status = "pending" if recipient else "skipped"
|
||||||
notification = SchedulingNotification(
|
notification = SchedulingNotification(
|
||||||
@@ -694,9 +723,12 @@ def _mirror_scheduling_notifications_to_center(
|
|||||||
),
|
),
|
||||||
enqueue_delivery=True,
|
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.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
|
continue
|
||||||
notification.metadata_ = {
|
notification.metadata_ = {
|
||||||
**(notification.metadata_ or {}),
|
**(notification.metadata_ or {}),
|
||||||
@@ -707,7 +739,7 @@ def _mirror_scheduling_notifications_to_center(
|
|||||||
notification.status = str(response["status"])
|
notification.status = str(response["status"])
|
||||||
elif response.get("status") == "skipped":
|
elif response.get("status") == "skipped":
|
||||||
notification.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()
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
@@ -1070,12 +1102,6 @@ def create_scheduling_request(
|
|||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
payload: SchedulingRequestCreateRequest,
|
payload: SchedulingRequestCreateRequest,
|
||||||
) -> tuple[SchedulingRequest, dict[str, str]]:
|
) -> 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:
|
if not payload.allow_external_participants:
|
||||||
for participant_input in payload.participants:
|
for participant_input in payload.participants:
|
||||||
if participant_input.participant_type == "external":
|
if participant_input.participant_type == "external":
|
||||||
@@ -1135,7 +1161,7 @@ def create_scheduling_request(
|
|||||||
email=participant_input.email,
|
email=participant_input.email,
|
||||||
participant_type=participant_input.participant_type,
|
participant_type=participant_input.participant_type,
|
||||||
required=participant_input.required,
|
required=participant_input.required,
|
||||||
status="invited" if issue_public_invitations else "draft",
|
status="draft",
|
||||||
metadata_=participant_input.metadata,
|
metadata_=participant_input.metadata,
|
||||||
)
|
)
|
||||||
session.add(participant)
|
session.add(participant)
|
||||||
@@ -1186,47 +1212,11 @@ def create_scheduling_request(
|
|||||||
for slot in _active_slots(request):
|
for slot in _active_slots(request):
|
||||||
slot.poll_option_id = options_by_position.get(slot.position)
|
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()
|
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]:
|
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,
|
session,
|
||||||
request=request,
|
request=request,
|
||||||
supplied_participants=payload.participants,
|
supplied_participants=payload.participants,
|
||||||
create_invitations=(
|
|
||||||
payload.create_participant_invitations
|
|
||||||
and request.status != "draft"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if deadline_changed:
|
if deadline_changed:
|
||||||
for participant in _active_participants(request):
|
for participant in _active_participants(request):
|
||||||
@@ -2218,7 +2204,7 @@ def update_scheduling_request_with_invitation_tokens(
|
|||||||
request_id: str,
|
request_id: str,
|
||||||
payload: SchedulingRequestUpdateRequest,
|
payload: SchedulingRequestUpdateRequest,
|
||||||
) -> tuple[SchedulingRequest, dict[str, str]]:
|
) -> 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(
|
return _update_scheduling_request_with_invitation_tokens(
|
||||||
session,
|
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,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
request: SchedulingRequest,
|
tenant_id: str,
|
||||||
participant: SchedulingParticipant,
|
request_id: str,
|
||||||
) -> 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:
|
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()
|
participation_provider = _poll_participation_provider()
|
||||||
if participation_provider is None:
|
if participation_provider is None:
|
||||||
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
|
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:
|
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(
|
invitation = participation_provider.create_governed_invitation(
|
||||||
session,
|
session,
|
||||||
tenant_id=request.tenant_id,
|
tenant_id=request.tenant_id,
|
||||||
@@ -2632,23 +2701,110 @@ def _create_reconciled_participant_invitation(
|
|||||||
command=PollGovernedInvitationCommand(
|
command=PollGovernedInvitationCommand(
|
||||||
gateway=_public_participation_gateway(request.id),
|
gateway=_public_participation_gateway(request.id),
|
||||||
policy=_public_participation_policy(request),
|
policy=_public_participation_policy(request),
|
||||||
respondent_id=respondent_id,
|
respondent_id=_stable_participant_respondent_id(participant),
|
||||||
respondent_label=participant.display_name,
|
respondent_label=participant.display_name,
|
||||||
email=participant.email,
|
email=participant.email,
|
||||||
expires_at=request.deadline_at,
|
expires_at=_new_public_invitation_expiry(request),
|
||||||
metadata={
|
metadata={
|
||||||
"scheduling_request_id": request.id,
|
"scheduling_request_id": request.id,
|
||||||
"scheduling_participant_id": participant.id,
|
"scheduling_participant_id": participant.id,
|
||||||
|
"public_link_issued": True,
|
||||||
|
"issue_action": action,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
|
|
||||||
except PollCapabilityError as exc:
|
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.poll_invitation_id = invitation.id
|
||||||
|
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
|
||||||
participant.last_invited_at = _now()
|
participant.last_invited_at = _now()
|
||||||
|
if participant.status in {"draft", "invited"}:
|
||||||
participant.status = "invited"
|
participant.status = "invited"
|
||||||
return invitation.token
|
|
||||||
|
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(
|
def _update_participant_invitation_expiry(
|
||||||
@@ -2695,7 +2851,6 @@ def _reconcile_scheduling_participants(
|
|||||||
*,
|
*,
|
||||||
request: SchedulingRequest,
|
request: SchedulingRequest,
|
||||||
supplied_participants: list[SchedulingParticipantReconcileInput],
|
supplied_participants: list[SchedulingParticipantReconcileInput],
|
||||||
create_invitations: bool,
|
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Apply a complete participant collection and revoke removed links."""
|
"""Apply a complete participant collection and revoke removed links."""
|
||||||
|
|
||||||
@@ -2753,12 +2908,6 @@ def _reconcile_scheduling_participants(
|
|||||||
)
|
)
|
||||||
session.add(participant)
|
session.add(participant)
|
||||||
session.flush()
|
session.flush()
|
||||||
if create_invitations:
|
|
||||||
invitation_tokens[participant.id] = _create_reconciled_participant_invitation(
|
|
||||||
session,
|
|
||||||
request=request,
|
|
||||||
participant=participant,
|
|
||||||
)
|
|
||||||
|
|
||||||
participation_provider = _poll_participation_provider()
|
participation_provider = _poll_participation_provider()
|
||||||
for participant in removed_participants:
|
for participant in removed_participants:
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from govoplan_scheduling.backend.service import (
|
|||||||
cancel_scheduling_request,
|
cancel_scheduling_request,
|
||||||
create_scheduling_request,
|
create_scheduling_request,
|
||||||
get_public_scheduling_participation,
|
get_public_scheduling_participation,
|
||||||
|
issue_scheduling_participant_invitation,
|
||||||
scheduling_request_summary,
|
scheduling_request_summary,
|
||||||
scheduling_slot_revision,
|
scheduling_slot_revision,
|
||||||
submit_scheduling_availability,
|
submit_scheduling_availability,
|
||||||
@@ -149,8 +150,9 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
|||||||
participants: list[SchedulingParticipantInput] | None = None,
|
participants: list[SchedulingParticipantInput] | None = None,
|
||||||
**settings,
|
**settings,
|
||||||
) -> tuple[SchedulingRequest, dict[str, str]]:
|
) -> tuple[SchedulingRequest, dict[str, str]]:
|
||||||
|
issue_links = bool(settings.pop("create_participant_invitations", True))
|
||||||
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
|
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
|
||||||
return create_scheduling_request(
|
request, automatic_tokens = create_scheduling_request(
|
||||||
self.session,
|
self.session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
user_id="organizer-1",
|
user_id="organizer-1",
|
||||||
@@ -184,6 +186,31 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
|||||||
**settings,
|
**settings,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
self.assertEqual(automatic_tokens, {})
|
||||||
|
tokens = (
|
||||||
|
{
|
||||||
|
participant.id: self._issue_copy(request, participant)
|
||||||
|
for participant in request.participants
|
||||||
|
}
|
||||||
|
if issue_links
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
return request, tokens
|
||||||
|
|
||||||
|
def _issue_copy(
|
||||||
|
self,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
participant: SchedulingParticipant,
|
||||||
|
) -> str:
|
||||||
|
result = issue_scheduling_participant_invitation(
|
||||||
|
self.session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
request_id=request.id,
|
||||||
|
participant_id=participant.id,
|
||||||
|
action="copy",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(result.action_url)
|
||||||
|
return str(result.action_url).rsplit("/", 1)[-1]
|
||||||
|
|
||||||
def _answer(
|
def _answer(
|
||||||
self,
|
self,
|
||||||
@@ -475,7 +502,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
token = tokens[public_request.participants[0].id]
|
self.assertEqual(tokens, {})
|
||||||
|
token = self._issue_copy(public_request, public_request.participants[0])
|
||||||
invitation = self.session.query(PollInvitation).filter(
|
invitation = self.session.query(PollInvitation).filter(
|
||||||
PollInvitation.id == public_request.participants[0].poll_invitation_id
|
PollInvitation.id == public_request.participants[0].poll_invitation_id
|
||||||
).one()
|
).one()
|
||||||
@@ -568,7 +596,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
|||||||
participants=[SchedulingParticipantInput(display_name="Guest")],
|
participants=[SchedulingParticipantInput(display_name="Guest")],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
token = tokens[request.participants[0].id]
|
self.assertEqual(tokens, {})
|
||||||
|
token = self._issue_copy(request, request.participants[0])
|
||||||
wrong = SchedulingPublicParticipationAccessRequest(password="wrong password")
|
wrong = SchedulingPublicParticipationAccessRequest(password="wrong password")
|
||||||
|
|
||||||
for attempt in range(10):
|
for attempt in range(10):
|
||||||
@@ -623,7 +652,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
participant = request.participants[0]
|
participant = request.participants[0]
|
||||||
token = tokens[participant.id]
|
self.assertEqual(tokens, {})
|
||||||
|
token = self._issue_copy(request, participant)
|
||||||
answer = SchedulingAvailabilityAnswerInput(
|
answer = SchedulingAvailabilityAnswerInput(
|
||||||
slot_id=request.slots[0].id,
|
slot_id=request.slots[0].id,
|
||||||
value="available",
|
value="available",
|
||||||
@@ -1036,8 +1066,16 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(removed_slot.deleted_at)
|
self.assertIsNotNone(removed_slot.deleted_at)
|
||||||
bob = next(item for item in response.participants if item.display_name == "Bob")
|
bob = next(item for item in response.participants if item.display_name == "Bob")
|
||||||
self.assertEqual(bob.email, "bob@example.test")
|
self.assertEqual(bob.email, "bob@example.test")
|
||||||
self.assertIsNotNone(bob.poll_invitation_id)
|
self.assertIsNone(bob.poll_invitation_id)
|
||||||
self.assertIsNotNone(bob.invitation_token)
|
self.assertIsNone(bob.invitation_token)
|
||||||
|
bob_model = next(
|
||||||
|
item
|
||||||
|
for item in request.participants
|
||||||
|
if item.deleted_at is None and item.display_name == "Bob"
|
||||||
|
)
|
||||||
|
self._issue_copy(request, bob_model)
|
||||||
|
bob_invitation_id = bob_model.poll_invitation_id
|
||||||
|
self.assertIsNotNone(bob_invitation_id)
|
||||||
current = api_get_my_scheduling_availability(
|
current = api_get_my_scheduling_availability(
|
||||||
request.id,
|
request.id,
|
||||||
session=self.session,
|
session=self.session,
|
||||||
@@ -1091,7 +1129,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
invitation = self.session.query(PollInvitation).filter(
|
invitation = self.session.query(PollInvitation).filter(
|
||||||
PollInvitation.id == bob.poll_invitation_id
|
PollInvitation.id == bob_invitation_id
|
||||||
).one()
|
).one()
|
||||||
self.assertIsNotNone(invitation.revoked_at)
|
self.assertIsNotNone(invitation.revoked_at)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException, Response
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
@@ -42,7 +42,9 @@ from govoplan_scheduling.backend.schemas import (
|
|||||||
SchedulingCalendarPreferences,
|
SchedulingCalendarPreferences,
|
||||||
SchedulingCandidateSlotInput,
|
SchedulingCandidateSlotInput,
|
||||||
SchedulingDecisionRequest,
|
SchedulingDecisionRequest,
|
||||||
|
SchedulingInvitationActionRequest,
|
||||||
SchedulingParticipantInput,
|
SchedulingParticipantInput,
|
||||||
|
SchedulingPublicParticipationAccessRequest,
|
||||||
SchedulingPublicParticipationSubmitRequest,
|
SchedulingPublicParticipationSubmitRequest,
|
||||||
SchedulingRequestCreateRequest,
|
SchedulingRequestCreateRequest,
|
||||||
SchedulingRequestUpdateRequest,
|
SchedulingRequestUpdateRequest,
|
||||||
@@ -55,9 +57,11 @@ from govoplan_scheduling.backend.router import (
|
|||||||
api_evaluate_calendar_freebusy,
|
api_evaluate_calendar_freebusy,
|
||||||
api_get_my_scheduling_availability,
|
api_get_my_scheduling_availability,
|
||||||
api_get_scheduling_request,
|
api_get_scheduling_request,
|
||||||
|
api_issue_scheduling_participant_invitation,
|
||||||
api_list_scheduling_requests,
|
api_list_scheduling_requests,
|
||||||
api_scheduling_summary,
|
api_scheduling_summary,
|
||||||
api_submit_scheduling_availability,
|
api_submit_scheduling_availability,
|
||||||
|
api_revoke_scheduling_participant_invitation,
|
||||||
)
|
)
|
||||||
from govoplan_scheduling.backend.service import (
|
from govoplan_scheduling.backend.service import (
|
||||||
SchedulingError,
|
SchedulingError,
|
||||||
@@ -70,6 +74,8 @@ from govoplan_scheduling.backend.service import (
|
|||||||
decide_scheduling_request,
|
decide_scheduling_request,
|
||||||
evaluate_calendar_freebusy,
|
evaluate_calendar_freebusy,
|
||||||
get_visible_scheduling_request,
|
get_visible_scheduling_request,
|
||||||
|
get_public_scheduling_participation,
|
||||||
|
issue_scheduling_participant_invitation,
|
||||||
list_scheduling_notifications,
|
list_scheduling_notifications,
|
||||||
list_visible_scheduling_notifications,
|
list_visible_scheduling_notifications,
|
||||||
list_visible_scheduling_requests,
|
list_visible_scheduling_requests,
|
||||||
@@ -206,7 +212,22 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_request_creates_poll_slots_and_signed_invitations(self) -> None:
|
def _issue_copy(
|
||||||
|
self,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
participant: SchedulingParticipant,
|
||||||
|
) -> str:
|
||||||
|
result = issue_scheduling_participant_invitation(
|
||||||
|
self.session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
request_id=request.id,
|
||||||
|
participant_id=participant.id,
|
||||||
|
action="copy",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(result.action_url)
|
||||||
|
return str(result.action_url).rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
def test_create_request_creates_poll_slots_without_implicit_invitations(self) -> None:
|
||||||
request, tokens = create_scheduling_request(
|
request, tokens = create_scheduling_request(
|
||||||
self.session,
|
self.session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -224,8 +245,20 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(poll.context_resource_id, request.id)
|
self.assertEqual(poll.context_resource_id, request.id)
|
||||||
self.assertEqual(len(request.slots), 2)
|
self.assertEqual(len(request.slots), 2)
|
||||||
self.assertTrue(all(slot.poll_option_id for slot in request.slots))
|
self.assertTrue(all(slot.poll_option_id for slot in request.slots))
|
||||||
self.assertEqual(len(tokens), 2)
|
self.assertEqual(tokens, {})
|
||||||
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants))
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
participant.status == "draft"
|
||||||
|
and participant.poll_invitation_id is None
|
||||||
|
for participant in request.participants
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.session.query(SchedulingNotification).filter(
|
||||||
|
SchedulingNotification.request_id == request.id
|
||||||
|
).count(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None:
|
def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None:
|
||||||
class RejectingNotificationProvider:
|
class RejectingNotificationProvider:
|
||||||
@@ -331,11 +364,13 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
first_participant = request.participants[0]
|
first_participant = request.participants[0]
|
||||||
first_slot = request.slots[0]
|
first_slot = request.slots[0]
|
||||||
second_slot = request.slots[1]
|
second_slot = request.slots[1]
|
||||||
|
self.assertEqual(tokens, {})
|
||||||
|
token = self._issue_copy(request, first_participant)
|
||||||
|
|
||||||
submit_public_scheduling_participation(
|
submit_public_scheduling_participation(
|
||||||
self.session,
|
self.session,
|
||||||
request_id=request.id,
|
request_id=request.id,
|
||||||
token=tokens[first_participant.id],
|
token=token,
|
||||||
payload=SchedulingPublicParticipationSubmitRequest(
|
payload=SchedulingPublicParticipationSubmitRequest(
|
||||||
answers=[
|
answers=[
|
||||||
SchedulingAvailabilityAnswerInput(
|
SchedulingAvailabilityAnswerInput(
|
||||||
@@ -584,7 +619,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
all_jobs = list_scheduling_notifications(self.session, tenant_id="tenant-1", request_id=request.id)
|
all_jobs = list_scheduling_notifications(self.session, tenant_id="tenant-1", request_id=request.id)
|
||||||
|
|
||||||
self.assertEqual(len(reminder_jobs), 2)
|
self.assertEqual(len(reminder_jobs), 2)
|
||||||
self.assertGreaterEqual(len(all_jobs), 4)
|
self.assertEqual(len(all_jobs), 2)
|
||||||
self.assertTrue(all(job.status == "pending" for job in reminder_jobs))
|
self.assertTrue(all(job.status == "pending" for job in reminder_jobs))
|
||||||
|
|
||||||
organizer_jobs = list_visible_scheduling_notifications(
|
organizer_jobs = list_visible_scheduling_notifications(
|
||||||
@@ -1057,6 +1092,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
target = request.participants[1]
|
target = request.participants[1]
|
||||||
|
self._issue_copy(request, target)
|
||||||
attacker = self._principal(
|
attacker = self._principal(
|
||||||
"attacker",
|
"attacker",
|
||||||
email="alice@example.test",
|
email="alice@example.test",
|
||||||
@@ -1095,7 +1131,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
principal=attacker,
|
principal=attacker,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(direct_response.exception.status_code, 400)
|
self.assertEqual(direct_response.exception.status_code, 404)
|
||||||
self.assertEqual([request.id], [item.id for item in listed.requests])
|
self.assertEqual([request.id], [item.id for item in listed.requests])
|
||||||
self.assertFalse(current.has_response)
|
self.assertFalse(current.has_response)
|
||||||
|
|
||||||
@@ -1225,10 +1261,12 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
participant = request.participants[0]
|
participant = request.participants[0]
|
||||||
|
self.assertEqual(tokens, {})
|
||||||
|
token = self._issue_copy(request, participant)
|
||||||
submit_public_scheduling_participation(
|
submit_public_scheduling_participation(
|
||||||
self.session,
|
self.session,
|
||||||
request_id=request.id,
|
request_id=request.id,
|
||||||
token=tokens[participant.id],
|
token=token,
|
||||||
payload=SchedulingPublicParticipationSubmitRequest(
|
payload=SchedulingPublicParticipationSubmitRequest(
|
||||||
answers=[
|
answers=[
|
||||||
SchedulingAvailabilityAnswerInput(
|
SchedulingAvailabilityAnswerInput(
|
||||||
@@ -1436,7 +1474,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
self.assertIsNone(request.selected_slot_id)
|
self.assertIsNone(request.selected_slot_id)
|
||||||
self.assertIsNone(request.calendar_event_id)
|
self.assertIsNone(request.calendar_event_id)
|
||||||
|
|
||||||
def test_initial_invitation_notifications_use_signed_poll_link_and_verified_recipient_id(self) -> None:
|
def test_explicit_invitation_send_uses_signed_link_and_verified_recipient_id(self) -> None:
|
||||||
class CapturingNotificationProvider:
|
class CapturingNotificationProvider:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.requests = []
|
self.requests = []
|
||||||
@@ -1466,22 +1504,41 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
"govoplan_scheduling.backend.service.notification_dispatch_provider",
|
"govoplan_scheduling.backend.service.notification_dispatch_provider",
|
||||||
return_value=provider,
|
return_value=provider,
|
||||||
):
|
):
|
||||||
request, tokens = create_scheduling_request(
|
request, automatic_tokens = create_scheduling_request(
|
||||||
self.session,
|
self.session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
user_id="user-1",
|
user_id="user-1",
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(provider.requests, [])
|
||||||
|
results = [
|
||||||
|
issue_scheduling_participant_invitation(
|
||||||
|
self.session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
request_id=request.id,
|
||||||
|
participant_id=participant.id,
|
||||||
|
action="send",
|
||||||
|
)
|
||||||
|
for participant in request.participants
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(automatic_tokens, {})
|
||||||
|
self.assertTrue(all(result.action_url is None for result in results))
|
||||||
|
self.assertTrue(all(result.status == "queued" for result in results))
|
||||||
self.assertEqual(len(provider.requests), 2)
|
self.assertEqual(len(provider.requests), 2)
|
||||||
self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"})
|
self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"})
|
||||||
self.assertEqual(
|
action_urls = {item.action_url for item in provider.requests}
|
||||||
{item.action_url for item in provider.requests},
|
self.assertTrue(
|
||||||
{
|
all(
|
||||||
f"/scheduling/public/{request.id}/{token}"
|
isinstance(action_url, str)
|
||||||
for token in tokens.values()
|
and action_url.startswith(f"/scheduling/public/{request.id}/")
|
||||||
},
|
for action_url in action_urls
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
tokens = {
|
||||||
|
str(action_url).rsplit("/", 1)[-1]
|
||||||
|
for action_url in action_urls
|
||||||
|
}
|
||||||
local_notifications = list_scheduling_notifications(
|
local_notifications = list_scheduling_notifications(
|
||||||
self.session,
|
self.session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -1489,7 +1546,228 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
for notification in local_notifications:
|
for notification in local_notifications:
|
||||||
serialized = repr({"payload": notification.payload, "metadata": notification.metadata_})
|
serialized = repr({"payload": notification.payload, "metadata": notification.metadata_})
|
||||||
self.assertTrue(all(token not in serialized for token in tokens.values()))
|
self.assertTrue(all(token not in serialized for token in tokens))
|
||||||
|
|
||||||
|
def test_invitation_router_rotates_revokes_and_enforces_management_policy(self) -> None:
|
||||||
|
request, automatic_tokens = create_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=self._payload().model_copy(
|
||||||
|
update={"calendar": SchedulingCalendarPreferences()}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
participant = request.participants[0]
|
||||||
|
organizer = self._principal(
|
||||||
|
"user-1",
|
||||||
|
scopes={SCHEDULING_WRITE_SCOPE},
|
||||||
|
)
|
||||||
|
response_headers = Response()
|
||||||
|
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
|
||||||
|
first = api_issue_scheduling_participant_invitation(
|
||||||
|
request.id,
|
||||||
|
participant.id,
|
||||||
|
SchedulingInvitationActionRequest(action="copy"),
|
||||||
|
response_headers,
|
||||||
|
session=self.session,
|
||||||
|
principal=organizer,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(automatic_tokens, {})
|
||||||
|
self.assertEqual(first.action, "copy")
|
||||||
|
self.assertEqual(first.status, "issued")
|
||||||
|
self.assertIsNotNone(first.action_url)
|
||||||
|
self.assertEqual(
|
||||||
|
response_headers.headers["cache-control"],
|
||||||
|
"no-store, private",
|
||||||
|
)
|
||||||
|
first_token = str(first.action_url).rsplit("/", 1)[-1]
|
||||||
|
self.assertNotIn(first_token, repr([call.kwargs for call in audit.call_args_list]))
|
||||||
|
|
||||||
|
unrelated_writer = self._principal(
|
||||||
|
"unrelated",
|
||||||
|
scopes={SCHEDULING_WRITE_SCOPE},
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as denied:
|
||||||
|
api_issue_scheduling_participant_invitation(
|
||||||
|
request.id,
|
||||||
|
participant.id,
|
||||||
|
SchedulingInvitationActionRequest(action="copy"),
|
||||||
|
Response(),
|
||||||
|
session=self.session,
|
||||||
|
principal=unrelated_writer,
|
||||||
|
)
|
||||||
|
self.assertEqual(denied.exception.status_code, 403)
|
||||||
|
self.assertEqual(
|
||||||
|
get_public_scheduling_participation(
|
||||||
|
self.session,
|
||||||
|
request_id=request.id,
|
||||||
|
token=first_token,
|
||||||
|
payload=SchedulingPublicParticipationAccessRequest(),
|
||||||
|
client_address="127.0.0.1",
|
||||||
|
)["request_id"],
|
||||||
|
request.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
administrator = self._principal(
|
||||||
|
"administrator",
|
||||||
|
scopes={SCHEDULING_ADMIN_SCOPE},
|
||||||
|
)
|
||||||
|
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
|
||||||
|
rotated = api_issue_scheduling_participant_invitation(
|
||||||
|
request.id,
|
||||||
|
participant.id,
|
||||||
|
SchedulingInvitationActionRequest(action="copy"),
|
||||||
|
Response(),
|
||||||
|
session=self.session,
|
||||||
|
principal=administrator,
|
||||||
|
)
|
||||||
|
rotated_token = str(rotated.action_url).rsplit("/", 1)[-1]
|
||||||
|
self.assertNotEqual(rotated_token, first_token)
|
||||||
|
self.assertNotIn(rotated_token, repr([call.kwargs for call in audit.call_args_list]))
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
Exception,
|
||||||
|
"Scheduling participation link or credentials are invalid",
|
||||||
|
):
|
||||||
|
get_public_scheduling_participation(
|
||||||
|
self.session,
|
||||||
|
request_id=request.id,
|
||||||
|
token=first_token,
|
||||||
|
payload=SchedulingPublicParticipationAccessRequest(),
|
||||||
|
client_address="127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
|
||||||
|
revoked = api_revoke_scheduling_participant_invitation(
|
||||||
|
request.id,
|
||||||
|
participant.id,
|
||||||
|
Response(),
|
||||||
|
session=self.session,
|
||||||
|
principal=organizer,
|
||||||
|
)
|
||||||
|
replayed = api_revoke_scheduling_participant_invitation(
|
||||||
|
request.id,
|
||||||
|
participant.id,
|
||||||
|
Response(),
|
||||||
|
session=self.session,
|
||||||
|
principal=organizer,
|
||||||
|
)
|
||||||
|
self.assertFalse(revoked.replayed)
|
||||||
|
self.assertTrue(replayed.replayed)
|
||||||
|
self.assertNotIn(rotated_token, repr([call.kwargs for call in audit.call_args_list]))
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
Exception,
|
||||||
|
"Scheduling participation link or credentials are invalid",
|
||||||
|
):
|
||||||
|
get_public_scheduling_participation(
|
||||||
|
self.session,
|
||||||
|
request_id=request.id,
|
||||||
|
token=rotated_token,
|
||||||
|
payload=SchedulingPublicParticipationAccessRequest(),
|
||||||
|
client_address="127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_links_can_be_issued_for_draft_and_closed_requests(self) -> None:
|
||||||
|
organizer = self._principal(
|
||||||
|
"user-1",
|
||||||
|
scopes={SCHEDULING_WRITE_SCOPE},
|
||||||
|
)
|
||||||
|
for request_status in ("draft", "collecting"):
|
||||||
|
request, _automatic_tokens = create_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=self._payload().model_copy(
|
||||||
|
update={
|
||||||
|
"status": request_status,
|
||||||
|
"calendar": SchedulingCalendarPreferences(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if request_status == "collecting":
|
||||||
|
close_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
request_id=request.id,
|
||||||
|
)
|
||||||
|
expected_status = "closed"
|
||||||
|
else:
|
||||||
|
expected_status = "draft"
|
||||||
|
with patch("govoplan_scheduling.backend.router.audit_event"):
|
||||||
|
issued = api_issue_scheduling_participant_invitation(
|
||||||
|
request.id,
|
||||||
|
request.participants[0].id,
|
||||||
|
SchedulingInvitationActionRequest(action="copy"),
|
||||||
|
Response(),
|
||||||
|
session=self.session,
|
||||||
|
principal=organizer,
|
||||||
|
)
|
||||||
|
self.assertEqual(request.status, expected_status)
|
||||||
|
self.assertTrue(
|
||||||
|
str(issued.action_url).startswith(
|
||||||
|
f"/scheduling/public/{request.id}/"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_send_response_audit_error_and_durable_job_never_store_token(self) -> None:
|
||||||
|
class EchoingFailureNotificationProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def enqueue_notification(self, _session, request, *, enqueue_delivery):
|
||||||
|
self.requests.append(request)
|
||||||
|
raise RuntimeError(f"failed dispatch for {request.action_url}")
|
||||||
|
|
||||||
|
provider = EchoingFailureNotificationProvider()
|
||||||
|
request, _automatic_tokens = create_scheduling_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=self._payload().model_copy(
|
||||||
|
update={"calendar": SchedulingCalendarPreferences()}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
participant = request.participants[0]
|
||||||
|
organizer = self._principal(
|
||||||
|
"user-1",
|
||||||
|
scopes={SCHEDULING_WRITE_SCOPE},
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_scheduling.backend.service.notification_dispatch_provider",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch("govoplan_scheduling.backend.router.audit_event") as audit,
|
||||||
|
):
|
||||||
|
result = api_issue_scheduling_participant_invitation(
|
||||||
|
request.id,
|
||||||
|
participant.id,
|
||||||
|
SchedulingInvitationActionRequest(action="send"),
|
||||||
|
Response(),
|
||||||
|
session=self.session,
|
||||||
|
principal=organizer,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(provider.requests), 1)
|
||||||
|
action_url = provider.requests[0].action_url
|
||||||
|
self.assertIsInstance(action_url, str)
|
||||||
|
token = str(action_url).rsplit("/", 1)[-1]
|
||||||
|
self.assertIsNone(result.action_url)
|
||||||
|
self.assertEqual(result.status, "failed")
|
||||||
|
self.assertEqual(result.notification.error, "Notification center enqueue failed")
|
||||||
|
self.assertNotIn(token, repr(result.model_dump()))
|
||||||
|
self.assertNotIn(token, repr([call.kwargs for call in audit.call_args_list]))
|
||||||
|
notification = self.session.query(SchedulingNotification).filter(
|
||||||
|
SchedulingNotification.id == result.notification.id
|
||||||
|
).one()
|
||||||
|
durable_projection = repr(
|
||||||
|
{
|
||||||
|
"payload": notification.payload,
|
||||||
|
"metadata": notification.metadata_,
|
||||||
|
"error": notification.error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertNotIn(token, durable_projection)
|
||||||
|
|
||||||
def test_external_participants_can_be_rejected(self) -> None:
|
def test_external_participants_can_be_rejected(self) -> None:
|
||||||
payload = self._payload().model_copy(update={"allow_external_participants": False})
|
payload = self._payload().model_copy(update={"allow_external_participants": False})
|
||||||
|
|||||||
Reference in New Issue
Block a user