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

@@ -131,9 +131,9 @@ poll-backed scheduling requests:
- a first Scheduling WebUI package with request creation, slot matrix, Calendar
actions, decisions, and notification-job creation
The next slices should add real notification delivery workers, richer public
participant pages, Calendar hold cleanup after decision, and advanced scoring
constraints such as required participants and quorum rules.
The next slices should add generic self-enrolment links after their abuse and
identity policy is agreed, Calendar hold cleanup after decision, and advanced
scoring constraints such as required participants and quorum rules.
The active backlog lives in Gitea issues.
@@ -168,11 +168,14 @@ same link usable again, and clearing the deadline removes its expiry. No raw
replacement token crosses the PATCH response, and existing responses plus
participant status remain attached to the same durable respondent identity.
Open lifecycle decision: cancellation closes the backing Poll, so submission
fails, but an otherwise valid invitation can still resolve the reduced public
view and show that the request was cancelled. Decide whether cancellation
should revoke links immediately or retain that acknowledgement view for a
bounded period; links without a deadline would otherwise remain readable.
Cancellation closes the backing Poll, so submission fails, while active links
remain usable as a reduced cancellation notice for a bounded period. The
deployment setting `SCHEDULING_CANCELLATION_NOTICE_DAYS` defaults to 30 and is
bounded to 190 days. Cancellation transactionally aligns governed invitation
expiry with that timestamp. The public projection contains only the request
title and cancellation timestamps; descriptions, locations, candidate slots,
comments, and previous answers are omitted. After the bound, access fails with
the same generic response as an invalid or expired link.
If the governed capability is absent, API responses advertise that policy
enforcement is unavailable and restricted links fail closed. Plaintext
@@ -187,15 +190,13 @@ projection; participants retain candidate-slot revisions, their own marker and
email, response settings, aggregates, and any roster names/statuses permitted
by the configured privacy policy.
The WebUI package exposes typed clients for the public access and submission
endpoints. A signed-out browser page cannot yet be registered by a module:
Core's `App` renders `PublicLandingPage` directly whenever `auth` is absent and
only mounts module route contributions inside the authenticated branch. Until
Core gains an explicit, allowlisted `publicRoutes` contract, notification action
URLs under `/scheduling/public/{request}/{token}` must be treated as a blocked
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
shell boundary is unresolved.
The WebUI package registers `/scheduling/public/{request}/{token}` through
Core's explicit, backend-allowlisted public-route contract. The guest page uses
the shared UI components, prompts for email/password only when needed, prefills
an existing response, and enforces the snapshotted response rules. The token
stays in the path and is never copied into query parameters or browser storage.
Signed-in users also receive an in-app deep link without weakening the signed
guest-link boundary.
Creating, editing, and opening a request never issue public tokens or enqueue
invitation delivery. The deprecated `create_participant_invitations` request

View File

@@ -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

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

View File

@@ -19,6 +19,7 @@ from govoplan_core.db.base import Base
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollOption,
PollParticipationSubmission,
PollResponse,
@@ -84,6 +85,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
PollResponse.__table__,
PollInvitation.__table__,
PollParticipationSubmission.__table__,
PollLifecycleTransition.__table__,
SchedulingRequest.__table__,
SchedulingCandidateSlot.__table__,
SchedulingParticipant.__table__,
@@ -104,6 +106,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
SchedulingRequest.__table__,
PollParticipationSubmission.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
PollResponse.__table__,
PollOption.__table__,
Poll.__table__,
@@ -600,6 +603,17 @@ class SchedulingResponseEditingTests(unittest.TestCase):
token = self._issue_copy(request, request.participants[0])
wrong = SchedulingPublicParticipationAccessRequest(password="wrong password")
for _attempt in range(20):
with self.assertRaises(SchedulingPublicParticipationError) as missing:
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="192.0.2.20",
)
self.assertEqual(missing.exception.retry_after_seconds, 0)
for attempt in range(10):
with self.assertRaises(SchedulingPublicParticipationError) as raised:
get_public_scheduling_participation(
@@ -1322,6 +1336,92 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertIsNotNone(cancelled.cancelled_at)
self.assertEqual(poll.status, "draft")
def test_cancellation_link_becomes_bounded_notice_without_request_details(self) -> None:
request, tokens = self._request_and_tokens()
participant = request.participants[0]
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == participant.poll_invitation_id
).one()
token = tokens[participant.id]
cancelled_at = datetime(2026, 7, 22, 12, tzinfo=timezone.utc)
with (
patch.object(scheduling_service, "_now", return_value=cancelled_at),
patch.object(
scheduling_service,
"get_settings",
return_value=SimpleNamespace(
scheduling_cancellation_notice_days=7
),
),
):
cancelled = cancel_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
)
notice_until = cancelled_at + timedelta(days=7)
self.assertEqual(
scheduling_service.response_datetime(
cancelled.cancellation_notice_until
),
notice_until,
)
self.assertEqual(
scheduling_service.response_datetime(invitation.expires_at),
notice_until,
)
with patch.object(
scheduling_service,
"_now",
return_value=cancelled_at + timedelta(days=1),
):
notice = get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="192.0.2.30",
)
self.assertTrue(notice["cancellation_notice_only"])
self.assertEqual(notice["status"], "cancelled")
for private_field in (
"description",
"location",
"deadline_at",
"comment",
"slots",
"answers",
):
self.assertNotIn(private_field, notice)
with patch.object(
scheduling_service,
"_now",
return_value=notice_until + timedelta(seconds=1),
):
with self.assertRaises(SchedulingPublicParticipationError):
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="192.0.2.30",
)
with self.assertRaisesRegex(
SchedulingError,
"cancellation notice has expired",
):
issue_scheduling_participant_invitation(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
participant_id=participant.id,
action="copy",
)
def test_fully_invalidated_response_becomes_unanswered(self) -> None:
request = self._request()
alice = self._principal(

View File

@@ -91,6 +91,7 @@ export type SchedulingRequest = {
calendar_event_id?: string | null;
handed_off_at?: string | null;
cancelled_at?: string | null;
cancellation_notice_until?: string | null;
created_at: string;
updated_at: string;
metadata?: Record<string, unknown>;
@@ -228,6 +229,9 @@ export type SchedulingPublicParticipationResponse = {
timezone: string;
status: SchedulingStatus;
deadline_at?: string | null;
cancelled_at?: string | null;
cancellation_notice_until?: string | null;
cancellation_notice_only: boolean;
participant_email_required: boolean;
anonymous_password_required: boolean;
single_choice: boolean;

View File

@@ -33,6 +33,8 @@ const I18N = {
available: "i18n:govoplan-scheduling.available.7c62a142",
backToScheduling: "i18n:govoplan-scheduling.open_in_scheduling.48df1541",
comment: "i18n:govoplan-scheduling.comment.d03495b1",
cancelled: "i18n:govoplan-scheduling.this_scheduling_request_was_cancelled.1af3c85e",
cancellationNoticeUntil: "i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6",
deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa",
email: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
invalidAccess: "i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197",
@@ -201,14 +203,19 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
{response.location && <><dt>i18n:govoplan-scheduling.location.d219c681</dt><dd>{response.location}</dd></>}
{response.deadline_at && <><dt>{I18N.deadline}</dt><dd>{formatDateTime(response.deadline_at)}</dd></>}
</dl>
{!collecting && <DismissibleAlert tone="info" dismissible={false}>{I18N.noLongerOpen}</DismissibleAlert>}
{response.cancellation_notice_only
? <DismissibleAlert tone="warning" dismissible={false}>{I18N.cancelled}</DismissibleAlert>
: !collecting && <DismissibleAlert tone="info" dismissible={false}>{I18N.noLongerOpen}</DismissibleAlert>}
{response.has_response && collecting && <DismissibleAlert tone="info" dismissible={false}>{I18N.alreadySubmitted}</DismissibleAlert>}
{response.cancellation_notice_only && response.cancellation_notice_until && (
<p className="muted">{I18N.cancellationNoticeUntil}: {formatDateTime(response.cancellation_notice_until)}</p>
)}
</Card>
{error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success">{success}</DismissibleAlert>}
<Card title={I18N.response}>
{!response.cancellation_notice_only && <Card title={I18N.response}>
<div className="scheduling-public-slots">
{response.slots.map((slot) => (
<fieldset className="scheduling-public-slot" key={slot.id} disabled={!collecting || saving}>
@@ -259,7 +266,7 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
<Button type="submit" variant="primary" disabled={saving}>{I18N.submit}</Button>
</div>
)}
</Card>
</Card>}
</form>
)}
</LoadingFrame>

View File

@@ -1,12 +1,14 @@
export const generatedTranslations = {
en: {
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Cancellation notice available until",
"i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c": "Enter the details supplied with the invitation. For privacy, invalid and expired links use the same response.",
"i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b": "Loading scheduling request…",
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "Open in Scheduling",
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Open scheduling request",
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Response deadline",
"i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197": "This scheduling link is invalid, expired, or the access details do not match.",
"i18n:govoplan-scheduling.this_scheduling_request_was_cancelled.1af3c85e": "This scheduling request was cancelled.",
"i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a": "This scheduling request is no longer accepting responses.",
"i18n:govoplan-scheduling.your_availability.f86c8215": "Your availability",
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Save or discard your unsent availability changes before leaving.",
@@ -153,12 +155,14 @@ export const generatedTranslations = {
},
de: {
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",
"i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c": "Geben Sie die mit der Einladung übermittelten Daten ein. Aus Datenschutzgründen wird für ungültige und abgelaufene Links dieselbe Meldung angezeigt.",
"i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b": "Terminanfrage wird geladen …",
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "In der Terminplanung öffnen",
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Terminanfrage öffnen",
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Antwortfrist",
"i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197": "Dieser Terminlink ist ungültig oder abgelaufen, oder die Zugangsdaten stimmen nicht überein.",
"i18n:govoplan-scheduling.this_scheduling_request_was_cancelled.1af3c85e": "Diese Terminanfrage wurde storniert.",
"i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a": "Diese Terminanfrage nimmt keine Antworten mehr an.",
"i18n:govoplan-scheduling.your_availability.f86c8215": "Ihre Verfügbarkeit",
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Speichern oder verwerfen Sie Ihre noch nicht gesendeten Verfügbarkeitsänderungen, bevor Sie die Ansicht verlassen.",