feat(scheduling): bound public cancellation notices

This commit is contained in:
2026-07-22 03:17:51 +02:00
parent ea2f721377
commit 58619484b6
7 changed files with 217 additions and 30 deletions

View File

@@ -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 {},