feat(scheduling): bound public cancellation notices
This commit is contained in:
35
README.md
35
README.md
@@ -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 1–90 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user