Compare commits
4 Commits
886579942f
...
658dcca9d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 658dcca9d6 | |||
| 2f6d46c06e | |||
| 82b158c170 | |||
| b1725b8f59 |
85
README.md
85
README.md
@@ -118,3 +118,88 @@ participant pages, 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.
|
||||
|
||||
Open lifecycle decision: cancellation closes the backing Poll, so submission
|
||||
fails, but an otherwise valid invitation can still resolve the reduced public
|
||||
view and show that the request was cancelled. Decide whether cancellation
|
||||
should revoke links immediately or retain that acknowledgement view for a
|
||||
bounded period; links without a deadline would otherwise remain readable.
|
||||
|
||||
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 exposes typed clients for the public access and submission
|
||||
endpoints. A signed-out browser page cannot yet be registered by a module:
|
||||
Core's `App` renders `PublicLandingPage` directly whenever `auth` is absent and
|
||||
only mounts module route contributions inside the authenticated branch. Until
|
||||
Core gains an explicit, allowlisted `publicRoutes` contract, notification action
|
||||
URLs under `/scheduling/public/{request}/{token}` must be treated as a blocked
|
||||
frontend handoff rather than a working guest page. The token is never moved into
|
||||
query parameters, browser storage, or an authenticated API contract while that
|
||||
shell boundary is unresolved.
|
||||
|
||||
Draft saves never issue public tokens or enqueue invitation delivery, even when
|
||||
`create_participant_invitations` is left at its compatibility default. A
|
||||
collecting request may explicitly issue invitations; authenticated in-module
|
||||
responses lazily create a gateway-bound invitation and discard its token. The
|
||||
draft-to-open transition therefore supports authenticated lazy responses, but
|
||||
does not make a guest link available from the current UI. The product decision
|
||||
still open is the explicit organizer workflow for issuing or reissuing public
|
||||
links after a draft is opened and, when Mail is installed, whether that action
|
||||
should also enqueue delivery or return links for separate distribution. Until
|
||||
that workflow is agreed, opening a draft does not silently send anything.
|
||||
|
||||
## 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.9"
|
||||
version = "0.1.10"
|
||||
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.9",
|
||||
"govoplan-poll>=0.1.9",
|
||||
"govoplan-core>=0.1.10",
|
||||
"govoplan-poll>=0.1.10",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.9"
|
||||
__version__ = "0.1.10"
|
||||
|
||||
@@ -37,6 +37,14 @@ class SchedulingRequest(Base, TimestampMixin):
|
||||
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)
|
||||
@@ -106,8 +114,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)
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ from govoplan_core.core.modules import (
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
|
||||
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
|
||||
from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
|
||||
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.9"
|
||||
MODULE_VERSION = "0.1.10"
|
||||
READ_SCOPE = "scheduling:schedule:read"
|
||||
WRITE_SCOPE = "scheduling:schedule:write"
|
||||
ADMIN_SCOPE = "scheduling:schedule:admin"
|
||||
@@ -47,8 +48,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."),
|
||||
)
|
||||
|
||||
@@ -56,7 +57,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(
|
||||
@@ -137,16 +138,19 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_CALENDAR_SCHEDULING,
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||
),
|
||||
required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
|
||||
required_capabilities=(
|
||||
CAPABILITY_POLL_SCHEDULING,
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.9"),
|
||||
ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.9"),
|
||||
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.9", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.9", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.9", 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.availability_matrix", version_min="0.1.10", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.10", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.10", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.10", 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),
|
||||
|
||||
@@ -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)
|
||||
@@ -3,7 +3,7 @@ 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, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
@@ -26,6 +26,9 @@ from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingRequestResponse,
|
||||
SchedulingRequestUpdateRequest,
|
||||
SchedulingPollSummaryResponse,
|
||||
SchedulingPublicParticipationAccessRequest,
|
||||
SchedulingPublicParticipationResponse,
|
||||
SchedulingPublicParticipationSubmitRequest,
|
||||
SchedulingStatusResponse,
|
||||
SchedulingSummaryResponse,
|
||||
)
|
||||
@@ -34,6 +37,7 @@ from govoplan_scheduling.backend.service import (
|
||||
SchedulingConflictError,
|
||||
SchedulingError,
|
||||
SchedulingPermissionError,
|
||||
SchedulingPublicParticipationError,
|
||||
cancel_scheduling_request,
|
||||
close_scheduling_request,
|
||||
create_final_calendar_event,
|
||||
@@ -42,7 +46,9 @@ from govoplan_scheduling.backend.service import (
|
||||
create_tentative_calendar_holds,
|
||||
decide_scheduling_request,
|
||||
evaluate_calendar_freebusy,
|
||||
get_scheduling_request,
|
||||
get_scheduling_availability_response,
|
||||
get_public_scheduling_participation,
|
||||
get_visible_scheduling_request,
|
||||
list_visible_scheduling_notifications,
|
||||
list_visible_scheduling_requests,
|
||||
@@ -53,8 +59,9 @@ from govoplan_scheduling.backend.service import (
|
||||
scheduling_request_response,
|
||||
scheduling_request_summary,
|
||||
submit_scheduling_availability,
|
||||
submit_public_scheduling_participation,
|
||||
update_scheduling_candidate_slot,
|
||||
update_scheduling_request,
|
||||
update_scheduling_request_with_invitation_tokens,
|
||||
)
|
||||
|
||||
|
||||
@@ -112,7 +119,40 @@ def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
|
||||
|
||||
|
||||
def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
|
||||
return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE)
|
||||
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}",
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
@@ -127,6 +167,25 @@ def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
|
||||
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 _request_response(
|
||||
request,
|
||||
*,
|
||||
@@ -144,6 +203,58 @@ def _request_response(
|
||||
)
|
||||
|
||||
|
||||
@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("/address-lookup", response_model=SchedulingAddressLookupResponse)
|
||||
def api_lookup_scheduling_addresses(
|
||||
query: str = Query(min_length=1),
|
||||
@@ -151,7 +262,7 @@ def api_lookup_scheduling_addresses(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingAddressLookupResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_scheduling_writer(principal)
|
||||
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
|
||||
if capability is None or not hasattr(capability, "lookup"):
|
||||
return SchedulingAddressLookupResponse(available=False, candidates=[])
|
||||
@@ -198,7 +309,7 @@ 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(
|
||||
session,
|
||||
@@ -294,9 +405,13 @@ 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:
|
||||
request = update_scheduling_request(
|
||||
request, invitation_tokens = update_scheduling_request_with_invitation_tokens(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
@@ -304,7 +419,11 @@ def api_update_scheduling_request(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
response = _request_response(request, principal=principal)
|
||||
response = _request_response(
|
||||
request,
|
||||
principal=principal,
|
||||
invitation_tokens=invitation_tokens,
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
@@ -317,7 +436,11 @@ def api_update_scheduling_candidate_slot(
|
||||
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:
|
||||
request = update_scheduling_candidate_slot(
|
||||
session,
|
||||
@@ -339,7 +462,7 @@ 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:
|
||||
@@ -355,7 +478,7 @@ 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:
|
||||
@@ -372,7 +495,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,
|
||||
@@ -398,7 +521,7 @@ 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)
|
||||
@@ -419,7 +542,7 @@ 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(
|
||||
@@ -446,7 +569,7 @@ 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(
|
||||
@@ -472,7 +595,7 @@ 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:
|
||||
@@ -547,7 +670,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,
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
||||
|
||||
|
||||
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
|
||||
@@ -25,6 +25,24 @@ def _known_timezone(value: str | None) -> str | None:
|
||||
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):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -91,6 +109,30 @@ 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)
|
||||
|
||||
|
||||
class SchedulingRequestCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -105,6 +147,14 @@ class SchedulingRequestCreateRequest(BaseModel):
|
||||
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)
|
||||
@@ -113,6 +163,14 @@ class SchedulingRequestCreateRequest(BaseModel):
|
||||
|
||||
_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")
|
||||
@@ -125,9 +183,40 @@ class SchedulingRequestUpdateRequest(BaseModel):
|
||||
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 = True
|
||||
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
|
||||
@@ -149,11 +238,12 @@ class SchedulingCandidateSlotResponse(BaseModel):
|
||||
|
||||
class SchedulingParticipantResponse(BaseModel):
|
||||
id: str
|
||||
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
|
||||
@@ -178,7 +268,7 @@ class SchedulingParticipantVisibilityDecisionResponse(BaseModel):
|
||||
|
||||
class SchedulingRequestResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
tenant_id: str | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
@@ -192,14 +282,23 @@ class SchedulingRequestResponse(BaseModel):
|
||||
allow_participant_updates: bool
|
||||
result_visibility: str
|
||||
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
|
||||
effective_participant_visibility: SchedulingParticipantVisibility
|
||||
participant_aggregate: SchedulingParticipantAggregateResponse
|
||||
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
|
||||
calendar_integration_enabled: bool
|
||||
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
|
||||
@@ -238,6 +337,7 @@ 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":
|
||||
@@ -258,6 +358,70 @@ class SchedulingAvailabilityResponse(BaseModel):
|
||||
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
|
||||
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):
|
||||
|
||||
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
@@ -17,7 +17,10 @@ class SchedulingManifestTests(unittest.TestCase):
|
||||
self.assertIn("access", manifest.optional_dependencies)
|
||||
self.assertIn("addresses", manifest.optional_dependencies)
|
||||
self.assertIn("policy", manifest.optional_dependencies)
|
||||
self.assertEqual(("poll.scheduling",), manifest.required_capabilities)
|
||||
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)
|
||||
@@ -29,14 +32,18 @@ class SchedulingManifestTests(unittest.TestCase):
|
||||
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("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
|
||||
required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces}
|
||||
self.assertEqual("0.1.9", required_interfaces["poll.availability_matrix"].version_min)
|
||||
self.assertEqual("0.1.9", required_interfaces["poll.response_collection"].version_min)
|
||||
self.assertEqual("0.1.9", required_interfaces["poll.workflow_context"].version_min)
|
||||
for interface_name in (
|
||||
"poll.availability_matrix",
|
||||
"poll.response_collection",
|
||||
"poll.workflow_context",
|
||||
"poll.governed_participation",
|
||||
):
|
||||
self.assertEqual("0.1.10", required_interfaces[interface_name].version_min)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -20,7 +20,7 @@ from govoplan_scheduling.backend.db.models import (
|
||||
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
|
||||
|
||||
|
||||
_SCHEDULING_HEAD = "9c2f4a7d1e6b"
|
||||
_SCHEDULING_HEAD = "ad7e3c9b2f10"
|
||||
_ENABLED_MODULES = ("poll", "scheduling")
|
||||
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
|
||||
|
||||
@@ -110,12 +110,27 @@ class SchedulingMigrationTests(unittest.TestCase):
|
||||
"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)
|
||||
@@ -130,7 +145,11 @@ class SchedulingMigrationTests(unittest.TestCase):
|
||||
|
||||
self.assertIn(_SCHEDULING_HEAD, heads)
|
||||
self.assertIn("participant_visibility", 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()
|
||||
@@ -219,6 +238,74 @@ class SchedulingMigrationTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
@@ -89,11 +90,46 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
||||
"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",
|
||||
@@ -102,6 +138,9 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
||||
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(
|
||||
@@ -152,10 +191,43 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
||||
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"])
|
||||
self.assertEqual(response.participants[0].email, "alice@example.test")
|
||||
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.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")
|
||||
@@ -165,11 +237,15 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
||||
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.assertEqual(own.respondent_id, "alice-id")
|
||||
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, {})
|
||||
|
||||
@@ -199,6 +275,18 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
||||
"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")
|
||||
|
||||
def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
|
||||
restricting_policy = _PrivacyPolicy("aggregates_only")
|
||||
@@ -211,8 +299,8 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
||||
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[0]["path"], "tenant:tenant-1")
|
||||
self.assertTrue(reduced.participant_visibility_decision.details["provider_session_available"])
|
||||
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")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,13 +19,21 @@ from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_access.backend.db.models import Account, User
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource
|
||||
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
|
||||
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
|
||||
from govoplan_poll.backend.db.models import (
|
||||
Poll,
|
||||
PollInvitation,
|
||||
PollLifecycleTransition,
|
||||
PollOption,
|
||||
PollParticipationSubmission,
|
||||
PollResponse,
|
||||
)
|
||||
from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest
|
||||
from govoplan_poll.backend.router import api_get_poll, api_list_polls, api_submit_poll_response
|
||||
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
|
||||
from govoplan_poll.backend.service import get_poll, submit_poll_response_with_token
|
||||
from govoplan_poll.backend.service import get_poll
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
|
||||
from govoplan_scheduling.backend.manifest import READ_SCOPE as SCHEDULING_READ_SCOPE
|
||||
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE as SCHEDULING_ADMIN_SCOPE
|
||||
from govoplan_scheduling.backend.manifest import RESPOND_SCOPE as SCHEDULING_RESPOND_SCOPE
|
||||
from govoplan_scheduling.backend.manifest import WRITE_SCOPE as SCHEDULING_WRITE_SCOPE
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
@@ -35,6 +43,7 @@ from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingCandidateSlotInput,
|
||||
SchedulingDecisionRequest,
|
||||
SchedulingParticipantInput,
|
||||
SchedulingPublicParticipationSubmitRequest,
|
||||
SchedulingRequestCreateRequest,
|
||||
SchedulingRequestUpdateRequest,
|
||||
)
|
||||
@@ -68,6 +77,7 @@ from govoplan_scheduling.backend.service import (
|
||||
require_visible_scheduling_results,
|
||||
scheduling_request_summary,
|
||||
scheduling_slot_revision,
|
||||
submit_public_scheduling_participation,
|
||||
update_scheduling_request,
|
||||
)
|
||||
from govoplan_scheduling.backend.runtime import configure_runtime
|
||||
@@ -88,6 +98,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
PollOption.__table__,
|
||||
PollResponse.__table__,
|
||||
PollInvitation.__table__,
|
||||
PollParticipationSubmission.__table__,
|
||||
PollLifecycleTransition.__table__,
|
||||
CalendarCollection.__table__,
|
||||
CalendarEvent.__table__,
|
||||
@@ -121,6 +132,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
CalendarSyncSource.__table__,
|
||||
CalendarEvent.__table__,
|
||||
CalendarCollection.__table__,
|
||||
PollParticipationSubmission.__table__,
|
||||
PollInvitation.__table__,
|
||||
PollLifecycleTransition.__table__,
|
||||
PollResponse.__table__,
|
||||
@@ -215,6 +227,82 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
self.assertEqual(len(tokens), 2)
|
||||
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants))
|
||||
|
||||
def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None:
|
||||
class RejectingNotificationProvider:
|
||||
def enqueue_notification(self, *_args, **_kwargs):
|
||||
raise AssertionError("A draft must not enqueue invitation delivery")
|
||||
|
||||
with patch(
|
||||
"govoplan_scheduling.backend.service.notification_dispatch_provider",
|
||||
return_value=RejectingNotificationProvider(),
|
||||
):
|
||||
request, tokens = create_scheduling_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
payload=self._payload().model_copy(update={"status": "draft"}),
|
||||
)
|
||||
|
||||
self.assertEqual(tokens, {})
|
||||
self.assertEqual(
|
||||
self.session.query(PollInvitation).filter(
|
||||
PollInvitation.poll_id == request.poll_id
|
||||
).count(),
|
||||
0,
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
participant.status == "draft"
|
||||
and participant.poll_invitation_id is None
|
||||
for participant in request.participants
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
self.session.query(SchedulingNotification).filter(
|
||||
SchedulingNotification.request_id == request.id
|
||||
).count(),
|
||||
0,
|
||||
)
|
||||
|
||||
open_scheduling_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
request_id=request.id,
|
||||
)
|
||||
api_submit_scheduling_availability(
|
||||
request.id,
|
||||
SchedulingAvailabilityResponseRequest(
|
||||
answers=[
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[0].id,
|
||||
value="available",
|
||||
option_revision=scheduling_slot_revision(request.slots[0]),
|
||||
)
|
||||
]
|
||||
),
|
||||
session=self.session,
|
||||
principal=self._principal(
|
||||
"alice-account",
|
||||
email="alice@example.test",
|
||||
scopes={SCHEDULING_RESPOND_SCOPE},
|
||||
),
|
||||
)
|
||||
alice = next(
|
||||
participant
|
||||
for participant in request.participants
|
||||
if participant.email == "alice@example.test"
|
||||
)
|
||||
self.assertEqual(alice.status, "responded")
|
||||
self.assertEqual(alice.participation_gateway, "scheduling")
|
||||
self.assertIsNotNone(alice.poll_invitation_id)
|
||||
self.assertEqual(
|
||||
self.session.query(SchedulingNotification).filter(
|
||||
SchedulingNotification.request_id == request.id,
|
||||
SchedulingNotification.event_kind == "invitation",
|
||||
).count(),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_create_route_persists_after_request_session_closes(self) -> None:
|
||||
response = api_create_scheduling_request(
|
||||
self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()}),
|
||||
@@ -244,15 +332,25 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
first_slot = request.slots[0]
|
||||
second_slot = request.slots[1]
|
||||
|
||||
submit_poll_response_with_token(
|
||||
submit_public_scheduling_participation(
|
||||
self.session,
|
||||
request_id=request.id,
|
||||
token=tokens[first_participant.id],
|
||||
payload=PollSubmitResponseRequest(
|
||||
payload=SchedulingPublicParticipationSubmitRequest(
|
||||
answers=[
|
||||
PollAnswerInput(option_id=first_slot.poll_option_id, value="available"),
|
||||
PollAnswerInput(option_id=second_slot.poll_option_id, value="maybe"),
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=first_slot.id,
|
||||
value="available",
|
||||
option_revision=scheduling_slot_revision(first_slot),
|
||||
),
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=second_slot.id,
|
||||
value="maybe",
|
||||
option_revision=scheduling_slot_revision(second_slot),
|
||||
),
|
||||
]
|
||||
),
|
||||
client_address="127.0.0.1",
|
||||
)
|
||||
summary = scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)
|
||||
|
||||
@@ -550,6 +648,142 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
).id,
|
||||
)
|
||||
|
||||
ordinary_writer = self._principal(
|
||||
"unrelated-writer",
|
||||
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE},
|
||||
)
|
||||
administrator = self._principal(
|
||||
"scheduling-admin",
|
||||
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_ADMIN_SCOPE},
|
||||
)
|
||||
self.assertEqual(
|
||||
[],
|
||||
api_list_scheduling_requests(
|
||||
status_filter=None,
|
||||
session=self.session,
|
||||
principal=ordinary_writer,
|
||||
).requests,
|
||||
)
|
||||
self.assertEqual(
|
||||
[request.id],
|
||||
[
|
||||
item.id
|
||||
for item in api_list_scheduling_requests(
|
||||
status_filter=None,
|
||||
session=self.session,
|
||||
principal=administrator,
|
||||
).requests
|
||||
],
|
||||
)
|
||||
|
||||
def test_participant_list_and_get_redact_management_and_connector_internals(self) -> None:
|
||||
request, _tokens = create_scheduling_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
payload=self._payload(),
|
||||
)
|
||||
request.calendar_event_id = "calendar-event-internal"
|
||||
request.metadata_ = {"connector_ref": "connector-internal"}
|
||||
slot = request.slots[0]
|
||||
slot.freebusy_status = "busy"
|
||||
slot.freebusy_conflicts = [
|
||||
{
|
||||
"calendar_id": "calendar-1",
|
||||
"event_id": "busy-event-internal",
|
||||
"uid": "busy-uid-internal",
|
||||
"recurrence_id": "busy-recurrence-internal",
|
||||
"start_at": "2026-07-20T09:15:00+00:00",
|
||||
"end_at": "2026-07-20T09:45:00+00:00",
|
||||
"status": "CONFIRMED",
|
||||
}
|
||||
]
|
||||
slot.tentative_hold_event_id = "hold-event-internal"
|
||||
slot.metadata_ = {"provider_ref": "provider-internal"}
|
||||
participant = request.participants[0]
|
||||
participant.respondent_id = "alice-identity-internal"
|
||||
participant.metadata_ = {"directory_ref": "directory-internal"}
|
||||
self.session.flush()
|
||||
|
||||
principal = self._principal(
|
||||
"alice-account",
|
||||
email="alice@example.test",
|
||||
scopes={SCHEDULING_READ_SCOPE},
|
||||
)
|
||||
listed = api_list_scheduling_requests(
|
||||
status_filter=None,
|
||||
session=self.session,
|
||||
principal=principal,
|
||||
).requests
|
||||
fetched = api_get_scheduling_request(
|
||||
request.id,
|
||||
session=self.session,
|
||||
principal=principal,
|
||||
)
|
||||
|
||||
self.assertEqual(len(listed), 1)
|
||||
for response in (listed[0], fetched):
|
||||
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.assertEqual(response.metadata, {})
|
||||
self.assertEqual(response.participant_aggregate.total, 2)
|
||||
self.assertEqual(len(response.participants), 1)
|
||||
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.poll_invitation_id)
|
||||
self.assertEqual(own.metadata, {})
|
||||
projected_slot = response.slots[0]
|
||||
self.assertIsNone(projected_slot.poll_option_id)
|
||||
self.assertEqual(projected_slot.freebusy_status, "busy")
|
||||
self.assertEqual(
|
||||
projected_slot.freebusy_conflicts,
|
||||
[
|
||||
{
|
||||
"start_at": "2026-07-20T09:15:00+00:00",
|
||||
"end_at": "2026-07-20T09:45:00+00:00",
|
||||
"status": "CONFIRMED",
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertIsNone(projected_slot.tentative_hold_event_id)
|
||||
self.assertEqual(projected_slot.metadata, {})
|
||||
|
||||
organizer = self._principal(
|
||||
"user-1",
|
||||
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE},
|
||||
)
|
||||
management = api_get_scheduling_request(
|
||||
request.id,
|
||||
session=self.session,
|
||||
principal=organizer,
|
||||
)
|
||||
self.assertEqual(management.tenant_id, "tenant-1")
|
||||
self.assertEqual(management.poll_id, request.poll_id)
|
||||
self.assertEqual(management.calendar_id, "calendar-1")
|
||||
self.assertEqual(management.calendar_event_id, "calendar-event-internal")
|
||||
self.assertEqual(management.metadata["connector_ref"], "connector-internal")
|
||||
self.assertEqual(management.slots[0].poll_option_id, slot.poll_option_id)
|
||||
self.assertEqual(
|
||||
management.slots[0].freebusy_conflicts[0]["uid"],
|
||||
"busy-uid-internal",
|
||||
)
|
||||
self.assertEqual(
|
||||
management.slots[0].tentative_hold_event_id,
|
||||
"hold-event-internal",
|
||||
)
|
||||
|
||||
def test_result_visibility_follows_existing_after_close_policy(self) -> None:
|
||||
request, _tokens = create_scheduling_request(
|
||||
self.session,
|
||||
@@ -800,7 +1034,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
organizer = self._principal("user-1", scopes={"poll:poll:read"})
|
||||
self.assertEqual(api_get_poll(request.poll_id, session=self.session, principal=organizer).id, request.poll_id)
|
||||
|
||||
def test_authenticated_response_reconciles_only_the_current_participant(self) -> None:
|
||||
def test_module_owned_poll_rejects_direct_response_and_uses_current_participant(self) -> None:
|
||||
participants = [
|
||||
SchedulingParticipantInput(
|
||||
respondent_id="alice-membership",
|
||||
@@ -835,15 +1069,21 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
response = api_submit_poll_response(
|
||||
request.poll_id,
|
||||
PollSubmitResponseRequest(
|
||||
answers=[PollAnswerInput(option_id=request.slots[0].poll_option_id, value="available")],
|
||||
metadata={"invitation_id": target.poll_invitation_id},
|
||||
),
|
||||
session=self.session,
|
||||
principal=attacker,
|
||||
)
|
||||
with self.assertRaises(HTTPException) as direct_response:
|
||||
api_submit_poll_response(
|
||||
request.poll_id,
|
||||
PollSubmitResponseRequest(
|
||||
answers=[
|
||||
PollAnswerInput(
|
||||
option_id=request.slots[0].poll_option_id,
|
||||
value="available",
|
||||
)
|
||||
],
|
||||
metadata={"invitation_id": target.poll_invitation_id},
|
||||
),
|
||||
session=self.session,
|
||||
principal=attacker,
|
||||
)
|
||||
listed = api_list_scheduling_requests(
|
||||
status_filter=None,
|
||||
session=self.session,
|
||||
@@ -855,8 +1095,29 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
principal=attacker,
|
||||
)
|
||||
|
||||
self.assertNotIn("invitation_id", response.metadata)
|
||||
self.assertEqual(direct_response.exception.status_code, 400)
|
||||
self.assertEqual([request.id], [item.id for item in listed.requests])
|
||||
self.assertFalse(current.has_response)
|
||||
|
||||
api_submit_scheduling_availability(
|
||||
request.id,
|
||||
SchedulingAvailabilityResponseRequest(
|
||||
answers=[
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[0].id,
|
||||
value="available",
|
||||
option_revision=scheduling_slot_revision(request.slots[0]),
|
||||
)
|
||||
]
|
||||
),
|
||||
session=self.session,
|
||||
principal=attacker,
|
||||
)
|
||||
current = api_get_my_scheduling_availability(
|
||||
request.id,
|
||||
session=self.session,
|
||||
principal=attacker,
|
||||
)
|
||||
self.assertTrue(current.has_response)
|
||||
self.assertEqual(
|
||||
[(answer.slot_id, answer.value) for answer in current.answers],
|
||||
@@ -964,17 +1225,20 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
payload=payload,
|
||||
)
|
||||
participant = request.participants[0]
|
||||
submit_poll_response_with_token(
|
||||
submit_public_scheduling_participation(
|
||||
self.session,
|
||||
request_id=request.id,
|
||||
token=tokens[participant.id],
|
||||
payload=PollSubmitResponseRequest(
|
||||
payload=SchedulingPublicParticipationSubmitRequest(
|
||||
answers=[
|
||||
PollAnswerInput(
|
||||
option_id=request.slots[0].poll_option_id,
|
||||
SchedulingAvailabilityAnswerInput(
|
||||
slot_id=request.slots[0].id,
|
||||
value="available",
|
||||
option_revision=scheduling_slot_revision(request.slots[0]),
|
||||
)
|
||||
]
|
||||
),
|
||||
client_address="127.0.0.1",
|
||||
)
|
||||
alice = self._principal(
|
||||
"alice-account",
|
||||
@@ -1010,7 +1274,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
self.assertEqual(len(responses), 1)
|
||||
self.assertEqual(
|
||||
responses[0].respondent_id,
|
||||
f"invitation:{participant.poll_invitation_id}",
|
||||
participant.respondent_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
responses[0].metadata_["invitation_id"],
|
||||
@@ -1071,13 +1335,17 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
for response in responses:
|
||||
own = next(item for item in response.participants if item.display_name == "Alice")
|
||||
other = next(item for item in response.participants if item.display_name == "Bob")
|
||||
self.assertEqual(own.respondent_id, "alice-id")
|
||||
self.assertTrue(own.is_current_participant)
|
||||
self.assertIsNone(own.respondent_id)
|
||||
self.assertEqual(own.email, "alice@example.test")
|
||||
self.assertEqual(own.metadata, {"private": "alice"})
|
||||
self.assertIsNotNone(own.poll_invitation_id)
|
||||
self.assertEqual(own.metadata, {})
|
||||
self.assertIsNone(own.poll_invitation_id)
|
||||
self.assertIsNone(own.invitation_token)
|
||||
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.assertIsNone(other.last_invited_at)
|
||||
self.assertIsNone(other.responded_at)
|
||||
@@ -1114,13 +1382,13 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
self.assertIsNone(request.selected_slot_id)
|
||||
|
||||
availability_reader = self._principal(
|
||||
"reader",
|
||||
"user-1",
|
||||
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_AVAILABILITY_READ_SCOPE},
|
||||
)
|
||||
freebusy = api_evaluate_calendar_freebusy(request.id, session=self.session, principal=availability_reader)
|
||||
self.assertTrue(freebusy.updated_slot_ids)
|
||||
event_writer = self._principal(
|
||||
"event-writer",
|
||||
"user-1",
|
||||
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_EVENT_WRITE_SCOPE},
|
||||
)
|
||||
holds = api_create_tentative_calendar_holds(request.id, session=self.session, principal=event_writer)
|
||||
@@ -1139,7 +1407,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
self.session.flush()
|
||||
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
|
||||
scheduling_writer = self._principal(
|
||||
"writer",
|
||||
"user-1",
|
||||
scopes={SCHEDULING_WRITE_SCOPE},
|
||||
)
|
||||
|
||||
@@ -1209,7 +1477,10 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"})
|
||||
self.assertEqual(
|
||||
{item.action_url for item in provider.requests},
|
||||
{f"/poll/public/{token}" for token in tokens.values()},
|
||||
{
|
||||
f"/scheduling/public/{request.id}/{token}"
|
||||
for token in tokens.values()
|
||||
},
|
||||
)
|
||||
local_notifications = list_scheduling_notifications(
|
||||
self.session,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/scheduling-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -18,7 +18,7 @@
|
||||
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -11,42 +11,60 @@ assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calend
|
||||
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]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*ModuleSubnav,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
|
||||
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
|
||||
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
|
||||
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
|
||||
assert.match(page, /className="scheduling-full-editor"/);
|
||||
assert.match(page, /className="scheduling-page-actions"/);
|
||||
|
||||
const editorActions = page.slice(
|
||||
page.indexOf('<div className="scheduling-page-actions">'),
|
||||
page.indexOf('</div>', page.indexOf('<div className="scheduling-page-actions">'))
|
||||
);
|
||||
assert.ok(editorActions.indexOf("I18N.discard") < editorActions.indexOf("I18N.save"));
|
||||
assert.match(editorActions, /form="scheduling-create-form"/);
|
||||
|
||||
const createBranchStart = page.indexOf(" if (creating) {");
|
||||
const createReturnStart = page.indexOf("\n return (", createBranchStart);
|
||||
const browseBranchStart = page.indexOf("\n return (", createReturnStart + 1);
|
||||
const createBranch = page.slice(createBranchStart, browseBranchStart);
|
||||
const browseBranch = page.slice(browseBranchStart);
|
||||
assert.match(createBranch, /<ModuleSubnav/);
|
||||
assert.match(createBranch, /className="scheduling-create-subnav"/);
|
||||
assert.doesNotMatch(browseBranch, /scheduling-sidebar|<aside/);
|
||||
|
||||
assert.match(browseBranch, /<h1>\{I18N\.requests\}<\/h1>/);
|
||||
assert.match(browseBranch, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.add\}/);
|
||||
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.doesNotMatch(page, /<button[\s\S]{0,160}scheduling-list-item/);
|
||||
assert.match(createBranch, /<Card title=\{I18N\.basicInformation\}>/);
|
||||
assert.match(createBranch, /<Card title=\{I18N\.calendarIntegration\}>/);
|
||||
|
||||
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(createBranch, /<ToggleSwitch[\s\S]*checked=\{calendarEnabled\}/);
|
||||
assert.match(page, /<ToggleSwitch[\s\S]*checked=\{participantRosterVisible\}/);
|
||||
assert.match(page, /participant_visibility: participantRosterVisible \? "names_and_statuses" : "aggregates_only"/);
|
||||
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, /create_participant_invitations: true/);
|
||||
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-create-participants-grid"/);
|
||||
assert.match(page, /id="scheduling-candidate-slots-grid"/);
|
||||
@@ -55,21 +73,43 @@ assert.match(page, /<DataGridRowActions/);
|
||||
assert.match(page, /<DataGridEmptyAction/);
|
||||
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
|
||||
assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/);
|
||||
assert.doesNotMatch(page, /header: I18N\.required|<table|scheduling-table|scheduling-card(?:\s|"|`)/);
|
||||
assert.match(page, /<TableActionGroup[\s\S]*label: i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
|
||||
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/);
|
||||
|
||||
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
|
||||
assert.match(page, /option_revision: slot\.revision/);
|
||||
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
|
||||
assert.match(page, /setAvailability\(Object\.fromEntries\(response\.answers\.map/);
|
||||
assert.match(page, /selected\.participant_aggregate\.total/);
|
||||
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.match(api, /participant_visibility: SchedulingParticipantVisibility/);
|
||||
assert.match(api, /participant_aggregate: SchedulingParticipantAggregate/);
|
||||
assert.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/);
|
||||
assert.match(page, /className="scheduling-selected-calendar"/);
|
||||
assert.match(page, /showPlanningCalendarActions/);
|
||||
assert.match(page, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/);
|
||||
|
||||
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"
|
||||
]) {
|
||||
assert.match(api, new RegExp(`${field}:`));
|
||||
}
|
||||
assert.match(api, /method: "PATCH"/);
|
||||
assert.match(page, /slots: slots\.map\(\(slot\) => \(\{/);
|
||||
assert.match(page, /participants: participants[\s\S]*create_participant_invitations: true/);
|
||||
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
|
||||
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
|
||||
assert.match(api, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/);
|
||||
assert.match(page, /useSearchParams\(\)/);
|
||||
assert.match(page, /Promise\.allSettled/);
|
||||
|
||||
console.log("Scheduling page structure satisfies the focused list/create/respond contract.");
|
||||
console.log("Scheduling page structure satisfies the two-pane editor, response, and policy contract.");
|
||||
|
||||
@@ -23,11 +23,12 @@ export type SchedulingCandidateSlot = {
|
||||
|
||||
export type SchedulingParticipant = {
|
||||
id: string;
|
||||
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;
|
||||
@@ -52,7 +53,7 @@ export type SchedulingParticipantVisibilityDecision = {
|
||||
|
||||
export type SchedulingRequest = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
tenant_id: string | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
@@ -69,11 +70,20 @@ export type SchedulingRequest = {
|
||||
effective_participant_visibility: SchedulingParticipantVisibility;
|
||||
participant_aggregate: SchedulingParticipantAggregate;
|
||||
participant_visibility_decision: SchedulingParticipantVisibilityDecision;
|
||||
calendar_integration_enabled: boolean;
|
||||
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;
|
||||
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;
|
||||
@@ -117,6 +127,14 @@ export type SchedulingRequestCreatePayload = {
|
||||
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;
|
||||
@@ -130,6 +148,33 @@ 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;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -144,6 +189,7 @@ export type SchedulingAvailabilityPayload = {
|
||||
value: SchedulingAvailabilityValue;
|
||||
option_revision: string;
|
||||
}>;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
export type SchedulingAvailabilityResponse = {
|
||||
@@ -151,12 +197,62 @@ export type SchedulingAvailabilityResponse = {
|
||||
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;
|
||||
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[];
|
||||
@@ -239,6 +335,29 @@ 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,
|
||||
@@ -261,6 +380,32 @@ export function getSchedulingAvailabilityResponse(
|
||||
);
|
||||
}
|
||||
|
||||
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({}));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
||||
import type { SchedulingParticipant, SchedulingRequest } from "../../api/scheduling";
|
||||
import type {
|
||||
SchedulingAvailabilityValue,
|
||||
SchedulingParticipant,
|
||||
SchedulingRequest
|
||||
} from "../../api/scheduling";
|
||||
|
||||
export type SchedulingActor = {
|
||||
accountId?: string | null;
|
||||
@@ -28,20 +32,27 @@ export function schedulingActorIds(actor: SchedulingActor): string[] {
|
||||
actor.membershipId,
|
||||
actor.identityId,
|
||||
actor.email
|
||||
].filter((value): value is string => Boolean(value))));
|
||||
].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) =>
|
||||
Boolean(
|
||||
return request.participants.find((participant) => {
|
||||
const email = participant.email?.trim().toLowerCase();
|
||||
return Boolean(
|
||||
(participant.respondent_id && ids.has(participant.respondent_id)) ||
|
||||
(participant.email && ids.has(participant.email))
|
||||
)
|
||||
) ?? null;
|
||||
(email && ids.has(email))
|
||||
);
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
export function schedulingRequestIsOwned(
|
||||
@@ -137,6 +148,26 @@ export function schedulingRequestIsPast(
|
||||
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
export const generatedTranslations = {
|
||||
en: {
|
||||
"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.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.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",
|
||||
@@ -21,7 +54,6 @@ export const generatedTranslations = {
|
||||
"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_and_open_scheduling_request.1dbc9345": "Create and open scheduling request",
|
||||
"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",
|
||||
@@ -108,6 +140,39 @@ export const generatedTranslations = {
|
||||
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
|
||||
},
|
||||
de: {
|
||||
"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.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.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",
|
||||
@@ -129,7 +194,6 @@ export const generatedTranslations = {
|
||||
"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_and_open_scheduling_request.1dbc9345": "Terminanfrage erstellen und öffnen",
|
||||
"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",
|
||||
|
||||
@@ -10,7 +10,7 @@ const scheduleRead = ["scheduling:schedule:read"];
|
||||
export const schedulingModule: PlatformWebModule = {
|
||||
id: "scheduling",
|
||||
label: "Scheduling",
|
||||
version: "1.0.0",
|
||||
version: "0.1.10",
|
||||
dependencies: ["poll"],
|
||||
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
|
||||
translations: generatedTranslations,
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scheduling-workspace,
|
||||
.scheduling-full-editor {
|
||||
.scheduling-workspace {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
@@ -26,6 +25,72 @@
|
||||
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-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;
|
||||
@@ -89,28 +154,9 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-alert,
|
||||
.scheduling-success {
|
||||
margin: 10px 14px 0;
|
||||
padding: 9px 11px;
|
||||
border: var(--border-line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.scheduling-alert {
|
||||
border-color: var(--danger-border-strong);
|
||||
color: var(--danger-text-strong);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.scheduling-success {
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||
}
|
||||
|
||||
.scheduling-detail,
|
||||
.scheduling-editor-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
@@ -124,15 +170,21 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scheduling-request-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
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: 270px;
|
||||
max-height: min(31vh, 320px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -171,14 +223,6 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scheduling-list-status {
|
||||
max-width: 120px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-list-empty {
|
||||
margin: 3px 0 8px;
|
||||
}
|
||||
@@ -187,46 +231,12 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scheduling-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-strong);
|
||||
background: var(--panel-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scheduling-status-collecting,
|
||||
.scheduling-status-handed_off {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.scheduling-slot-title {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.scheduling-freebusy {
|
||||
display: inline-block;
|
||||
margin-top: 3px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
background: var(--panel-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-freebusy.free {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.scheduling-freebusy.busy,
|
||||
.scheduling-freebusy.error {
|
||||
color: var(--danger-text-strong);
|
||||
}
|
||||
|
||||
.scheduling-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -284,20 +294,6 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.scheduling-response-grid select,
|
||||
.scheduling-field input,
|
||||
.scheduling-field textarea,
|
||||
.scheduling-grid-input {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 7px 9px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: var(--control-bg);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.scheduling-action-help {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -308,47 +304,14 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scheduling-editor-page {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.scheduling-editor-layout {
|
||||
.scheduling-setting-with-field {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 198px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scheduling-create-subnav {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scheduling-create-section {
|
||||
min-width: 0;
|
||||
scroll-margin-top: 14px;
|
||||
}
|
||||
|
||||
.scheduling-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scheduling-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.scheduling-field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.scheduling-field > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
.scheduling-setting-with-field .form-field {
|
||||
padding-left: 48px;
|
||||
}
|
||||
|
||||
.scheduling-capability-note {
|
||||
@@ -362,9 +325,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.scheduling-editor-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
.scheduling-workspace-layout {
|
||||
grid-template-columns: minmax(270px, 320px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
@@ -375,16 +339,27 @@
|
||||
}
|
||||
|
||||
.scheduling-workspace,
|
||||
.scheduling-full-editor,
|
||||
.scheduling-editor-layout,
|
||||
.scheduling-workspace-layout,
|
||||
.scheduling-main-panel,
|
||||
.scheduling-editor-surface,
|
||||
.scheduling-detail,
|
||||
.scheduling-editor-scroll {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.scheduling-columns,
|
||||
.scheduling-form-grid {
|
||||
.scheduling-workspace-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.scheduling-request-sidebar {
|
||||
max-height: 48vh;
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { SchedulingRequest } from "../src/api/scheduling.ts";
|
||||
import {
|
||||
applySchedulingAvailabilityChoice,
|
||||
groupSchedulingRequests,
|
||||
schedulingSortPhase,
|
||||
type SchedulingActor
|
||||
@@ -47,6 +48,15 @@ function request(
|
||||
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",
|
||||
calendar_integration_enabled: false,
|
||||
calendar_freebusy_enabled: false,
|
||||
calendar_hold_enabled: false,
|
||||
@@ -61,11 +71,13 @@ function request(
|
||||
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",
|
||||
@@ -88,6 +100,29 @@ test("groups owned, invited, and administrator-only requests without hiding any"
|
||||
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",
|
||||
@@ -122,3 +157,31 @@ test("orders unanswered by nearest slot before answered, closed, determined, and
|
||||
assert.equal(schedulingSortPhase(groups.invited[0], actor, now), "unanswered");
|
||||
assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past");
|
||||
});
|
||||
|
||||
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