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

View File

@@ -316,6 +316,7 @@ class SchedulingRequestResponse(BaseModel):
calendar_event_id: str | None = None calendar_event_id: str | None = None
handed_off_at: datetime | None = None handed_off_at: datetime | None = None
cancelled_at: datetime | None = None cancelled_at: datetime | None = None
cancellation_notice_until: datetime | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
@@ -423,6 +424,9 @@ class SchedulingPublicParticipationResponse(BaseModel):
timezone: str timezone: str
status: str status: str
deadline_at: datetime | None = None 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 participant_email_required: bool
anonymous_password_required: bool anonymous_password_required: bool
single_choice: bool single_choice: bool

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from functools import lru_cache from functools import lru_cache
from typing import Any, cast from typing import Any, cast
@@ -160,6 +160,19 @@ def _now() -> datetime:
return utcnow() 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: 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})" 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: if request is None:
raise SchedulingPublicParticipationError() 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: if lock_for_submission:
# Scheduling mutations acquire their request/participant locks before # Scheduling mutations acquire their request/participant locks before
# entering Poll. Public response submission must use the same order: # entering Poll. Public response submission must use the same order:
@@ -1717,11 +1735,11 @@ def _resolve_public_scheduling_participation(
raise SchedulingPublicParticipationError( raise SchedulingPublicParticipationError(
retry_after_seconds=decision.retry_after_seconds, retry_after_seconds=decision.retry_after_seconds,
) )
supplied_password = ( if payload.password is None:
payload.password.get_secret_value() # The UI first probes whether the link needs credentials. Missing
if payload.password is not None # credentials are not a failed password attempt.
else "" raise SchedulingPublicParticipationError()
) supplied_password = payload.password.get_secret_value()
if not verify_participant_password( if not verify_participant_password(
supplied_password, supplied_password,
request.anonymous_password_hash, request.anonymous_password_hash,
@@ -1791,6 +1809,27 @@ def _public_scheduling_participation_response(
*, *,
response: PollGovernedResponseRef | None = None, response: PollGovernedResponseRef | None = None,
) -> dict[str, Any]: ) -> 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 current_response = response or context.response
slot_by_option_id = { slot_by_option_id = {
slot.poll_option_id: slot slot.poll_option_id: slot
@@ -1823,6 +1862,9 @@ def _public_scheduling_participation_response(
"timezone": request.timezone, "timezone": request.timezone,
"status": request.status, "status": request.status,
"deadline_at": response_datetime(request.deadline_at), "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, "participant_email_required": context.policy.participant_email_required,
"anonymous_password_required": context.policy.anonymous_password_required, "anonymous_password_required": context.policy.anonymous_password_required,
"single_choice": context.policy.single_choice, "single_choice": context.policy.single_choice,
@@ -2142,6 +2184,7 @@ def _update_scheduling_request_with_invitation_tokens(
request=request, request=request,
participant=participant, participant=participant,
invitation_id=previous_invitation_id, invitation_id=previous_invitation_id,
expires_at=request.deadline_at,
) )
if ( if (
not request.allow_external_participants not request.allow_external_participants
@@ -2627,6 +2670,11 @@ def _new_public_invitation_expiry(
expiry on top of this lifecycle helper. 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) deadline = response_datetime(request.deadline_at)
return deadline if deadline is not None and deadline > _now() else None 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" "Notification delivery is unavailable; copy the link instead"
) )
invitation_expiry = _new_public_invitation_expiry(request)
previous_invitation_id = participant.poll_invitation_id previous_invitation_id = participant.poll_invitation_id
try: try:
if previous_invitation_id is not None: if previous_invitation_id is not None:
@@ -2704,7 +2753,7 @@ def issue_scheduling_participant_invitation(
respondent_id=_stable_participant_respondent_id(participant), 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=_new_public_invitation_expiry(request), expires_at=invitation_expiry,
metadata={ metadata={
"scheduling_request_id": request.id, "scheduling_request_id": request.id,
"scheduling_participant_id": participant.id, "scheduling_participant_id": participant.id,
@@ -2813,8 +2862,9 @@ def _update_participant_invitation_expiry(
request: SchedulingRequest, request: SchedulingRequest,
participant: SchedulingParticipant, participant: SchedulingParticipant,
invitation_id: str, invitation_id: str,
expires_at: datetime | None,
) -> 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 Poll owns invitation rows and performs the owner- and gateway-bound expiry
mutation under Poll -> invitation row locks. The invitation identity and mutation under Poll -> invitation row locks. The invitation identity and
@@ -2836,7 +2886,7 @@ def _update_participant_invitation_expiry(
poll_id=request.poll_id, poll_id=request.poll_id,
invitation_id=invitation_id, invitation_id=invitation_id,
gateway=_public_participation_gateway(request.id), gateway=_public_participation_gateway(request.id),
expires_at=request.deadline_at, expires_at=expires_at,
) )
except PollCapabilityError as exc: except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from 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( raise SchedulingError(
"Only draft, collecting, or closed scheduling requests can be cancelled" "Only draft, collecting, or closed scheduling requests can be cancelled"
) )
cancelled_at = _now()
request.status = "cancelled" 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: if request.poll_id is not None:
try: try:
provider = _poll_provider() provider = _poll_provider()
@@ -3443,6 +3507,9 @@ def scheduling_request_response(
"calendar_event_id": request.calendar_event_id if full_roster else None, "calendar_event_id": request.calendar_event_id if full_roster else None,
"handed_off_at": response_datetime(request.handed_off_at), "handed_off_at": response_datetime(request.handed_off_at),
"cancelled_at": response_datetime(request.cancelled_at), "cancelled_at": response_datetime(request.cancelled_at),
"cancellation_notice_until": response_datetime(
request.cancellation_notice_until
),
"created_at": response_datetime(request.created_at), "created_at": response_datetime(request.created_at),
"updated_at": response_datetime(request.updated_at), "updated_at": response_datetime(request.updated_at),
"metadata": (request.metadata_ or {}) if full_roster else {}, "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 ( from govoplan_poll.backend.db.models import (
Poll, Poll,
PollInvitation, PollInvitation,
PollLifecycleTransition,
PollOption, PollOption,
PollParticipationSubmission, PollParticipationSubmission,
PollResponse, PollResponse,
@@ -84,6 +85,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
PollResponse.__table__, PollResponse.__table__,
PollInvitation.__table__, PollInvitation.__table__,
PollParticipationSubmission.__table__, PollParticipationSubmission.__table__,
PollLifecycleTransition.__table__,
SchedulingRequest.__table__, SchedulingRequest.__table__,
SchedulingCandidateSlot.__table__, SchedulingCandidateSlot.__table__,
SchedulingParticipant.__table__, SchedulingParticipant.__table__,
@@ -104,6 +106,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
SchedulingRequest.__table__, SchedulingRequest.__table__,
PollParticipationSubmission.__table__, PollParticipationSubmission.__table__,
PollInvitation.__table__, PollInvitation.__table__,
PollLifecycleTransition.__table__,
PollResponse.__table__, PollResponse.__table__,
PollOption.__table__, PollOption.__table__,
Poll.__table__, Poll.__table__,
@@ -600,6 +603,17 @@ class SchedulingResponseEditingTests(unittest.TestCase):
token = self._issue_copy(request, request.participants[0]) token = self._issue_copy(request, request.participants[0])
wrong = SchedulingPublicParticipationAccessRequest(password="wrong password") 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): for attempt in range(10):
with self.assertRaises(SchedulingPublicParticipationError) as raised: with self.assertRaises(SchedulingPublicParticipationError) as raised:
get_public_scheduling_participation( get_public_scheduling_participation(
@@ -1322,6 +1336,92 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertIsNotNone(cancelled.cancelled_at) self.assertIsNotNone(cancelled.cancelled_at)
self.assertEqual(poll.status, "draft") 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: def test_fully_invalidated_response_becomes_unanswered(self) -> None:
request = self._request() request = self._request()
alice = self._principal( alice = self._principal(

View File

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

View File

@@ -33,6 +33,8 @@ const I18N = {
available: "i18n:govoplan-scheduling.available.7c62a142", available: "i18n:govoplan-scheduling.available.7c62a142",
backToScheduling: "i18n:govoplan-scheduling.open_in_scheduling.48df1541", backToScheduling: "i18n:govoplan-scheduling.open_in_scheduling.48df1541",
comment: "i18n:govoplan-scheduling.comment.d03495b1", 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", deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa",
email: "i18n:govoplan-scheduling.participant_email.2cadfd9e", email: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
invalidAccess: "i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197", 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.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></>} {response.deadline_at && <><dt>{I18N.deadline}</dt><dd>{formatDateTime(response.deadline_at)}</dd></>}
</dl> </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.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> </Card>
{error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success">{success}</DismissibleAlert>} {success && <DismissibleAlert tone="success">{success}</DismissibleAlert>}
<Card title={I18N.response}> {!response.cancellation_notice_only && <Card title={I18N.response}>
<div className="scheduling-public-slots"> <div className="scheduling-public-slots">
{response.slots.map((slot) => ( {response.slots.map((slot) => (
<fieldset className="scheduling-public-slot" key={slot.id} disabled={!collecting || saving}> <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> <Button type="submit" variant="primary" disabled={saving}>{I18N.submit}</Button>
</div> </div>
)} )}
</Card> </Card>}
</form> </form>
)} )}
</LoadingFrame> </LoadingFrame>

View File

@@ -1,12 +1,14 @@
export const generatedTranslations = { export const generatedTranslations = {
en: { en: {
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details", "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.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.loading_scheduling_request.43c39c1b": "Loading scheduling request…",
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "Open in Scheduling", "i18n:govoplan-scheduling.open_in_scheduling.48df1541": "Open in Scheduling",
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Open scheduling request", "i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Open scheduling request",
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Response deadline", "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_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.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.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.", "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: { de: {
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten", "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.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.loading_scheduling_request.43c39c1b": "Terminanfrage wird geladen …",
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "In der Terminplanung öffnen", "i18n:govoplan-scheduling.open_in_scheduling.48df1541": "In der Terminplanung öffnen",
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Terminanfrage öffnen", "i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Terminanfrage öffnen",
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Antwortfrist", "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_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.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.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.", "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.",