Compare commits
43 Commits
2f1b7fb6b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b029c8edf | |||
| b9f557e95b | |||
| 60b01e5a16 | |||
| ffb30a7aa3 | |||
| b328df67a3 | |||
| fc356e22c6 | |||
| f512784dd3 | |||
| ed39f83688 | |||
| 95c9f654e1 | |||
| 55447bd05c | |||
| 27fa24cf4d | |||
| 448546e487 | |||
| 835ad82916 | |||
| a316341226 | |||
| 58619484b6 | |||
| ea2f721377 | |||
| 2cb86c90dc | |||
| ed828685f6 | |||
| 4279ea2827 | |||
| 95928e0585 | |||
| 658dcca9d6 | |||
| 2f6d46c06e | |||
| 82b158c170 | |||
| b1725b8f59 | |||
| 886579942f | |||
| 0452100242 | |||
| 74e4508b8f | |||
| bb7cea03df | |||
| 770ecf8786 | |||
| 98b797a95d | |||
| 4ddac65367 | |||
| 2570192e06 | |||
| 297e3ad96e | |||
| 705ac823ce | |||
| 6141f39bf8 | |||
| d0b95eee58 | |||
| bbf503a7d7 | |||
| 0f33e25c83 | |||
| 6f298c36fe | |||
| ec538f524d | |||
| 887e064ae9 | |||
| fc418e63ac | |||
| de7e68c97a |
169
README.md
169
README.md
@@ -82,10 +82,12 @@ uses Poll context fields to point back to its request or proposal resource.
|
||||
Typical workflow steps are collect availability, rank candidates, decide, notify
|
||||
participants, and hand off to Calendar or Appointments.
|
||||
|
||||
The manifest declares `access` and `evaluation` as optional dependencies.
|
||||
Scheduling may use Access for identity, groups, and permissions, and may trigger
|
||||
post-event or post-appointment feedback through Evaluation. It must not require
|
||||
either module just to find a meeting time.
|
||||
The manifest declares `access`, `addresses`, and `evaluation` as optional
|
||||
dependencies. Scheduling uses Core's principal-aware people-search boundary to
|
||||
combine only the account and contact records visible to the current organizer;
|
||||
it never calls the instance-wide Identity search. It may trigger post-event or
|
||||
post-appointment feedback through Evaluation, and must not require any of these
|
||||
optional modules just to find a meeting time.
|
||||
|
||||
## Expected Integrations
|
||||
|
||||
@@ -98,6 +100,22 @@ either module just to find a meeting time.
|
||||
- `govoplan-portal`: external participant scheduling flows
|
||||
- `govoplan-workflow` and `govoplan-tasks`: follow-up work after a time is selected
|
||||
|
||||
## Participant selection boundary
|
||||
|
||||
The editor uses Core's shared `PeoplePicker`. Its server-side search aggregates
|
||||
the optional `access.people_search` and `addresses.people_search` capabilities,
|
||||
and each provider applies the active principal and tenant visibility rules
|
||||
before returning a candidate. Scheduling exposes only the fields needed to
|
||||
select a person; provider provenance, address-book topology, group membership,
|
||||
and other internals are not returned by its picker endpoint.
|
||||
|
||||
An account selection becomes an internal participant bound to that account.
|
||||
A visible address-book contact becomes an external participant with a bounded
|
||||
directory-selection reference and revision for later organizer editing. Manual
|
||||
name-and-email entry is available only while the request allows external
|
||||
participants. The picker stores neither provider-internal provenance nor a
|
||||
global Identity record reference in participant metadata.
|
||||
|
||||
## First Package Scaffold Decision
|
||||
|
||||
The first backend implementation slice adds runtime APIs and storage around
|
||||
@@ -109,12 +127,147 @@ poll-backed scheduling requests:
|
||||
- request lifecycle APIs: draft, collecting, closed, decided, handed off, cancelled
|
||||
- result summaries sourced from Poll response aggregation
|
||||
- optional Calendar free/busy checks, tentative holds, and final event creation
|
||||
- notification outbox jobs for invitations, reminders, decisions, and cancellations
|
||||
- notification outbox jobs for invitations, reminders, decisions,
|
||||
cancellations, and participant access changes
|
||||
- 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.
|
||||
|
||||
## Signed-participation policy gate
|
||||
|
||||
Scheduling persists the response policy and snapshots it onto each governed
|
||||
Poll invitation. Public access uses
|
||||
`/scheduling/public/{request_id}/{token}`; Poll's ordinary public routes reject
|
||||
these gateway-bound tokens, so callers cannot bypass Scheduling's password and
|
||||
request checks. Passwords are write-only, stored only as salted PBKDF2 hashes,
|
||||
and failed checks are throttled through Redis when configured with a bounded
|
||||
in-process fallback.
|
||||
|
||||
Poll is the transactional authority for response rules. Its governed submission
|
||||
holds the Poll-row lock while enforcing single choice, `Maybe`, participant
|
||||
email, comments, idempotency and per-option capacity. Scheduling then updates
|
||||
its participant projection in the same database transaction. Candidate-slot
|
||||
add/remove and participant-link revocation also go through the governed Poll
|
||||
capability; exact retries are idempotent, and option mutations invalidate only
|
||||
the affected answers.
|
||||
|
||||
Public submissions lock the Scheduling request and participant rows before
|
||||
entering Poll, matching organizer edit lock order and making first-use email
|
||||
binding atomic. Both authenticated and public submission paths independently
|
||||
require the Scheduling request to be collecting and its deadline not to have
|
||||
passed, so a stale or incorrectly reopened Poll projection cannot accept a
|
||||
response. Changing a request deadline transactionally updates each governed
|
||||
invitation's expiry in Poll under owner- and gateway-bound row locks. The same
|
||||
link and invitation identity survive future, past, extended, and cleared
|
||||
deadlines: a past deadline makes the link unusable, a later extension makes the
|
||||
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.
|
||||
|
||||
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
|
||||
passwords, token hashes, response-gateway internals and the participant roster
|
||||
are not exposed by the public response model.
|
||||
|
||||
Authenticated participant list/detail projections likewise omit tenant,
|
||||
organizer, Poll and Calendar identifiers, connector metadata, invitation and
|
||||
identity bindings. Free/busy conflicts are reduced to useful time/status
|
||||
fields. Organizers and scheduling administrators retain the full management
|
||||
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 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
|
||||
field remains accepted for compatibility, defaults to `false`, and has no side
|
||||
effect. Authenticated in-module responses can still lazily create a
|
||||
gateway-bound invitation whose token is discarded.
|
||||
|
||||
Organizers and Scheduling administrators use the participant-specific
|
||||
invitation action instead:
|
||||
|
||||
- `POST /scheduling/requests/{request_id}/participants/{participant_id}/invitation`
|
||||
with `{"action":"copy","participant_revision":"..."}` rotates the previous
|
||||
invitation and returns the new relative action URL once. The response is
|
||||
marked `no-store`.
|
||||
- The same endpoint with `{"action":"send"}` rotates the invitation and passes
|
||||
its URL directly to the notification dispatch job; it also requires the
|
||||
current `participant_revision`. Neither the API response nor Scheduling's
|
||||
durable notification projection contains the token.
|
||||
- `DELETE` on the same resource uses a JSON body containing the current
|
||||
`participant_revision` and revokes the active link immediately. A revoke
|
||||
against the refreshed no-link projection is an idempotent replay.
|
||||
|
||||
The semantic participant revision includes the current invitation identity.
|
||||
It is checked after the participant row is locked, so stale copy, send, and
|
||||
revoke commands return `409` before rotating a newer link or delivering to a
|
||||
changed recipient.
|
||||
|
||||
The participant DataGrid presents copy, send, and revoke as a fixed icon-only
|
||||
action group. Authorized but unavailable actions remain visible and explain
|
||||
why they are disabled: for example, delivery is disabled without a dispatch
|
||||
provider or recipient target, and revoke is disabled when no active link
|
||||
exists. The delivery capability is exposed only in management projections.
|
||||
Copy accepts only the same-origin Scheduling public path and does not persist
|
||||
the bearer URL in component state, logs, or browser storage.
|
||||
|
||||
Links can be issued in any request state. Collection state and deadline checks
|
||||
remain independent submission requirements, so a link to a draft, closed, or
|
||||
decided request is read-only. Issue, copy, send-request, and revoke actions are
|
||||
audited with request and participant identifiers but never a bearer token.
|
||||
Only an organizer (under the ordinary Scheduling write policy) or a tenant-wide
|
||||
Scheduling administrator can use these actions. If notification delivery is
|
||||
not installed, `send` fails before rotating the current link and the organizer
|
||||
can use `copy` for separate distribution.
|
||||
|
||||
Participant edits carry a semantic revision so a stale organizer form cannot
|
||||
overwrite an invitation or response change. Corrections that retain a stable
|
||||
account or directory identity update the existing participant. A display-name
|
||||
correction keeps its invitation; changing a delivery email revokes the stale
|
||||
link but keeps a response tied to the unchanged account identity.
|
||||
|
||||
Changing the canonical identity creates a new participant instead of assigning
|
||||
the former participant's response to another person. In the same transaction,
|
||||
Scheduling revokes the old invitation and Poll soft-deletes every matching
|
||||
live response. Poll retains the answers and a bounded retirement record for
|
||||
audit, while result summaries and capacity checks immediately exclude them.
|
||||
Removing an invited or responding participant follows the same retirement
|
||||
path. Scheduling records privacy-safe audit facts and queues a removal or
|
||||
replacement notice to the former recipient without placing email addresses or
|
||||
response contents in the audit event.
|
||||
|
||||
## FieldLabel omission register
|
||||
|
||||
Scheduling uses the shared `FieldLabel` for form controls that need explanatory
|
||||
inline help. The only intentional omissions are:
|
||||
|
||||
- Candidate-slot and participant DataGrid cells: column headers supply the
|
||||
visible label and each interactive cell has an accessible name.
|
||||
- Per-slot availability selects: the enclosing visible slot/date text is the
|
||||
native label for that individual choice.
|
||||
|
||||
There are no other authorized Scheduling omissions. Inline help remains
|
||||
controlled by the user's shared interface setting; hiding it does not remove
|
||||
accessible labels.
|
||||
|
||||
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-scheduling"
|
||||
version = "0.1.8"
|
||||
version = "0.1.11"
|
||||
description = "GovOPlaN meeting scheduling and Terminfindung module seed."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-poll>=0.1.8",
|
||||
"govoplan-core>=0.1.11",
|
||||
"govoplan-poll>=0.1.11",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "0.1.11"
|
||||
|
||||
@@ -36,6 +36,15 @@ class SchedulingRequest(Base, TimestampMixin):
|
||||
allow_external_participants: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
|
||||
participant_visibility: Mapped[str] = mapped_column(String(32), default="aggregates_only", nullable=False)
|
||||
notify_on_answers: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
single_choice: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
max_participants_per_option: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
allow_maybe: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_comments: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
participant_email_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
anonymous_password_protection_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
anonymous_password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
@@ -44,6 +53,7 @@ class SchedulingRequest(Base, TimestampMixin):
|
||||
calendar_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
handed_off_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cancellation_notice_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
@@ -105,8 +115,10 @@ class SchedulingParticipant(Base, TimestampMixin):
|
||||
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), default="invited", nullable=False, index=True)
|
||||
poll_invitation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
participation_gateway: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
last_invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
response_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_SCHEDULING
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
@@ -14,14 +16,23 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
PublicFrontendRoute,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.people import (
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||
)
|
||||
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
|
||||
from govoplan_core.core.poll_participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
|
||||
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
|
||||
|
||||
MODULE_ID = "scheduling"
|
||||
MODULE_NAME = "Scheduling"
|
||||
MODULE_VERSION = "0.1.8"
|
||||
MODULE_VERSION = "0.1.11"
|
||||
READ_SCOPE = "scheduling:schedule:read"
|
||||
WRITE_SCOPE = "scheduling:schedule:write"
|
||||
ADMIN_SCOPE = "scheduling:schedule:admin"
|
||||
@@ -44,8 +55,8 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View scheduling", "Read scheduling polls, proposals, participant state, and selected outcomes."),
|
||||
_permission(WRITE_SCOPE, "Manage scheduling", "Create and update scheduling polls, candidate slots, reminders, and decision handoff."),
|
||||
_permission(ADMIN_SCOPE, "Administer scheduling", "Configure tenant-level scheduling policies, external participation, and retention defaults."),
|
||||
_permission(WRITE_SCOPE, "Manage own scheduling", "Create scheduling polls and manage requests for which the account is the organizer."),
|
||||
_permission(ADMIN_SCOPE, "Administer scheduling", "Manage every tenant scheduling request and configure scheduling policies, external participation, and retention defaults."),
|
||||
_permission(RESPOND_SCOPE, "Respond to scheduling polls", "Submit and update own scheduling availability responses."),
|
||||
)
|
||||
|
||||
@@ -53,7 +64,7 @@ ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="scheduling_manager",
|
||||
name="Scheduling manager",
|
||||
description="Create scheduling polls, manage candidate slots, and decide outcomes.",
|
||||
description="Create scheduling polls and manage candidate slots and outcomes for requests the account organizes.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE, RESPOND_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
@@ -127,19 +138,34 @@ manifest = ModuleManifest(
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("poll",),
|
||||
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
|
||||
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "policy", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_CALENDAR_SCHEDULING,
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||
),
|
||||
required_capabilities=(
|
||||
CAPABILITY_POLL_SCHEDULING,
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
|
||||
ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.8"),
|
||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.8", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.8", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="poll.option_ordering", version_min="0.1.11", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.11", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.11", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.11", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.11", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
@@ -147,7 +173,31 @@ manifest = ModuleManifest(
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/scheduling-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/scheduling",
|
||||
component="SchedulingPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=56,
|
||||
),
|
||||
),
|
||||
public_routes=(
|
||||
PublicFrontendRoute(
|
||||
path="/scheduling/public/:requestId/:token",
|
||||
component="SchedulingPublicPage",
|
||||
order=10,
|
||||
),
|
||||
),
|
||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="scheduling.widget.open-requests",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Scheduling requests widget",
|
||||
order=45,
|
||||
),
|
||||
),
|
||||
),
|
||||
route_factory=_scheduling_router,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
|
||||
@@ -16,7 +16,171 @@ branch_labels = None
|
||||
depends_on = "2a3b4c5d6e7f"
|
||||
|
||||
|
||||
_BASELINE_COLUMNS = {
|
||||
"scheduling_requests": {
|
||||
"id",
|
||||
"tenant_id",
|
||||
"title",
|
||||
"description",
|
||||
"location",
|
||||
"timezone",
|
||||
"status",
|
||||
"poll_id",
|
||||
"selected_slot_id",
|
||||
"organizer_user_id",
|
||||
"deadline_at",
|
||||
"allow_external_participants",
|
||||
"allow_participant_updates",
|
||||
"result_visibility",
|
||||
"calendar_integration_enabled",
|
||||
"calendar_id",
|
||||
"calendar_freebusy_enabled",
|
||||
"calendar_hold_enabled",
|
||||
"create_calendar_event_on_decision",
|
||||
"calendar_event_id",
|
||||
"handed_off_at",
|
||||
"cancelled_at",
|
||||
"deleted_at",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
},
|
||||
"scheduling_candidate_slots": {
|
||||
"id",
|
||||
"tenant_id",
|
||||
"request_id",
|
||||
"poll_option_id",
|
||||
"label",
|
||||
"description",
|
||||
"start_at",
|
||||
"end_at",
|
||||
"timezone",
|
||||
"location",
|
||||
"position",
|
||||
"deleted_at",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
},
|
||||
"scheduling_participants": {
|
||||
"id",
|
||||
"tenant_id",
|
||||
"request_id",
|
||||
"respondent_id",
|
||||
"display_name",
|
||||
"email",
|
||||
"participant_type",
|
||||
"required",
|
||||
"status",
|
||||
"poll_invitation_id",
|
||||
"last_invited_at",
|
||||
"responded_at",
|
||||
"deleted_at",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
},
|
||||
}
|
||||
|
||||
_BASELINE_INDEXES = {
|
||||
"scheduling_requests": {
|
||||
"ix_scheduling_requests_calendar_event_id",
|
||||
"ix_scheduling_requests_calendar_id",
|
||||
"ix_scheduling_requests_deadline",
|
||||
"ix_scheduling_requests_deadline_at",
|
||||
"ix_scheduling_requests_deleted_at",
|
||||
"ix_scheduling_requests_organizer_user_id",
|
||||
"ix_scheduling_requests_poll_id",
|
||||
"ix_scheduling_requests_selected_slot_id",
|
||||
"ix_scheduling_requests_status",
|
||||
"ix_scheduling_requests_tenant_id",
|
||||
"ix_scheduling_requests_tenant_poll",
|
||||
"ix_scheduling_requests_tenant_status",
|
||||
},
|
||||
"scheduling_candidate_slots": {
|
||||
"ix_scheduling_candidate_slots_deleted_at",
|
||||
"ix_scheduling_candidate_slots_end_at",
|
||||
"ix_scheduling_candidate_slots_poll_option_id",
|
||||
"ix_scheduling_candidate_slots_range",
|
||||
"ix_scheduling_candidate_slots_request_id",
|
||||
"ix_scheduling_candidate_slots_request_position",
|
||||
"ix_scheduling_candidate_slots_start_at",
|
||||
"ix_scheduling_candidate_slots_tenant_id",
|
||||
},
|
||||
"scheduling_participants": {
|
||||
"ix_scheduling_participants_deleted_at",
|
||||
"ix_scheduling_participants_email",
|
||||
"ix_scheduling_participants_last_invited_at",
|
||||
"ix_scheduling_participants_participant_type",
|
||||
"ix_scheduling_participants_poll_invitation_id",
|
||||
"ix_scheduling_participants_request",
|
||||
"ix_scheduling_participants_request_email",
|
||||
"ix_scheduling_participants_request_id",
|
||||
"ix_scheduling_participants_request_respondent",
|
||||
"ix_scheduling_participants_responded_at",
|
||||
"ix_scheduling_participants_respondent_id",
|
||||
"ix_scheduling_participants_status",
|
||||
"ix_scheduling_participants_tenant_id",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _request_foreign_key_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return any(
|
||||
tuple(item.get("constrained_columns") or ()) == ("request_id",)
|
||||
and item.get("referred_table") == "scheduling_requests"
|
||||
and tuple(item.get("referred_columns") or ()) == ("id",)
|
||||
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
|
||||
for item in inspector.get_foreign_keys(table_name)
|
||||
)
|
||||
|
||||
|
||||
def _adopt_existing_baseline() -> bool:
|
||||
"""Adopt the complete unversioned Scheduling baseline, never a partial one."""
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
expected_tables = set(_BASELINE_COLUMNS)
|
||||
present_tables = expected_tables.intersection(inspector.get_table_names())
|
||||
if not present_tables:
|
||||
return False
|
||||
|
||||
problems: list[str] = []
|
||||
missing_tables = expected_tables - present_tables
|
||||
if missing_tables:
|
||||
problems.append(f"missing tables: {', '.join(sorted(missing_tables))}")
|
||||
|
||||
for table_name in sorted(present_tables):
|
||||
columns = {item["name"] for item in inspector.get_columns(table_name)}
|
||||
missing_columns = _BASELINE_COLUMNS[table_name] - columns
|
||||
if missing_columns:
|
||||
problems.append(f"{table_name} missing columns: {', '.join(sorted(missing_columns))}")
|
||||
|
||||
indexes = {item["name"] for item in inspector.get_indexes(table_name)}
|
||||
missing_indexes = _BASELINE_INDEXES[table_name] - indexes
|
||||
if missing_indexes:
|
||||
problems.append(f"{table_name} missing indexes: {', '.join(sorted(missing_indexes))}")
|
||||
|
||||
primary_key = tuple(inspector.get_pk_constraint(table_name).get("constrained_columns") or ())
|
||||
if primary_key != ("id",):
|
||||
problems.append(f"{table_name} has unexpected primary key: {primary_key!r}")
|
||||
|
||||
for table_name in ("scheduling_candidate_slots", "scheduling_participants"):
|
||||
if table_name in present_tables and not _request_foreign_key_exists(inspector, table_name):
|
||||
problems.append(f"{table_name} is missing its cascading request_id foreign key")
|
||||
|
||||
if problems:
|
||||
detail = "; ".join(problems)
|
||||
raise RuntimeError(
|
||||
"Cannot adopt the existing Scheduling v0.1.8 baseline because it is incomplete or ambiguous: "
|
||||
f"{detail}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _adopt_existing_baseline():
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"scheduling_requests",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
|
||||
@@ -6,6 +6,8 @@ Create Date: 2026-07-12 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -16,7 +18,136 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_CALENDAR_SLOT_COLUMNS = {
|
||||
"freebusy_checked_at",
|
||||
"freebusy_status",
|
||||
"freebusy_conflicts",
|
||||
"tentative_hold_event_id",
|
||||
}
|
||||
_CALENDAR_SLOT_INDEXES = {
|
||||
"ix_scheduling_candidate_slots_freebusy_status",
|
||||
"ix_scheduling_candidate_slots_tentative_hold_event_id",
|
||||
}
|
||||
_NOTIFICATION_COLUMNS = {
|
||||
"id",
|
||||
"tenant_id",
|
||||
"request_id",
|
||||
"participant_id",
|
||||
"event_kind",
|
||||
"channel",
|
||||
"recipient",
|
||||
"status",
|
||||
"payload",
|
||||
"error",
|
||||
"sent_at",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
_NOTIFICATION_INDEXES = {
|
||||
"ix_scheduling_notifications_channel",
|
||||
"ix_scheduling_notifications_event_kind",
|
||||
"ix_scheduling_notifications_participant_id",
|
||||
"ix_scheduling_notifications_recipient",
|
||||
"ix_scheduling_notifications_request_id",
|
||||
"ix_scheduling_notifications_request_status",
|
||||
"ix_scheduling_notifications_status",
|
||||
"ix_scheduling_notifications_tenant_id",
|
||||
"ix_scheduling_notifications_tenant_status",
|
||||
}
|
||||
|
||||
|
||||
def _slot_extension_problems(inspector: Any, slot_columns: set[str]) -> list[str]:
|
||||
problems: list[str] = []
|
||||
missing_columns = _CALENDAR_SLOT_COLUMNS - slot_columns
|
||||
if missing_columns:
|
||||
problems.append(
|
||||
"scheduling_candidate_slots missing columns: "
|
||||
f"{', '.join(sorted(missing_columns))}"
|
||||
)
|
||||
indexes = {item["name"] for item in inspector.get_indexes("scheduling_candidate_slots")}
|
||||
missing_indexes = _CALENDAR_SLOT_INDEXES - indexes
|
||||
if missing_indexes:
|
||||
problems.append(
|
||||
"scheduling_candidate_slots missing indexes: "
|
||||
f"{', '.join(sorted(missing_indexes))}"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def _has_cascading_notification_request_foreign_key(inspector: Any) -> bool:
|
||||
return any(
|
||||
tuple(item.get("constrained_columns") or ()) == ("request_id",)
|
||||
and item.get("referred_table") == "scheduling_requests"
|
||||
and tuple(item.get("referred_columns") or ()) == ("id",)
|
||||
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
|
||||
for item in inspector.get_foreign_keys("scheduling_notifications")
|
||||
)
|
||||
|
||||
|
||||
def _notification_extension_problems(inspector: Any) -> list[str]:
|
||||
problems: list[str] = []
|
||||
columns = {item["name"] for item in inspector.get_columns("scheduling_notifications")}
|
||||
missing_columns = _NOTIFICATION_COLUMNS - columns
|
||||
if missing_columns:
|
||||
problems.append(
|
||||
"scheduling_notifications missing columns: "
|
||||
f"{', '.join(sorted(missing_columns))}"
|
||||
)
|
||||
|
||||
indexes = {item["name"] for item in inspector.get_indexes("scheduling_notifications")}
|
||||
missing_indexes = _NOTIFICATION_INDEXES - indexes
|
||||
if missing_indexes:
|
||||
problems.append(
|
||||
"scheduling_notifications missing indexes: "
|
||||
f"{', '.join(sorted(missing_indexes))}"
|
||||
)
|
||||
|
||||
primary_key = tuple(
|
||||
inspector.get_pk_constraint("scheduling_notifications").get("constrained_columns") or ()
|
||||
)
|
||||
if primary_key != ("id",):
|
||||
problems.append(f"scheduling_notifications has unexpected primary key: {primary_key!r}")
|
||||
if not _has_cascading_notification_request_foreign_key(inspector):
|
||||
problems.append("scheduling_notifications is missing its cascading request_id foreign key")
|
||||
return problems
|
||||
|
||||
|
||||
def _adopt_existing_calendar_notifications() -> bool:
|
||||
"""Adopt only the complete calendar/notification extension."""
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "scheduling_candidate_slots" not in tables:
|
||||
raise RuntimeError(
|
||||
"Cannot apply the Scheduling calendar migration because scheduling_candidate_slots is missing"
|
||||
)
|
||||
|
||||
slot_columns = {item["name"] for item in inspector.get_columns("scheduling_candidate_slots")}
|
||||
present_slot_columns = _CALENDAR_SLOT_COLUMNS.intersection(slot_columns)
|
||||
notification_exists = "scheduling_notifications" in tables
|
||||
if not present_slot_columns and not notification_exists:
|
||||
return False
|
||||
|
||||
problems = _slot_extension_problems(inspector, slot_columns)
|
||||
if not notification_exists:
|
||||
problems.append("missing table: scheduling_notifications")
|
||||
else:
|
||||
problems.extend(_notification_extension_problems(inspector))
|
||||
|
||||
if problems:
|
||||
detail = "; ".join(problems)
|
||||
raise RuntimeError(
|
||||
"Cannot adopt the existing Scheduling v0.1.8 calendar/notification schema because it is "
|
||||
f"incomplete or ambiguous: {detail}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _adopt_existing_calendar_notifications():
|
||||
return
|
||||
|
||||
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_status", sa.String(length=40), nullable=True))
|
||||
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]"))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""v0.1.9 scheduling participant visibility
|
||||
|
||||
Revision ID: 9c2f4a7d1e6b
|
||||
Revises: 4d5e6f7a8b9c
|
||||
Create Date: 2026-07-20 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "9c2f4a7d1e6b"
|
||||
down_revision = "4d5e6f7a8b9c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
columns = {item["name"]: item for item in inspector.get_columns("scheduling_requests")}
|
||||
existing = columns.get("participant_visibility")
|
||||
if existing is not None:
|
||||
column_type = existing["type"]
|
||||
if existing.get("nullable") or not isinstance(column_type, sa.String) or column_type.length != 32:
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_requests.participant_visibility because its schema is unexpected"
|
||||
)
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column(
|
||||
"participant_visibility",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="aggregates_only",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scheduling_requests", "participant_visibility")
|
||||
@@ -0,0 +1,168 @@
|
||||
"""v0.1.10 scheduling response settings
|
||||
|
||||
Revision ID: ad7e3c9b2f10
|
||||
Revises: 9c2f4a7d1e6b
|
||||
Create Date: 2026-07-21 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "ad7e3c9b2f10"
|
||||
down_revision = "9c2f4a7d1e6b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_REQUEST_COLUMNS = (
|
||||
"notify_on_answers",
|
||||
"single_choice",
|
||||
"max_participants_per_option",
|
||||
"allow_maybe",
|
||||
"allow_comments",
|
||||
"participant_email_required",
|
||||
"anonymous_password_protection_enabled",
|
||||
"anonymous_password_hash",
|
||||
)
|
||||
|
||||
|
||||
def _adopted_columns(inspector: sa.Inspector, table_name: str, names: tuple[str, ...]) -> bool:
|
||||
columns = {item["name"]: item for item in inspector.get_columns(table_name)}
|
||||
present = [name for name in names if name in columns]
|
||||
if not present:
|
||||
return False
|
||||
if len(present) != len(names):
|
||||
missing = sorted(set(names) - set(present))
|
||||
raise RuntimeError(
|
||||
f"Cannot adopt partial {table_name} scheduling response settings; missing columns: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _require_compatible_adopted_schema(inspector: sa.Inspector) -> None:
|
||||
request_columns = {
|
||||
item["name"]: item
|
||||
for item in inspector.get_columns("scheduling_requests")
|
||||
}
|
||||
for name in (
|
||||
"notify_on_answers",
|
||||
"single_choice",
|
||||
"allow_maybe",
|
||||
"allow_comments",
|
||||
"participant_email_required",
|
||||
"anonymous_password_protection_enabled",
|
||||
):
|
||||
column = request_columns[name]
|
||||
if column.get("nullable") or not isinstance(column["type"], sa.Boolean):
|
||||
raise RuntimeError(
|
||||
f"Cannot adopt scheduling_requests.{name} because its schema is unexpected"
|
||||
)
|
||||
capacity = request_columns["max_participants_per_option"]
|
||||
if not capacity.get("nullable") or not isinstance(capacity["type"], sa.Integer):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_requests.max_participants_per_option because its schema is unexpected"
|
||||
)
|
||||
password_hash = request_columns["anonymous_password_hash"]
|
||||
if (
|
||||
not password_hash.get("nullable")
|
||||
or not isinstance(password_hash["type"], sa.String)
|
||||
or password_hash["type"].length != 500
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_requests.anonymous_password_hash because its schema is unexpected"
|
||||
)
|
||||
participant_columns = {
|
||||
item["name"]: item
|
||||
for item in inspector.get_columns("scheduling_participants")
|
||||
}
|
||||
comment = participant_columns["response_comment"]
|
||||
if not comment.get("nullable") or not isinstance(comment["type"], sa.Text):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_participants.response_comment because its schema is unexpected"
|
||||
)
|
||||
gateway = participant_columns["participation_gateway"]
|
||||
if (
|
||||
not gateway.get("nullable")
|
||||
or not isinstance(gateway["type"], sa.String)
|
||||
or gateway["type"].length != 40
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_participants.participation_gateway because its schema is unexpected"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
request_columns_present = _adopted_columns(
|
||||
inspector,
|
||||
"scheduling_requests",
|
||||
_REQUEST_COLUMNS,
|
||||
)
|
||||
participant_columns_present = _adopted_columns(
|
||||
inspector,
|
||||
"scheduling_participants",
|
||||
("response_comment", "participation_gateway"),
|
||||
)
|
||||
if request_columns_present != participant_columns_present:
|
||||
raise RuntimeError(
|
||||
"Cannot adopt partial scheduling response settings across request and participant tables"
|
||||
)
|
||||
if request_columns_present:
|
||||
_require_compatible_adopted_schema(inspector)
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("notify_on_answers", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("single_choice", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("max_participants_per_option", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("allow_maybe", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("allow_comments", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("participant_email_required", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column(
|
||||
"anonymous_password_protection_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("anonymous_password_hash", sa.String(length=500), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_participants",
|
||||
sa.Column("response_comment", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_participants",
|
||||
sa.Column("participation_gateway", sa.String(length=40), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scheduling_participants", "participation_gateway")
|
||||
op.drop_column("scheduling_participants", "response_comment")
|
||||
for name in reversed(_REQUEST_COLUMNS):
|
||||
op.drop_column("scheduling_requests", name)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""v0.1.10 scheduling participation gateway schema repair
|
||||
|
||||
Revision ID: be8f4d2c1a70
|
||||
Revises: ad7e3c9b2f10
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
|
||||
Some development databases were stamped at ``ad7e3c9b2f10`` before the
|
||||
``scheduling_participants.participation_gateway`` column was present. A
|
||||
forward repair is required because Alembic correctly does not replay an
|
||||
already-recorded revision. The column is nullable, so adding it preserves all
|
||||
existing participant rows without inventing gateway ownership for legacy
|
||||
invitations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "be8f4d2c1a70"
|
||||
down_revision = "ad7e3c9b2f10"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _require_compatible_column(column: dict[str, object]) -> None:
|
||||
column_type = column["type"]
|
||||
if (
|
||||
not column.get("nullable")
|
||||
or not isinstance(column_type, sa.String)
|
||||
or column_type.length != 40
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_participants.participation_gateway "
|
||||
"because its schema is unexpected"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "scheduling_participants" not in inspector.get_table_names():
|
||||
raise RuntimeError(
|
||||
"Cannot repair Scheduling participation gateways because "
|
||||
"scheduling_participants is missing"
|
||||
)
|
||||
|
||||
columns = {
|
||||
item["name"]: item
|
||||
for item in inspector.get_columns("scheduling_participants")
|
||||
}
|
||||
existing = columns.get("participation_gateway")
|
||||
if existing is not None:
|
||||
_require_compatible_column(existing)
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
"scheduling_participants",
|
||||
sa.Column("participation_gateway", sa.String(length=40), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ad7e3c9b2f10 already owns this column in the canonical schema. The repair
|
||||
# revision must therefore not remove it when returning to that revision.
|
||||
pass
|
||||
@@ -0,0 +1,43 @@
|
||||
"""v0.1.11 bounded scheduling cancellation notice
|
||||
|
||||
Revision ID: c9d4e7f1a2b3
|
||||
Revises: be8f4d2c1a70
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c9d4e7f1a2b3"
|
||||
down_revision = "be8f4d2c1a70"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
columns = {
|
||||
item["name"]: item
|
||||
for item in inspector.get_columns("scheduling_requests")
|
||||
}
|
||||
existing = columns.get("cancellation_notice_until")
|
||||
if existing is not None:
|
||||
if (
|
||||
not existing.get("nullable")
|
||||
or not isinstance(existing["type"], sa.DateTime)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_requests.cancellation_notice_until "
|
||||
"because its schema is unexpected"
|
||||
)
|
||||
return
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("cancellation_notice_until", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scheduling_requests", "cancellation_notice_until")
|
||||
@@ -1,33 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
|
||||
from govoplan_core.core.people import search_visible_people
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
||||
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
|
||||
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingAddressLookupCandidate,
|
||||
SchedulingAddressLookupResponse,
|
||||
SchedulingAvailabilityResponse,
|
||||
SchedulingAvailabilityResponseRequest,
|
||||
SchedulingCalendarActionResponse,
|
||||
SchedulingCandidateSlotUpdateRequest,
|
||||
SchedulingDecisionRequest,
|
||||
SchedulingInvitationActionRequest,
|
||||
SchedulingInvitationActionResponse,
|
||||
SchedulingInvitationRevokeRequest,
|
||||
SchedulingNotificationCreateRequest,
|
||||
SchedulingNotificationListResponse,
|
||||
SchedulingNotificationResponse,
|
||||
SchedulingPeopleSearchCandidate,
|
||||
SchedulingPeopleSearchGroup,
|
||||
SchedulingPeopleSearchResponse,
|
||||
SchedulingRequestCreateRequest,
|
||||
SchedulingRequestListResponse,
|
||||
SchedulingRequestResponse,
|
||||
SchedulingRequestUpdateRequest,
|
||||
SchedulingPollSummaryResponse,
|
||||
SchedulingPublicParticipationAccessRequest,
|
||||
SchedulingPublicParticipationResponse,
|
||||
SchedulingPublicParticipationSubmitRequest,
|
||||
SchedulingStatusResponse,
|
||||
SchedulingSummaryResponse,
|
||||
)
|
||||
from govoplan_scheduling.backend.runtime import get_registry
|
||||
from govoplan_scheduling.backend.service import (
|
||||
SchedulingConflictError,
|
||||
SchedulingError,
|
||||
SchedulingPermissionError,
|
||||
SchedulingPublicParticipationError,
|
||||
cancel_scheduling_request,
|
||||
close_scheduling_request,
|
||||
create_final_calendar_event,
|
||||
@@ -37,50 +52,26 @@ from govoplan_scheduling.backend.service import (
|
||||
decide_scheduling_request,
|
||||
evaluate_calendar_freebusy,
|
||||
get_scheduling_request,
|
||||
list_scheduling_notifications,
|
||||
list_scheduling_requests,
|
||||
get_scheduling_availability_response,
|
||||
get_public_scheduling_participation,
|
||||
get_visible_scheduling_request,
|
||||
list_visible_scheduling_notifications,
|
||||
list_visible_scheduling_requests,
|
||||
issue_scheduling_participant_invitation,
|
||||
open_scheduling_request,
|
||||
require_visible_scheduling_results,
|
||||
revoke_scheduling_participant_invitation,
|
||||
scheduling_notification_response,
|
||||
scheduling_request_response,
|
||||
scheduling_request_summary,
|
||||
update_scheduling_request,
|
||||
submit_scheduling_availability,
|
||||
submit_public_scheduling_participation,
|
||||
update_scheduling_candidate_slot,
|
||||
update_scheduling_request_with_change_log,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/scheduling", tags=["scheduling"])
|
||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||
|
||||
|
||||
def _capability_payload(value: object) -> dict[str, Any]:
|
||||
if dataclasses.is_dataclass(value):
|
||||
return dataclasses.asdict(value)
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
payload: dict[str, Any] = {}
|
||||
for key in (
|
||||
"contact_id",
|
||||
"address_book_id",
|
||||
"display_name",
|
||||
"email",
|
||||
"email_label",
|
||||
"organization",
|
||||
"role_title",
|
||||
"tags",
|
||||
"source_kind",
|
||||
"source_ref",
|
||||
"source_revision",
|
||||
"provenance",
|
||||
):
|
||||
if hasattr(value, key):
|
||||
payload[key] = getattr(value, key)
|
||||
return payload
|
||||
|
||||
|
||||
def _registry_capability(name: str) -> object | None:
|
||||
registry = get_registry()
|
||||
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
||||
@@ -88,45 +79,239 @@ def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
|
||||
if str(exc) == "Scheduling request not found":
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
|
||||
candidates = (
|
||||
principal.account_id,
|
||||
principal.membership_id,
|
||||
getattr(principal.user, "id", None),
|
||||
principal.identity_id,
|
||||
principal.principal.service_account_id,
|
||||
principal.email,
|
||||
)
|
||||
return tuple(dict.fromkeys(str(value) for value in candidates if value))
|
||||
|
||||
|
||||
def _request_response(request, *, invitation_tokens: dict[str, str] | None = None) -> SchedulingRequestResponse:
|
||||
return SchedulingRequestResponse.model_validate(
|
||||
scheduling_request_response(request, invitation_tokens=invitation_tokens)
|
||||
def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
|
||||
return has_scope(principal, ADMIN_SCOPE)
|
||||
|
||||
|
||||
def _require_scheduling_writer(principal: ApiPrincipal) -> None:
|
||||
if has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing scope: {WRITE_SCOPE} or {ADMIN_SCOPE}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
|
||||
def api_lookup_scheduling_addresses(
|
||||
def _require_request_editor(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
request_id: str,
|
||||
) -> None:
|
||||
_require_scheduling_writer(principal)
|
||||
if has_scope(principal, ADMIN_SCOPE):
|
||||
return
|
||||
try:
|
||||
request = get_scheduling_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
if request.organizer_user_id not in _principal_actor_ids(principal):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the organizer or a scheduling administrator can edit this request",
|
||||
)
|
||||
|
||||
|
||||
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
|
||||
if isinstance(exc, SchedulingConflictError):
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, SchedulingPermissionError):
|
||||
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
if str(exc) == "Scheduling request not found":
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if str(exc) == "Scheduling results are not visible":
|
||||
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
|
||||
def _public_participation_http_error(
|
||||
exc: SchedulingPublicParticipationError,
|
||||
) -> HTTPException:
|
||||
if exc.retry_after_seconds:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=str(exc),
|
||||
headers={"Retry-After": str(exc.retry_after_seconds)},
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _client_address(request: Request) -> str | None:
|
||||
return request.client.host if request.client is not None else None
|
||||
|
||||
|
||||
def _set_sensitive_response_headers(response: Response) -> None:
|
||||
response.headers["Cache-Control"] = "no-store, private"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
|
||||
|
||||
def _audit_invitation_action(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
request_id: str,
|
||||
participant_id: str,
|
||||
action: str,
|
||||
details: dict[str, Any],
|
||||
) -> None:
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=(getattr(principal.user, "id", None) or principal.account_id),
|
||||
api_key_id=principal.api_key_id,
|
||||
action=action,
|
||||
object_type="scheduling_request",
|
||||
object_id=request_id,
|
||||
details={"participant_id": participant_id, **details},
|
||||
)
|
||||
|
||||
|
||||
def _request_response(
|
||||
request,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
) -> SchedulingRequestResponse:
|
||||
return SchedulingRequestResponse.model_validate(
|
||||
scheduling_request_response(
|
||||
request,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
actor_user_id=principal.account_id,
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/public/{request_id}/{token}",
|
||||
response_model=SchedulingPublicParticipationResponse,
|
||||
)
|
||||
def api_get_public_scheduling_participation(
|
||||
request_id: str,
|
||||
token: str,
|
||||
payload: SchedulingPublicParticipationAccessRequest,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SchedulingPublicParticipationResponse:
|
||||
try:
|
||||
response = get_public_scheduling_participation(
|
||||
session,
|
||||
request_id=request_id,
|
||||
token=token,
|
||||
payload=payload,
|
||||
client_address=_client_address(request),
|
||||
)
|
||||
except SchedulingPublicParticipationError as exc:
|
||||
raise _public_participation_http_error(exc) from exc
|
||||
return SchedulingPublicParticipationResponse.model_validate(response)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/public/{request_id}/{token}/responses",
|
||||
response_model=SchedulingPublicParticipationResponse,
|
||||
)
|
||||
def api_submit_public_scheduling_participation(
|
||||
request_id: str,
|
||||
token: str,
|
||||
payload: SchedulingPublicParticipationSubmitRequest,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SchedulingPublicParticipationResponse:
|
||||
try:
|
||||
response = submit_public_scheduling_participation(
|
||||
session,
|
||||
request_id=request_id,
|
||||
token=token,
|
||||
payload=payload,
|
||||
client_address=_client_address(request),
|
||||
)
|
||||
except SchedulingPublicParticipationError as exc:
|
||||
raise _public_participation_http_error(exc) from exc
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
validated = SchedulingPublicParticipationResponse.model_validate(response)
|
||||
session.commit()
|
||||
return validated
|
||||
|
||||
|
||||
@router.get("/people", response_model=SchedulingPeopleSearchResponse)
|
||||
def api_search_scheduling_people(
|
||||
query: str = Query(min_length=1),
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingAddressLookupResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
|
||||
if capability is None or not hasattr(capability, "lookup"):
|
||||
return SchedulingAddressLookupResponse(available=False, candidates=[])
|
||||
candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit)
|
||||
return SchedulingAddressLookupResponse(
|
||||
available=True,
|
||||
candidates=[SchedulingAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates],
|
||||
) -> SchedulingPeopleSearchResponse:
|
||||
_require_scheduling_writer(principal)
|
||||
groups = search_visible_people(
|
||||
get_registry(),
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=limit,
|
||||
)
|
||||
return SchedulingPeopleSearchResponse(
|
||||
groups=[
|
||||
SchedulingPeopleSearchGroup(
|
||||
key=group.key,
|
||||
label=group.label,
|
||||
candidates=[
|
||||
SchedulingPeopleSearchCandidate(
|
||||
selection_key=candidate.selection_key,
|
||||
kind=candidate.kind,
|
||||
reference_id=candidate.reference_id,
|
||||
display_name=candidate.display_name,
|
||||
email=candidate.email,
|
||||
source_module=candidate.source_module,
|
||||
source_label=candidate.source_label,
|
||||
source_revision=candidate.source_revision,
|
||||
description=candidate.description,
|
||||
)
|
||||
for candidate in group.candidates
|
||||
],
|
||||
)
|
||||
for group in groups
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/requests", response_model=SchedulingRequestListResponse)
|
||||
def api_list_scheduling_requests(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
limit: int = 100,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingRequestListResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
requests = list_scheduling_requests(session, tenant_id=principal.tenant_id, status=status_filter)
|
||||
return SchedulingRequestListResponse(requests=[_request_response(request) for request in requests])
|
||||
requests = list_visible_scheduling_requests(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
status=status_filter,
|
||||
limit=limit,
|
||||
)
|
||||
return SchedulingRequestListResponse(
|
||||
requests=[_request_response(request, principal=principal) for request in requests]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -135,9 +320,9 @@ def api_create_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingRequestResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_scheduling_writer(principal)
|
||||
try:
|
||||
request, invitation_tokens = create_scheduling_request(
|
||||
request, _invitation_tokens = create_scheduling_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.account_id,
|
||||
@@ -145,7 +330,56 @@ def api_create_scheduling_request(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return _request_response(request, invitation_tokens=invitation_tokens)
|
||||
response = _request_response(request, principal=principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/responses", response_model=SchedulingStatusResponse)
|
||||
def api_submit_scheduling_availability(
|
||||
request_id: str,
|
||||
payload: SchedulingAvailabilityResponseRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, RESPOND_SCOPE)
|
||||
try:
|
||||
request = submit_scheduling_availability(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
respondent_id=principal.account_id,
|
||||
respondent_label=principal.display_name or principal.email,
|
||||
payload=payload,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
response = SchedulingStatusResponse(
|
||||
request=_request_response(request, principal=principal)
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}/responses/me", response_model=SchedulingAvailabilityResponse)
|
||||
def api_get_my_scheduling_availability(
|
||||
request_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingAvailabilityResponse:
|
||||
_require_scope(principal, RESPOND_SCOPE)
|
||||
try:
|
||||
response = get_scheduling_availability_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
respondent_id=principal.account_id,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingAvailabilityResponse.model_validate(response)
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}", response_model=SchedulingRequestResponse)
|
||||
@@ -156,9 +390,16 @@ def api_get_scheduling_request(
|
||||
) -> SchedulingRequestResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
try:
|
||||
return _request_response(get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id))
|
||||
request = get_visible_scheduling_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return _request_response(request, principal=principal)
|
||||
|
||||
|
||||
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse)
|
||||
@@ -168,13 +409,193 @@ def api_update_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingRequestResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
)
|
||||
try:
|
||||
return _request_response(
|
||||
update_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
|
||||
request, participant_mutations = update_scheduling_request_with_change_log(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
for mutation in participant_mutations:
|
||||
_audit_invitation_action(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
participant_id=mutation.participant_id,
|
||||
action=mutation.action,
|
||||
details={
|
||||
"replacement_participant_id": mutation.replacement_participant_id,
|
||||
"changed_fields": list(mutation.changed_fields),
|
||||
"invitation_revoked": mutation.invitation_revoked,
|
||||
"retired_response_count": mutation.retired_response_count,
|
||||
"notification_id": mutation.notification_id,
|
||||
},
|
||||
)
|
||||
response = _request_response(request, principal=principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/requests/{request_id}/participants/{participant_id}/invitation",
|
||||
response_model=SchedulingInvitationActionResponse,
|
||||
)
|
||||
def api_issue_scheduling_participant_invitation(
|
||||
request_id: str,
|
||||
participant_id: str,
|
||||
payload: SchedulingInvitationActionRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingInvitationActionResponse:
|
||||
_require_request_editor(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
)
|
||||
try:
|
||||
result = issue_scheduling_participant_invitation(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
participant_id=participant_id,
|
||||
participant_revision=payload.participant_revision,
|
||||
action=payload.action,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
audit_details = {
|
||||
"request_status": result.request.status,
|
||||
"delivery_action": payload.action,
|
||||
"replaced_existing": result.replaced_existing,
|
||||
}
|
||||
_audit_invitation_action(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
participant_id=participant_id,
|
||||
action="scheduling.invitation_issued",
|
||||
details=audit_details,
|
||||
)
|
||||
_audit_invitation_action(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
participant_id=participant_id,
|
||||
action=(
|
||||
"scheduling.invitation_copied"
|
||||
if payload.action == "copy"
|
||||
else "scheduling.invitation_send_requested"
|
||||
),
|
||||
details={
|
||||
**audit_details,
|
||||
"notification_id": (
|
||||
result.notification.id if result.notification is not None else None
|
||||
),
|
||||
"notification_status": result.status,
|
||||
},
|
||||
)
|
||||
validated = SchedulingInvitationActionResponse(
|
||||
participant_id=result.participant.id,
|
||||
action=payload.action,
|
||||
status=result.status,
|
||||
action_url=result.action_url,
|
||||
issued_at=result.participant.last_invited_at,
|
||||
notification=(
|
||||
SchedulingNotificationResponse.model_validate(
|
||||
scheduling_notification_response(result.notification)
|
||||
)
|
||||
if result.notification is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
_set_sensitive_response_headers(response)
|
||||
session.commit()
|
||||
return validated
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/requests/{request_id}/participants/{participant_id}/invitation",
|
||||
response_model=SchedulingInvitationActionResponse,
|
||||
)
|
||||
def api_revoke_scheduling_participant_invitation(
|
||||
request_id: str,
|
||||
participant_id: str,
|
||||
payload: SchedulingInvitationRevokeRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingInvitationActionResponse:
|
||||
_require_request_editor(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
)
|
||||
try:
|
||||
result = revoke_scheduling_participant_invitation(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
participant_id=participant_id,
|
||||
participant_revision=payload.participant_revision,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
_audit_invitation_action(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
participant_id=participant_id,
|
||||
action="scheduling.invitation_revoked",
|
||||
details={
|
||||
"request_status": result.request.status,
|
||||
"replayed": result.replayed,
|
||||
},
|
||||
)
|
||||
validated = SchedulingInvitationActionResponse(
|
||||
participant_id=result.participant.id,
|
||||
action="revoke",
|
||||
status=result.status,
|
||||
replayed=result.replayed,
|
||||
)
|
||||
_set_sensitive_response_headers(response)
|
||||
session.commit()
|
||||
return validated
|
||||
|
||||
|
||||
@router.patch("/requests/{request_id}/slots/{slot_id}", response_model=SchedulingRequestResponse)
|
||||
def api_update_scheduling_candidate_slot(
|
||||
request_id: str,
|
||||
slot_id: str,
|
||||
payload: SchedulingCandidateSlotUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingRequestResponse:
|
||||
_require_request_editor(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
)
|
||||
try:
|
||||
request = update_scheduling_candidate_slot(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
slot_id=slot_id,
|
||||
payload=payload,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
response = _request_response(request, principal=principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse)
|
||||
@@ -183,12 +604,14 @@ def api_open_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingStatusResponse(request=_request_response(request))
|
||||
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/close", response_model=SchedulingStatusResponse)
|
||||
@@ -197,12 +620,14 @@ def api_close_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingStatusResponse(request=_request_response(request))
|
||||
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/decide", response_model=SchedulingStatusResponse)
|
||||
@@ -212,7 +637,7 @@ def api_decide_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
request = decide_scheduling_request(
|
||||
session,
|
||||
@@ -220,10 +645,16 @@ def api_decide_scheduling_request(
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
user_id=principal.account_id,
|
||||
allow_calendar_handoff=has_scope(
|
||||
principal,
|
||||
CALENDAR_EVENT_WRITE_SCOPE,
|
||||
),
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingStatusResponse(request=_request_response(request))
|
||||
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/calendar/freebusy", response_model=SchedulingCalendarActionResponse)
|
||||
@@ -232,16 +663,19 @@ def api_evaluate_calendar_freebusy(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
|
||||
try:
|
||||
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingCalendarActionResponse(
|
||||
request=_request_response(request),
|
||||
response = SchedulingCalendarActionResponse(
|
||||
request=_request_response(request, principal=principal),
|
||||
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
|
||||
warnings=warnings,
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse)
|
||||
@@ -250,7 +684,8 @@ def api_create_tentative_calendar_holds(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
||||
session,
|
||||
@@ -260,12 +695,14 @@ def api_create_tentative_calendar_holds(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingCalendarActionResponse(
|
||||
request=_request_response(request),
|
||||
response = SchedulingCalendarActionResponse(
|
||||
request=_request_response(request, principal=principal),
|
||||
created_event_ids=created_event_ids,
|
||||
updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids],
|
||||
warnings=warnings,
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse)
|
||||
@@ -274,7 +711,8 @@ def api_create_final_calendar_event(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
request, event_id, warnings = create_final_calendar_event(
|
||||
session,
|
||||
@@ -284,11 +722,13 @@ def api_create_final_calendar_event(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingCalendarActionResponse(
|
||||
request=_request_response(request),
|
||||
response = SchedulingCalendarActionResponse(
|
||||
request=_request_response(request, principal=principal),
|
||||
created_event_ids=[event_id] if event_id else [],
|
||||
warnings=warnings,
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
|
||||
@@ -297,12 +737,14 @@ def api_cancel_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingStatusResponse(request=_request_response(request))
|
||||
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}/summary", response_model=SchedulingSummaryResponse)
|
||||
@@ -313,14 +755,30 @@ def api_scheduling_summary(
|
||||
) -> SchedulingSummaryResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
try:
|
||||
request = get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
request = get_visible_scheduling_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
)
|
||||
require_visible_scheduling_results(
|
||||
session,
|
||||
request=request,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
)
|
||||
summary = scheduling_request_summary(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingSummaryResponse(
|
||||
request=_request_response(request),
|
||||
poll_summary=PollResultSummaryResponse.model_validate(summary),
|
||||
response = SchedulingSummaryResponse(
|
||||
request=_request_response(request, principal=principal),
|
||||
poll_summary=SchedulingPollSummaryResponse.model_validate(summary),
|
||||
)
|
||||
# Refreshing the summary synchronizes participant response state and can
|
||||
# enqueue response notifications, so persist that work before teardown.
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=SchedulingNotificationListResponse)
|
||||
@@ -331,9 +789,11 @@ def api_list_scheduling_notifications(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingNotificationListResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
notifications = list_scheduling_notifications(
|
||||
notifications = list_visible_scheduling_notifications(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
request_id=request_id,
|
||||
status=status_filter,
|
||||
)
|
||||
@@ -352,7 +812,7 @@ def api_create_scheduling_notifications(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingNotificationListResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
notifications = create_scheduling_notification_jobs(
|
||||
session,
|
||||
@@ -364,9 +824,11 @@ def api_create_scheduling_notifications(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingNotificationListResponse(
|
||||
response = SchedulingNotificationListResponse(
|
||||
notifications=[
|
||||
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
|
||||
for notification in notifications
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
@@ -2,16 +2,45 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
||||
|
||||
|
||||
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
|
||||
SchedulingParticipantType = Literal["internal", "external", "resource"]
|
||||
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
|
||||
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
||||
SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"]
|
||||
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||
|
||||
|
||||
def _known_timezone(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise ValueError("timezone must be a valid IANA timezone") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _participant_email(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().casefold()
|
||||
if not normalized:
|
||||
return None
|
||||
local, separator, domain = normalized.partition("@")
|
||||
if (
|
||||
separator != "@"
|
||||
or not local
|
||||
or not domain
|
||||
or "@" in domain
|
||||
or any(character.isspace() for character in normalized)
|
||||
):
|
||||
raise ValueError("participant_email must be a valid email address")
|
||||
return normalized
|
||||
|
||||
|
||||
class SchedulingCalendarPreferences(BaseModel):
|
||||
@@ -29,12 +58,14 @@ class SchedulingCandidateSlotInput(BaseModel):
|
||||
|
||||
label: str | None = Field(default=None, max_length=500)
|
||||
description: str | None = None
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
start_at: AwareDatetime
|
||||
end_at: AwareDatetime
|
||||
timezone: str | None = Field(default=None, max_length=100)
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_range(self) -> "SchedulingCandidateSlotInput":
|
||||
if self.end_at <= self.start_at:
|
||||
@@ -42,6 +73,32 @@ class SchedulingCandidateSlotInput(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingCandidateSlotUpdateRequest(BaseModel):
|
||||
"""Partial update of one candidate slot.
|
||||
|
||||
A semantic option change invalidates existing answers for this slot in the
|
||||
backing Poll. Exact repeats are safe no-ops.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
label: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
description: str | None = None
|
||||
start_at: AwareDatetime | None = None
|
||||
end_at: AwareDatetime | None = None
|
||||
timezone: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_range(self) -> "SchedulingCandidateSlotUpdateRequest":
|
||||
if self.start_at is not None and self.end_at is not None and self.end_at <= self.start_at:
|
||||
raise ValueError("end_at must be after start_at")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingParticipantInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -52,6 +109,44 @@ class SchedulingParticipantInput(BaseModel):
|
||||
required: bool = True
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
_validate_email = field_validator("email")(_participant_email)
|
||||
|
||||
|
||||
class SchedulingCandidateSlotReconcileInput(SchedulingCandidateSlotInput):
|
||||
id: str | None = Field(default=None, max_length=36)
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
pattern=r"^[0-9a-f]{64}$",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_existing_revision(self) -> "SchedulingCandidateSlotReconcileInput":
|
||||
if self.id is not None and self.revision is None:
|
||||
raise ValueError("revision is required for an existing scheduling slot")
|
||||
if self.id is None and self.revision is not None:
|
||||
raise ValueError("revision can only be supplied for an existing scheduling slot")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingParticipantReconcileInput(SchedulingParticipantInput):
|
||||
id: str | None = Field(default=None, max_length=36)
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
pattern=r"^[0-9a-f]{64}$",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_existing_revision(self) -> "SchedulingParticipantReconcileInput":
|
||||
if self.id is not None and self.revision is None:
|
||||
raise ValueError("revision is required for an existing scheduling participant")
|
||||
if self.id is None and self.revision is not None:
|
||||
raise ValueError("revision can only be supplied for an existing scheduling participant")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingRequestCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -65,12 +160,38 @@ class SchedulingRequestCreateRequest(BaseModel):
|
||||
allow_external_participants: bool = True
|
||||
allow_participant_updates: bool = True
|
||||
result_visibility: SchedulingResultVisibility = "after_close"
|
||||
participant_visibility: SchedulingParticipantVisibility = "aggregates_only"
|
||||
notify_on_answers: bool = True
|
||||
single_choice: bool = False
|
||||
max_participants_per_option: int | None = Field(default=None, ge=1)
|
||||
allow_maybe: bool = True
|
||||
allow_comments: bool = False
|
||||
participant_email_required: bool = False
|
||||
anonymous_password_protection_enabled: bool = False
|
||||
anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024)
|
||||
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
|
||||
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
|
||||
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
|
||||
create_participant_invitations: bool = True
|
||||
create_participant_invitations: bool = Field(
|
||||
default=False,
|
||||
deprecated=True,
|
||||
description=(
|
||||
"Compatibility field; participant links are issued only through "
|
||||
"the explicit participant invitation action."
|
||||
),
|
||||
)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_anonymous_password(self) -> "SchedulingRequestCreateRequest":
|
||||
if self.anonymous_password_protection_enabled and self.anonymous_password is None:
|
||||
raise ValueError("anonymous_password is required when password protection is enabled")
|
||||
if not self.anonymous_password_protection_enabled and self.anonymous_password is not None:
|
||||
raise ValueError("anonymous_password requires password protection to be enabled")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingRequestUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -82,9 +203,48 @@ class SchedulingRequestUpdateRequest(BaseModel):
|
||||
allow_external_participants: bool | None = None
|
||||
allow_participant_updates: bool | None = None
|
||||
result_visibility: SchedulingResultVisibility | None = None
|
||||
participant_visibility: SchedulingParticipantVisibility | None = None
|
||||
notify_on_answers: bool | None = None
|
||||
single_choice: bool | None = None
|
||||
max_participants_per_option: int | None = Field(default=None, ge=1)
|
||||
allow_maybe: bool | None = None
|
||||
allow_comments: bool | None = None
|
||||
participant_email_required: bool | None = None
|
||||
anonymous_password_protection_enabled: bool | None = None
|
||||
anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024)
|
||||
calendar: SchedulingCalendarPreferences | None = None
|
||||
slots: list[SchedulingCandidateSlotReconcileInput] | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
)
|
||||
participants: list[SchedulingParticipantReconcileInput] | None = None
|
||||
create_participant_invitations: bool = Field(
|
||||
default=False,
|
||||
deprecated=True,
|
||||
description=(
|
||||
"Compatibility field; participant links are issued only through "
|
||||
"the explicit participant invitation action."
|
||||
),
|
||||
)
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_anonymous_password(self) -> "SchedulingRequestUpdateRequest":
|
||||
if self.anonymous_password_protection_enabled is False and self.anonymous_password is not None:
|
||||
raise ValueError("anonymous_password cannot be set while password protection is disabled")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_reconciliation_ids(self) -> "SchedulingRequestUpdateRequest":
|
||||
for field_name in ("slots", "participants"):
|
||||
values = getattr(self, field_name)
|
||||
if values is None:
|
||||
continue
|
||||
ids = [value.id for value in values if value.id is not None]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError(f"Duplicate ids are not allowed in {field_name}")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingCandidateSlotResponse(BaseModel):
|
||||
id: str
|
||||
@@ -96,6 +256,7 @@ class SchedulingCandidateSlotResponse(BaseModel):
|
||||
timezone: str
|
||||
location: str | None = None
|
||||
position: int
|
||||
revision: str
|
||||
freebusy_checked_at: datetime | None = None
|
||||
freebusy_status: str | None = None
|
||||
freebusy_conflicts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
@@ -105,11 +266,13 @@ class SchedulingCandidateSlotResponse(BaseModel):
|
||||
|
||||
class SchedulingParticipantResponse(BaseModel):
|
||||
id: str
|
||||
revision: str | None = None
|
||||
is_current_participant: bool = False
|
||||
respondent_id: str | None = None
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
participant_type: str
|
||||
required: bool
|
||||
participant_type: str | None = None
|
||||
required: bool | None = None
|
||||
status: str
|
||||
poll_invitation_id: str | None = None
|
||||
invitation_token: str | None = None
|
||||
@@ -118,9 +281,23 @@ class SchedulingParticipantResponse(BaseModel):
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingParticipantAggregateResponse(BaseModel):
|
||||
total: int = 0
|
||||
status_counts: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingParticipantVisibilityDecisionResponse(BaseModel):
|
||||
requested_visibility: SchedulingParticipantVisibility
|
||||
effective_visibility: SchedulingParticipantVisibility
|
||||
policy_applied: bool = False
|
||||
reason: str | None = None
|
||||
source_path: list[dict[str, Any]] = Field(default_factory=list)
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingRequestResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
tenant_id: str | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
@@ -133,14 +310,29 @@ class SchedulingRequestResponse(BaseModel):
|
||||
allow_external_participants: bool
|
||||
allow_participant_updates: bool
|
||||
result_visibility: str
|
||||
calendar_integration_enabled: bool
|
||||
participant_visibility: SchedulingParticipantVisibility
|
||||
notify_on_answers: bool
|
||||
single_choice: bool
|
||||
max_participants_per_option: int | None = None
|
||||
allow_maybe: bool
|
||||
allow_comments: bool
|
||||
participant_email_required: bool
|
||||
anonymous_password_protection_enabled: bool
|
||||
public_participation_policy_enforcement_available: bool | None = None
|
||||
public_participation_policy_enforcement_reason: str | None = None
|
||||
participant_invitation_delivery_available: bool | None = None
|
||||
effective_participant_visibility: SchedulingParticipantVisibility
|
||||
participant_aggregate: SchedulingParticipantAggregateResponse
|
||||
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
|
||||
calendar_integration_enabled: bool | None = None
|
||||
calendar_id: str | None = None
|
||||
calendar_freebusy_enabled: bool
|
||||
calendar_hold_enabled: bool
|
||||
create_calendar_event_on_decision: bool
|
||||
calendar_freebusy_enabled: bool | None = None
|
||||
calendar_hold_enabled: bool | None = None
|
||||
create_calendar_event_on_decision: bool | None = None
|
||||
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)
|
||||
@@ -164,9 +356,130 @@ class SchedulingDecisionRequest(BaseModel):
|
||||
handoff_to_calendar: bool | None = None
|
||||
|
||||
|
||||
class SchedulingAvailabilityAnswerInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slot_id: str
|
||||
value: SchedulingAvailabilityValue
|
||||
option_revision: str = Field(min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class SchedulingAvailabilityResponseRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
|
||||
comment: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest":
|
||||
slot_ids = [answer.slot_id for answer in self.answers]
|
||||
if len(slot_ids) != len(set(slot_ids)):
|
||||
raise ValueError("Each scheduling slot can be answered only once")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingAvailabilityAnswerResponse(BaseModel):
|
||||
slot_id: str
|
||||
value: SchedulingAvailabilityValue
|
||||
|
||||
|
||||
class SchedulingAvailabilityResponse(BaseModel):
|
||||
request_id: str
|
||||
participant_id: str
|
||||
has_response: bool = False
|
||||
submitted_at: datetime | None = None
|
||||
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class SchedulingPublicParticipationAccessRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
participant_email: str | None = Field(default=None, max_length=320)
|
||||
password: SecretStr | None = Field(default=None, max_length=1024)
|
||||
|
||||
_validate_participant_email = field_validator("participant_email")(_participant_email)
|
||||
|
||||
|
||||
class SchedulingPublicParticipationSubmitRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
|
||||
participant_email: str | None = Field(default=None, max_length=320)
|
||||
password: SecretStr | None = Field(default=None, max_length=1024)
|
||||
comment: str | None = Field(default=None, max_length=4000)
|
||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
|
||||
_validate_participant_email = field_validator("participant_email")(_participant_email)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_slots(self) -> "SchedulingPublicParticipationSubmitRequest":
|
||||
slot_ids = [answer.slot_id for answer in self.answers]
|
||||
if len(slot_ids) != len(set(slot_ids)):
|
||||
raise ValueError("Each scheduling slot can be answered only once")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingPublicCandidateSlotResponse(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
timezone: str
|
||||
location: str | None = None
|
||||
position: int
|
||||
revision: str
|
||||
|
||||
|
||||
class SchedulingPublicParticipationResponse(BaseModel):
|
||||
request_id: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
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
|
||||
max_participants_per_option: int | None = None
|
||||
allow_maybe: bool
|
||||
allow_comments: bool
|
||||
allow_participant_updates: bool
|
||||
has_response: bool = False
|
||||
submitted_at: datetime | None = None
|
||||
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
|
||||
comment: str | None = None
|
||||
replayed: bool = False
|
||||
slots: list[SchedulingPublicCandidateSlotResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SchedulingPollOptionResultResponse(BaseModel):
|
||||
option_id: str
|
||||
option_key: str
|
||||
label: str
|
||||
count: int = 0
|
||||
score: int = 0
|
||||
values: dict[str, int] = Field(default_factory=dict)
|
||||
ranks: dict[int, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingPollSummaryResponse(BaseModel):
|
||||
poll_id: str
|
||||
kind: str
|
||||
status: str
|
||||
response_count: int
|
||||
option_results: list[SchedulingPollOptionResultResponse] = Field(default_factory=list)
|
||||
leading_option_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SchedulingSummaryResponse(BaseModel):
|
||||
request: SchedulingRequestResponse
|
||||
poll_summary: PollResultSummaryResponse
|
||||
poll_summary: SchedulingPollSummaryResponse
|
||||
|
||||
|
||||
class SchedulingCalendarActionResponse(BaseModel):
|
||||
@@ -204,21 +517,68 @@ class SchedulingNotificationCreateRequest(BaseModel):
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingAddressLookupCandidate(BaseModel):
|
||||
contact_id: str
|
||||
address_book_id: str
|
||||
class SchedulingInvitationActionRequest(BaseModel):
|
||||
"""Explicitly issue one fresh participant-specific participation link."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: Literal["copy", "send"]
|
||||
participant_revision: str = Field(
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
pattern=r"^[0-9a-f]{64}$",
|
||||
description=(
|
||||
"Semantic revision from the participant management projection; "
|
||||
"stale actions are rejected before rotating or delivering a link."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SchedulingInvitationRevokeRequest(BaseModel):
|
||||
"""Revoke the link represented by one current participant projection."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
participant_revision: str = Field(
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
pattern=r"^[0-9a-f]{64}$",
|
||||
description=(
|
||||
"Semantic revision from the participant management projection; "
|
||||
"stale revocations are rejected before changing access."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SchedulingInvitationActionResponse(BaseModel):
|
||||
participant_id: str
|
||||
action: Literal["copy", "send", "revoke"]
|
||||
status: str
|
||||
action_url: str | None = None
|
||||
issued_at: datetime | None = None
|
||||
replayed: bool = False
|
||||
notification: SchedulingNotificationResponse | None = None
|
||||
|
||||
|
||||
class SchedulingPeopleSearchCandidate(BaseModel):
|
||||
"""Opaque, task-safe projection of a visible directory candidate."""
|
||||
|
||||
selection_key: str
|
||||
kind: str
|
||||
reference_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
email_label: str | None = None
|
||||
organization: str | None = None
|
||||
role_title: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
source_kind: str = "local"
|
||||
source_ref: str | None = None
|
||||
source_module: str | None = None
|
||||
source_label: str | None = None
|
||||
source_revision: str | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SchedulingAddressLookupResponse(BaseModel):
|
||||
available: bool = False
|
||||
candidates: list[SchedulingAddressLookupCandidate] = Field(default_factory=list)
|
||||
class SchedulingPeopleSearchGroup(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
candidates: list[SchedulingPeopleSearchCandidate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SchedulingPeopleSearchResponse(BaseModel):
|
||||
groups: list[SchedulingPeopleSearchGroup] = Field(default_factory=list)
|
||||
|
||||
61
src/govoplan_scheduling/backend/security.py
Normal file
61
src/govoplan_scheduling/backend/security.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
|
||||
|
||||
_ALGORITHM = "pbkdf2_sha256"
|
||||
_DEFAULT_ITERATIONS = 260_000
|
||||
_SALT_BYTES = 16
|
||||
|
||||
|
||||
def hash_participant_password(
|
||||
password: str,
|
||||
*,
|
||||
iterations: int = _DEFAULT_ITERATIONS,
|
||||
) -> str:
|
||||
"""Hash a public-participant access password for durable storage."""
|
||||
|
||||
salt = os.urandom(_SALT_BYTES)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
iterations,
|
||||
)
|
||||
return "$".join(
|
||||
(
|
||||
_ALGORITHM,
|
||||
str(iterations),
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(digest).decode("ascii"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def verify_participant_password(password: str, encoded: str | None) -> bool:
|
||||
"""Verify a participant password without exposing the stored hash."""
|
||||
|
||||
if not encoded:
|
||||
return False
|
||||
try:
|
||||
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
|
||||
if algorithm != _ALGORITHM:
|
||||
return False
|
||||
iterations = int(iterations_text)
|
||||
salt = base64.b64decode(salt_b64.encode("ascii"), validate=True)
|
||||
expected = base64.b64decode(digest_b64.encode("ascii"), validate=True)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
iterations,
|
||||
)
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
__all__ = ["hash_participant_password", "verify_participant_password"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,17 +16,41 @@ class SchedulingManifestTests(unittest.TestCase):
|
||||
self.assertNotIn("access", manifest.dependencies)
|
||||
self.assertIn("access", manifest.optional_dependencies)
|
||||
self.assertIn("addresses", manifest.optional_dependencies)
|
||||
self.assertFalse(manifest.required_capabilities)
|
||||
self.assertIn("policy", manifest.optional_dependencies)
|
||||
self.assertEqual(
|
||||
("poll.scheduling", "poll.participation_gateway"),
|
||||
manifest.required_capabilities,
|
||||
)
|
||||
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
|
||||
self.assertIn("poll.scheduling", manifest.required_capabilities)
|
||||
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
|
||||
self.assertIn("policy.schedulingParticipantPrivacy", manifest.optional_capabilities)
|
||||
self.assertIn("access.people_search", manifest.optional_capabilities)
|
||||
self.assertIn("addresses.people_search", manifest.optional_capabilities)
|
||||
self.assertIn("evaluation", manifest.optional_dependencies)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertIsNotNone(manifest.frontend)
|
||||
self.assertEqual(
|
||||
["/scheduling/public/:requestId/:token"],
|
||||
[route.path for route in manifest.frontend.public_routes],
|
||||
)
|
||||
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("poll.response_collection", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("poll.governed_participation", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("access.people_search", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("addresses.people_search", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
|
||||
required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces}
|
||||
for interface_name in (
|
||||
"poll.availability_matrix",
|
||||
"poll.response_collection",
|
||||
"poll.workflow_context",
|
||||
"poll.governed_participation",
|
||||
):
|
||||
self.assertEqual("0.1.11", required_interfaces[interface_name].version_min)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
370
tests/test_migrations.py
Normal file
370
tests/test_migrations.py
Normal file
@@ -0,0 +1,370 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, func, inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest
|
||||
from govoplan_scheduling.backend.db.models import (
|
||||
SchedulingCandidateSlot,
|
||||
SchedulingNotification,
|
||||
SchedulingParticipant,
|
||||
SchedulingRequest,
|
||||
)
|
||||
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
|
||||
|
||||
|
||||
_SCHEDULING_HEAD = "c9d4e7f1a2b3"
|
||||
_SCHEDULING_RESPONSE_SETTINGS_REVISION = "ad7e3c9b2f10"
|
||||
_ENABLED_MODULES = ("poll", "scheduling")
|
||||
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
|
||||
|
||||
|
||||
class SchedulingMigrationTests(unittest.TestCase):
|
||||
def _migrate(self, url: str) -> None:
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=_ENABLED_MODULES,
|
||||
manifest_factories=_MANIFEST_FACTORIES,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _remove_scheduling_revision(
|
||||
connection, *, remove_privacy_column: bool = True
|
||||
) -> None:
|
||||
connection.execute(
|
||||
text("DELETE FROM alembic_version WHERE version_num = :revision"),
|
||||
{"revision": _SCHEDULING_HEAD},
|
||||
)
|
||||
if remove_privacy_column:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE scheduling_requests DROP COLUMN participant_visibility"
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _seed_scheduling_rows(engine) -> None:
|
||||
with Session(engine) as session:
|
||||
request = SchedulingRequest(
|
||||
id="request-1",
|
||||
tenant_id="tenant-1",
|
||||
title="Migration adoption probe",
|
||||
)
|
||||
request.slots.append(
|
||||
SchedulingCandidateSlot(
|
||||
id="slot-1",
|
||||
tenant_id="tenant-1",
|
||||
label="First slot",
|
||||
start_at=datetime(2026, 7, 21, 8, 0),
|
||||
end_at=datetime(2026, 7, 21, 9, 0),
|
||||
)
|
||||
)
|
||||
request.participants.append(
|
||||
SchedulingParticipant(
|
||||
id="participant-1",
|
||||
tenant_id="tenant-1",
|
||||
display_name="Ada",
|
||||
email="ada@example.test",
|
||||
)
|
||||
)
|
||||
session.add(request)
|
||||
session.flush()
|
||||
session.add(
|
||||
SchedulingNotification(
|
||||
id="notification-1",
|
||||
tenant_id="tenant-1",
|
||||
request_id=request.id,
|
||||
event_kind="invitation",
|
||||
recipient="ada@example.test",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_adopts_complete_unversioned_v018_schema_and_preserves_rows(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-scheduling-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
||||
self._migrate(url)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
self._seed_scheduling_rows(engine)
|
||||
with engine.begin() as connection:
|
||||
self._remove_scheduling_revision(connection)
|
||||
|
||||
self._migrate(url)
|
||||
|
||||
with engine.connect() as connection:
|
||||
heads = set(
|
||||
MigrationContext.configure(connection).get_current_heads()
|
||||
)
|
||||
columns = {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_columns(
|
||||
"scheduling_requests"
|
||||
)
|
||||
}
|
||||
participant_columns = {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_columns(
|
||||
"scheduling_participants"
|
||||
)
|
||||
}
|
||||
visibility = connection.execute(
|
||||
text(
|
||||
"SELECT participant_visibility FROM scheduling_requests "
|
||||
"WHERE id = 'request-1'"
|
||||
)
|
||||
).scalar_one()
|
||||
response_defaults = connection.execute(
|
||||
text(
|
||||
"SELECT notify_on_answers, single_choice, "
|
||||
"max_participants_per_option, allow_maybe, allow_comments, "
|
||||
"participant_email_required, anonymous_password_protection_enabled, "
|
||||
"anonymous_password_hash FROM scheduling_requests "
|
||||
"WHERE id = 'request-1'"
|
||||
)
|
||||
).one()
|
||||
counts = {
|
||||
model.__tablename__: connection.execute(
|
||||
select(func.count()).select_from(model)
|
||||
).scalar_one()
|
||||
for model in (
|
||||
SchedulingRequest,
|
||||
SchedulingCandidateSlot,
|
||||
SchedulingParticipant,
|
||||
SchedulingNotification,
|
||||
)
|
||||
}
|
||||
|
||||
self.assertIn(_SCHEDULING_HEAD, heads)
|
||||
self.assertIn("participant_visibility", columns)
|
||||
self.assertIn("cancellation_notice_until", columns)
|
||||
self.assertIn("max_participants_per_option", columns)
|
||||
self.assertIn("response_comment", participant_columns)
|
||||
self.assertIn("participation_gateway", participant_columns)
|
||||
self.assertEqual(visibility, "aggregates_only")
|
||||
self.assertEqual(tuple(response_defaults), (1, 0, None, 1, 0, 0, 0, None))
|
||||
self.assertEqual(set(counts.values()), {1})
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_adopts_already_present_compatible_privacy_column(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-scheduling-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
||||
self._migrate(url)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
self._remove_scheduling_revision(
|
||||
connection, remove_privacy_column=False
|
||||
)
|
||||
|
||||
self._migrate(url)
|
||||
|
||||
with engine.connect() as connection:
|
||||
heads = set(
|
||||
MigrationContext.configure(connection).get_current_heads()
|
||||
)
|
||||
matching_columns = [
|
||||
item
|
||||
for item in inspect(connection).get_columns(
|
||||
"scheduling_requests"
|
||||
)
|
||||
if item["name"] == "participant_visibility"
|
||||
]
|
||||
|
||||
self.assertIn(_SCHEDULING_HEAD, heads)
|
||||
self.assertEqual(len(matching_columns), 1)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_rejects_incomplete_existing_baseline(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-scheduling-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
||||
self._migrate(url)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
self._remove_scheduling_revision(connection)
|
||||
connection.execute(text("DROP INDEX ix_scheduling_requests_status"))
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"scheduling_requests missing indexes: ix_scheduling_requests_status",
|
||||
):
|
||||
self._migrate(url)
|
||||
|
||||
with engine.connect() as connection:
|
||||
heads = set(
|
||||
MigrationContext.configure(connection).get_current_heads()
|
||||
)
|
||||
self.assertNotIn(_SCHEDULING_HEAD, heads)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_rejects_partial_existing_calendar_extension(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-scheduling-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
||||
self._migrate(url)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
self._remove_scheduling_revision(connection)
|
||||
connection.execute(text("DROP TABLE scheduling_notifications"))
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"missing table: scheduling_notifications",
|
||||
):
|
||||
self._migrate(url)
|
||||
|
||||
with engine.connect() as connection:
|
||||
heads = set(
|
||||
MigrationContext.configure(connection).get_current_heads()
|
||||
)
|
||||
self.assertNotIn(_SCHEDULING_HEAD, heads)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_rejects_partial_existing_response_settings(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-scheduling-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
||||
self._migrate(url)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
self._remove_scheduling_revision(
|
||||
connection,
|
||||
remove_privacy_column=False,
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE scheduling_requests "
|
||||
"DROP COLUMN allow_comments"
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"partial scheduling_requests scheduling response settings",
|
||||
):
|
||||
self._migrate(url)
|
||||
|
||||
with engine.connect() as connection:
|
||||
heads = set(
|
||||
MigrationContext.configure(connection).get_current_heads()
|
||||
)
|
||||
self.assertNotIn(_SCHEDULING_HEAD, heads)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_rejects_partial_existing_participant_response_settings(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-scheduling-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
||||
self._migrate(url)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
self._remove_scheduling_revision(
|
||||
connection,
|
||||
remove_privacy_column=False,
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE scheduling_participants "
|
||||
"DROP COLUMN participation_gateway"
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"partial scheduling_participants scheduling response settings",
|
||||
):
|
||||
self._migrate(url)
|
||||
|
||||
with engine.connect() as connection:
|
||||
heads = set(
|
||||
MigrationContext.configure(connection).get_current_heads()
|
||||
)
|
||||
self.assertNotIn(_SCHEDULING_HEAD, heads)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_repairs_missing_participation_gateway_after_previous_head_was_stamped(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-scheduling-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
|
||||
self._migrate(url)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
self._seed_scheduling_rows(engine)
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"DELETE FROM alembic_version WHERE version_num = :revision"
|
||||
),
|
||||
{"revision": _SCHEDULING_HEAD},
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO alembic_version (version_num) VALUES (:revision)"
|
||||
),
|
||||
{"revision": _SCHEDULING_RESPONSE_SETTINGS_REVISION},
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE scheduling_participants "
|
||||
"DROP COLUMN participation_gateway"
|
||||
)
|
||||
)
|
||||
|
||||
self._migrate(url)
|
||||
|
||||
with engine.connect() as connection:
|
||||
heads = set(
|
||||
MigrationContext.configure(connection).get_current_heads()
|
||||
)
|
||||
participant_columns = {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_columns(
|
||||
"scheduling_participants"
|
||||
)
|
||||
}
|
||||
participant = connection.execute(
|
||||
text(
|
||||
"SELECT display_name, email, participation_gateway "
|
||||
"FROM scheduling_participants WHERE id = 'participant-1'"
|
||||
)
|
||||
).one()
|
||||
|
||||
self.assertIn(_SCHEDULING_HEAD, heads)
|
||||
self.assertIn("participation_gateway", participant_columns)
|
||||
self.assertEqual(
|
||||
tuple(participant),
|
||||
("Ada", "ada@example.test", None),
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
35
tests/test_module_boundary.py
Normal file
35
tests/test_module_boundary.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class SchedulingModuleBoundaryTests(unittest.TestCase):
|
||||
def test_runtime_source_does_not_import_poll_implementation_internals(self) -> None:
|
||||
offenders: list[str] = []
|
||||
source_root = ROOT / "src" / "govoplan_scheduling"
|
||||
for path in source_root.rglob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
imported_modules = [
|
||||
node.module
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.ImportFrom) and node.module is not None
|
||||
]
|
||||
imported_modules.extend(
|
||||
alias.name
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Import)
|
||||
for alias in node.names
|
||||
)
|
||||
if any(module.startswith("govoplan_poll") for module in imported_modules):
|
||||
offenders.append(str(path.relative_to(ROOT)))
|
||||
|
||||
self.assertEqual([], offenders)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
354
tests/test_participant_privacy.py
Normal file
354
tests/test_participant_privacy.py
Normal file
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||
PolicySourceStep,
|
||||
SchedulingParticipantPrivacyDecision,
|
||||
SchedulingParticipantPrivacyRequest,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_scheduling.backend.db.models import (
|
||||
SchedulingCandidateSlot,
|
||||
SchedulingParticipant,
|
||||
SchedulingRequest,
|
||||
)
|
||||
from govoplan_scheduling.backend.runtime import configure_runtime
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingCandidateSlotInput,
|
||||
SchedulingRequestCreateRequest,
|
||||
SchedulingRequestResponse,
|
||||
SchedulingRequestUpdateRequest,
|
||||
)
|
||||
from govoplan_scheduling.backend.service import scheduling_request_response
|
||||
|
||||
|
||||
class _PrivacyPolicy:
|
||||
def __init__(self, effective_visibility: str) -> None:
|
||||
self.effective_visibility = effective_visibility
|
||||
self.requests: list[SchedulingParticipantPrivacyRequest] = []
|
||||
|
||||
def resolve_scheduling_participant_visibility(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SchedulingParticipantPrivacyRequest,
|
||||
) -> SchedulingParticipantPrivacyDecision:
|
||||
self.requests.append(request)
|
||||
return SchedulingParticipantPrivacyDecision(
|
||||
effective_visibility=self.effective_visibility, # type: ignore[arg-type]
|
||||
reason="Tenant participant privacy policy",
|
||||
source_path=(
|
||||
PolicySourceStep(
|
||||
scope_type="tenant",
|
||||
scope_id=request.tenant_id,
|
||||
label="Tenant",
|
||||
applied_fields=("scheduling_participant_visibility",),
|
||||
),
|
||||
),
|
||||
details={"provider_session_available": session is not None},
|
||||
)
|
||||
|
||||
|
||||
class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
configure_runtime(registry=PlatformRegistry())
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
SchedulingRequest.__table__,
|
||||
SchedulingCandidateSlot.__table__,
|
||||
SchedulingParticipant.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session: Session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
SchedulingParticipant.__table__,
|
||||
SchedulingCandidateSlot.__table__,
|
||||
SchedulingRequest.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
configure_runtime(registry=PlatformRegistry())
|
||||
|
||||
def _request(self, *, participant_visibility: str | None = None) -> SchedulingRequest:
|
||||
values = {
|
||||
"tenant_id": "tenant-1",
|
||||
"title": "Privacy review",
|
||||
"status": "collecting",
|
||||
"poll_id": "poll-internal",
|
||||
"organizer_user_id": "organizer-1",
|
||||
"calendar_integration_enabled": True,
|
||||
"calendar_id": "calendar-internal",
|
||||
"calendar_freebusy_enabled": True,
|
||||
"calendar_hold_enabled": True,
|
||||
"create_calendar_event_on_decision": True,
|
||||
"calendar_event_id": "event-internal",
|
||||
"metadata_": {"connector_secret_ref": "secret-internal"},
|
||||
}
|
||||
if participant_visibility is not None:
|
||||
values["participant_visibility"] = participant_visibility
|
||||
request = SchedulingRequest(**values)
|
||||
request.slots = [
|
||||
SchedulingCandidateSlot(
|
||||
tenant_id="tenant-1",
|
||||
poll_option_id="poll-option-internal",
|
||||
label="Monday morning",
|
||||
start_at=datetime(2026, 7, 27, 9, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 27, 10, tzinfo=timezone.utc),
|
||||
timezone="Europe/Berlin",
|
||||
position=0,
|
||||
freebusy_checked_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc),
|
||||
freebusy_status="busy",
|
||||
freebusy_conflicts=[
|
||||
{
|
||||
"calendar_id": "calendar-internal",
|
||||
"event_id": "event-internal",
|
||||
"uid": "connector-uid-internal",
|
||||
"recurrence_id": "recurrence-internal",
|
||||
"start_at": "2026-07-27T09:30:00+00:00",
|
||||
"end_at": "2026-07-27T09:45:00+00:00",
|
||||
"status": "CONFIRMED",
|
||||
},
|
||||
{"error": "connector-internal failure"},
|
||||
],
|
||||
tentative_hold_event_id="hold-internal",
|
||||
metadata_={"provider_ref": "provider-internal"},
|
||||
)
|
||||
]
|
||||
request.participants = [
|
||||
SchedulingParticipant(
|
||||
tenant_id="tenant-1",
|
||||
respondent_id="alice-id",
|
||||
display_name="Alice",
|
||||
email="alice@example.test",
|
||||
participant_type="internal",
|
||||
status="responded",
|
||||
poll_invitation_id="invitation-alice-internal",
|
||||
last_invited_at=datetime(2026, 7, 20, 12, tzinfo=timezone.utc),
|
||||
responded_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc),
|
||||
metadata_={"private": "alice"},
|
||||
),
|
||||
SchedulingParticipant(
|
||||
tenant_id="tenant-1",
|
||||
respondent_id="bob-id",
|
||||
display_name="Bob",
|
||||
email="bob@example.test",
|
||||
participant_type="external",
|
||||
status="invited",
|
||||
metadata_={"private": "bob"},
|
||||
),
|
||||
]
|
||||
self.session.add(request)
|
||||
self.session.flush()
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def _participant_projection(request: SchedulingRequest) -> dict[str, object]:
|
||||
return scheduling_request_response(
|
||||
request,
|
||||
actor_ids=("alice-id", "alice@example.test"),
|
||||
actor_user_id="alice-account",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _configure_policy(provider: _PrivacyPolicy) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="policy_test",
|
||||
name="Policy test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: lambda context: provider,
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
configure_runtime(registry=registry)
|
||||
|
||||
def test_secure_default_returns_own_row_and_aggregate_counts(self) -> None:
|
||||
request = self._request()
|
||||
|
||||
with patch(
|
||||
"govoplan_scheduling.backend.service.notification_dispatch_provider",
|
||||
return_value=object(),
|
||||
):
|
||||
payload = self._participant_projection(request)
|
||||
response = SchedulingRequestResponse.model_validate(payload)
|
||||
|
||||
self.assertEqual(request.participant_visibility, "aggregates_only")
|
||||
self.assertEqual(response.participant_visibility, "aggregates_only")
|
||||
self.assertEqual(response.effective_participant_visibility, "aggregates_only")
|
||||
self.assertEqual([participant.display_name for participant in response.participants], ["Alice"])
|
||||
own = response.participants[0]
|
||||
self.assertTrue(own.is_current_participant)
|
||||
self.assertEqual(own.email, "alice@example.test")
|
||||
self.assertIsNone(own.respondent_id)
|
||||
self.assertIsNone(own.participant_type)
|
||||
self.assertIsNone(own.required)
|
||||
self.assertIsNone(own.poll_invitation_id)
|
||||
self.assertEqual(own.metadata, {})
|
||||
self.assertEqual(response.participant_aggregate.total, 2)
|
||||
self.assertEqual(response.participant_aggregate.status_counts["responded"], 1)
|
||||
self.assertEqual(response.participant_aggregate.status_counts["invited"], 1)
|
||||
self.assertIsNone(response.tenant_id)
|
||||
self.assertIsNone(response.poll_id)
|
||||
self.assertIsNone(response.organizer_user_id)
|
||||
self.assertIsNone(response.calendar_integration_enabled)
|
||||
self.assertIsNone(response.calendar_id)
|
||||
self.assertIsNone(response.calendar_freebusy_enabled)
|
||||
self.assertIsNone(response.calendar_hold_enabled)
|
||||
self.assertIsNone(response.create_calendar_event_on_decision)
|
||||
self.assertIsNone(response.calendar_event_id)
|
||||
self.assertIsNone(response.public_participation_policy_enforcement_available)
|
||||
self.assertIsNone(response.participant_invitation_delivery_available)
|
||||
self.assertEqual(response.metadata, {})
|
||||
slot = response.slots[0]
|
||||
self.assertIsNone(slot.poll_option_id)
|
||||
self.assertEqual(slot.freebusy_status, "busy")
|
||||
self.assertEqual(
|
||||
slot.freebusy_conflicts,
|
||||
[
|
||||
{
|
||||
"start_at": "2026-07-27T09:30:00+00:00",
|
||||
"end_at": "2026-07-27T09:45:00+00:00",
|
||||
"status": "CONFIRMED",
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertIsNone(slot.tentative_hold_event_id)
|
||||
self.assertEqual(slot.metadata, {})
|
||||
|
||||
def test_configured_roster_returns_other_names_and_statuses_with_sensitive_fields_redacted(self) -> None:
|
||||
request = self._request(participant_visibility="names_and_statuses")
|
||||
|
||||
response = SchedulingRequestResponse.model_validate(self._participant_projection(request))
|
||||
|
||||
self.assertEqual(response.effective_participant_visibility, "names_and_statuses")
|
||||
own = next(participant for participant in response.participants if participant.display_name == "Alice")
|
||||
other = next(participant for participant in response.participants if participant.display_name == "Bob")
|
||||
self.assertTrue(own.is_current_participant)
|
||||
self.assertIsNone(own.respondent_id)
|
||||
self.assertEqual(own.email, "alice@example.test")
|
||||
self.assertEqual(other.status, "invited")
|
||||
self.assertFalse(other.is_current_participant)
|
||||
self.assertIsNone(other.respondent_id)
|
||||
self.assertIsNone(other.email)
|
||||
self.assertIsNone(other.participant_type)
|
||||
self.assertIsNone(other.required)
|
||||
self.assertIsNone(other.poll_invitation_id)
|
||||
self.assertEqual(other.metadata, {})
|
||||
|
||||
def test_manager_and_organizer_bypass_participant_roster_restriction(self) -> None:
|
||||
request = self._request()
|
||||
|
||||
manager = SchedulingRequestResponse.model_validate(
|
||||
scheduling_request_response(
|
||||
request,
|
||||
actor_ids=("manager-1",),
|
||||
actor_user_id="manager-1",
|
||||
can_manage=True,
|
||||
)
|
||||
)
|
||||
organizer = SchedulingRequestResponse.model_validate(
|
||||
scheduling_request_response(
|
||||
request,
|
||||
actor_ids=("organizer-1",),
|
||||
actor_user_id="organizer-1",
|
||||
)
|
||||
)
|
||||
|
||||
for response in (manager, organizer):
|
||||
self.assertEqual(response.effective_participant_visibility, "names_and_statuses")
|
||||
self.assertEqual({participant.email for participant in response.participants}, {
|
||||
"alice@example.test",
|
||||
"bob@example.test",
|
||||
})
|
||||
self.assertTrue(response.participant_visibility_decision.details["management_access"])
|
||||
self.assertEqual(response.tenant_id, "tenant-1")
|
||||
self.assertEqual(response.poll_id, "poll-internal")
|
||||
self.assertEqual(response.organizer_user_id, "organizer-1")
|
||||
self.assertEqual(response.calendar_id, "calendar-internal")
|
||||
self.assertEqual(response.calendar_event_id, "event-internal")
|
||||
self.assertEqual(response.metadata["connector_secret_ref"], "secret-internal")
|
||||
self.assertEqual(response.slots[0].poll_option_id, "poll-option-internal")
|
||||
self.assertEqual(
|
||||
response.slots[0].freebusy_conflicts[0]["uid"],
|
||||
"connector-uid-internal",
|
||||
)
|
||||
self.assertEqual(response.slots[0].tentative_hold_event_id, "hold-internal")
|
||||
self.assertFalse(response.participant_invitation_delivery_available)
|
||||
|
||||
with patch(
|
||||
"govoplan_scheduling.backend.service.notification_dispatch_provider",
|
||||
return_value=object(),
|
||||
):
|
||||
delivery_enabled = SchedulingRequestResponse.model_validate(
|
||||
scheduling_request_response(
|
||||
request,
|
||||
actor_ids=("manager-1",),
|
||||
actor_user_id="manager-1",
|
||||
can_manage=True,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(delivery_enabled.participant_invitation_delivery_available)
|
||||
|
||||
def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
|
||||
restricting_policy = _PrivacyPolicy("aggregates_only")
|
||||
self._configure_policy(restricting_policy)
|
||||
visible_request = self._request(participant_visibility="names_and_statuses")
|
||||
|
||||
reduced = SchedulingRequestResponse.model_validate(self._participant_projection(visible_request))
|
||||
|
||||
self.assertEqual(reduced.effective_participant_visibility, "aggregates_only")
|
||||
self.assertEqual([participant.display_name for participant in reduced.participants], ["Alice"])
|
||||
self.assertTrue(reduced.participant_visibility_decision.policy_applied)
|
||||
self.assertEqual(reduced.participant_visibility_decision.reason, "Tenant participant privacy policy")
|
||||
self.assertEqual(reduced.participant_visibility_decision.source_path, [])
|
||||
self.assertEqual(reduced.participant_visibility_decision.details, {})
|
||||
self.assertEqual(restricting_policy.requests[0].actor_user_id, "alice-account")
|
||||
|
||||
widening_policy = _PrivacyPolicy("names_and_statuses")
|
||||
self._configure_policy(widening_policy)
|
||||
private_request = self._request()
|
||||
|
||||
clamped = SchedulingRequestResponse.model_validate(self._participant_projection(private_request))
|
||||
|
||||
self.assertEqual(clamped.effective_participant_visibility, "aggregates_only")
|
||||
self.assertEqual([participant.display_name for participant in clamped.participants], ["Alice"])
|
||||
self.assertEqual(widening_policy.requests[0].requested_visibility, "aggregates_only")
|
||||
|
||||
def test_create_and_update_schemas_expose_the_configuration(self) -> None:
|
||||
slot = SchedulingCandidateSlotInput.model_validate(
|
||||
{
|
||||
"start_at": "2026-07-20T09:00:00Z",
|
||||
"end_at": "2026-07-20T10:00:00Z",
|
||||
}
|
||||
)
|
||||
|
||||
created = SchedulingRequestCreateRequest(title="Privacy", slots=[slot])
|
||||
updated = SchedulingRequestUpdateRequest(participant_visibility="names_and_statuses")
|
||||
|
||||
self.assertEqual(created.participant_visibility, "aggregates_only")
|
||||
self.assertEqual(updated.participant_visibility, "names_and_statuses")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
138
tests/test_people_search.py
Normal file
138
tests/test_people_search.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.people import (
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
PeopleSearchGroup,
|
||||
PersonSearchCandidate,
|
||||
)
|
||||
from govoplan_scheduling.backend.manifest import WRITE_SCOPE
|
||||
from govoplan_scheduling.backend.router import api_search_scheduling_people
|
||||
from govoplan_scheduling.backend.runtime import configure_runtime
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capabilities: dict[str, object] | None = None) -> None:
|
||||
self.capabilities = capabilities or {}
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self.capabilities
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
return self.capabilities[name]
|
||||
|
||||
|
||||
class _PeopleProvider:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[object, object, str, int]] = []
|
||||
|
||||
def search_people(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
) -> tuple[PeopleSearchGroup, ...]:
|
||||
self.calls.append((session, principal, query, limit))
|
||||
return (
|
||||
PeopleSearchGroup(
|
||||
key="accounts",
|
||||
label="Accounts",
|
||||
candidates=(
|
||||
PersonSearchCandidate(
|
||||
selection_key="account:account-2",
|
||||
kind="account",
|
||||
reference_id="account-2",
|
||||
display_name="Ada Lovelace",
|
||||
email="ada@example.test",
|
||||
source_module="access",
|
||||
source_label="Accounts",
|
||||
source_ref="access:account:account-2",
|
||||
source_revision="revision-1",
|
||||
description="Research",
|
||||
provenance={"tenant_id": "tenant-1", "internal": "secret"},
|
||||
metadata={"internal_group_ids": ["group-1"]},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _principal(*scopes: str) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
email="organizer@example.test",
|
||||
display_name="Organizer",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="membership-1"),
|
||||
)
|
||||
|
||||
|
||||
class SchedulingPeopleSearchTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
configure_runtime(registry=_Registry())
|
||||
|
||||
def test_search_uses_principal_aware_core_aggregator_and_redacts_provider_internals(self) -> None:
|
||||
provider = _PeopleProvider()
|
||||
registry = _Registry({CAPABILITY_ACCESS_PEOPLE_SEARCH: provider})
|
||||
configure_runtime(registry=registry)
|
||||
session = object()
|
||||
principal = _principal(WRITE_SCOPE)
|
||||
|
||||
response = api_search_scheduling_people(
|
||||
query="ada",
|
||||
limit=12,
|
||||
session=session, # type: ignore[arg-type] - provider contract is intentionally generic
|
||||
principal=principal,
|
||||
)
|
||||
|
||||
self.assertEqual([(session, principal, "ada", 12)], provider.calls)
|
||||
payload = response.model_dump()
|
||||
self.assertEqual("account:account-2", payload["groups"][0]["candidates"][0]["selection_key"])
|
||||
self.assertEqual("revision-1", payload["groups"][0]["candidates"][0]["source_revision"])
|
||||
self.assertNotIn("source_ref", payload["groups"][0]["candidates"][0])
|
||||
self.assertNotIn("provenance", payload["groups"][0]["candidates"][0])
|
||||
self.assertNotIn("metadata", payload["groups"][0]["candidates"][0])
|
||||
|
||||
def test_search_is_empty_when_no_optional_directory_provider_is_installed(self) -> None:
|
||||
configure_runtime(registry=_Registry())
|
||||
|
||||
response = api_search_scheduling_people(
|
||||
query="ada",
|
||||
limit=25,
|
||||
session=object(), # type: ignore[arg-type]
|
||||
principal=_principal(WRITE_SCOPE),
|
||||
)
|
||||
|
||||
self.assertEqual([], response.groups)
|
||||
|
||||
def test_search_requires_scheduling_write_or_admin_access(self) -> None:
|
||||
provider = _PeopleProvider()
|
||||
configure_runtime(registry=_Registry({CAPABILITY_ACCESS_PEOPLE_SEARCH: provider}))
|
||||
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
api_search_scheduling_people(
|
||||
query="ada",
|
||||
limit=25,
|
||||
session=object(), # type: ignore[arg-type]
|
||||
principal=_principal("scheduling:schedule:read"),
|
||||
)
|
||||
|
||||
self.assertEqual(403, raised.exception.status_code)
|
||||
self.assertEqual([], provider.calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
283
tests/test_reconciliation_plans.py
Normal file
283
tests/test_reconciliation_plans.py
Normal file
@@ -0,0 +1,283 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_scheduling.backend.db.models import (
|
||||
SchedulingCandidateSlot,
|
||||
SchedulingParticipant,
|
||||
SchedulingRequest,
|
||||
)
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingCandidateSlotReconcileInput,
|
||||
SchedulingParticipantReconcileInput,
|
||||
SchedulingRequestUpdateRequest,
|
||||
)
|
||||
from govoplan_scheduling.backend.service import (
|
||||
SchedulingError,
|
||||
_plan_scheduling_participant_reconciliation,
|
||||
_plan_scheduling_request_update,
|
||||
_plan_scheduling_slot_reconciliation,
|
||||
scheduling_participant_revision,
|
||||
scheduling_slot_revision,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _request() -> SchedulingRequest:
|
||||
return SchedulingRequest(
|
||||
id="request-1",
|
||||
tenant_id="tenant-1",
|
||||
title="Steering group",
|
||||
timezone="Europe/Berlin",
|
||||
status="collecting",
|
||||
poll_id="poll-1",
|
||||
allow_external_participants=True,
|
||||
allow_participant_updates=True,
|
||||
result_visibility="after_close",
|
||||
participant_visibility="aggregates_only",
|
||||
notify_on_answers=True,
|
||||
single_choice=False,
|
||||
max_participants_per_option=None,
|
||||
allow_maybe=True,
|
||||
allow_comments=False,
|
||||
participant_email_required=False,
|
||||
anonymous_password_protection_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def _slot(
|
||||
request: SchedulingRequest,
|
||||
*,
|
||||
slot_id: str,
|
||||
position: int,
|
||||
start_offset: int,
|
||||
) -> SchedulingCandidateSlot:
|
||||
slot = SchedulingCandidateSlot(
|
||||
id=slot_id,
|
||||
tenant_id=request.tenant_id,
|
||||
request=request,
|
||||
poll_option_id=f"option-{slot_id}",
|
||||
label=f"Slot {position + 1}",
|
||||
start_at=NOW + timedelta(hours=start_offset),
|
||||
end_at=NOW + timedelta(hours=start_offset + 1),
|
||||
timezone=request.timezone,
|
||||
position=position,
|
||||
freebusy_conflicts=[],
|
||||
metadata_={},
|
||||
)
|
||||
return slot
|
||||
|
||||
|
||||
def _slot_input(
|
||||
slot: SchedulingCandidateSlot,
|
||||
*,
|
||||
label: str | None = None,
|
||||
) -> SchedulingCandidateSlotReconcileInput:
|
||||
return SchedulingCandidateSlotReconcileInput(
|
||||
id=slot.id,
|
||||
revision=scheduling_slot_revision(slot),
|
||||
label=label or slot.label,
|
||||
start_at=slot.start_at,
|
||||
end_at=slot.end_at,
|
||||
timezone=slot.timezone,
|
||||
location=slot.location,
|
||||
metadata=slot.metadata_ or {},
|
||||
)
|
||||
|
||||
|
||||
def _participant(
|
||||
request: SchedulingRequest,
|
||||
*,
|
||||
participant_id: str,
|
||||
respondent_id: str,
|
||||
email: str,
|
||||
required: bool,
|
||||
status: str = "invited",
|
||||
invitation_id: str | None = None,
|
||||
) -> SchedulingParticipant:
|
||||
return SchedulingParticipant(
|
||||
id=participant_id,
|
||||
tenant_id=request.tenant_id,
|
||||
request=request,
|
||||
respondent_id=respondent_id,
|
||||
display_name=participant_id.title(),
|
||||
email=email,
|
||||
participant_type="internal",
|
||||
required=required,
|
||||
status=status,
|
||||
poll_invitation_id=invitation_id,
|
||||
participation_gateway="scheduling" if invitation_id else None,
|
||||
metadata_={},
|
||||
)
|
||||
|
||||
|
||||
def _participant_input(
|
||||
participant: SchedulingParticipant,
|
||||
**changes: object,
|
||||
) -> SchedulingParticipantReconcileInput:
|
||||
values = {
|
||||
"id": participant.id,
|
||||
"revision": scheduling_participant_revision(participant),
|
||||
"respondent_id": participant.respondent_id,
|
||||
"display_name": participant.display_name,
|
||||
"email": participant.email,
|
||||
"participant_type": participant.participant_type,
|
||||
"required": participant.required,
|
||||
"metadata": participant.metadata_ or {},
|
||||
}
|
||||
values.update(changes)
|
||||
return SchedulingParticipantReconcileInput.model_validate(values)
|
||||
|
||||
|
||||
def test_slot_plan_is_inspectable_and_does_not_mutate_models() -> None:
|
||||
request = _request()
|
||||
first = _slot(request, slot_id="slot-1", position=0, start_offset=1)
|
||||
second = _slot(request, slot_id="slot-2", position=1, start_offset=3)
|
||||
supplied = [
|
||||
_slot_input(first, label="Updated first slot"),
|
||||
SchedulingCandidateSlotReconcileInput(
|
||||
label="New slot",
|
||||
start_at=NOW + timedelta(hours=5),
|
||||
end_at=NOW + timedelta(hours=6),
|
||||
timezone=request.timezone,
|
||||
),
|
||||
]
|
||||
|
||||
plan = _plan_scheduling_slot_reconciliation(
|
||||
request=request,
|
||||
supplied_slots=supplied,
|
||||
option_mutation_available=True,
|
||||
)
|
||||
|
||||
assert [update.slot_id for update in plan.updates] == ["slot-1"]
|
||||
assert plan.updates[0].changes.label == "Updated first slot"
|
||||
assert len(plan.additions) == 1
|
||||
assert plan.removals == ("slot-2",)
|
||||
assert plan.changed is True
|
||||
assert first.label == "Slot 1"
|
||||
assert second.deleted_at is None
|
||||
assert len(request.slots) == 2
|
||||
|
||||
|
||||
def test_exact_slot_replay_produces_a_noop_plan() -> None:
|
||||
request = _request()
|
||||
first = _slot(request, slot_id="slot-1", position=0, start_offset=1)
|
||||
|
||||
plan = _plan_scheduling_slot_reconciliation(
|
||||
request=request,
|
||||
supplied_slots=[_slot_input(first)],
|
||||
option_mutation_available=True,
|
||||
)
|
||||
|
||||
assert plan.changed is False
|
||||
assert plan.updates == ()
|
||||
assert plan.additions == ()
|
||||
assert plan.removals == ()
|
||||
|
||||
|
||||
def test_slot_plan_rejects_removal_of_a_tentative_calendar_hold() -> None:
|
||||
request = _request()
|
||||
held = _slot(request, slot_id="slot-held", position=0, start_offset=1)
|
||||
held.tentative_hold_event_id = "event-1"
|
||||
|
||||
with pytest.raises(SchedulingError, match="tentative calendar hold"):
|
||||
_plan_scheduling_slot_reconciliation(
|
||||
request=request,
|
||||
supplied_slots=[],
|
||||
option_mutation_available=True,
|
||||
)
|
||||
|
||||
|
||||
def test_participant_plan_distinguishes_updates_additions_and_retirements() -> None:
|
||||
request = _request()
|
||||
alice = _participant(
|
||||
request,
|
||||
participant_id="alice",
|
||||
respondent_id="user-alice",
|
||||
email="alice@example.test",
|
||||
required=True,
|
||||
invitation_id="invitation-alice",
|
||||
)
|
||||
bob = _participant(
|
||||
request,
|
||||
participant_id="bob",
|
||||
respondent_id="user-bob",
|
||||
email="bob@example.test",
|
||||
required=False,
|
||||
status="responded",
|
||||
)
|
||||
supplied = [
|
||||
_participant_input(alice, email="alice.new@example.test", required=False),
|
||||
SchedulingParticipantReconcileInput(
|
||||
respondent_id="user-charlie",
|
||||
display_name="Charlie",
|
||||
email="charlie@example.test",
|
||||
participant_type="internal",
|
||||
required=True,
|
||||
),
|
||||
]
|
||||
|
||||
plan = _plan_scheduling_participant_reconciliation(
|
||||
request=request,
|
||||
supplied_participants=supplied,
|
||||
participation_available=True,
|
||||
retirement_available=True,
|
||||
)
|
||||
|
||||
assert len(plan.updates) == 1
|
||||
assert plan.updates[0].participant_id == "alice"
|
||||
assert plan.updates[0].revoke_invitation is True
|
||||
assert set(plan.updates[0].changed_fields) == {"email", "required"}
|
||||
assert plan.creations[0].replaces_participant_id is None
|
||||
assert plan.creations[0].supplied.required is True
|
||||
assert plan.retirements == ("bob",)
|
||||
assert alice.email == "alice@example.test"
|
||||
assert alice.required is True
|
||||
assert bob.status == "responded"
|
||||
|
||||
|
||||
def test_request_plan_records_invitation_expiry_work_without_tokens() -> None:
|
||||
request = _request()
|
||||
participant = _participant(
|
||||
request,
|
||||
participant_id="alice",
|
||||
respondent_id="user-alice",
|
||||
email="alice@example.test",
|
||||
required=True,
|
||||
invitation_id="invitation-alice",
|
||||
)
|
||||
deadline = NOW + timedelta(days=2)
|
||||
|
||||
plan = _plan_scheduling_request_update(
|
||||
request=request,
|
||||
payload=SchedulingRequestUpdateRequest(deadline_at=deadline),
|
||||
participation_available=True,
|
||||
)
|
||||
|
||||
assert plan.deadline_changed is True
|
||||
assert plan.retained_invitation_ids == ((participant.id, "invitation-alice"),)
|
||||
assert "token" not in repr(plan).casefold()
|
||||
assert request.deadline_at is None
|
||||
|
||||
|
||||
def test_request_plan_rejects_policy_change_after_link_issuance() -> None:
|
||||
request = _request()
|
||||
_participant(
|
||||
request,
|
||||
participant_id="alice",
|
||||
respondent_id="user-alice",
|
||||
email="alice@example.test",
|
||||
required=True,
|
||||
invitation_id="invitation-alice",
|
||||
)
|
||||
|
||||
with pytest.raises(SchedulingError, match="cannot change"):
|
||||
_plan_scheduling_request_update(
|
||||
request=request,
|
||||
payload=SchedulingRequestUpdateRequest(single_choice=True),
|
||||
participation_available=True,
|
||||
)
|
||||
1882
tests/test_response_editing.py
Normal file
1882
tests/test_response_editing.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/scheduling-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,12 +13,16 @@
|
||||
},
|
||||
"./styles/scheduling.css": "./src/styles/scheduling.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:view-model": "node --experimental-strip-types --test tests/scheduling-view-model.test.ts",
|
||||
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-router-dom": ">=7.18.2 <8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
|
||||
160
webui/scripts/test-scheduling-page-structure.mjs
Normal file
160
webui/scripts/test-scheduling-page-structure.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url));
|
||||
const publicPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPublicPage.tsx", import.meta.url));
|
||||
const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
|
||||
const modulePath = fileURLToPath(new URL("../src/module.ts", import.meta.url));
|
||||
const page = readFileSync(pagePath, "utf8");
|
||||
const publicPage = readFileSync(publicPagePath, "utf8");
|
||||
const api = readFileSync(apiPath, "utf8");
|
||||
const moduleSource = readFileSync(modulePath, "utf8");
|
||||
|
||||
assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calendar\.picker"\)/);
|
||||
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
|
||||
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
|
||||
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
|
||||
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*PeoplePicker,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
|
||||
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
|
||||
|
||||
assert.match(page, /className="scheduling-workspace-layout"/);
|
||||
assert.match(page, /<aside className="scheduling-request-sidebar">/);
|
||||
assert.match(page, /<section className="scheduling-main-panel">/);
|
||||
assert.match(page, /title=\{I18N\.requests\}[\s\S]*<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/);
|
||||
assert.match(page, /title=\{I18N\.myRequests\}/);
|
||||
assert.match(page, /title=\{I18N\.invitedRequests\}/);
|
||||
assert.ok(page.indexOf("title={I18N.myRequests}") < page.indexOf("title={I18N.invitedRequests}"));
|
||||
assert.match(page, /<SelectionList label=\{title\} className="scheduling-request-list">/);
|
||||
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selectedId === request\.id\}[\s\S]*className="scheduling-list-item"/);
|
||||
assert.match(page, /className="scheduling-list-item"[\s\S]{0,100}disabled=\{disabled\}/);
|
||||
|
||||
assert.match(page, /editorMode \? \(/);
|
||||
assert.match(page, /id="scheduling-editor-form"/);
|
||||
assert.doesNotMatch(page, /ModuleSubnav|scheduling-create-subnav|scheduling-full-editor/);
|
||||
const editorStart = page.indexOf('<div className="scheduling-editor-surface">');
|
||||
const editorEnd = page.indexOf('</form>', editorStart);
|
||||
const editor = page.slice(editorStart, editorEnd);
|
||||
assert.ok(editor.indexOf("I18N.discard") < editor.indexOf("I18N.save"));
|
||||
assert.match(editor, /form="scheduling-editor-form"/);
|
||||
assert.match(editor, /<Card title=\{I18N\.basicInformation\}>/);
|
||||
assert.match(editor, /<Card title=\{I18N\.calendarIntegration\}>/);
|
||||
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
|
||||
assert.match(page, /<Card title=\{I18N\.participants\}>/);
|
||||
assert.match(page, /<Card title=\{I18N\.generalSettings\}>/);
|
||||
assert.match(page, /<Card title=\{I18N\.participantPrivacy\}>/);
|
||||
assert.match(editor, /<FormField label=\{I18N\.title\}>/);
|
||||
assert.match(page, /useUnsavedDraftGuard\(/);
|
||||
assert.match(page, /requestDiscard\(exitEditor\)/);
|
||||
assert.match(page, /dirty: responseDirty,[\s\S]*onSave: persistAvailability,[\s\S]*onDiscard: resetResponseDraft/);
|
||||
assert.doesNotMatch(page, /window\.(?:alert|confirm)\(/);
|
||||
|
||||
for (const setting of [
|
||||
"participantRosterVisible",
|
||||
"notifyOnAnswers",
|
||||
"singleChoice",
|
||||
"participantLimitEnabled",
|
||||
"allowMaybe",
|
||||
"allowComments",
|
||||
"participantEmailRequired",
|
||||
"anonymousPasswordProtectionEnabled"
|
||||
]) {
|
||||
assert.match(page, new RegExp(`checked=\\{${setting}\\}`));
|
||||
}
|
||||
assert.match(page, /<PasswordField[\s\S]*minLength=\{8\}/);
|
||||
assert.match(page, /type="number"[\s\S]*min=\{1\}/);
|
||||
assert.match(page, /min=\{addLocalMinutes\(slot\.start_at, 1\)\}/);
|
||||
assert.match(page, /allow_external_participants: allowExternalParticipants/);
|
||||
assert.doesNotMatch(page, /usesGatewayPolicy|updateSchedulingCandidateSlot/);
|
||||
assert.match(page, /public_participation_policy_enforcement_available/);
|
||||
assert.match(page, /const canCreateOrWrite = canWrite \|\| canAdminister/);
|
||||
assert.match(page, /policyLocked=\{participationPolicyLocked\}/);
|
||||
|
||||
assert.match(page, /id="scheduling-create-candidate-slots-grid"/);
|
||||
assert.match(page, /id="scheduling-participant-picker"/);
|
||||
assert.match(page, /id="scheduling-candidate-slots-grid"/);
|
||||
assert.match(page, /id="scheduling-participants-grid"/);
|
||||
assert.match(page, /<DataGridRowActions/);
|
||||
assert.match(page, /disabled=\{!canCreateOrWrite\}[\s\S]{0,80}reorderable/);
|
||||
assert.doesNotMatch(page, /reorderable=\{editorMode === "create"\}/);
|
||||
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
|
||||
assert.doesNotMatch(page, /<input[\s\S]{0,220}aria-label=\{I18N\.participantEmail\}/);
|
||||
assert.match(page, /allowManualExternal=\{allowExternalParticipants\}/);
|
||||
assert.match(page, /search=\{participantSearch\}/);
|
||||
assert.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
|
||||
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
|
||||
assert.match(page, /showDecisionAction=\{canManageSelected\}/);
|
||||
assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/);
|
||||
assert.doesNotMatch(page, /AdminIconButton/);
|
||||
|
||||
const participantGridStart = page.indexOf("function ParticipantsGrid(");
|
||||
const participantGridEnd = page.indexOf("function invitationActionDisabledReason", participantGridStart);
|
||||
const participantGrid = page.slice(participantGridStart, participantGridEnd);
|
||||
assert.match(participantGrid, /\.\.\.\(canManage \? \[\{/);
|
||||
assert.match(participantGrid, /minimumSlots=\{3\}/);
|
||||
assert.ok(participantGrid.indexOf('id: "copy-invitation"') < participantGrid.indexOf('id: "send-invitation"'));
|
||||
assert.ok(participantGrid.indexOf('id: "send-invitation"') < participantGrid.indexOf('id: "revoke-invitation"'));
|
||||
assert.match(participantGrid, /schedulingInvitationActionBlocks\(request, participant, now\)/);
|
||||
assert.match(participantGrid, /disabledReason: copyDisabledReason/);
|
||||
assert.match(participantGrid, /disabledReason: deliveryDisabledReason/);
|
||||
assert.match(participantGrid, /disabledReason: revokeDisabledReason/);
|
||||
assert.match(page, /<ConfirmDialog[\s\S]*title=\{I18N\.revokeInvitationLabel\}[\s\S]*tone="danger"/);
|
||||
assert.match(page, /navigator\.clipboard\.writeText\(value\)/);
|
||||
assert.match(page, /navigator\.clipboard\.write\(\[new ClipboardItem/);
|
||||
assert.match(page, /schedulingPublicInvitationUrl\(response\.action_url, window\.location\.origin\)/);
|
||||
assert.match(page, /\["failed", "skipped"\]\.includes\(result\.status\)/);
|
||||
assert.match(page, /isApiError\(err, 409\)/);
|
||||
assert.match(page, /scheduleExpiryRefresh/);
|
||||
assert.doesNotMatch(page, /(?:localStorage|sessionStorage).*action_url|action_url.*(?:localStorage|sessionStorage)/);
|
||||
|
||||
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
|
||||
assert.match(page, /option_revision: slot\.revision/);
|
||||
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
|
||||
assert.match(page, /const answers = Object\.fromEntries\(response\.answers\.map/);
|
||||
assert.match(page, /setSavedAvailability\(answers\)/);
|
||||
assert.match(page, /const comment = response\.comment \?\? ""/);
|
||||
assert.match(page, /setSavedAvailabilityComment\(comment\)/);
|
||||
assert.match(page, /<ParticipationStats/);
|
||||
assert.match(page, /selected\.effective_participant_visibility === "names_and_statuses"/);
|
||||
assert.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/);
|
||||
assert.match(page, /className="scheduling-selected-calendar"/);
|
||||
|
||||
for (const field of [
|
||||
"notify_on_answers",
|
||||
"single_choice",
|
||||
"max_participants_per_option",
|
||||
"allow_maybe",
|
||||
"allow_comments",
|
||||
"participant_email_required",
|
||||
"anonymous_password_protection_enabled",
|
||||
"public_participation_policy_enforcement_available",
|
||||
"participant_invitation_delivery_available"
|
||||
]) {
|
||||
assert.match(api, new RegExp(`${field}:`));
|
||||
}
|
||||
assert.match(api, /method: "PATCH"/);
|
||||
assert.match(api, /\/api\/v1\/scheduling\/people\?/);
|
||||
assert.doesNotMatch(api, /address-lookup/);
|
||||
assert.match(page, /slots: slots\.map\(\(slot\) => \(\{/);
|
||||
assert.match(page, /participants: participants\.map\(participantPayload\)/);
|
||||
assert.doesNotMatch(page, /create_participant_invitations/);
|
||||
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
|
||||
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
|
||||
assert.match(api, /issueSchedulingParticipantInvitation\([\s\S]*json\(\{ action, participant_revision: participantRevision \}\)/);
|
||||
assert.match(api, /revokeSchedulingParticipantInvitation\([\s\S]*method: "DELETE"[\s\S]*participant_revision: participantRevision/);
|
||||
assert.match(api, /participants\/\$\{encodeURIComponent\(participantId\)\}\/invitation/);
|
||||
assert.match(api, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/);
|
||||
assert.match(page, /useSearchParams\(\)/);
|
||||
assert.match(page, /Promise\.allSettled/);
|
||||
|
||||
assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/);
|
||||
assert.match(moduleSource, /SchedulingPublicPage/);
|
||||
assert.match(publicPage, /Card,[\s\S]*DismissibleAlert,[\s\S]*FormField,[\s\S]*LoadingFrame,[\s\S]*from "@govoplan\/core-webui"/);
|
||||
assert.match(publicPage, /getPublicSchedulingParticipation\(settings, requestId, token, \{\}\)/);
|
||||
assert.match(publicPage, /applySchedulingAvailabilityChoice\(/);
|
||||
assert.match(publicPage, /option_revision: slot\.revision/);
|
||||
assert.match(publicPage, /idempotency_key: newIdempotencyKey\(\)/);
|
||||
assert.doesNotMatch(publicPage, /window\.(?:alert|confirm)\(/);
|
||||
assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/);
|
||||
|
||||
console.log("Scheduling pages satisfy the two-pane editor, public response, and policy contracts.");
|
||||
@@ -1,6 +1,11 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
import {
|
||||
apiFetch,
|
||||
type ApiSettings,
|
||||
type PeoplePickerSearchGroup
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
|
||||
export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses";
|
||||
|
||||
export type SchedulingCandidateSlot = {
|
||||
id: string;
|
||||
@@ -12,6 +17,7 @@ export type SchedulingCandidateSlot = {
|
||||
timezone: string;
|
||||
location?: string | null;
|
||||
position: number;
|
||||
revision: string;
|
||||
freebusy_checked_at?: string | null;
|
||||
freebusy_status?: string | null;
|
||||
freebusy_conflicts: Record<string, unknown>[];
|
||||
@@ -21,11 +27,13 @@ export type SchedulingCandidateSlot = {
|
||||
|
||||
export type SchedulingParticipant = {
|
||||
id: string;
|
||||
revision?: string | null;
|
||||
is_current_participant: boolean;
|
||||
respondent_id?: string | null;
|
||||
display_name?: string | null;
|
||||
email?: string | null;
|
||||
participant_type: string;
|
||||
required: boolean;
|
||||
participant_type: string | null;
|
||||
required: boolean | null;
|
||||
status: string;
|
||||
poll_invitation_id?: string | null;
|
||||
invitation_token?: string | null;
|
||||
@@ -34,9 +42,23 @@ export type SchedulingParticipant = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingParticipantAggregate = {
|
||||
total: number;
|
||||
status_counts: Record<string, number>;
|
||||
};
|
||||
|
||||
export type SchedulingParticipantVisibilityDecision = {
|
||||
requested_visibility: SchedulingParticipantVisibility;
|
||||
effective_visibility: SchedulingParticipantVisibility;
|
||||
policy_applied: boolean;
|
||||
reason?: string | null;
|
||||
source_path: Record<string, unknown>[];
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingRequest = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
tenant_id: string | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
@@ -49,14 +71,29 @@ export type SchedulingRequest = {
|
||||
allow_external_participants: boolean;
|
||||
allow_participant_updates: boolean;
|
||||
result_visibility: string;
|
||||
calendar_integration_enabled: boolean;
|
||||
participant_visibility: SchedulingParticipantVisibility;
|
||||
effective_participant_visibility: SchedulingParticipantVisibility;
|
||||
participant_aggregate: SchedulingParticipantAggregate;
|
||||
participant_visibility_decision: SchedulingParticipantVisibilityDecision;
|
||||
notify_on_answers: boolean;
|
||||
single_choice: boolean;
|
||||
max_participants_per_option: number | null;
|
||||
allow_maybe: boolean;
|
||||
allow_comments: boolean;
|
||||
participant_email_required: boolean;
|
||||
anonymous_password_protection_enabled: boolean;
|
||||
public_participation_policy_enforcement_available: boolean | null;
|
||||
public_participation_policy_enforcement_reason?: string | null;
|
||||
participant_invitation_delivery_available: boolean | null;
|
||||
calendar_integration_enabled: boolean | null;
|
||||
calendar_id?: string | null;
|
||||
calendar_freebusy_enabled: boolean;
|
||||
calendar_hold_enabled: boolean;
|
||||
create_calendar_event_on_decision: boolean;
|
||||
calendar_freebusy_enabled: boolean | null;
|
||||
calendar_hold_enabled: boolean | null;
|
||||
create_calendar_event_on_decision: boolean | null;
|
||||
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>;
|
||||
@@ -96,6 +133,15 @@ export type SchedulingRequestCreatePayload = {
|
||||
allow_external_participants?: boolean;
|
||||
allow_participant_updates?: boolean;
|
||||
result_visibility?: "organizer" | "after_response" | "after_close" | "public";
|
||||
participant_visibility?: SchedulingParticipantVisibility;
|
||||
notify_on_answers?: boolean;
|
||||
single_choice?: boolean;
|
||||
max_participants_per_option?: number | null;
|
||||
allow_maybe?: boolean;
|
||||
allow_comments?: boolean;
|
||||
participant_email_required?: boolean;
|
||||
anonymous_password_protection_enabled?: boolean;
|
||||
anonymous_password?: string;
|
||||
calendar?: {
|
||||
enabled?: boolean;
|
||||
calendar_id?: string | null;
|
||||
@@ -109,12 +155,115 @@ export type SchedulingRequestCreatePayload = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingRequestUpdatePayload = Omit<
|
||||
SchedulingRequestCreatePayload,
|
||||
"timezone" | "status" | "slots" | "participants"
|
||||
> & {
|
||||
slots?: SchedulingCandidateSlotReconcilePayload[];
|
||||
participants?: SchedulingParticipantReconcilePayload[];
|
||||
};
|
||||
|
||||
export type SchedulingCandidateSlotReconcilePayload = SchedulingCandidateSlotPayload & {
|
||||
id?: string;
|
||||
revision?: string;
|
||||
};
|
||||
|
||||
export type SchedulingParticipantReconcilePayload = SchedulingParticipantPayload & {
|
||||
id?: string;
|
||||
revision?: string;
|
||||
};
|
||||
|
||||
export type SchedulingCandidateSlotUpdatePayload = {
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
start_at?: string | null;
|
||||
end_at?: string | null;
|
||||
timezone?: string | null;
|
||||
location?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type SchedulingDecisionPayload = {
|
||||
slot_id?: string | null;
|
||||
poll_option_id?: string | null;
|
||||
handoff_to_calendar?: boolean | null;
|
||||
};
|
||||
|
||||
export type SchedulingAvailabilityValue = "available" | "maybe" | "unavailable";
|
||||
|
||||
export type SchedulingAvailabilityPayload = {
|
||||
answers: Array<{
|
||||
slot_id: string;
|
||||
value: SchedulingAvailabilityValue;
|
||||
option_revision: string;
|
||||
}>;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
export type SchedulingAvailabilityResponse = {
|
||||
request_id: string;
|
||||
participant_id: string;
|
||||
has_response: boolean;
|
||||
submitted_at?: string | null;
|
||||
comment?: string | null;
|
||||
answers: Array<{
|
||||
slot_id: string;
|
||||
value: SchedulingAvailabilityValue;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SchedulingPublicCandidateSlot = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
timezone: string;
|
||||
location?: string | null;
|
||||
position: number;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type SchedulingPublicParticipationResponse = {
|
||||
request_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
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;
|
||||
max_participants_per_option: number | null;
|
||||
allow_maybe: boolean;
|
||||
allow_comments: boolean;
|
||||
allow_participant_updates: boolean;
|
||||
has_response: boolean;
|
||||
submitted_at?: string | null;
|
||||
answers: Array<{
|
||||
slot_id: string;
|
||||
value: SchedulingAvailabilityValue;
|
||||
}>;
|
||||
comment?: string | null;
|
||||
replayed: boolean;
|
||||
slots: SchedulingPublicCandidateSlot[];
|
||||
};
|
||||
|
||||
export type SchedulingPublicParticipationAccessPayload = {
|
||||
participant_email?: string | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
export type SchedulingPublicParticipationSubmitPayload = SchedulingPublicParticipationAccessPayload & {
|
||||
answers: SchedulingAvailabilityPayload["answers"];
|
||||
comment?: string | null;
|
||||
idempotency_key?: string;
|
||||
};
|
||||
|
||||
export type SchedulingCalendarActionResponse = {
|
||||
request: SchedulingRequest;
|
||||
created_event_ids: string[];
|
||||
@@ -140,6 +289,18 @@ export type SchedulingNotification = {
|
||||
|
||||
export type SchedulingNotificationListResponse = { notifications: SchedulingNotification[] };
|
||||
|
||||
export type SchedulingInvitationAction = "copy" | "send" | "revoke";
|
||||
|
||||
export type SchedulingInvitationActionResponse = {
|
||||
participant_id: string;
|
||||
action: SchedulingInvitationAction;
|
||||
status: string;
|
||||
action_url?: string | null;
|
||||
issued_at?: string | null;
|
||||
replayed: boolean;
|
||||
notification?: SchedulingNotification | null;
|
||||
};
|
||||
|
||||
export type SchedulingPollOptionResult = {
|
||||
option_id: string;
|
||||
option_key: string;
|
||||
@@ -161,31 +322,25 @@ export type SchedulingSummaryResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type SchedulingAddressLookupCandidate = {
|
||||
contact_id: string;
|
||||
address_book_id: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
email_label?: string | null;
|
||||
organization?: string | null;
|
||||
role_title?: string | null;
|
||||
tags: string[];
|
||||
source_kind: string;
|
||||
source_ref?: string | null;
|
||||
source_revision?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingAddressLookupResponse = {
|
||||
available: boolean;
|
||||
candidates: SchedulingAddressLookupCandidate[];
|
||||
export type SchedulingPeopleSearchResponse = {
|
||||
groups: PeoplePickerSearchGroup[];
|
||||
};
|
||||
|
||||
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) });
|
||||
|
||||
export function lookupSchedulingAddresses(settings: ApiSettings, query: string, limit = 25): Promise<SchedulingAddressLookupResponse> {
|
||||
export async function searchSchedulingPeople(
|
||||
settings: ApiSettings,
|
||||
query: string,
|
||||
limit = 25,
|
||||
signal?: AbortSignal
|
||||
): Promise<PeoplePickerSearchGroup[]> {
|
||||
const params = new URLSearchParams({ query, limit: String(limit) });
|
||||
return apiFetch<SchedulingAddressLookupResponse>(settings, `/api/v1/scheduling/address-lookup?${params.toString()}`);
|
||||
const response = await apiFetch<SchedulingPeopleSearchResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/people?${params.toString()}`,
|
||||
{ signal }
|
||||
);
|
||||
return response.groups;
|
||||
}
|
||||
|
||||
export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> {
|
||||
@@ -197,6 +352,77 @@ export function createSchedulingRequest(settings: ApiSettings, payload: Scheduli
|
||||
return apiFetch<SchedulingRequest>(settings, "/api/v1/scheduling/requests", json(payload));
|
||||
}
|
||||
|
||||
export function updateSchedulingRequest(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
payload: SchedulingRequestUpdatePayload
|
||||
): Promise<SchedulingRequest> {
|
||||
return apiFetch<SchedulingRequest>(settings, `/api/v1/scheduling/requests/${requestId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSchedulingCandidateSlot(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
slotId: string,
|
||||
payload: SchedulingCandidateSlotUpdatePayload
|
||||
): Promise<SchedulingRequest> {
|
||||
return apiFetch<SchedulingRequest>(settings, `/api/v1/scheduling/requests/${requestId}/slots/${slotId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function submitSchedulingAvailability(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
payload: SchedulingAvailabilityPayload
|
||||
): Promise<SchedulingStatusResponse> {
|
||||
return apiFetch<SchedulingStatusResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/requests/${requestId}/responses`,
|
||||
json(payload)
|
||||
);
|
||||
}
|
||||
|
||||
export function getSchedulingAvailabilityResponse(
|
||||
settings: ApiSettings,
|
||||
requestId: string
|
||||
): Promise<SchedulingAvailabilityResponse> {
|
||||
return apiFetch<SchedulingAvailabilityResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/requests/${requestId}/responses/me`
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublicSchedulingParticipation(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
token: string,
|
||||
payload: SchedulingPublicParticipationAccessPayload
|
||||
): Promise<SchedulingPublicParticipationResponse> {
|
||||
return apiFetch<SchedulingPublicParticipationResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/public/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}`,
|
||||
json(payload)
|
||||
);
|
||||
}
|
||||
|
||||
export function submitPublicSchedulingParticipation(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
token: string,
|
||||
payload: SchedulingPublicParticipationSubmitPayload
|
||||
): Promise<SchedulingPublicParticipationResponse> {
|
||||
return apiFetch<SchedulingPublicParticipationResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/public/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}/responses`,
|
||||
json(payload)
|
||||
);
|
||||
}
|
||||
|
||||
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
|
||||
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
|
||||
}
|
||||
@@ -233,3 +459,30 @@ export function listSchedulingNotifications(settings: ApiSettings, requestId?: s
|
||||
const query = requestId ? `?request_id=${encodeURIComponent(requestId)}` : "";
|
||||
return apiFetch<SchedulingNotificationListResponse>(settings, `/api/v1/scheduling/notifications${query}`);
|
||||
}
|
||||
|
||||
export function issueSchedulingParticipantInvitation(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
participantId: string,
|
||||
participantRevision: string,
|
||||
action: Exclude<SchedulingInvitationAction, "revoke">
|
||||
): Promise<SchedulingInvitationActionResponse> {
|
||||
return apiFetch<SchedulingInvitationActionResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/requests/${encodeURIComponent(requestId)}/participants/${encodeURIComponent(participantId)}/invitation`,
|
||||
json({ action, participant_revision: participantRevision })
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeSchedulingParticipantInvitation(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
participantId: string,
|
||||
participantRevision: string
|
||||
): Promise<SchedulingInvitationActionResponse> {
|
||||
return apiFetch<SchedulingInvitationActionResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/requests/${encodeURIComponent(requestId)}/participants/${encodeURIComponent(participantId)}/invitation`,
|
||||
{ method: "DELETE", body: JSON.stringify({ participant_revision: participantRevision }) }
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
275
webui/src/features/scheduling/SchedulingPublicPage.tsx
Normal file
275
webui/src/features/scheduling/SchedulingPublicPage.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
formatDateTime,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
getPublicSchedulingParticipation,
|
||||
submitPublicSchedulingParticipation,
|
||||
type SchedulingAvailabilityValue,
|
||||
type SchedulingPublicParticipationAccessPayload,
|
||||
type SchedulingPublicParticipationResponse
|
||||
} from "../../api/scheduling";
|
||||
import { applySchedulingAvailabilityChoice } from "./schedulingViewModel";
|
||||
|
||||
type SchedulingPublicPageProps = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
};
|
||||
|
||||
const I18N = {
|
||||
accessDetails: "i18n:govoplan-scheduling.access_details.79c06b89",
|
||||
accessHelp: "i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c",
|
||||
accessRequest: "i18n:govoplan-scheduling.open_scheduling_request.31829cce",
|
||||
alreadySubmitted: "i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe",
|
||||
answerRequired: "i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f",
|
||||
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",
|
||||
loading: "i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b",
|
||||
maybe: "i18n:govoplan-scheduling.maybe.56dd8d0b",
|
||||
noLongerOpen: "i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a",
|
||||
password: "i18n:govoplan-scheduling.guest_password.94545e82",
|
||||
response: "i18n:govoplan-scheduling.your_availability.f86c8215",
|
||||
saved: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
|
||||
submit: "i18n:govoplan-scheduling.submit_response.a5f0c053",
|
||||
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79"
|
||||
} as const;
|
||||
|
||||
function accessPayload(email: string, password: string): SchedulingPublicParticipationAccessPayload {
|
||||
return {
|
||||
participant_email: email.trim() || null,
|
||||
password: password || null
|
||||
};
|
||||
}
|
||||
|
||||
function initialAvailability(response: SchedulingPublicParticipationResponse): Record<string, SchedulingAvailabilityValue | ""> {
|
||||
const previous = new Map(response.answers.map((answer) => [answer.slot_id, answer.value]));
|
||||
return Object.fromEntries(response.slots.map((slot) => [slot.id, previous.get(slot.id) ?? ""]));
|
||||
}
|
||||
|
||||
function newIdempotencyKey(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||
return `scheduling-response-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export default function SchedulingPublicPage({ settings, auth }: SchedulingPublicPageProps) {
|
||||
const { requestId = "", token = "" } = useParams();
|
||||
const [response, setResponse] = useState<SchedulingPublicParticipationResponse | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
|
||||
const [comment, setComment] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [accessAttempted, setAccessAttempted] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const slotIds = useMemo(() => response?.slots.map((slot) => slot.id) ?? [], [response]);
|
||||
const collecting = response?.status === "collecting";
|
||||
|
||||
function applyResponse(next: SchedulingPublicParticipationResponse) {
|
||||
setResponse(next);
|
||||
setAvailability(initialAvailability(next));
|
||||
setComment(next.comment ?? "");
|
||||
setError("");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setResponse(null);
|
||||
setAccessAttempted(false);
|
||||
setError("");
|
||||
void getPublicSchedulingParticipation(settings, requestId, token, {})
|
||||
.then((next) => {
|
||||
if (!cancelled) applyResponse(next);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [requestId, settings.apiBaseUrl, settings.apiKey, token]);
|
||||
|
||||
async function openRequest(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setAccessAttempted(true);
|
||||
setError("");
|
||||
try {
|
||||
applyResponse(await getPublicSchedulingParticipation(settings, requestId, token, accessPayload(email, password)));
|
||||
} catch {
|
||||
setResponse(null);
|
||||
setError(I18N.invalidAccess);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveResponse(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!response) return;
|
||||
if (!response.slots.some((slot) => availability[slot.id])) {
|
||||
setError(I18N.answerRequired);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const next = await submitPublicSchedulingParticipation(settings, requestId, token, {
|
||||
...accessPayload(email, password),
|
||||
answers: response.slots
|
||||
.filter((slot) => availability[slot.id])
|
||||
.map((slot) => ({
|
||||
slot_id: slot.id,
|
||||
value: availability[slot.id] as SchedulingAvailabilityValue,
|
||||
option_revision: slot.revision
|
||||
})),
|
||||
comment: response.allow_comments ? comment : null,
|
||||
idempotency_key: newIdempotencyKey()
|
||||
});
|
||||
applyResponse(next);
|
||||
setSuccess(I18N.saved);
|
||||
} catch {
|
||||
setError(I18N.invalidAccess);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="scheduling-public-page">
|
||||
<LoadingFrame loading={loading} label={I18N.loading}>
|
||||
{auth && (
|
||||
<div className="scheduling-public-deep-link">
|
||||
<Link className="btn btn-secondary" to={`/scheduling?request_id=${encodeURIComponent(requestId)}`}>
|
||||
{I18N.backToScheduling}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!response && !loading && (
|
||||
<Card title={I18N.accessDetails}>
|
||||
<form className="scheduling-public-access-form" onSubmit={openRequest}>
|
||||
<p className="muted">{I18N.accessHelp}</p>
|
||||
{accessAttempted && error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
|
||||
<div className="form-grid two-col">
|
||||
<FormField label={I18N.email}>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={I18N.password}>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="scheduling-public-actions">
|
||||
<Button type="submit" variant="primary">{I18N.accessRequest}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{response && (
|
||||
<form className="scheduling-public-content" onSubmit={saveResponse}>
|
||||
<Card title={response.title}>
|
||||
{response.description && <p className="scheduling-public-description">{response.description}</p>}
|
||||
<dl className="scheduling-public-summary">
|
||||
{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>
|
||||
{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>}
|
||||
|
||||
{!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}>
|
||||
<legend>{slot.label}</legend>
|
||||
<p>{formatDateTime(slot.start_at)} – {formatDateTime(slot.end_at)}</p>
|
||||
{slot.description && <p className="muted">{slot.description}</p>}
|
||||
{(slot.location || response.location) && <p className="muted">{slot.location || response.location}</p>}
|
||||
<div className="scheduling-public-choice-group">
|
||||
{([
|
||||
["available", I18N.available],
|
||||
...(response.allow_maybe ? [["maybe", I18N.maybe] as const] : []),
|
||||
["unavailable", I18N.unavailable]
|
||||
] as Array<[SchedulingAvailabilityValue, string]>).map(([value, label]) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`slot-${slot.id}`}
|
||||
value={value}
|
||||
checked={availability[slot.id] === value}
|
||||
onChange={() => setAvailability((current) => applySchedulingAvailabilityChoice(
|
||||
slotIds,
|
||||
current,
|
||||
slot.id,
|
||||
value,
|
||||
response.single_choice
|
||||
))}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
{response.allow_comments && (
|
||||
<FormField label={I18N.comment}>
|
||||
<textarea
|
||||
rows={4}
|
||||
maxLength={4000}
|
||||
disabled={!collecting || saving}
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{collecting && (
|
||||
<div className="scheduling-public-actions">
|
||||
<Button type="submit" variant="primary" disabled={saving}>{I18N.submit}</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>}
|
||||
</form>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
120
webui/src/features/scheduling/SchedulingRequestsWidget.tsx
Normal file
120
webui/src/features/scheduling/SchedulingRequestsWidget.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useCallback } from "react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
StatusBadge,
|
||||
useDashboardWidgetData,
|
||||
type ApiSettings,
|
||||
type DashboardWidgetConfiguration
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
listSchedulingRequests,
|
||||
type SchedulingRequest
|
||||
} from "../../api/scheduling";
|
||||
|
||||
export default function SchedulingRequestsWidget({
|
||||
settings,
|
||||
refreshKey,
|
||||
configuration
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
refreshKey: number;
|
||||
configuration: DashboardWidgetConfiguration;
|
||||
}) {
|
||||
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
|
||||
const includeDrafts = configuration.includeDrafts === true;
|
||||
const load = useCallback(async () => {
|
||||
const response = await listSchedulingRequests(settings);
|
||||
return response.requests
|
||||
.filter(
|
||||
(request) =>
|
||||
request.status === "collecting"
|
||||
|| (includeDrafts && request.status === "draft")
|
||||
)
|
||||
.sort(compareRequests)
|
||||
.slice(0, maxItems);
|
||||
}, [includeDrafts, maxItems, settings]);
|
||||
const { data: requests, loading, error } = useDashboardWidgetData(
|
||||
load,
|
||||
refreshKey
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="Loading scheduling requests">
|
||||
{error && (
|
||||
<DismissibleAlert tone="warning" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
<DashboardWidgetList
|
||||
emptyText="No scheduling requests are awaiting responses."
|
||||
items={(requests ?? []).map((request) => ({
|
||||
id: request.id,
|
||||
title: request.title,
|
||||
detail: responseLabel(request),
|
||||
meta: deadlineLabel(request),
|
||||
leading: <CalendarClock size={17} aria-hidden="true" />,
|
||||
trailing: (
|
||||
<StatusBadge
|
||||
status={request.status}
|
||||
label={request.status === "collecting" ? "Open" : "Draft"}
|
||||
/>
|
||||
),
|
||||
to: "/scheduling"
|
||||
}))}
|
||||
/>
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Link className="btn btn-secondary" to="/scheduling">
|
||||
Open scheduling
|
||||
</Link>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function compareRequests(
|
||||
left: SchedulingRequest,
|
||||
right: SchedulingRequest
|
||||
): number {
|
||||
if (left.status !== right.status) {
|
||||
return left.status === "collecting" ? -1 : 1;
|
||||
}
|
||||
const leftDeadline = left.deadline_at
|
||||
? new Date(left.deadline_at).getTime()
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const rightDeadline = right.deadline_at
|
||||
? new Date(right.deadline_at).getTime()
|
||||
: Number.POSITIVE_INFINITY;
|
||||
return (
|
||||
leftDeadline - rightDeadline
|
||||
|| new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
function responseLabel(request: SchedulingRequest): string {
|
||||
const responded = request.participant_aggregate.status_counts.responded ?? 0;
|
||||
return `${responded} of ${request.participant_aggregate.total} responded`;
|
||||
}
|
||||
|
||||
function deadlineLabel(request: SchedulingRequest): string {
|
||||
if (!request.deadline_at) return `${request.slots.length} options`;
|
||||
return `Due ${new Intl.DateTimeFormat(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short"
|
||||
}).format(new Date(request.deadline_at))}`;
|
||||
}
|
||||
|
||||
function numberSetting(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number
|
||||
): number {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numeric)
|
||||
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
|
||||
: fallback;
|
||||
}
|
||||
370
webui/src/features/scheduling/schedulingViewModel.ts
Normal file
370
webui/src/features/scheduling/schedulingViewModel.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import type {
|
||||
SchedulingAvailabilityValue,
|
||||
SchedulingParticipant,
|
||||
SchedulingParticipantPayload,
|
||||
SchedulingRequest
|
||||
} from "../../api/scheduling";
|
||||
import type { PeoplePickerItem } from "@govoplan/core-webui";
|
||||
|
||||
const DIRECTORY_SELECTION_METADATA_KEY = "directory_selection";
|
||||
|
||||
export type SchedulingParticipantDraft = PeoplePickerItem & {
|
||||
draftId: string;
|
||||
sourceId?: string;
|
||||
revision?: string;
|
||||
respondent_id?: string | null;
|
||||
display_name: string;
|
||||
email: string;
|
||||
participant_type: "internal" | "external" | "resource";
|
||||
required: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
identityLocked?: boolean;
|
||||
};
|
||||
|
||||
export type SchedulingActor = {
|
||||
accountId?: string | null;
|
||||
userId?: string | null;
|
||||
membershipId?: string | null;
|
||||
identityId?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export type SchedulingRequestGroups = {
|
||||
owned: SchedulingRequest[];
|
||||
invited: SchedulingRequest[];
|
||||
other: SchedulingRequest[];
|
||||
};
|
||||
|
||||
export type SchedulingSortPhase =
|
||||
| "unanswered"
|
||||
| "answered"
|
||||
| "closed"
|
||||
| "determined"
|
||||
| "past";
|
||||
|
||||
export type SchedulingInvitationActionBlock =
|
||||
| "participation_policy_unavailable"
|
||||
| "cancellation_notice_expired"
|
||||
| "delivery_unavailable"
|
||||
| "no_delivery_target"
|
||||
| "no_active_invitation"
|
||||
| "participant_revision_unavailable";
|
||||
|
||||
export type SchedulingInvitationActionBlocks = {
|
||||
copy: SchedulingInvitationActionBlock | null;
|
||||
send: SchedulingInvitationActionBlock | null;
|
||||
revoke: SchedulingInvitationActionBlock | null;
|
||||
};
|
||||
|
||||
type DirectorySelection = {
|
||||
selection_key?: string;
|
||||
kind?: PeoplePickerItem["kind"];
|
||||
reference_id?: string | null;
|
||||
source_module?: string | null;
|
||||
source_label?: string | null;
|
||||
source_revision?: string | null;
|
||||
};
|
||||
|
||||
function directorySelection(metadata?: Record<string, unknown>): DirectorySelection | null {
|
||||
const value = metadata?.[DIRECTORY_SELECTION_METADATA_KEY];
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as DirectorySelection;
|
||||
}
|
||||
|
||||
function normalizedParticipantType(value: string | null): SchedulingParticipantDraft["participant_type"] {
|
||||
return value === "internal" || value === "resource" ? value : "external";
|
||||
}
|
||||
|
||||
function selectionMetadata(item: PeoplePickerItem): Record<string, unknown> {
|
||||
if (item.kind === "external") return {};
|
||||
return {
|
||||
[DIRECTORY_SELECTION_METADATA_KEY]: {
|
||||
selection_key: item.selection_key,
|
||||
kind: item.kind,
|
||||
reference_id: item.reference_id ?? null,
|
||||
source_module: item.source_module ?? null,
|
||||
source_label: item.source_label ?? null,
|
||||
source_revision: item.source_revision ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function participantDraftFromResponse(
|
||||
participant: SchedulingParticipant,
|
||||
draftId: string
|
||||
): SchedulingParticipantDraft {
|
||||
const selection = directorySelection(participant.metadata);
|
||||
const kind = selection?.kind ?? (participant.respondent_id ? "account" : "external");
|
||||
const referenceId = selection?.reference_id
|
||||
?? (kind === "account" ? participant.respondent_id : null);
|
||||
const email = participant.email?.trim().toLowerCase() || null;
|
||||
return {
|
||||
selection_key: selection?.selection_key
|
||||
?? (email ? `${kind}:${email}` : `${kind}:participant:${participant.id}`),
|
||||
kind,
|
||||
reference_id: referenceId,
|
||||
display_name: participant.display_name?.trim() || email || "—",
|
||||
email: email ?? "",
|
||||
source_module: selection?.source_module ?? null,
|
||||
source_label: selection?.source_label ?? null,
|
||||
source_revision: selection?.source_revision ?? null,
|
||||
draftId,
|
||||
sourceId: participant.id,
|
||||
revision: participant.revision ?? undefined,
|
||||
respondent_id: participant.respondent_id,
|
||||
participant_type: normalizedParticipantType(participant.participant_type),
|
||||
required: participant.required ?? true,
|
||||
metadata: participant.metadata ?? {},
|
||||
identityLocked: Boolean(participant.poll_invitation_id)
|
||||
};
|
||||
}
|
||||
|
||||
export function participantDraftsFromPicker(
|
||||
selected: PeoplePickerItem[],
|
||||
current: SchedulingParticipantDraft[],
|
||||
nextDraftId: () => string
|
||||
): SchedulingParticipantDraft[] {
|
||||
const currentByKey = new Map(current.map((participant) => [participant.selection_key, participant]));
|
||||
return selected.map((item) => {
|
||||
const existing = currentByKey.get(item.selection_key);
|
||||
if (existing) return existing;
|
||||
const kind = item.kind === "account" ? "account" : item.kind === "contact" ? "contact" : "external";
|
||||
return {
|
||||
...item,
|
||||
kind,
|
||||
email: item.email?.trim().toLowerCase() || "",
|
||||
draftId: nextDraftId(),
|
||||
respondent_id: kind === "account" ? item.reference_id ?? null : null,
|
||||
participant_type: kind === "account" ? "internal" : "external",
|
||||
required: true,
|
||||
metadata: selectionMetadata(item),
|
||||
identityLocked: false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function participantPayload(
|
||||
participant: SchedulingParticipantDraft
|
||||
): SchedulingParticipantPayload & { id?: string; revision?: string } {
|
||||
return {
|
||||
...(participant.sourceId
|
||||
? { id: participant.sourceId, revision: participant.revision }
|
||||
: {}),
|
||||
respondent_id: participant.respondent_id ?? null,
|
||||
display_name: participant.display_name.trim() || null,
|
||||
email: participant.email.trim() || null,
|
||||
participant_type: participant.participant_type,
|
||||
required: participant.required,
|
||||
metadata: participant.metadata ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
export function schedulingActorIds(actor: SchedulingActor): string[] {
|
||||
return Array.from(new Set([
|
||||
actor.accountId,
|
||||
actor.userId,
|
||||
actor.membershipId,
|
||||
actor.identityId,
|
||||
actor.email
|
||||
].filter((value): value is string => Boolean(value)).flatMap((value) =>
|
||||
value.includes("@") ? [value, value.trim().toLowerCase()] : [value]
|
||||
)));
|
||||
}
|
||||
|
||||
export function schedulingParticipantForActor(
|
||||
request: SchedulingRequest,
|
||||
actor: SchedulingActor
|
||||
): SchedulingParticipant | null {
|
||||
const projected = request.participants.find(
|
||||
(participant) => participant.is_current_participant
|
||||
);
|
||||
if (projected) return projected;
|
||||
const ids = new Set(schedulingActorIds(actor));
|
||||
return request.participants.find((participant) => {
|
||||
const email = participant.email?.trim().toLowerCase();
|
||||
return Boolean(
|
||||
(participant.respondent_id && ids.has(participant.respondent_id)) ||
|
||||
(email && ids.has(email))
|
||||
);
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
export function schedulingRequestIsOwned(
|
||||
request: SchedulingRequest,
|
||||
actor: SchedulingActor
|
||||
): boolean {
|
||||
return Boolean(
|
||||
request.organizer_user_id &&
|
||||
schedulingActorIds(actor).includes(request.organizer_user_id)
|
||||
);
|
||||
}
|
||||
|
||||
export function schedulingSortPhase(
|
||||
request: SchedulingRequest,
|
||||
actor: SchedulingActor,
|
||||
now = new Date()
|
||||
): SchedulingSortPhase {
|
||||
if (schedulingRequestIsPast(request, now)) return "past";
|
||||
if (["decided", "handed_off"].includes(request.status)) return "determined";
|
||||
if (["closed", "cancelled", "archived"].includes(request.status)) return "closed";
|
||||
if (schedulingRequestIsOwned(request, actor)) return "unanswered";
|
||||
const participant = schedulingParticipantForActor(request, actor);
|
||||
return participant && ["responded", "declined"].includes(participant.status)
|
||||
? "answered"
|
||||
: "unanswered";
|
||||
}
|
||||
|
||||
export function compareSchedulingRequests(
|
||||
left: SchedulingRequest,
|
||||
right: SchedulingRequest,
|
||||
actor: SchedulingActor,
|
||||
now = new Date()
|
||||
): number {
|
||||
const leftPhase = schedulingSortPhase(left, actor, now);
|
||||
const rightPhase = schedulingSortPhase(right, actor, now);
|
||||
const phaseDifference = SORT_PHASE_ORDER[leftPhase] - SORT_PHASE_ORDER[rightPhase];
|
||||
if (phaseDifference !== 0) return phaseDifference;
|
||||
const leftDate = schedulingRelevantTimestamp(left, now);
|
||||
const rightDate = schedulingRelevantTimestamp(right, now);
|
||||
const dateDifference = leftPhase === "past"
|
||||
? rightDate - leftDate
|
||||
: leftDate - rightDate;
|
||||
if (dateDifference !== 0) return dateDifference;
|
||||
const titleDifference = left.title.localeCompare(right.title);
|
||||
return titleDifference || left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
export function groupSchedulingRequests(
|
||||
requests: SchedulingRequest[],
|
||||
actor: SchedulingActor,
|
||||
now = new Date()
|
||||
): SchedulingRequestGroups {
|
||||
const groups: SchedulingRequestGroups = { owned: [], invited: [], other: [] };
|
||||
for (const request of requests) {
|
||||
if (schedulingRequestIsOwned(request, actor)) {
|
||||
groups.owned.push(request);
|
||||
} else if (schedulingParticipantForActor(request, actor)) {
|
||||
groups.invited.push(request);
|
||||
} else {
|
||||
groups.other.push(request);
|
||||
}
|
||||
}
|
||||
for (const group of Object.values(groups)) {
|
||||
group.sort((left, right) => compareSchedulingRequests(left, right, actor, now));
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function schedulingRelevantTimestamp(
|
||||
request: SchedulingRequest,
|
||||
now = new Date()
|
||||
): number {
|
||||
const nowValue = now.getTime();
|
||||
const futureStarts = request.slots
|
||||
.map((slot) => Date.parse(slot.start_at))
|
||||
.filter((value) => Number.isFinite(value) && value >= nowValue)
|
||||
.sort((left, right) => left - right);
|
||||
if (futureStarts[0] !== undefined) return futureStarts[0];
|
||||
const slotEnds = request.slots
|
||||
.map((slot) => Date.parse(slot.end_at))
|
||||
.filter(Number.isFinite);
|
||||
if (slotEnds.length) return Math.max(...slotEnds);
|
||||
const fallback = Date.parse(request.deadline_at || request.updated_at || request.created_at);
|
||||
return Number.isFinite(fallback) ? fallback : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
export function schedulingRequestIsPast(
|
||||
request: SchedulingRequest,
|
||||
now = new Date()
|
||||
): boolean {
|
||||
if (!request.slots.length) return false;
|
||||
const slotEnds = request.slots.map((slot) => Date.parse(slot.end_at));
|
||||
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
|
||||
}
|
||||
|
||||
export function schedulingInvitationActionBlocks(
|
||||
request: SchedulingRequest,
|
||||
participant: SchedulingParticipant,
|
||||
now = new Date()
|
||||
): SchedulingInvitationActionBlocks {
|
||||
if (!participant.revision) {
|
||||
return {
|
||||
copy: "participant_revision_unavailable",
|
||||
send: "participant_revision_unavailable",
|
||||
revoke: "participant_revision_unavailable"
|
||||
};
|
||||
}
|
||||
const policyAvailable = Boolean(
|
||||
request.poll_id &&
|
||||
request.public_participation_policy_enforcement_available === true
|
||||
);
|
||||
let issueBlock: SchedulingInvitationActionBlock | null = policyAvailable
|
||||
? null
|
||||
: "participation_policy_unavailable";
|
||||
if (!issueBlock && request.status === "cancelled") {
|
||||
const noticeUntil = request.cancellation_notice_until
|
||||
? Date.parse(request.cancellation_notice_until)
|
||||
: Number.NaN;
|
||||
if (!Number.isFinite(noticeUntil) || noticeUntil <= now.getTime()) {
|
||||
issueBlock = "cancellation_notice_expired";
|
||||
}
|
||||
}
|
||||
|
||||
const respondentId = participant.respondent_id?.trim() ?? "";
|
||||
const hasDeliveryTarget = Boolean(
|
||||
participant.email?.trim() ||
|
||||
(respondentId && !respondentId.startsWith("scheduling-participant:"))
|
||||
);
|
||||
const sendBlock = issueBlock
|
||||
?? (request.participant_invitation_delivery_available === true
|
||||
? null
|
||||
: "delivery_unavailable")
|
||||
?? (hasDeliveryTarget ? null : "no_delivery_target");
|
||||
const revokeBlock = participant.poll_invitation_id
|
||||
? (policyAvailable ? null : "participation_policy_unavailable")
|
||||
: "no_active_invitation";
|
||||
|
||||
return { copy: issueBlock, send: sendBlock, revoke: revokeBlock };
|
||||
}
|
||||
|
||||
export function schedulingPublicInvitationUrl(
|
||||
actionUrl: string,
|
||||
applicationOrigin: string
|
||||
): string | null {
|
||||
try {
|
||||
const origin = new URL(applicationOrigin);
|
||||
const url = new URL(actionUrl, origin);
|
||||
if (url.origin !== origin.origin || !url.pathname.startsWith("/scheduling/public/")) return null;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySchedulingAvailabilityChoice(
|
||||
slotIds: string[],
|
||||
current: Record<string, SchedulingAvailabilityValue | "">,
|
||||
slotId: string,
|
||||
value: SchedulingAvailabilityValue | "",
|
||||
singleChoice: boolean
|
||||
): Record<string, SchedulingAvailabilityValue | ""> {
|
||||
if (!singleChoice || !["available", "maybe"].includes(value)) {
|
||||
return { ...current, [slotId]: value };
|
||||
}
|
||||
return Object.fromEntries(slotIds.map((candidateId) => [
|
||||
candidateId,
|
||||
candidateId === slotId
|
||||
? value
|
||||
: current[candidateId] && current[candidateId] !== "unavailable"
|
||||
? "unavailable"
|
||||
: current[candidateId] ?? ""
|
||||
]));
|
||||
}
|
||||
|
||||
const SORT_PHASE_ORDER: Record<SchedulingSortPhase, number> = {
|
||||
unanswered: 0,
|
||||
answered: 1,
|
||||
closed: 2,
|
||||
determined: 3,
|
||||
past: 4
|
||||
};
|
||||
@@ -1,4 +1,346 @@
|
||||
export const generatedTranslations = {
|
||||
en: {},
|
||||
de: {}
|
||||
en: {
|
||||
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
|
||||
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Automatic invitation delivery is unavailable; copy the link instead.",
|
||||
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Cancellation notice available until",
|
||||
"i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79": "Copy a fresh invitation link for {value0}",
|
||||
"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.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306": "Invitation delivery failed. The link was created but was not delivered.",
|
||||
"i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba": "Invitation delivery requested.",
|
||||
"i18n:govoplan-scheduling.invitation_link_copied.332973ec": "Invitation link copied.",
|
||||
"i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4": "Invitation link revoked.",
|
||||
"i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc": "No active invitation link to revoke.",
|
||||
"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.reload_the_request_before_changing_this_invitation.9e685df4": "Reload the request before changing this invitation.",
|
||||
"i18n:govoplan-scheduling.participant.554f4235": "Participant",
|
||||
"i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf": "Revoke invitation link",
|
||||
"i18n:govoplan-scheduling.revoke_link.da371ee1": "Revoke link",
|
||||
"i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817": "Revoke the current invitation link for {value0}? It will stop working immediately.",
|
||||
"i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa": "Revoke the invitation link for {value0}",
|
||||
"i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea": "Send a fresh invitation to {value0}",
|
||||
"i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c": "The cancellation notice has expired; a new link cannot be issued.",
|
||||
"i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc": "The invitation link could not be copied. Check browser clipboard permissions and try again.",
|
||||
"i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180": "This participant has no deliverable email address or account.",
|
||||
"i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533": "This invitation changed. The request was reloaded; try again.",
|
||||
"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.",
|
||||
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "A slot can be selected after the request is closed.",
|
||||
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Add Maybe between Available and Unavailable for each candidate slot.",
|
||||
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Allow comments",
|
||||
"i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Allow external participants",
|
||||
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Awaiting",
|
||||
"i18n:govoplan-scheduling.capacity.d3c375f8": "Capacity",
|
||||
"i18n:govoplan-scheduling.comment.d03495b1": "Comment",
|
||||
"i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8": "Create an organizer notification whenever a response is submitted or updated.",
|
||||
"i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465": "Each participant can select Available or Maybe for at most one candidate slot.",
|
||||
"i18n:govoplan-scheduling.edit_scheduling_request.7e749c19": "Edit scheduling request",
|
||||
"i18n:govoplan-scheduling.invited_participant_identity_is_locked_remove_and_add_the_participant_to_change_it.34e1201a": "An invited participant's name and email are locked. Remove and add the participant to change them.",
|
||||
"i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0": "Guest links are not issued while the configured participation controls cannot be enforced by the public response gateway. Signed-in participants can still respond here.",
|
||||
"i18n:govoplan-scheduling.guest_password.94545e82": "Guest password",
|
||||
"i18n:govoplan-scheduling.let_participants_add_a_comment_to_their_response.9dce8d17": "Let participants add a comment to their response.",
|
||||
"i18n:govoplan-scheduling.limit_participants_per_option.1e9aa51d": "Limit participants per option",
|
||||
"i18n:govoplan-scheduling.maximum_participants_per_option.5abdfb27": "Maximum participants per option",
|
||||
"i18n:govoplan-scheduling.notify_me_about_each_answer.505749d6": "Notify me about each answer",
|
||||
"i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c": "Participants can choose only one option",
|
||||
"i18n:govoplan-scheduling.participation.9ad70cc4": "Participation",
|
||||
"i18n:govoplan-scheduling.participation_rate.46bc5504": "Participation rate",
|
||||
"i18n:govoplan-scheduling.participation_settings.8dc6f62c": "Participation settings",
|
||||
"i18n:govoplan-scheduling.participant_privacy.108c470f": "Participant privacy",
|
||||
"i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740": "Participation controls are locked after invitation links are issued. Participant visibility and answer notifications can still be changed.",
|
||||
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Password-protect guest access",
|
||||
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "People responding without an account must provide an email address.",
|
||||
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "People who are not signed in must enter this password before viewing the request.",
|
||||
"i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44": "Search accounts and contacts you are allowed to discover. If external participants are enabled, you can also add a name and email address manually.",
|
||||
"i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735": "When enabled, people outside the configured accounts and contacts can be added manually.",
|
||||
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Provide a Maybe option",
|
||||
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "Require an email address from guests",
|
||||
"i18n:govoplan-scheduling.responses.3427e3ab": "Responses",
|
||||
"i18n:govoplan-scheduling.response_results_are_not_available_for_this_view.1e82db18": "Response results are not available for this view.",
|
||||
"i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3": "Notifications could not be loaded.",
|
||||
"i18n:govoplan-scheduling.stop_accepting_yes_responses_for_an_option_when_its_limit.79af21db": "Stop accepting Available responses for an option when its limit is reached.",
|
||||
"i18n:govoplan-scheduling.use_at_least_8_characters_the_password_is_never_displayed.035708a2": "Use at least 8 characters. The password is never displayed after it is saved.",
|
||||
"i18n:govoplan-scheduling.add_participant.6cff957d": "Add participant",
|
||||
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Add scheduling request",
|
||||
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Add slot",
|
||||
"i18n:govoplan-scheduling.answered.e0aafffa": "Answered",
|
||||
"i18n:govoplan-scheduling.available.7c62a142": "Available",
|
||||
"i18n:govoplan-scheduling.awaiting_response.ee646ea9": "Awaiting response",
|
||||
"i18n:govoplan-scheduling.basic_information.d8bc7383": "Basic information",
|
||||
"i18n:govoplan-scheduling.busy.592a9d16": "Busy",
|
||||
"i18n:govoplan-scheduling.calendar.adab5090": "Calendar",
|
||||
"i18n:govoplan-scheduling.calendar_coordination.291ab00a": "Calendar coordination",
|
||||
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Configured calendar",
|
||||
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Calendar integration",
|
||||
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Calendar integration requires the Calendar module plus calendar-read, availability-read, and event-write access.",
|
||||
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Candidate availability",
|
||||
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Candidate slots",
|
||||
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Check free/busy",
|
||||
"i18n:govoplan-scheduling.choose_availability.ac95b8f6": "Choose availability",
|
||||
"i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f": "Choose availability for at least one candidate slot.",
|
||||
"i18n:govoplan-scheduling.close_poll.a6a18916": "Close poll",
|
||||
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Close stops accepting new availability responses.",
|
||||
"i18n:govoplan-scheduling.closed.88d86b77": "Closed",
|
||||
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Create calendar event",
|
||||
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Create tentative holds",
|
||||
"i18n:govoplan-scheduling.cancellation.319aaae4": "Cancellation",
|
||||
"i18n:govoplan-scheduling.decision.7f59a1f1": "Decision",
|
||||
"i18n:govoplan-scheduling.declined.ff59b80f": "Declined",
|
||||
"i18n:govoplan-scheduling.decide_on_value.196409cd": "Decide on {value0}",
|
||||
"i18n:govoplan-scheduling.description.55f8ebc8": "Description",
|
||||
"i18n:govoplan-scheduling.determined.9f23293d": "Determined",
|
||||
"i18n:govoplan-scheduling.discard.36fff63c": "Discard",
|
||||
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Discard this unsaved scheduling request?",
|
||||
"i18n:govoplan-scheduling.draft.23d33e22": "Draft",
|
||||
"i18n:govoplan-scheduling.end.a2bb9d34": "End",
|
||||
"i18n:govoplan-scheduling.error.7f2f6a15": "Error",
|
||||
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Every candidate slot must end after it starts.",
|
||||
"i18n:govoplan-scheduling.failed.09fef5d8": "Failed",
|
||||
"i18n:govoplan-scheduling.free.75f52718": "Free",
|
||||
"i18n:govoplan-scheduling.free_busy_checks_each_candidate_slot_against_the_selecte.50f0371a": "Free/busy checks each candidate slot against the selected calendar.",
|
||||
"i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d": "Loading scheduling requests…",
|
||||
"i18n:govoplan-scheduling.invitation.6306ef74": "Invitation",
|
||||
"i18n:govoplan-scheduling.invited.53469df1": "Invited",
|
||||
"i18n:govoplan-scheduling.location.d219c681": "Location",
|
||||
"i18n:govoplan-scheduling.managed_scheduling_requests.7a45972f": "Managed scheduling requests",
|
||||
"i18n:govoplan-scheduling.maybe.56dd8d0b": "Maybe",
|
||||
"i18n:govoplan-scheduling.my_scheduling_requests.d28ef235": "My scheduling requests",
|
||||
"i18n:govoplan-scheduling.name.709a2322": "Name",
|
||||
"i18n:govoplan-scheduling.new_scheduling_request.2080f675": "New scheduling request",
|
||||
"i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3": "No notifications have been created.",
|
||||
"i18n:govoplan-scheduling.no_participants.73a89101": "No participants",
|
||||
"i18n:govoplan-scheduling.no_requests_in_this_group.a63c1b40": "No requests in this group",
|
||||
"i18n:govoplan-scheduling.no_scheduling_request_selected.ac940664": "No scheduling request selected",
|
||||
"i18n:govoplan-scheduling.notifications.753a22b2": "Notifications",
|
||||
"i18n:govoplan-scheduling.open.cf9b7706": "Open",
|
||||
"i18n:govoplan-scheduling.open_poll.2beac9a7": "Open poll",
|
||||
"i18n:govoplan-scheduling.open_starts_collecting_availability_responses.c8ec34e5": "Open starts collecting availability responses.",
|
||||
"i18n:govoplan-scheduling.option_value.3f643dc0": "Option {value0}",
|
||||
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Other scheduling requests",
|
||||
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "Participant email",
|
||||
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Participant names and statuses are hidden. Aggregate counts remain visible.",
|
||||
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Share participant names and response statuses",
|
||||
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "When enabled, participants can see other participants' names and response statuses. Email addresses and invitation details remain private.",
|
||||
"i18n:govoplan-scheduling.participants.cd56e083": "Participants",
|
||||
"i18n:govoplan-scheduling.past.405c12fb": "Past",
|
||||
"i18n:govoplan-scheduling.pending.96f608c1": "Pending",
|
||||
"i18n:govoplan-scheduling.queued.6a599877": "Queued",
|
||||
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Refresh requests",
|
||||
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Reminder creates a notification job for every active participant.",
|
||||
"i18n:govoplan-scheduling.remove.e963907d": "Remove",
|
||||
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Remove participant {value0}",
|
||||
"i18n:govoplan-scheduling.remove_slot_value.3adc7576": "Remove slot {value0}",
|
||||
"i18n:govoplan-scheduling.removed.b5e77c5c": "Removed",
|
||||
"i18n:govoplan-scheduling.reminder.b87a1929": "Reminder",
|
||||
"i18n:govoplan-scheduling.request_failed.9fcda32c": "Request failed",
|
||||
"i18n:govoplan-scheduling.required.eed6bfb4": "Required",
|
||||
"i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b": "Requires Calendar availability-read access.",
|
||||
"i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763": "Requires Calendar event-write access.",
|
||||
"i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe": "Responses may be updated while this request remains open.",
|
||||
"i18n:govoplan-scheduling.responded.4f218211": "Responded",
|
||||
"i18n:govoplan-scheduling.save.efc007a3": "Save",
|
||||
"i18n:govoplan-scheduling.saving.56a2285c": "Saving…",
|
||||
"i18n:govoplan-scheduling.scheduling_requests.b3c12f4d": "Scheduling requests",
|
||||
"i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be": "Scheduling request title is required.",
|
||||
"i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba": "Scheduling requests for me",
|
||||
"i18n:govoplan-scheduling.select_a_calendar.f6af95bb": "Select a calendar",
|
||||
"i18n:govoplan-scheduling.select_a_calendar_or_turn_off_calendar_integration.cc3652a9": "Select a calendar or turn off Calendar integration.",
|
||||
"i18n:govoplan-scheduling.send_reminder.cf5eb3bf": "Send reminder",
|
||||
"i18n:govoplan-scheduling.sending.ceafde86": "Sending",
|
||||
"i18n:govoplan-scheduling.sent.35f49dcf": "Sent",
|
||||
"i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped",
|
||||
"i18n:govoplan-scheduling.start.952f3754": "Start",
|
||||
"i18n:govoplan-scheduling.status.bae7d5be": "Status",
|
||||
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Submit response",
|
||||
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Tentative holds create one provisional calendar event per candidate slot.",
|
||||
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "The final event is created only for the selected slot.",
|
||||
"i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53": "The response replaces your previous availability choices.",
|
||||
"i18n:govoplan-scheduling.title.768e0c1c": "Title",
|
||||
"i18n:govoplan-scheduling.unavailable.2c9c1f79": "Unavailable",
|
||||
"i18n:govoplan-scheduling.unchecked.1b927dec": "Unchecked",
|
||||
"i18n:govoplan-scheduling.update_response.346233cf": "Update response",
|
||||
"i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa": "Use a calendar for availability checks, tentative holds, and the final event.",
|
||||
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} participants",
|
||||
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} responses",
|
||||
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "What do these actions do?",
|
||||
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "You can respond here or use the invitation link you received.",
|
||||
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
|
||||
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Die automatische Einladungszustellung ist nicht verfügbar; kopieren Sie stattdessen den Link.",
|
||||
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",
|
||||
"i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79": "Einen neuen Einladungslink für {value0} kopieren",
|
||||
"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.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306": "Die Zustellung der Einladung ist fehlgeschlagen. Der Link wurde erstellt, aber nicht zugestellt.",
|
||||
"i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba": "Die Zustellung der Einladung wurde angefordert.",
|
||||
"i18n:govoplan-scheduling.invitation_link_copied.332973ec": "Einladungslink kopiert.",
|
||||
"i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4": "Einladungslink widerrufen.",
|
||||
"i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc": "Kein aktiver Einladungslink zum Widerrufen vorhanden.",
|
||||
"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.reload_the_request_before_changing_this_invitation.9e685df4": "Laden Sie die Anfrage neu, bevor Sie diese Einladung ändern.",
|
||||
"i18n:govoplan-scheduling.participant.554f4235": "Teilnehmende Person",
|
||||
"i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf": "Einladungslink widerrufen",
|
||||
"i18n:govoplan-scheduling.revoke_link.da371ee1": "Link widerrufen",
|
||||
"i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817": "Den aktuellen Einladungslink für {value0} widerrufen? Er funktioniert danach sofort nicht mehr.",
|
||||
"i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa": "Den Einladungslink für {value0} widerrufen",
|
||||
"i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea": "Eine neue Einladung an {value0} senden",
|
||||
"i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c": "Der Stornierungshinweis ist abgelaufen; ein neuer Link kann nicht ausgestellt werden.",
|
||||
"i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc": "Der Einladungslink konnte nicht kopiert werden. Prüfen Sie die Zwischenablageberechtigungen des Browsers und versuchen Sie es erneut.",
|
||||
"i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180": "Für diese teilnehmende Person ist weder eine zustellbare E-Mail-Adresse noch ein Konto hinterlegt.",
|
||||
"i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533": "Diese Einladung wurde zwischenzeitlich geändert. Die Anfrage wurde neu geladen; versuchen Sie es erneut.",
|
||||
"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.",
|
||||
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "Ein Terminvorschlag kann ausgewählt werden, nachdem die Anfrage geschlossen wurde.",
|
||||
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Für jeden Terminvorschlag Vielleicht zwischen Verfügbar und Nicht verfügbar anbieten.",
|
||||
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Kommentare erlauben",
|
||||
"i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Externe Teilnehmende erlauben",
|
||||
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Ausstehend",
|
||||
"i18n:govoplan-scheduling.capacity.d3c375f8": "Kapazität",
|
||||
"i18n:govoplan-scheduling.comment.d03495b1": "Kommentar",
|
||||
"i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8": "Bei jeder neuen oder geänderten Antwort eine Benachrichtigung für die organisierende Person erstellen.",
|
||||
"i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465": "Jede teilnehmende Person kann höchstens einen Terminvorschlag als Verfügbar oder Vielleicht markieren.",
|
||||
"i18n:govoplan-scheduling.edit_scheduling_request.7e749c19": "Terminanfrage bearbeiten",
|
||||
"i18n:govoplan-scheduling.invited_participant_identity_is_locked_remove_and_add_the_participant_to_change_it.34e1201a": "Name und E-Mail-Adresse einer eingeladenen Person sind gesperrt. Entfernen Sie die Person und fügen Sie sie erneut hinzu, um diese Angaben zu ändern.",
|
||||
"i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0": "Gastlinks werden nicht ausgegeben, solange die konfigurierten Teilnahmeregeln am öffentlichen Antwortzugang nicht durchgesetzt werden können. Angemeldete Teilnehmende können weiterhin hier antworten.",
|
||||
"i18n:govoplan-scheduling.guest_password.94545e82": "Gastpasswort",
|
||||
"i18n:govoplan-scheduling.let_participants_add_a_comment_to_their_response.9dce8d17": "Teilnehmende können ihrer Antwort einen Kommentar hinzufügen.",
|
||||
"i18n:govoplan-scheduling.limit_participants_per_option.1e9aa51d": "Teilnehmende je Terminvorschlag begrenzen",
|
||||
"i18n:govoplan-scheduling.maximum_participants_per_option.5abdfb27": "Maximale Teilnehmendenzahl je Terminvorschlag",
|
||||
"i18n:govoplan-scheduling.notify_me_about_each_answer.505749d6": "Über jede Antwort benachrichtigen",
|
||||
"i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c": "Teilnehmende können nur einen Terminvorschlag wählen",
|
||||
"i18n:govoplan-scheduling.participation.9ad70cc4": "Teilnahme",
|
||||
"i18n:govoplan-scheduling.participation_rate.46bc5504": "Teilnahmequote",
|
||||
"i18n:govoplan-scheduling.participation_settings.8dc6f62c": "Teilnahmeeinstellungen",
|
||||
"i18n:govoplan-scheduling.participant_privacy.108c470f": "Datenschutz für Teilnehmende",
|
||||
"i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740": "Teilnahmeregeln sind gesperrt, nachdem Einladungslinks erstellt wurden. Sichtbarkeit und Antwortbenachrichtigungen können weiterhin geändert werden.",
|
||||
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Gastzugang mit Passwort schützen",
|
||||
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "Personen ohne Konto müssen für ihre Antwort eine E-Mail-Adresse angeben.",
|
||||
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "Nicht angemeldete Personen müssen dieses Passwort eingeben, bevor sie die Anfrage sehen können.",
|
||||
"i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44": "Suchen Sie nach Konten und Kontakten, die Sie sehen dürfen. Wenn externe Teilnehmende erlaubt sind, können Sie Name und E-Mail-Adresse auch manuell hinzufügen.",
|
||||
"i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735": "Wenn diese Option aktiviert ist, können Personen außerhalb der eingerichteten Konten und Kontakte manuell hinzugefügt werden.",
|
||||
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Antwort Vielleicht anbieten",
|
||||
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "E-Mail-Adresse von Gästen verlangen",
|
||||
"i18n:govoplan-scheduling.responses.3427e3ab": "Antworten",
|
||||
"i18n:govoplan-scheduling.response_results_are_not_available_for_this_view.1e82db18": "Antwortergebnisse sind für diese Ansicht nicht verfügbar.",
|
||||
"i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3": "Benachrichtigungen konnten nicht geladen werden.",
|
||||
"i18n:govoplan-scheduling.stop_accepting_yes_responses_for_an_option_when_its_limit.79af21db": "Keine weiteren Verfügbar-Antworten annehmen, sobald die Begrenzung eines Terminvorschlags erreicht ist.",
|
||||
"i18n:govoplan-scheduling.use_at_least_8_characters_the_password_is_never_displayed.035708a2": "Verwenden Sie mindestens 8 Zeichen. Das Passwort wird nach dem Speichern nicht mehr angezeigt.",
|
||||
"i18n:govoplan-scheduling.add_participant.6cff957d": "Teilnehmende Person hinzufügen",
|
||||
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Terminanfrage hinzufügen",
|
||||
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Terminvorschlag hinzufügen",
|
||||
"i18n:govoplan-scheduling.answered.e0aafffa": "Beantwortet",
|
||||
"i18n:govoplan-scheduling.available.7c62a142": "Verfügbar",
|
||||
"i18n:govoplan-scheduling.awaiting_response.ee646ea9": "Antwort ausstehend",
|
||||
"i18n:govoplan-scheduling.basic_information.d8bc7383": "Grunddaten",
|
||||
"i18n:govoplan-scheduling.busy.592a9d16": "Belegt",
|
||||
"i18n:govoplan-scheduling.calendar.adab5090": "Kalender",
|
||||
"i18n:govoplan-scheduling.calendar_coordination.291ab00a": "Kalenderabgleich",
|
||||
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Ausgewählter Kalender",
|
||||
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Kalenderintegration",
|
||||
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Die Kalenderintegration benötigt das Kalendermodul sowie Leserechte für Kalender und Verfügbarkeiten und Schreibrechte für Termine.",
|
||||
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Verfügbarkeit zu den Vorschlägen",
|
||||
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Terminvorschläge",
|
||||
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Frei/Belegt prüfen",
|
||||
"i18n:govoplan-scheduling.choose_availability.ac95b8f6": "Verfügbarkeit auswählen",
|
||||
"i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f": "Wählen Sie für mindestens einen Terminvorschlag eine Verfügbarkeit aus.",
|
||||
"i18n:govoplan-scheduling.close_poll.a6a18916": "Abstimmung schließen",
|
||||
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Schließen beendet die Annahme neuer Verfügbarkeitsantworten.",
|
||||
"i18n:govoplan-scheduling.closed.88d86b77": "Geschlossen",
|
||||
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Kalendertermin erstellen",
|
||||
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Vorläufige Reservierungen erstellen",
|
||||
"i18n:govoplan-scheduling.cancellation.319aaae4": "Stornierung",
|
||||
"i18n:govoplan-scheduling.decision.7f59a1f1": "Entscheidung",
|
||||
"i18n:govoplan-scheduling.declined.ff59b80f": "Abgelehnt",
|
||||
"i18n:govoplan-scheduling.decide_on_value.196409cd": "{value0} als Termin festlegen",
|
||||
"i18n:govoplan-scheduling.description.55f8ebc8": "Beschreibung",
|
||||
"i18n:govoplan-scheduling.determined.9f23293d": "Festgelegt",
|
||||
"i18n:govoplan-scheduling.discard.36fff63c": "Verwerfen",
|
||||
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Diese ungespeicherte Terminanfrage verwerfen?",
|
||||
"i18n:govoplan-scheduling.draft.23d33e22": "Entwurf",
|
||||
"i18n:govoplan-scheduling.end.a2bb9d34": "Ende",
|
||||
"i18n:govoplan-scheduling.error.7f2f6a15": "Fehler",
|
||||
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Jeder Terminvorschlag muss nach seinem Beginn enden.",
|
||||
"i18n:govoplan-scheduling.failed.09fef5d8": "Fehlgeschlagen",
|
||||
"i18n:govoplan-scheduling.free.75f52718": "Frei",
|
||||
"i18n:govoplan-scheduling.free_busy_checks_each_candidate_slot_against_the_selecte.50f0371a": "Frei/Belegt prüft jeden Terminvorschlag gegen den ausgewählten Kalender.",
|
||||
"i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d": "Terminanfragen werden geladen …",
|
||||
"i18n:govoplan-scheduling.invitation.6306ef74": "Einladung",
|
||||
"i18n:govoplan-scheduling.invited.53469df1": "Eingeladen",
|
||||
"i18n:govoplan-scheduling.location.d219c681": "Ort",
|
||||
"i18n:govoplan-scheduling.managed_scheduling_requests.7a45972f": "Verwaltete Terminanfragen",
|
||||
"i18n:govoplan-scheduling.maybe.56dd8d0b": "Vielleicht",
|
||||
"i18n:govoplan-scheduling.my_scheduling_requests.d28ef235": "Meine Terminanfragen",
|
||||
"i18n:govoplan-scheduling.name.709a2322": "Name",
|
||||
"i18n:govoplan-scheduling.new_scheduling_request.2080f675": "Neue Terminanfrage",
|
||||
"i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3": "Es wurden noch keine Benachrichtigungen erstellt.",
|
||||
"i18n:govoplan-scheduling.no_participants.73a89101": "Keine Teilnehmenden",
|
||||
"i18n:govoplan-scheduling.no_requests_in_this_group.a63c1b40": "Keine Anfragen in dieser Gruppe",
|
||||
"i18n:govoplan-scheduling.no_scheduling_request_selected.ac940664": "Keine Terminanfrage ausgewählt",
|
||||
"i18n:govoplan-scheduling.notifications.753a22b2": "Benachrichtigungen",
|
||||
"i18n:govoplan-scheduling.open.cf9b7706": "Offen",
|
||||
"i18n:govoplan-scheduling.open_poll.2beac9a7": "Abstimmung öffnen",
|
||||
"i18n:govoplan-scheduling.open_starts_collecting_availability_responses.c8ec34e5": "Öffnen startet die Erfassung von Verfügbarkeitsantworten.",
|
||||
"i18n:govoplan-scheduling.option_value.3f643dc0": "Vorschlag {value0}",
|
||||
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Andere Terminanfragen",
|
||||
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "E-Mail der teilnehmenden Person",
|
||||
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Namen und Antwortstatus der Teilnehmenden sind ausgeblendet. Zusammengefasste Anzahlen bleiben sichtbar.",
|
||||
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Namen und Antwortstatus der Teilnehmenden freigeben",
|
||||
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "Wenn aktiviert, sehen Teilnehmende die Namen und Antwortstatus anderer Teilnehmender. E-Mail-Adressen und Einladungsdetails bleiben privat.",
|
||||
"i18n:govoplan-scheduling.participants.cd56e083": "Teilnehmende",
|
||||
"i18n:govoplan-scheduling.past.405c12fb": "Vergangen",
|
||||
"i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend",
|
||||
"i18n:govoplan-scheduling.queued.6a599877": "Eingereiht",
|
||||
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Anfragen aktualisieren",
|
||||
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Erinnern erstellt für jede aktive teilnehmende Person einen Benachrichtigungsauftrag.",
|
||||
"i18n:govoplan-scheduling.remove.e963907d": "Entfernen",
|
||||
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Teilnehmende Person {value0} entfernen",
|
||||
"i18n:govoplan-scheduling.remove_slot_value.3adc7576": "Terminvorschlag {value0} entfernen",
|
||||
"i18n:govoplan-scheduling.removed.b5e77c5c": "Entfernt",
|
||||
"i18n:govoplan-scheduling.reminder.b87a1929": "Erinnerung",
|
||||
"i18n:govoplan-scheduling.request_failed.9fcda32c": "Anfrage fehlgeschlagen",
|
||||
"i18n:govoplan-scheduling.required.eed6bfb4": "Erforderlich",
|
||||
"i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b": "Erfordert Leserechte für Kalenderverfügbarkeiten.",
|
||||
"i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763": "Erfordert Schreibrechte für Kalendertermine.",
|
||||
"i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe": "Antworten können aktualisiert werden, solange diese Anfrage offen ist.",
|
||||
"i18n:govoplan-scheduling.responded.4f218211": "Geantwortet",
|
||||
"i18n:govoplan-scheduling.save.efc007a3": "Speichern",
|
||||
"i18n:govoplan-scheduling.saving.56a2285c": "Wird gespeichert …",
|
||||
"i18n:govoplan-scheduling.scheduling_requests.b3c12f4d": "Terminanfragen",
|
||||
"i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be": "Für die Terminanfrage ist ein Titel erforderlich.",
|
||||
"i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba": "Terminanfragen an mich",
|
||||
"i18n:govoplan-scheduling.select_a_calendar.f6af95bb": "Kalender auswählen",
|
||||
"i18n:govoplan-scheduling.select_a_calendar_or_turn_off_calendar_integration.cc3652a9": "Wählen Sie einen Kalender aus oder schalten Sie die Kalenderintegration aus.",
|
||||
"i18n:govoplan-scheduling.send_reminder.cf5eb3bf": "Erinnerung senden",
|
||||
"i18n:govoplan-scheduling.sending.ceafde86": "Wird gesendet",
|
||||
"i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet",
|
||||
"i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen",
|
||||
"i18n:govoplan-scheduling.start.952f3754": "Beginn",
|
||||
"i18n:govoplan-scheduling.status.bae7d5be": "Status",
|
||||
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Antwort senden",
|
||||
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Vorläufige Reservierungen erstellen je Terminvorschlag einen provisorischen Kalendereintrag.",
|
||||
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "Der endgültige Kalendereintrag wird nur für den ausgewählten Termin erstellt.",
|
||||
"i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53": "Die Antwort ersetzt Ihre vorherige Verfügbarkeitsauswahl.",
|
||||
"i18n:govoplan-scheduling.title.768e0c1c": "Titel",
|
||||
"i18n:govoplan-scheduling.unavailable.2c9c1f79": "Nicht verfügbar",
|
||||
"i18n:govoplan-scheduling.unchecked.1b927dec": "Nicht geprüft",
|
||||
"i18n:govoplan-scheduling.update_response.346233cf": "Antwort aktualisieren",
|
||||
"i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa": "Einen Kalender für Verfügbarkeitsprüfungen, vorläufige Reservierungen und den endgültigen Termin verwenden.",
|
||||
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} Teilnehmende",
|
||||
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} Antworten",
|
||||
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "Was bewirken diese Aktionen?",
|
||||
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "Sie können hier oder über den erhaltenen Einladungslink antworten.",
|
||||
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Ihre Antwort wurde gespeichert."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,23 +1,91 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import type {
|
||||
DashboardWidgetsUiCapability,
|
||||
PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import SchedulingRequestsWidget from "./features/scheduling/SchedulingRequestsWidget";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/scheduling.css";
|
||||
|
||||
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
|
||||
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
|
||||
|
||||
const scheduleRead = ["scheduling:schedule:read"];
|
||||
const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
widgets: [
|
||||
{
|
||||
id: "scheduling.open-requests",
|
||||
surfaceId: "scheduling.widget.open-requests",
|
||||
title: "Scheduling requests",
|
||||
description: "Open scheduling polls and their response progress.",
|
||||
moduleId: "scheduling",
|
||||
category: "Planning",
|
||||
order: 45,
|
||||
defaultVisible: false,
|
||||
defaultSize: "medium",
|
||||
supportedSizes: ["medium", "wide"],
|
||||
anyOf: scheduleRead,
|
||||
refreshIntervalMs: 60_000,
|
||||
defaultConfiguration: {
|
||||
maxItems: 5,
|
||||
includeDrafts: false
|
||||
},
|
||||
configurationFields: [
|
||||
{
|
||||
id: "maxItems",
|
||||
label: "Maximum requests",
|
||||
kind: "number",
|
||||
min: 1,
|
||||
max: 12,
|
||||
step: 1,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
id: "includeDrafts",
|
||||
label: "Include drafts",
|
||||
kind: "boolean"
|
||||
}
|
||||
],
|
||||
render: ({ settings, refreshKey, configuration }) =>
|
||||
createElement(SchedulingRequestsWidget, {
|
||||
settings,
|
||||
refreshKey,
|
||||
configuration
|
||||
})
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const schedulingModule: PlatformWebModule = {
|
||||
id: "scheduling",
|
||||
label: "Scheduling",
|
||||
version: "1.0.0",
|
||||
version: "0.1.11",
|
||||
dependencies: ["poll"],
|
||||
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
|
||||
optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
|
||||
translations: generatedTranslations,
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "scheduling.widget.open-requests",
|
||||
moduleId: "scheduling",
|
||||
kind: "section",
|
||||
label: "Scheduling requests widget",
|
||||
order: 45
|
||||
}
|
||||
],
|
||||
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
|
||||
routes: [
|
||||
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
|
||||
]
|
||||
],
|
||||
publicRoutes: [
|
||||
{
|
||||
path: "/scheduling/public/:requestId/:token",
|
||||
order: 10,
|
||||
render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth })
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
"dashboard.widgets": schedulingDashboardWidgets
|
||||
}
|
||||
};
|
||||
|
||||
export default schedulingModule;
|
||||
|
||||
@@ -2,95 +2,316 @@
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 115px);
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.scheduling-public-page {
|
||||
width: min(920px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 24px 0 48px;
|
||||
}
|
||||
|
||||
.scheduling-public-page .loading-frame {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.scheduling-public-content,
|
||||
.scheduling-public-access-form,
|
||||
.scheduling-public-slots {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.scheduling-public-deep-link,
|
||||
.scheduling-public-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.scheduling-public-actions {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scheduling-public-description {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.scheduling-public-summary {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 6px 14px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.scheduling-public-summary dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.scheduling-public-summary dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scheduling-public-slot {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.scheduling-public-slot legend {
|
||||
padding: 0 4px;
|
||||
color: var(--text-strong);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.scheduling-public-slot p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.scheduling-public-choice-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.scheduling-public-choice-group label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scheduling-public-choice-group label:has(input:checked) {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.scheduling-public-page {
|
||||
width: min(100% - 20px, 920px);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.scheduling-public-page .form-grid.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.scheduling-public-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.scheduling-page *,
|
||||
.scheduling-page *::before,
|
||||
.scheduling-page *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scheduling-shell {
|
||||
min-height: 0;
|
||||
.scheduling-workspace {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 310px) minmax(0, 1fr);
|
||||
border: var(--border-line);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-sidebar {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.scheduling-workspace-layout {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(310px, 370px) minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-request-sidebar,
|
||||
.scheduling-main-panel,
|
||||
.scheduling-editor-surface {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scheduling-request-sidebar {
|
||||
padding: 12px;
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.scheduling-sidebar-bar,
|
||||
.scheduling-topbar,
|
||||
.scheduling-panel-heading,
|
||||
.scheduling-mini-heading,
|
||||
.scheduling-status-row,
|
||||
.scheduling-request-sidebar > .card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scheduling-request-sidebar > .card > .card-body {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.scheduling-sidebar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-sidebar-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.scheduling-main-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.scheduling-main-panel > .alert {
|
||||
flex: 0 0 auto;
|
||||
margin: 12px 14px 0;
|
||||
}
|
||||
|
||||
.scheduling-editor-surface {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-page-header {
|
||||
min-height: 58px;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.scheduling-page-title,
|
||||
.scheduling-page-actions,
|
||||
.scheduling-card-actions,
|
||||
.scheduling-actions,
|
||||
.scheduling-title-line {
|
||||
.scheduling-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scheduling-sidebar-bar {
|
||||
justify-content: space-between;
|
||||
min-height: 54px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
.scheduling-page-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scheduling-icon-button.btn {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
.scheduling-page-title > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scheduling-list {
|
||||
.scheduling-page-title h1 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.scheduling-page-title p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scheduling-page-actions,
|
||||
.scheduling-card-actions,
|
||||
.scheduling-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.scheduling-page-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.scheduling-page-actions .btn,
|
||||
.scheduling-card-actions .btn,
|
||||
.scheduling-actions .btn,
|
||||
.scheduling-response-card + .btn {
|
||||
min-height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-detail,
|
||||
.scheduling-editor-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.scheduling-detail,
|
||||
.scheduling-editor-scroll {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scheduling-request-group + .scheduling-request-group {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-request-group h2 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-strong);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.scheduling-list-group {
|
||||
min-height: 52px;
|
||||
max-height: min(31vh, 320px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scheduling-request-list {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.scheduling-list-item {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
margin: 0 0 4px;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scheduling-list-item:hover,
|
||||
.scheduling-list-item.is-selected {
|
||||
background: var(--hover-bg);
|
||||
.scheduling-list-item > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.scheduling-list-item span,
|
||||
.scheduling-slot-row strong,
|
||||
.scheduling-list-item strong,
|
||||
.scheduling-list-item small,
|
||||
.scheduling-compact-row span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -99,210 +320,171 @@
|
||||
}
|
||||
|
||||
.scheduling-list-item small,
|
||||
.scheduling-slot-row small,
|
||||
.scheduling-compact-row small,
|
||||
.scheduling-note {
|
||||
.scheduling-note,
|
||||
.scheduling-list-empty,
|
||||
.scheduling-compact-row small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scheduling-workspace {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-topbar {
|
||||
justify-content: space-between;
|
||||
min-height: 54px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.scheduling-title-line {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.scheduling-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.scheduling-actions .btn,
|
||||
.scheduling-create-panel .btn,
|
||||
.scheduling-slot-row .btn {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-alert {
|
||||
margin: 8px 12px 0;
|
||||
padding: 8px 10px;
|
||||
border: var(--border-line);
|
||||
border-color: var(--danger);
|
||||
border-radius: 6px;
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.scheduling-content {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-create-panel,
|
||||
.scheduling-detail {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.scheduling-create-panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.scheduling-create-panel label,
|
||||
.scheduling-form-section {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.scheduling-create-panel label span,
|
||||
.scheduling-mini-heading span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-create-panel input {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
}
|
||||
|
||||
.scheduling-checkbox {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scheduling-slot-draft,
|
||||
.scheduling-participant-draft {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-slot-draft {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
.scheduling-list-empty {
|
||||
margin: 3px 0 8px;
|
||||
}
|
||||
|
||||
.scheduling-status-row {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.scheduling-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-strong);
|
||||
background: var(--panel-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-status-collecting,
|
||||
.scheduling-status-handed_off {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.scheduling-slot-table {
|
||||
.scheduling-slot-title {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-slot-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) auto auto auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 46px;
|
||||
padding: 8px;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-slot-row:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.scheduling-freebusy {
|
||||
min-width: 74px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.scheduling-freebusy.free {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.scheduling-freebusy.busy,
|
||||
.scheduling-freebusy.error {
|
||||
color: var(--danger);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.scheduling-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.scheduling-compact-row {
|
||||
min-height: 36px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-empty {
|
||||
padding: 20px;
|
||||
.scheduling-compact-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.scheduling-response-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scheduling-response-card > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scheduling-response-grid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-response-grid label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(160px, 240px);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-response-grid label:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.scheduling-response-grid label > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.scheduling-response-grid small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.scheduling-shell,
|
||||
.scheduling-content,
|
||||
.scheduling-columns {
|
||||
.scheduling-action-help {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scheduling-empty {
|
||||
padding: 28px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scheduling-setting-with-field {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scheduling-setting-with-field .form-field {
|
||||
padding-left: 48px;
|
||||
}
|
||||
|
||||
.scheduling-capability-note {
|
||||
margin: 10px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scheduling-selected-calendar {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.scheduling-workspace-layout {
|
||||
grid-template-columns: minmax(270px, 320px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.scheduling-page {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 115px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scheduling-workspace,
|
||||
.scheduling-workspace-layout,
|
||||
.scheduling-main-panel,
|
||||
.scheduling-editor-surface,
|
||||
.scheduling-detail,
|
||||
.scheduling-editor-scroll {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.scheduling-workspace-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.scheduling-sidebar,
|
||||
.scheduling-create-panel {
|
||||
.scheduling-request-sidebar {
|
||||
max-height: 48vh;
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-slot-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
.scheduling-columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.scheduling-page-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scheduling-page-actions,
|
||||
.scheduling-card-actions,
|
||||
.scheduling-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scheduling-page-actions .btn:last-child,
|
||||
.scheduling-card-actions .btn:last-child,
|
||||
.scheduling-actions .btn:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.scheduling-response-grid label {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
380
webui/tests/scheduling-view-model.test.ts
Normal file
380
webui/tests/scheduling-view-model.test.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { SchedulingRequest } from "../src/api/scheduling.ts";
|
||||
import {
|
||||
applySchedulingAvailabilityChoice,
|
||||
groupSchedulingRequests,
|
||||
participantDraftFromResponse,
|
||||
participantDraftsFromPicker,
|
||||
participantPayload,
|
||||
schedulingInvitationActionBlocks,
|
||||
schedulingPublicInvitationUrl,
|
||||
schedulingSortPhase,
|
||||
type SchedulingActor
|
||||
} from "../src/features/scheduling/schedulingViewModel.ts";
|
||||
|
||||
test("maps visible account and contact selections into bounded scheduling participant payloads", () => {
|
||||
let sequence = 0;
|
||||
const selected = participantDraftsFromPicker([
|
||||
{
|
||||
selection_key: "account:account-2",
|
||||
kind: "account",
|
||||
reference_id: "account-2",
|
||||
display_name: "Ada Account",
|
||||
email: "ADA@EXAMPLE.TEST",
|
||||
source_module: "access",
|
||||
source_label: "Accounts",
|
||||
provenance: { tenant_id: "must-not-be-persisted" },
|
||||
metadata: { group_ids: ["must-not-be-persisted"] }
|
||||
},
|
||||
{
|
||||
selection_key: "contact:contact-3:contact@example.test",
|
||||
kind: "contact",
|
||||
reference_id: "contact-3",
|
||||
display_name: "Contact Person",
|
||||
email: "contact@example.test",
|
||||
source_module: "addresses",
|
||||
source_label: "Contacts",
|
||||
source_revision: "revision-3",
|
||||
provenance: { address_book_id: "must-not-be-persisted" }
|
||||
}
|
||||
], [], () => `participant-${++sequence}`);
|
||||
|
||||
assert.equal(selected[0].respondent_id, "account-2");
|
||||
assert.equal(selected[0].participant_type, "internal");
|
||||
assert.equal(selected[0].email, "ada@example.test");
|
||||
assert.deepEqual(selected[0].metadata, {
|
||||
directory_selection: {
|
||||
selection_key: "account:account-2",
|
||||
kind: "account",
|
||||
reference_id: "account-2",
|
||||
source_module: "access",
|
||||
source_label: "Accounts",
|
||||
source_revision: null
|
||||
}
|
||||
});
|
||||
assert.equal(selected[1].respondent_id, null);
|
||||
assert.equal(selected[1].participant_type, "external");
|
||||
assert.equal(
|
||||
(selected[1].metadata?.directory_selection as { source_revision: string }).source_revision,
|
||||
"revision-3"
|
||||
);
|
||||
assert.deepEqual(participantPayload(selected[1]), {
|
||||
respondent_id: null,
|
||||
display_name: "Contact Person",
|
||||
email: "contact@example.test",
|
||||
participant_type: "external",
|
||||
required: true,
|
||||
metadata: selected[1].metadata
|
||||
});
|
||||
});
|
||||
|
||||
test("reconstructs saved directory selections and preserves existing reconciliation identity", () => {
|
||||
const responseParticipant = {
|
||||
id: "stored-participant",
|
||||
revision: "a".repeat(64),
|
||||
is_current_participant: false,
|
||||
respondent_id: "account-2",
|
||||
display_name: "Ada Account",
|
||||
email: "ada@example.test",
|
||||
participant_type: "internal",
|
||||
required: true,
|
||||
status: "invited",
|
||||
poll_invitation_id: "invitation-1",
|
||||
metadata: {
|
||||
directory_selection: {
|
||||
selection_key: "account:account-2",
|
||||
kind: "account",
|
||||
reference_id: "account-2",
|
||||
source_module: "access",
|
||||
source_label: "Accounts"
|
||||
}
|
||||
}
|
||||
};
|
||||
const draft = participantDraftFromResponse(responseParticipant, "draft-1");
|
||||
const remapped = participantDraftsFromPicker([draft], [draft], () => "unexpected");
|
||||
|
||||
assert.equal(remapped[0], draft);
|
||||
assert.equal(draft.sourceId, "stored-participant");
|
||||
assert.equal(draft.identityLocked, true);
|
||||
assert.equal(participantPayload(draft).id, "stored-participant");
|
||||
assert.equal(participantPayload(draft).revision, "a".repeat(64));
|
||||
});
|
||||
|
||||
const now = new Date("2026-07-20T10:00:00Z");
|
||||
const actor: SchedulingActor = {
|
||||
accountId: "account-1",
|
||||
membershipId: "membership-1",
|
||||
email: "person@example.test"
|
||||
};
|
||||
|
||||
function request(
|
||||
id: string,
|
||||
options: {
|
||||
organizer?: string;
|
||||
participantStatus?: string;
|
||||
status?: SchedulingRequest["status"];
|
||||
start?: string;
|
||||
end?: string;
|
||||
} = {}
|
||||
): SchedulingRequest {
|
||||
return {
|
||||
id,
|
||||
tenant_id: "tenant-1",
|
||||
title: id,
|
||||
timezone: "UTC",
|
||||
status: options.status ?? "collecting",
|
||||
organizer_user_id: options.organizer ?? "organizer-elsewhere",
|
||||
allow_external_participants: true,
|
||||
allow_participant_updates: true,
|
||||
result_visibility: "after_close",
|
||||
participant_visibility: "aggregates_only",
|
||||
effective_participant_visibility: "aggregates_only",
|
||||
participant_aggregate: {
|
||||
total: options.participantStatus ? 1 : 0,
|
||||
status_counts: options.participantStatus ? { [options.participantStatus]: 1 } : {}
|
||||
},
|
||||
participant_visibility_decision: {
|
||||
requested_visibility: "aggregates_only",
|
||||
effective_visibility: "aggregates_only",
|
||||
policy_applied: false,
|
||||
source_path: [],
|
||||
details: {}
|
||||
},
|
||||
notify_on_answers: true,
|
||||
single_choice: false,
|
||||
max_participants_per_option: null,
|
||||
allow_maybe: true,
|
||||
allow_comments: false,
|
||||
participant_email_required: false,
|
||||
anonymous_password_protection_enabled: false,
|
||||
public_participation_policy_enforcement_available: false,
|
||||
public_participation_policy_enforcement_reason: "Public participation gateway not installed",
|
||||
participant_invitation_delivery_available: false,
|
||||
calendar_integration_enabled: false,
|
||||
calendar_freebusy_enabled: false,
|
||||
calendar_hold_enabled: false,
|
||||
create_calendar_event_on_decision: false,
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
updated_at: "2026-07-01T00:00:00Z",
|
||||
metadata: {},
|
||||
slots: [{
|
||||
id: `slot-${id}`,
|
||||
label: id,
|
||||
start_at: options.start ?? "2026-07-21T09:00:00Z",
|
||||
end_at: options.end ?? "2026-07-21T10:00:00Z",
|
||||
timezone: "UTC",
|
||||
position: 0,
|
||||
revision: "0".repeat(64),
|
||||
freebusy_conflicts: [],
|
||||
metadata: {}
|
||||
}],
|
||||
participants: options.participantStatus ? [{
|
||||
id: `participant-${id}`,
|
||||
is_current_participant: true,
|
||||
respondent_id: "membership-1",
|
||||
email: "person@example.test",
|
||||
participant_type: "internal",
|
||||
required: true,
|
||||
status: options.participantStatus,
|
||||
metadata: {}
|
||||
}] : []
|
||||
};
|
||||
}
|
||||
|
||||
test("groups owned, invited, and administrator-only requests without hiding any", () => {
|
||||
const groups = groupSchedulingRequests([
|
||||
request("managed"),
|
||||
request("mine", { organizer: "account-1" }),
|
||||
request("invited", { participantStatus: "invited" })
|
||||
], actor, now);
|
||||
|
||||
assert.deepEqual(groups.owned.map((item) => item.id), ["mine"]);
|
||||
assert.deepEqual(groups.invited.map((item) => item.id), ["invited"]);
|
||||
assert.deepEqual(groups.other.map((item) => item.id), ["managed"]);
|
||||
});
|
||||
|
||||
test("matches email-only invitations without case sensitivity", () => {
|
||||
const invited = request("email-case", { participantStatus: "invited" });
|
||||
invited.participants[0].is_current_participant = false;
|
||||
invited.participants[0].respondent_id = null;
|
||||
invited.participants[0].email = "Person@Example.Test";
|
||||
|
||||
const groups = groupSchedulingRequests([invited], actor, now);
|
||||
|
||||
assert.deepEqual(groups.invited.map((item) => item.id), ["email-case"]);
|
||||
assert.deepEqual(groups.other, []);
|
||||
});
|
||||
|
||||
test("recognizes the current participant after identity bindings are redacted", () => {
|
||||
const invited = request("safe-projection", { participantStatus: "invited" });
|
||||
invited.participants[0].respondent_id = null;
|
||||
invited.participants[0].email = null;
|
||||
|
||||
const groups = groupSchedulingRequests([invited], actor, now);
|
||||
|
||||
assert.deepEqual(groups.invited.map((item) => item.id), ["safe-projection"]);
|
||||
assert.deepEqual(groups.other, []);
|
||||
});
|
||||
|
||||
test("keeps an organizer's active request in the open phase even if they also responded", () => {
|
||||
const owned = request("mine-and-invited", {
|
||||
organizer: "account-1",
|
||||
participantStatus: "responded"
|
||||
});
|
||||
|
||||
assert.equal(schedulingSortPhase(owned, actor, now), "unanswered");
|
||||
});
|
||||
|
||||
test("orders unanswered by nearest slot before answered, closed, determined, and past", () => {
|
||||
const groups = groupSchedulingRequests([
|
||||
request("past", {
|
||||
participantStatus: "invited",
|
||||
start: "2026-07-18T09:00:00Z",
|
||||
end: "2026-07-18T10:00:00Z"
|
||||
}),
|
||||
request("determined", { participantStatus: "invited", status: "decided" }),
|
||||
request("closed", { participantStatus: "invited", status: "closed" }),
|
||||
request("answered", { participantStatus: "responded" }),
|
||||
request("later", { participantStatus: "invited", start: "2026-07-23T09:00:00Z" }),
|
||||
request("nearer", { participantStatus: "invited", start: "2026-07-21T09:00:00Z" })
|
||||
], actor, now);
|
||||
|
||||
assert.deepEqual(groups.invited.map((item) => item.id), [
|
||||
"nearer",
|
||||
"later",
|
||||
"answered",
|
||||
"closed",
|
||||
"determined",
|
||||
"past"
|
||||
]);
|
||||
assert.equal(schedulingSortPhase(groups.invited[0], actor, now), "unanswered");
|
||||
assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past");
|
||||
});
|
||||
|
||||
test("derives stable invitation action blocks from policy, delivery, and participant state", () => {
|
||||
const managed = request("invitation-actions", { participantStatus: "invited" });
|
||||
managed.poll_id = "poll-1";
|
||||
managed.public_participation_policy_enforcement_available = true;
|
||||
managed.public_participation_policy_enforcement_reason = null;
|
||||
managed.participant_invitation_delivery_available = true;
|
||||
const participant = managed.participants[0];
|
||||
assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), {
|
||||
copy: "participant_revision_unavailable",
|
||||
send: "participant_revision_unavailable",
|
||||
revoke: "participant_revision_unavailable"
|
||||
});
|
||||
participant.revision = "a".repeat(64);
|
||||
participant.poll_invitation_id = "invitation-1";
|
||||
|
||||
assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), {
|
||||
copy: null,
|
||||
send: null,
|
||||
revoke: null
|
||||
});
|
||||
|
||||
managed.participant_invitation_delivery_available = false;
|
||||
assert.equal(
|
||||
schedulingInvitationActionBlocks(managed, participant, now).send,
|
||||
"delivery_unavailable"
|
||||
);
|
||||
|
||||
managed.participant_invitation_delivery_available = true;
|
||||
participant.email = null;
|
||||
participant.respondent_id = `scheduling-participant:${participant.id}`;
|
||||
assert.equal(
|
||||
schedulingInvitationActionBlocks(managed, participant, now).send,
|
||||
"no_delivery_target"
|
||||
);
|
||||
|
||||
managed.public_participation_policy_enforcement_available = false;
|
||||
assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), {
|
||||
copy: "participation_policy_unavailable",
|
||||
send: "participation_policy_unavailable",
|
||||
revoke: "participation_policy_unavailable"
|
||||
});
|
||||
|
||||
participant.poll_invitation_id = null;
|
||||
assert.equal(
|
||||
schedulingInvitationActionBlocks(managed, participant, now).revoke,
|
||||
"no_active_invitation"
|
||||
);
|
||||
});
|
||||
|
||||
test("allows bounded cancelled-request links until the notice expires without blocking revocation", () => {
|
||||
const cancelled = request("cancelled-invitation", {
|
||||
participantStatus: "invited",
|
||||
status: "cancelled"
|
||||
});
|
||||
cancelled.poll_id = "poll-1";
|
||||
cancelled.public_participation_policy_enforcement_available = true;
|
||||
cancelled.participant_invitation_delivery_available = true;
|
||||
cancelled.cancellation_notice_until = "2026-07-20T11:00:00Z";
|
||||
cancelled.participants[0].revision = "b".repeat(64);
|
||||
cancelled.participants[0].poll_invitation_id = "invitation-1";
|
||||
|
||||
assert.deepEqual(schedulingInvitationActionBlocks(cancelled, cancelled.participants[0], now), {
|
||||
copy: null,
|
||||
send: null,
|
||||
revoke: null
|
||||
});
|
||||
|
||||
cancelled.cancellation_notice_until = "2026-07-20T09:00:00Z";
|
||||
assert.deepEqual(schedulingInvitationActionBlocks(cancelled, cancelled.participants[0], now), {
|
||||
copy: "cancellation_notice_expired",
|
||||
send: "cancellation_notice_expired",
|
||||
revoke: null
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts only same-origin Scheduling public invitation URLs", () => {
|
||||
assert.equal(
|
||||
schedulingPublicInvitationUrl(
|
||||
"/scheduling/public/request-1/token-1",
|
||||
"https://govoplan.example"
|
||||
),
|
||||
"https://govoplan.example/scheduling/public/request-1/token-1"
|
||||
);
|
||||
assert.equal(
|
||||
schedulingPublicInvitationUrl(
|
||||
"https://attacker.example/scheduling/public/request-1/token-1",
|
||||
"https://govoplan.example"
|
||||
),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
schedulingPublicInvitationUrl(
|
||||
"/scheduling/publicity/request-1/token-1",
|
||||
"https://govoplan.example"
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("single-choice availability keeps one positive choice while preserving explicit no answers", () => {
|
||||
const next = applySchedulingAvailabilityChoice(
|
||||
["slot-a", "slot-b", "slot-c"],
|
||||
{ "slot-a": "available", "slot-b": "maybe", "slot-c": "unavailable" },
|
||||
"slot-b",
|
||||
"available",
|
||||
true
|
||||
);
|
||||
|
||||
assert.deepEqual(next, {
|
||||
"slot-a": "unavailable",
|
||||
"slot-b": "available",
|
||||
"slot-c": "unavailable"
|
||||
});
|
||||
});
|
||||
|
||||
test("multi-choice availability changes only the selected slot", () => {
|
||||
const next = applySchedulingAvailabilityChoice(
|
||||
["slot-a", "slot-b"],
|
||||
{ "slot-a": "available" },
|
||||
"slot-b",
|
||||
"maybe",
|
||||
false
|
||||
);
|
||||
|
||||
assert.deepEqual(next, { "slot-a": "available", "slot-b": "maybe" });
|
||||
});
|
||||
Reference in New Issue
Block a user