From 58619484b6d012241c08aac09408eb610ba8a6f9 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 03:17:51 +0200 Subject: [PATCH] feat(scheduling): bound public cancellation notices --- README.md | 35 +++--- src/govoplan_scheduling/backend/schemas.py | 4 + src/govoplan_scheduling/backend/service.py | 87 +++++++++++++-- tests/test_response_editing.py | 100 ++++++++++++++++++ webui/src/api/scheduling.ts | 4 + .../scheduling/SchedulingPublicPage.tsx | 13 ++- webui/src/i18n/generatedTranslations.ts | 4 + 7 files changed, 217 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d3ea533..d3a286e 100644 --- a/README.md +++ b/README.md @@ -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 1–90 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 diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index ab6b59e..52ee77c 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -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 diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 4620c26..06a38d5 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -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 {}, diff --git a/tests/test_response_editing.py b/tests/test_response_editing.py index 1cdb9e9..b4fb6c5 100644 --- a/tests/test_response_editing.py +++ b/tests/test_response_editing.py @@ -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( diff --git a/webui/src/api/scheduling.ts b/webui/src/api/scheduling.ts index 73fa193..2339dc8 100644 --- a/webui/src/api/scheduling.ts +++ b/webui/src/api/scheduling.ts @@ -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; @@ -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; diff --git a/webui/src/features/scheduling/SchedulingPublicPage.tsx b/webui/src/features/scheduling/SchedulingPublicPage.tsx index 7d782a6..986c1c1 100644 --- a/webui/src/features/scheduling/SchedulingPublicPage.tsx +++ b/webui/src/features/scheduling/SchedulingPublicPage.tsx @@ -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 && <>
i18n:govoplan-scheduling.location.d219c681
{response.location}
} {response.deadline_at && <>
{I18N.deadline}
{formatDateTime(response.deadline_at)}
} - {!collecting && {I18N.noLongerOpen}} + {response.cancellation_notice_only + ? {I18N.cancelled} + : !collecting && {I18N.noLongerOpen}} {response.has_response && collecting && {I18N.alreadySubmitted}} + {response.cancellation_notice_only && response.cancellation_notice_until && ( +

{I18N.cancellationNoticeUntil}: {formatDateTime(response.cancellation_notice_until)}

+ )} {error && {error}} {success && {success}} - + {!response.cancellation_notice_only &&
{response.slots.map((slot) => (
@@ -259,7 +266,7 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
)} -
+
} )} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 1b00b01..d08a61c 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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.",