feat(scheduling): bound public cancellation notices
This commit is contained in:
@@ -316,6 +316,7 @@ class SchedulingRequestResponse(BaseModel):
|
||||
calendar_event_id: str | None = None
|
||||
handed_off_at: datetime | None = None
|
||||
cancelled_at: datetime | None = None
|
||||
cancellation_notice_until: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -423,6 +424,9 @@ class SchedulingPublicParticipationResponse(BaseModel):
|
||||
timezone: str
|
||||
status: str
|
||||
deadline_at: datetime | None = None
|
||||
cancelled_at: datetime | None = None
|
||||
cancellation_notice_until: datetime | None = None
|
||||
cancellation_notice_only: bool = False
|
||||
participant_email_required: bool
|
||||
anonymous_password_required: bool
|
||||
single_choice: bool
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -160,6 +160,19 @@ def _now() -> datetime:
|
||||
return utcnow()
|
||||
|
||||
|
||||
def _cancellation_notice_days() -> int:
|
||||
configured = getattr(
|
||||
get_settings(),
|
||||
"scheduling_cancellation_notice_days",
|
||||
30,
|
||||
)
|
||||
try:
|
||||
value = int(configured)
|
||||
except (TypeError, ValueError):
|
||||
return 30
|
||||
return max(1, min(value, 90))
|
||||
|
||||
|
||||
def _slot_label(start_at: datetime, end_at: datetime, *, timezone_name: str) -> str:
|
||||
return f"{response_datetime(start_at).isoformat()} - {response_datetime(end_at).isoformat()} ({timezone_name})"
|
||||
|
||||
@@ -1682,6 +1695,11 @@ def _resolve_public_scheduling_participation(
|
||||
if request is None:
|
||||
raise SchedulingPublicParticipationError()
|
||||
|
||||
if request.status == "cancelled":
|
||||
notice_until = response_datetime(request.cancellation_notice_until)
|
||||
if notice_until is None or notice_until <= _now():
|
||||
raise SchedulingPublicParticipationError()
|
||||
|
||||
if lock_for_submission:
|
||||
# Scheduling mutations acquire their request/participant locks before
|
||||
# entering Poll. Public response submission must use the same order:
|
||||
@@ -1717,11 +1735,11 @@ def _resolve_public_scheduling_participation(
|
||||
raise SchedulingPublicParticipationError(
|
||||
retry_after_seconds=decision.retry_after_seconds,
|
||||
)
|
||||
supplied_password = (
|
||||
payload.password.get_secret_value()
|
||||
if payload.password is not None
|
||||
else ""
|
||||
)
|
||||
if payload.password is None:
|
||||
# The UI first probes whether the link needs credentials. Missing
|
||||
# credentials are not a failed password attempt.
|
||||
raise SchedulingPublicParticipationError()
|
||||
supplied_password = payload.password.get_secret_value()
|
||||
if not verify_participant_password(
|
||||
supplied_password,
|
||||
request.anonymous_password_hash,
|
||||
@@ -1791,6 +1809,27 @@ def _public_scheduling_participation_response(
|
||||
*,
|
||||
response: PollGovernedResponseRef | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if request.status == "cancelled":
|
||||
# A cancelled link remains useful only as a bounded notice. Do not
|
||||
# retain the description, location, candidate slots, or prior answer
|
||||
# projection on this public surface.
|
||||
return {
|
||||
"request_id": request.id,
|
||||
"title": request.title,
|
||||
"timezone": request.timezone,
|
||||
"status": request.status,
|
||||
"cancelled_at": response_datetime(request.cancelled_at),
|
||||
"cancellation_notice_until": response_datetime(
|
||||
request.cancellation_notice_until
|
||||
),
|
||||
"cancellation_notice_only": True,
|
||||
"participant_email_required": False,
|
||||
"anonymous_password_required": False,
|
||||
"single_choice": False,
|
||||
"allow_maybe": False,
|
||||
"allow_comments": False,
|
||||
"allow_participant_updates": False,
|
||||
}
|
||||
current_response = response or context.response
|
||||
slot_by_option_id = {
|
||||
slot.poll_option_id: slot
|
||||
@@ -1823,6 +1862,9 @@ def _public_scheduling_participation_response(
|
||||
"timezone": request.timezone,
|
||||
"status": request.status,
|
||||
"deadline_at": response_datetime(request.deadline_at),
|
||||
"cancelled_at": None,
|
||||
"cancellation_notice_until": None,
|
||||
"cancellation_notice_only": False,
|
||||
"participant_email_required": context.policy.participant_email_required,
|
||||
"anonymous_password_required": context.policy.anonymous_password_required,
|
||||
"single_choice": context.policy.single_choice,
|
||||
@@ -2142,6 +2184,7 @@ def _update_scheduling_request_with_invitation_tokens(
|
||||
request=request,
|
||||
participant=participant,
|
||||
invitation_id=previous_invitation_id,
|
||||
expires_at=request.deadline_at,
|
||||
)
|
||||
if (
|
||||
not request.allow_external_participants
|
||||
@@ -2627,6 +2670,11 @@ def _new_public_invitation_expiry(
|
||||
expiry on top of this lifecycle helper.
|
||||
"""
|
||||
|
||||
if request.status == "cancelled":
|
||||
notice_until = response_datetime(request.cancellation_notice_until)
|
||||
if notice_until is None or notice_until <= _now():
|
||||
raise SchedulingError("Scheduling cancellation notice has expired")
|
||||
return notice_until
|
||||
deadline = response_datetime(request.deadline_at)
|
||||
return deadline if deadline is not None and deadline > _now() else None
|
||||
|
||||
@@ -2685,6 +2733,7 @@ def issue_scheduling_participant_invitation(
|
||||
"Notification delivery is unavailable; copy the link instead"
|
||||
)
|
||||
|
||||
invitation_expiry = _new_public_invitation_expiry(request)
|
||||
previous_invitation_id = participant.poll_invitation_id
|
||||
try:
|
||||
if previous_invitation_id is not None:
|
||||
@@ -2704,7 +2753,7 @@ def issue_scheduling_participant_invitation(
|
||||
respondent_id=_stable_participant_respondent_id(participant),
|
||||
respondent_label=participant.display_name,
|
||||
email=participant.email,
|
||||
expires_at=_new_public_invitation_expiry(request),
|
||||
expires_at=invitation_expiry,
|
||||
metadata={
|
||||
"scheduling_request_id": request.id,
|
||||
"scheduling_participant_id": participant.id,
|
||||
@@ -2813,8 +2862,9 @@ def _update_participant_invitation_expiry(
|
||||
request: SchedulingRequest,
|
||||
participant: SchedulingParticipant,
|
||||
invitation_id: str,
|
||||
expires_at: datetime | None,
|
||||
) -> None:
|
||||
"""Align one retained participant link with the request deadline.
|
||||
"""Align one retained participant link with its lifecycle expiry.
|
||||
|
||||
Poll owns invitation rows and performs the owner- and gateway-bound expiry
|
||||
mutation under Poll -> invitation row locks. The invitation identity and
|
||||
@@ -2836,7 +2886,7 @@ def _update_participant_invitation_expiry(
|
||||
poll_id=request.poll_id,
|
||||
invitation_id=invitation_id,
|
||||
gateway=_public_participation_gateway(request.id),
|
||||
expires_at=request.deadline_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
except PollCapabilityError as exc:
|
||||
raise SchedulingError(str(exc)) from exc
|
||||
@@ -3114,8 +3164,22 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
|
||||
raise SchedulingError(
|
||||
"Only draft, collecting, or closed scheduling requests can be cancelled"
|
||||
)
|
||||
cancelled_at = _now()
|
||||
request.status = "cancelled"
|
||||
request.cancelled_at = _now()
|
||||
request.cancelled_at = cancelled_at
|
||||
request.cancellation_notice_until = cancelled_at + timedelta(
|
||||
days=_cancellation_notice_days()
|
||||
)
|
||||
for participant in _active_participants(request):
|
||||
if participant.poll_invitation_id is None:
|
||||
continue
|
||||
_update_participant_invitation_expiry(
|
||||
session,
|
||||
request=request,
|
||||
participant=participant,
|
||||
invitation_id=participant.poll_invitation_id,
|
||||
expires_at=request.cancellation_notice_until,
|
||||
)
|
||||
if request.poll_id is not None:
|
||||
try:
|
||||
provider = _poll_provider()
|
||||
@@ -3443,6 +3507,9 @@ def scheduling_request_response(
|
||||
"calendar_event_id": request.calendar_event_id if full_roster else None,
|
||||
"handed_off_at": response_datetime(request.handed_off_at),
|
||||
"cancelled_at": response_datetime(request.cancelled_at),
|
||||
"cancellation_notice_until": response_datetime(
|
||||
request.cancellation_notice_until
|
||||
),
|
||||
"created_at": response_datetime(request.created_at),
|
||||
"updated_at": response_datetime(request.updated_at),
|
||||
"metadata": (request.metadata_ or {}) if full_roster else {},
|
||||
|
||||
Reference in New Issue
Block a user