Compare commits

...

10 Commits

22 changed files with 2650 additions and 726 deletions

View File

@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-scheduling" name = "govoplan-scheduling"
version = "0.1.8" version = "0.1.9"
description = "GovOPlaN meeting scheduling and Terminfindung module seed." description = "GovOPlaN meeting scheduling and Terminfindung module seed."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.9",
"govoplan-poll>=0.1.8", "govoplan-poll>=0.1.9",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.8" __version__ = "0.1.9"

View File

@@ -36,6 +36,7 @@ class SchedulingRequest(Base, TimestampMixin):
allow_external_participants: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) allow_external_participants: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False) 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)
calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) 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_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View File

@@ -19,11 +19,12 @@ from govoplan_core.core.modules import (
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
MODULE_ID = "scheduling" MODULE_ID = "scheduling"
MODULE_NAME = "Scheduling" MODULE_NAME = "Scheduling"
MODULE_VERSION = "0.1.8" MODULE_VERSION = "0.1.9"
READ_SCOPE = "scheduling:schedule:read" READ_SCOPE = "scheduling:schedule:read"
WRITE_SCOPE = "scheduling:schedule:write" WRITE_SCOPE = "scheduling:schedule:write"
ADMIN_SCOPE = "scheduling:schedule:admin" ADMIN_SCOPE = "scheduling:schedule:admin"
@@ -129,20 +130,22 @@ manifest = ModuleManifest(
name=MODULE_NAME, name=MODULE_NAME,
version=MODULE_VERSION, version=MODULE_VERSION,
dependencies=("poll",), dependencies=("poll",),
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"), optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "policy", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
optional_capabilities=( optional_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_CALENDAR_SCHEDULING, CAPABILITY_CALENDAR_SCHEDULING,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
), ),
required_capabilities=(CAPABILITY_POLL_SCHEDULING,), required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"), ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.9"),
ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.8"), ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.9"),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.8", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.9", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.8", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="poll.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.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), 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="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),

View File

@@ -16,7 +16,171 @@ branch_labels = None
depends_on = "2a3b4c5d6e7f" depends_on = "2a3b4c5d6e7f"
_BASELINE_COLUMNS = {
"scheduling_requests": {
"id",
"tenant_id",
"title",
"description",
"location",
"timezone",
"status",
"poll_id",
"selected_slot_id",
"organizer_user_id",
"deadline_at",
"allow_external_participants",
"allow_participant_updates",
"result_visibility",
"calendar_integration_enabled",
"calendar_id",
"calendar_freebusy_enabled",
"calendar_hold_enabled",
"create_calendar_event_on_decision",
"calendar_event_id",
"handed_off_at",
"cancelled_at",
"deleted_at",
"metadata",
"created_at",
"updated_at",
},
"scheduling_candidate_slots": {
"id",
"tenant_id",
"request_id",
"poll_option_id",
"label",
"description",
"start_at",
"end_at",
"timezone",
"location",
"position",
"deleted_at",
"metadata",
"created_at",
"updated_at",
},
"scheduling_participants": {
"id",
"tenant_id",
"request_id",
"respondent_id",
"display_name",
"email",
"participant_type",
"required",
"status",
"poll_invitation_id",
"last_invited_at",
"responded_at",
"deleted_at",
"metadata",
"created_at",
"updated_at",
},
}
_BASELINE_INDEXES = {
"scheduling_requests": {
"ix_scheduling_requests_calendar_event_id",
"ix_scheduling_requests_calendar_id",
"ix_scheduling_requests_deadline",
"ix_scheduling_requests_deadline_at",
"ix_scheduling_requests_deleted_at",
"ix_scheduling_requests_organizer_user_id",
"ix_scheduling_requests_poll_id",
"ix_scheduling_requests_selected_slot_id",
"ix_scheduling_requests_status",
"ix_scheduling_requests_tenant_id",
"ix_scheduling_requests_tenant_poll",
"ix_scheduling_requests_tenant_status",
},
"scheduling_candidate_slots": {
"ix_scheduling_candidate_slots_deleted_at",
"ix_scheduling_candidate_slots_end_at",
"ix_scheduling_candidate_slots_poll_option_id",
"ix_scheduling_candidate_slots_range",
"ix_scheduling_candidate_slots_request_id",
"ix_scheduling_candidate_slots_request_position",
"ix_scheduling_candidate_slots_start_at",
"ix_scheduling_candidate_slots_tenant_id",
},
"scheduling_participants": {
"ix_scheduling_participants_deleted_at",
"ix_scheduling_participants_email",
"ix_scheduling_participants_last_invited_at",
"ix_scheduling_participants_participant_type",
"ix_scheduling_participants_poll_invitation_id",
"ix_scheduling_participants_request",
"ix_scheduling_participants_request_email",
"ix_scheduling_participants_request_id",
"ix_scheduling_participants_request_respondent",
"ix_scheduling_participants_responded_at",
"ix_scheduling_participants_respondent_id",
"ix_scheduling_participants_status",
"ix_scheduling_participants_tenant_id",
},
}
def _request_foreign_key_exists(inspector: sa.Inspector, table_name: str) -> bool:
return any(
tuple(item.get("constrained_columns") or ()) == ("request_id",)
and item.get("referred_table") == "scheduling_requests"
and tuple(item.get("referred_columns") or ()) == ("id",)
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
for item in inspector.get_foreign_keys(table_name)
)
def _adopt_existing_baseline() -> bool:
"""Adopt the complete unversioned Scheduling baseline, never a partial one."""
inspector = sa.inspect(op.get_bind())
expected_tables = set(_BASELINE_COLUMNS)
present_tables = expected_tables.intersection(inspector.get_table_names())
if not present_tables:
return False
problems: list[str] = []
missing_tables = expected_tables - present_tables
if missing_tables:
problems.append(f"missing tables: {', '.join(sorted(missing_tables))}")
for table_name in sorted(present_tables):
columns = {item["name"] for item in inspector.get_columns(table_name)}
missing_columns = _BASELINE_COLUMNS[table_name] - columns
if missing_columns:
problems.append(f"{table_name} missing columns: {', '.join(sorted(missing_columns))}")
indexes = {item["name"] for item in inspector.get_indexes(table_name)}
missing_indexes = _BASELINE_INDEXES[table_name] - indexes
if missing_indexes:
problems.append(f"{table_name} missing indexes: {', '.join(sorted(missing_indexes))}")
primary_key = tuple(inspector.get_pk_constraint(table_name).get("constrained_columns") or ())
if primary_key != ("id",):
problems.append(f"{table_name} has unexpected primary key: {primary_key!r}")
for table_name in ("scheduling_candidate_slots", "scheduling_participants"):
if table_name in present_tables and not _request_foreign_key_exists(inspector, table_name):
problems.append(f"{table_name} is missing its cascading request_id foreign key")
if problems:
detail = "; ".join(problems)
raise RuntimeError(
"Cannot adopt the existing Scheduling v0.1.8 baseline because it is incomplete or ambiguous: "
f"{detail}"
)
return True
def upgrade() -> None: def upgrade() -> None:
if _adopt_existing_baseline():
return
op.create_table( op.create_table(
"scheduling_requests", "scheduling_requests",
sa.Column("id", sa.String(length=36), nullable=False), sa.Column("id", sa.String(length=36), nullable=False),

View File

@@ -16,7 +16,128 @@ branch_labels = None
depends_on = None depends_on = None
_CALENDAR_SLOT_COLUMNS = {
"freebusy_checked_at",
"freebusy_status",
"freebusy_conflicts",
"tentative_hold_event_id",
}
_CALENDAR_SLOT_INDEXES = {
"ix_scheduling_candidate_slots_freebusy_status",
"ix_scheduling_candidate_slots_tentative_hold_event_id",
}
_NOTIFICATION_COLUMNS = {
"id",
"tenant_id",
"request_id",
"participant_id",
"event_kind",
"channel",
"recipient",
"status",
"payload",
"error",
"sent_at",
"metadata",
"created_at",
"updated_at",
}
_NOTIFICATION_INDEXES = {
"ix_scheduling_notifications_channel",
"ix_scheduling_notifications_event_kind",
"ix_scheduling_notifications_participant_id",
"ix_scheduling_notifications_recipient",
"ix_scheduling_notifications_request_id",
"ix_scheduling_notifications_request_status",
"ix_scheduling_notifications_status",
"ix_scheduling_notifications_tenant_id",
"ix_scheduling_notifications_tenant_status",
}
def _adopt_existing_calendar_notifications() -> bool:
"""Adopt only the complete calendar/notification extension."""
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "scheduling_candidate_slots" not in tables:
raise RuntimeError(
"Cannot apply the Scheduling calendar migration because scheduling_candidate_slots is missing"
)
slot_columns = {item["name"] for item in inspector.get_columns("scheduling_candidate_slots")}
present_slot_columns = _CALENDAR_SLOT_COLUMNS.intersection(slot_columns)
notification_exists = "scheduling_notifications" in tables
if not present_slot_columns and not notification_exists:
return False
problems: list[str] = []
missing_slot_columns = _CALENDAR_SLOT_COLUMNS - slot_columns
if missing_slot_columns:
problems.append(
"scheduling_candidate_slots missing columns: "
f"{', '.join(sorted(missing_slot_columns))}"
)
slot_indexes = {item["name"] for item in inspector.get_indexes("scheduling_candidate_slots")}
missing_slot_indexes = _CALENDAR_SLOT_INDEXES - slot_indexes
if missing_slot_indexes:
problems.append(
"scheduling_candidate_slots missing indexes: "
f"{', '.join(sorted(missing_slot_indexes))}"
)
if not notification_exists:
problems.append("missing table: scheduling_notifications")
else:
notification_columns = {
item["name"] for item in inspector.get_columns("scheduling_notifications")
}
missing_notification_columns = _NOTIFICATION_COLUMNS - notification_columns
if missing_notification_columns:
problems.append(
"scheduling_notifications missing columns: "
f"{', '.join(sorted(missing_notification_columns))}"
)
notification_indexes = {
item["name"] for item in inspector.get_indexes("scheduling_notifications")
}
missing_notification_indexes = _NOTIFICATION_INDEXES - notification_indexes
if missing_notification_indexes:
problems.append(
"scheduling_notifications missing indexes: "
f"{', '.join(sorted(missing_notification_indexes))}"
)
primary_key = tuple(
inspector.get_pk_constraint("scheduling_notifications").get("constrained_columns") or ()
)
if primary_key != ("id",):
problems.append(f"scheduling_notifications has unexpected primary key: {primary_key!r}")
request_foreign_key_exists = any(
tuple(item.get("constrained_columns") or ()) == ("request_id",)
and item.get("referred_table") == "scheduling_requests"
and tuple(item.get("referred_columns") or ()) == ("id",)
and str((item.get("options") or {}).get("ondelete", "")).upper() == "CASCADE"
for item in inspector.get_foreign_keys("scheduling_notifications")
)
if not request_foreign_key_exists:
problems.append("scheduling_notifications is missing its cascading request_id foreign key")
if problems:
detail = "; ".join(problems)
raise RuntimeError(
"Cannot adopt the existing Scheduling v0.1.8 calendar/notification schema because it is "
f"incomplete or ambiguous: {detail}"
)
return True
def upgrade() -> None: def upgrade() -> None:
if _adopt_existing_calendar_notifications():
return
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_checked_at", sa.DateTime(timezone=True), nullable=True)) op.add_column("scheduling_candidate_slots", sa.Column("freebusy_checked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_status", sa.String(length=40), nullable=True)) op.add_column("scheduling_candidate_slots", sa.Column("freebusy_status", sa.String(length=40), nullable=True))
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]")) op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]"))

View File

@@ -0,0 +1,43 @@
"""v0.1.9 scheduling participant visibility
Revision ID: 9c2f4a7d1e6b
Revises: 4d5e6f7a8b9c
Create Date: 2026-07-20 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "9c2f4a7d1e6b"
down_revision = "4d5e6f7a8b9c"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {item["name"]: item for item in inspector.get_columns("scheduling_requests")}
existing = columns.get("participant_visibility")
if existing is not None:
column_type = existing["type"]
if existing.get("nullable") or not isinstance(column_type, sa.String) or column_type.length != 32:
raise RuntimeError(
"Cannot adopt scheduling_requests.participant_visibility because its schema is unexpected"
)
return
op.add_column(
"scheduling_requests",
sa.Column(
"participant_visibility",
sa.String(length=32),
nullable=False,
server_default="aggregates_only",
),
)
def downgrade() -> None:
op.drop_column("scheduling_requests", "participant_visibility")

View File

@@ -11,10 +11,12 @@ from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALEND
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import ( from govoplan_scheduling.backend.schemas import (
SchedulingAvailabilityResponseRequest,
SchedulingAddressLookupCandidate, SchedulingAddressLookupCandidate,
SchedulingAddressLookupResponse, SchedulingAddressLookupResponse,
SchedulingAvailabilityResponse,
SchedulingAvailabilityResponseRequest,
SchedulingCalendarActionResponse, SchedulingCalendarActionResponse,
SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest, SchedulingDecisionRequest,
SchedulingNotificationCreateRequest, SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse, SchedulingNotificationListResponse,
@@ -29,6 +31,7 @@ from govoplan_scheduling.backend.schemas import (
) )
from govoplan_scheduling.backend.runtime import get_registry from govoplan_scheduling.backend.runtime import get_registry
from govoplan_scheduling.backend.service import ( from govoplan_scheduling.backend.service import (
SchedulingConflictError,
SchedulingError, SchedulingError,
SchedulingPermissionError, SchedulingPermissionError,
cancel_scheduling_request, cancel_scheduling_request,
@@ -39,6 +42,7 @@ from govoplan_scheduling.backend.service import (
create_tentative_calendar_holds, create_tentative_calendar_holds,
decide_scheduling_request, decide_scheduling_request,
evaluate_calendar_freebusy, evaluate_calendar_freebusy,
get_scheduling_availability_response,
get_visible_scheduling_request, get_visible_scheduling_request,
list_visible_scheduling_notifications, list_visible_scheduling_notifications,
list_visible_scheduling_requests, list_visible_scheduling_requests,
@@ -49,6 +53,7 @@ from govoplan_scheduling.backend.service import (
scheduling_request_response, scheduling_request_response,
scheduling_request_summary, scheduling_request_summary,
submit_scheduling_availability, submit_scheduling_availability,
update_scheduling_candidate_slot,
update_scheduling_request, update_scheduling_request,
) )
@@ -111,6 +116,8 @@ def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
def _scheduling_http_error(exc: SchedulingError) -> HTTPException: def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
if isinstance(exc, SchedulingConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, SchedulingPermissionError): if isinstance(exc, SchedulingPermissionError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
if str(exc) == "Scheduling request not found": if str(exc) == "Scheduling request not found":
@@ -131,6 +138,7 @@ def _request_response(
request, request,
invitation_tokens=invitation_tokens, invitation_tokens=invitation_tokens,
actor_ids=_principal_actor_ids(principal), actor_ids=_principal_actor_ids(principal),
actor_user_id=principal.account_id,
can_manage=_can_manage_scheduling(principal), can_manage=_can_manage_scheduling(principal),
) )
) )
@@ -232,6 +240,26 @@ def api_submit_scheduling_availability(
return response return response
@router.get("/requests/{request_id}/responses/me", response_model=SchedulingAvailabilityResponse)
def api_get_my_scheduling_availability(
request_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingAvailabilityResponse:
_require_scope(principal, RESPOND_SCOPE)
try:
response = get_scheduling_availability_response(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
actor_ids=_principal_actor_ids(principal),
respondent_id=principal.account_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
return SchedulingAvailabilityResponse.model_validate(response)
@router.get("/requests/{request_id}", response_model=SchedulingRequestResponse) @router.get("/requests/{request_id}", response_model=SchedulingRequestResponse)
def api_get_scheduling_request( def api_get_scheduling_request(
request_id: str, request_id: str,
@@ -281,6 +309,30 @@ def api_update_scheduling_request(
return response return response
@router.patch("/requests/{request_id}/slots/{slot_id}", response_model=SchedulingRequestResponse)
def api_update_scheduling_candidate_slot(
request_id: str,
slot_id: str,
payload: SchedulingCandidateSlotUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
try:
request = update_scheduling_candidate_slot(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
slot_id=slot_id,
payload=payload,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal)
session.commit()
return response
@router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse) @router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse)
def api_open_scheduling_request( def api_open_scheduling_request(
request_id: str, request_id: str,

View File

@@ -2,8 +2,9 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"] SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
@@ -11,6 +12,17 @@ SchedulingParticipantType = Literal["internal", "external", "resource"]
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"] SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"] SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"] SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
def _known_timezone(value: str | None) -> str | None:
if value is None:
return None
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("timezone must be a valid IANA timezone") from exc
return value
class SchedulingCalendarPreferences(BaseModel): class SchedulingCalendarPreferences(BaseModel):
@@ -28,12 +40,14 @@ class SchedulingCandidateSlotInput(BaseModel):
label: str | None = Field(default=None, max_length=500) label: str | None = Field(default=None, max_length=500)
description: str | None = None description: str | None = None
start_at: datetime start_at: AwareDatetime
end_at: datetime end_at: AwareDatetime
timezone: str | None = Field(default=None, max_length=100) timezone: str | None = Field(default=None, max_length=100)
location: str | None = Field(default=None, max_length=500) location: str | None = Field(default=None, max_length=500)
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
_validate_timezone = field_validator("timezone")(_known_timezone)
@model_validator(mode="after") @model_validator(mode="after")
def validate_range(self) -> "SchedulingCandidateSlotInput": def validate_range(self) -> "SchedulingCandidateSlotInput":
if self.end_at <= self.start_at: if self.end_at <= self.start_at:
@@ -41,6 +55,32 @@ class SchedulingCandidateSlotInput(BaseModel):
return self return self
class SchedulingCandidateSlotUpdateRequest(BaseModel):
"""Partial update of one candidate slot.
A semantic option change invalidates existing answers for this slot in the
backing Poll. Exact repeats are safe no-ops.
"""
model_config = ConfigDict(extra="forbid")
label: str | None = Field(default=None, min_length=1, max_length=500)
description: str | None = None
start_at: AwareDatetime | None = None
end_at: AwareDatetime | None = None
timezone: str | None = Field(default=None, min_length=1, max_length=100)
location: str | None = Field(default=None, max_length=500)
metadata: dict[str, Any] | None = None
_validate_timezone = field_validator("timezone")(_known_timezone)
@model_validator(mode="after")
def validate_range(self) -> "SchedulingCandidateSlotUpdateRequest":
if self.start_at is not None and self.end_at is not None and self.end_at <= self.start_at:
raise ValueError("end_at must be after start_at")
return self
class SchedulingParticipantInput(BaseModel): class SchedulingParticipantInput(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -64,12 +104,15 @@ class SchedulingRequestCreateRequest(BaseModel):
allow_external_participants: bool = True allow_external_participants: bool = True
allow_participant_updates: bool = True allow_participant_updates: bool = True
result_visibility: SchedulingResultVisibility = "after_close" result_visibility: SchedulingResultVisibility = "after_close"
participant_visibility: SchedulingParticipantVisibility = "aggregates_only"
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences) calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1) slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
participants: list[SchedulingParticipantInput] = Field(default_factory=list) participants: list[SchedulingParticipantInput] = Field(default_factory=list)
create_participant_invitations: bool = True create_participant_invitations: bool = True
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
_validate_timezone = field_validator("timezone")(_known_timezone)
class SchedulingRequestUpdateRequest(BaseModel): class SchedulingRequestUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -81,6 +124,7 @@ class SchedulingRequestUpdateRequest(BaseModel):
allow_external_participants: bool | None = None allow_external_participants: bool | None = None
allow_participant_updates: bool | None = None allow_participant_updates: bool | None = None
result_visibility: SchedulingResultVisibility | None = None result_visibility: SchedulingResultVisibility | None = None
participant_visibility: SchedulingParticipantVisibility | None = None
calendar: SchedulingCalendarPreferences | None = None calendar: SchedulingCalendarPreferences | None = None
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
@@ -95,6 +139,7 @@ class SchedulingCandidateSlotResponse(BaseModel):
timezone: str timezone: str
location: str | None = None location: str | None = None
position: int position: int
revision: str
freebusy_checked_at: datetime | None = None freebusy_checked_at: datetime | None = None
freebusy_status: str | None = None freebusy_status: str | None = None
freebusy_conflicts: list[dict[str, Any]] = Field(default_factory=list) freebusy_conflicts: list[dict[str, Any]] = Field(default_factory=list)
@@ -117,6 +162,20 @@ class SchedulingParticipantResponse(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingParticipantAggregateResponse(BaseModel):
total: int = 0
status_counts: dict[str, int] = Field(default_factory=dict)
class SchedulingParticipantVisibilityDecisionResponse(BaseModel):
requested_visibility: SchedulingParticipantVisibility
effective_visibility: SchedulingParticipantVisibility
policy_applied: bool = False
reason: str | None = None
source_path: list[dict[str, Any]] = Field(default_factory=list)
details: dict[str, Any] = Field(default_factory=dict)
class SchedulingRequestResponse(BaseModel): class SchedulingRequestResponse(BaseModel):
id: str id: str
tenant_id: str tenant_id: str
@@ -132,6 +191,10 @@ class SchedulingRequestResponse(BaseModel):
allow_external_participants: bool allow_external_participants: bool
allow_participant_updates: bool allow_participant_updates: bool
result_visibility: str result_visibility: str
participant_visibility: SchedulingParticipantVisibility
effective_participant_visibility: SchedulingParticipantVisibility
participant_aggregate: SchedulingParticipantAggregateResponse
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
calendar_integration_enabled: bool calendar_integration_enabled: bool
calendar_id: str | None = None calendar_id: str | None = None
calendar_freebusy_enabled: bool calendar_freebusy_enabled: bool
@@ -168,6 +231,7 @@ class SchedulingAvailabilityAnswerInput(BaseModel):
slot_id: str slot_id: str
value: SchedulingAvailabilityValue value: SchedulingAvailabilityValue
option_revision: str = Field(min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")
class SchedulingAvailabilityResponseRequest(BaseModel): class SchedulingAvailabilityResponseRequest(BaseModel):
@@ -183,6 +247,19 @@ class SchedulingAvailabilityResponseRequest(BaseModel):
return self return self
class SchedulingAvailabilityAnswerResponse(BaseModel):
slot_id: str
value: SchedulingAvailabilityValue
class SchedulingAvailabilityResponse(BaseModel):
request_id: str
participant_id: str
has_response: bool = False
submitted_at: datetime | None = None
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
class SchedulingPollOptionResultResponse(BaseModel): class SchedulingPollOptionResultResponse(BaseModel):
option_id: str option_id: str
option_key: str option_key: str

View File

@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, object_session
from govoplan_core.core.calendar import ( from govoplan_core.core.calendar import (
CalendarCapabilityError, CalendarCapabilityError,
@@ -17,7 +19,9 @@ from govoplan_core.core.poll import (
PollCapabilityError, PollCapabilityError,
PollCreateCommand, PollCreateCommand,
PollInvitationCommand, PollInvitationCommand,
PollOptionUpdateCommand,
PollOptionRequest, PollOptionRequest,
PollResponseRef,
PollResponseSubmissionProvider, PollResponseSubmissionProvider,
PollSchedulingProvider, PollSchedulingProvider,
PollSubmitResponseCommand, PollSubmitResponseCommand,
@@ -25,10 +29,16 @@ from govoplan_core.core.poll import (
poll_response_submission_provider, poll_response_submission_provider,
poll_scheduling_provider, poll_scheduling_provider,
) )
from govoplan_core.core.policy import (
SchedulingParticipantPrivacyDecision,
SchedulingParticipantPrivacyRequest,
scheduling_participant_privacy_policy,
)
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.schemas import ( from govoplan_scheduling.backend.schemas import (
SchedulingAvailabilityResponseRequest, SchedulingAvailabilityResponseRequest,
SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest, SchedulingDecisionRequest,
SchedulingRequestCreateRequest, SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest, SchedulingRequestUpdateRequest,
@@ -44,6 +54,10 @@ class SchedulingPermissionError(SchedulingError):
pass pass
class SchedulingConflictError(SchedulingError):
pass
WORKFLOW_DRAFT = "draft" WORKFLOW_DRAFT = "draft"
WORKFLOW_COLLECTING = "collecting_availability" WORKFLOW_COLLECTING = "collecting_availability"
WORKFLOW_CLOSED = "availability_closed" WORKFLOW_CLOSED = "availability_closed"
@@ -72,6 +86,27 @@ def _jsonable(value: Any) -> Any:
return value return value
def scheduling_slot_revision(slot: SchedulingCandidateSlot) -> str:
"""Stable semantic revision used to reject responses from stale forms."""
snapshot = {
"label": slot.label,
"description": slot.description,
"start_at": response_datetime(slot.start_at),
"end_at": response_datetime(slot.end_at),
"timezone": slot.timezone,
"location": slot.location,
"metadata": slot.metadata_ or {},
}
encoded = json.dumps(
_jsonable(snapshot),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _now() -> datetime: def _now() -> datetime:
return utcnow() return utcnow()
@@ -117,6 +152,60 @@ def _active_participants(request: SchedulingRequest) -> list[SchedulingParticipa
return [participant for participant in request.participants if participant.deleted_at is None] return [participant for participant in request.participants if participant.deleted_at is None]
def _participant_for_actor(
request: SchedulingRequest,
*,
actor_ids: tuple[str, ...],
) -> SchedulingParticipant:
ids = _actor_ids(actor_ids)
participants = [
participant
for participant in _active_participants(request)
if participant.respondent_id in ids or participant.email in ids
]
if len(participants) != 1:
raise SchedulingPermissionError(
"A unique participant assignment is required to respond"
)
return participants[0]
def _participant_respondent_id(
participant: SchedulingParticipant,
*,
fallback_respondent_id: str | None,
) -> str:
if participant.respondent_id:
return participant.respondent_id
if participant.poll_invitation_id:
return f"invitation:{participant.poll_invitation_id}"
if fallback_respondent_id:
return fallback_respondent_id
raise SchedulingPermissionError("An authenticated account is required to respond")
def _participant_poll_response(
session: Session,
*,
request: SchedulingRequest,
participant: SchedulingParticipant,
actor_ids: tuple[str, ...],
canonical_respondent_id: str,
) -> PollResponseRef | None:
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
try:
return _poll_provider().get_response(
session,
tenant_id=request.tenant_id,
poll_id=request.poll_id,
respondent_ids=_actor_ids((*actor_ids, canonical_respondent_id)),
invitation_id=participant.poll_invitation_id,
)
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
def _poll_option_inputs(request: SchedulingRequest) -> list[PollOptionRequest]: def _poll_option_inputs(request: SchedulingRequest) -> list[PollOptionRequest]:
options: list[PollOptionRequest] = [] options: list[PollOptionRequest] = []
for slot in _active_slots(request): for slot in _active_slots(request):
@@ -703,21 +792,22 @@ def refresh_participant_response_state(
*, *,
request: SchedulingRequest, request: SchedulingRequest,
actor_ids: tuple[str, ...] = (), actor_ids: tuple[str, ...] = (),
reset_missing: bool = False,
) -> None: ) -> None:
if request.poll_id is None: if request.poll_id is None:
return return
participants = _active_participants(request)
by_invitation_id = { by_invitation_id = {
participant.poll_invitation_id: participant participant.poll_invitation_id: participant
for participant in _active_participants(request) for participant in participants
if participant.poll_invitation_id is not None if participant.poll_invitation_id is not None
} }
by_respondent_id = { by_respondent_id: dict[str, SchedulingParticipant] = {}
participant.respondent_id: participant for participant in participants:
for participant in _active_participants(request) stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
if participant.respondent_id is not None for identity in (participant.respondent_id, stored_identity):
} if isinstance(identity, str) and identity:
if not by_invitation_id and not by_respondent_id: by_respondent_id[identity] = participant
return
try: try:
responses = _poll_provider().list_responses( responses = _poll_provider().list_responses(
session, session,
@@ -730,9 +820,11 @@ def refresh_participant_response_state(
ids = _actor_ids(actor_ids) ids = _actor_ids(actor_ids)
actor_participants = [ actor_participants = [
participant participant
for participant in _active_participants(request) for participant in participants
if participant.respondent_id in ids or participant.email in ids if participant.respondent_id in ids or participant.email in ids
] ]
matched_participant_ids: set[str] = set()
unmatched_response_count = 0
for response in responses: for response in responses:
participant = by_invitation_id.get(response.invitation_id) participant = by_invitation_id.get(response.invitation_id)
if participant is None and response.respondent_id is not None: if participant is None and response.respondent_id is not None:
@@ -747,7 +839,19 @@ def refresh_participant_response_state(
and len(actor_participants) == 1 and len(actor_participants) == 1
): ):
participant = actor_participants[0] participant = actor_participants[0]
if participant is not None and participant.status != "responded": if participant is None:
unmatched_response_count += 1
continue
matched_participant_ids.add(participant.id)
if response.respondent_id:
metadata = participant.metadata_ or {}
if metadata.get("poll_response_respondent_id") != response.respondent_id:
participant.metadata_ = {
**metadata,
"poll_response_respondent_id": response.respondent_id,
}
changed = True
if participant.status != "responded":
participant.status = "responded" participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at) participant.responded_at = response_datetime(response.submitted_at)
_emit_scheduling_center_notification( _emit_scheduling_center_notification(
@@ -759,6 +863,24 @@ def refresh_participant_response_state(
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.", body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.",
) )
changed = True changed = True
if reset_missing:
for participant in participants:
if participant.status != "responded" or participant.id in matched_participant_ids:
continue
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
if not isinstance(stored_identity, str) and unmatched_response_count:
# Do not reset an older projection while an active response
# exists that cannot yet be mapped safely to a participant.
continue
participant.status = "invited" if participant.poll_invitation_id else "draft"
participant.responded_at = None
if isinstance(stored_identity, str):
participant.metadata_ = {
key: value
for key, value in (participant.metadata_ or {}).items()
if key != "poll_response_respondent_id"
}
changed = True
if changed: if changed:
session.flush() session.flush()
@@ -786,6 +908,7 @@ def create_scheduling_request(
allow_external_participants=payload.allow_external_participants, allow_external_participants=payload.allow_external_participants,
allow_participant_updates=payload.allow_participant_updates, allow_participant_updates=payload.allow_participant_updates,
result_visibility=payload.result_visibility, result_visibility=payload.result_visibility,
participant_visibility=payload.participant_visibility,
metadata_=payload.metadata, metadata_=payload.metadata,
) )
_set_calendar_preferences(request, payload) _set_calendar_preferences(request, payload)
@@ -1034,27 +1157,29 @@ def submit_scheduling_availability(
raise SchedulingError("Scheduling request has no backing poll") raise SchedulingError("Scheduling request has no backing poll")
if respondent_id is None: if respondent_id is None:
raise SchedulingPermissionError("An authenticated account is required to respond") raise SchedulingPermissionError("An authenticated account is required to respond")
ids = _actor_ids(actor_ids) participant = _participant_for_actor(request, actor_ids=actor_ids)
participants = [ canonical_respondent_id = _participant_respondent_id(
participant participant,
for participant in _active_participants(request) fallback_respondent_id=respondent_id,
if participant.respondent_id in ids or participant.email in ids
]
if len(participants) != 1:
raise SchedulingPermissionError(
"A unique participant assignment is required to respond"
)
participant = participants[0]
canonical_respondent_id = participant.respondent_id or (
f"invitation:{participant.poll_invitation_id}"
if participant.poll_invitation_id
else respondent_id
) )
existing_response = _participant_poll_response(
session,
request=request,
participant=participant,
actor_ids=actor_ids,
canonical_respondent_id=canonical_respondent_id,
)
if existing_response is not None and existing_response.respondent_id:
canonical_respondent_id = existing_response.respondent_id
answers: list[PollAnswerRequest] = [] answers: list[PollAnswerRequest] = []
for answer in payload.answers: for answer in payload.answers:
slot = _selected_slot(request, slot_id=answer.slot_id) slot = _selected_slot(request, slot_id=answer.slot_id)
if slot.poll_option_id is None: if slot.poll_option_id is None:
raise SchedulingError("Scheduling slot has no backing poll option") raise SchedulingError("Scheduling slot has no backing poll option")
if answer.option_revision != scheduling_slot_revision(slot):
raise SchedulingConflictError(
"Scheduling options changed after this response form was loaded; reload before responding"
)
answers.append( answers.append(
PollAnswerRequest( PollAnswerRequest(
option_id=slot.poll_option_id, option_id=slot.poll_option_id,
@@ -1085,6 +1210,11 @@ def submit_scheduling_availability(
first_response = participant.status != "responded" first_response = participant.status != "responded"
participant.status = "responded" participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at) participant.responded_at = response_datetime(response.submitted_at)
if response.respondent_id:
participant.metadata_ = {
**(participant.metadata_ or {}),
"poll_response_respondent_id": response.respondent_id,
}
if first_response: if first_response:
_emit_scheduling_center_notification( _emit_scheduling_center_notification(
session, session,
@@ -1101,6 +1231,61 @@ def submit_scheduling_availability(
return request return request
def get_scheduling_availability_response(
session: Session,
*,
tenant_id: str,
request_id: str,
actor_ids: tuple[str, ...],
respondent_id: str | None,
) -> dict[str, Any]:
"""Return only the caller's still-valid availability answers."""
request = get_visible_scheduling_request(
session,
tenant_id=tenant_id,
request_id=request_id,
actor_ids=actor_ids,
)
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
participant = _participant_for_actor(request, actor_ids=actor_ids)
canonical_respondent_id = _participant_respondent_id(
participant,
fallback_respondent_id=respondent_id,
)
response = _participant_poll_response(
session,
request=request,
participant=participant,
actor_ids=actor_ids,
canonical_respondent_id=canonical_respondent_id,
)
slot_by_option_id = {
slot.poll_option_id: slot
for slot in _active_slots(request)
if slot.poll_option_id is not None
}
slot_by_option_key = {
f"slot-{slot.position + 1}": slot
for slot in _active_slots(request)
}
answers: list[dict[str, str]] = []
if response is not None:
for answer in response.answers:
slot = slot_by_option_id.get(answer.option_id) or slot_by_option_key.get(answer.option_key)
if slot is None or answer.value not in {"available", "maybe", "unavailable"}:
continue
answers.append({"slot_id": slot.id, "value": str(answer.value)})
return {
"request_id": request.id,
"participant_id": participant.id,
"has_response": response is not None,
"submitted_at": response_datetime(response.submitted_at) if response is not None else None,
"answers": answers,
}
def require_visible_scheduling_results( def require_visible_scheduling_results(
session: Session, session: Session,
*, *,
@@ -1140,7 +1325,16 @@ def update_scheduling_request(
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.status in {"decided", "handed_off", "cancelled", "archived"}: if request.status in {"decided", "handed_off", "cancelled", "archived"}:
raise SchedulingError("Decided, handed off, cancelled, or archived scheduling requests cannot be edited") raise SchedulingError("Decided, handed off, cancelled, or archived scheduling requests cannot be edited")
for field in ("title", "description", "location", "deadline_at", "allow_external_participants", "allow_participant_updates", "result_visibility"): for field in (
"title",
"description",
"location",
"deadline_at",
"allow_external_participants",
"allow_participant_updates",
"result_visibility",
"participant_visibility",
):
value = getattr(payload, field) value = getattr(payload, field)
if value is not None: if value is not None:
setattr(request, field, value) setattr(request, field, value)
@@ -1170,6 +1364,109 @@ def update_scheduling_request(
return request return request
def update_scheduling_candidate_slot(
session: Session,
*,
tenant_id: str,
request_id: str,
slot_id: str,
payload: SchedulingCandidateSlotUpdateRequest,
) -> SchedulingRequest:
"""Update one slot and invalidate only its Poll answers atomically."""
request = _lock_scheduling_request(
session,
tenant_id=tenant_id,
request_id=request_id,
lock_slots=True,
)
if request.status not in {"draft", "collecting"}:
raise SchedulingError("Only draft or collecting scheduling request slots can be edited")
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
slot = _selected_slot(request, slot_id=slot_id)
if slot.poll_option_id is None:
raise SchedulingError("Scheduling slot has no backing poll option")
supplied = payload.model_fields_set
for field in ("label", "start_at", "end_at", "timezone"):
if field in supplied and getattr(payload, field) is None:
raise SchedulingError(f"{field} cannot be null")
label = payload.label if "label" in supplied else slot.label
description = payload.description if "description" in supplied else slot.description
start_at = payload.start_at if "start_at" in supplied else slot.start_at
end_at = payload.end_at if "end_at" in supplied else slot.end_at
timezone_name = payload.timezone if "timezone" in supplied else slot.timezone
location = payload.location if "location" in supplied else slot.location
metadata = (payload.metadata or {}) if "metadata" in supplied else (slot.metadata_ or {})
normalized_start = response_datetime(start_at)
normalized_end = response_datetime(end_at)
if normalized_end <= normalized_start:
raise SchedulingError("end_at must be after start_at")
temporal_or_location_changed = (
normalized_start != response_datetime(slot.start_at)
or normalized_end != response_datetime(slot.end_at)
or timezone_name != slot.timezone
or location != slot.location
)
changed = (
label != slot.label
or description != slot.description
or temporal_or_location_changed
or metadata != (slot.metadata_ or {})
)
if not changed:
return request
if temporal_or_location_changed and slot.tentative_hold_event_id:
raise SchedulingError(
"A slot with a tentative calendar hold cannot change time, timezone, or location"
)
try:
_poll_provider().update_option(
session,
tenant_id=tenant_id,
poll_id=request.poll_id,
option_id=slot.poll_option_id,
command=PollOptionUpdateCommand(
label=label,
description=description,
value={
"slot_id": slot.id,
"start_at": normalized_start.isoformat(),
"end_at": normalized_end.isoformat(),
"timezone": timezone_name,
"location": location,
},
metadata=metadata,
),
)
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
refresh_participant_response_state(
session,
request=request,
reset_missing=True,
)
slot.label = label
slot.description = description
slot.start_at = start_at
slot.end_at = end_at
slot.timezone = timezone_name
slot.location = location
slot.metadata_ = metadata
if temporal_or_location_changed:
slot.freebusy_checked_at = None
slot.freebusy_status = None
slot.freebusy_conflicts = []
session.flush()
return request
def open_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest: def open_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
request = _lock_scheduling_request( request = _lock_scheduling_request(
session, session,
@@ -1303,7 +1600,7 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
try: try:
provider = _poll_provider() provider = _poll_provider()
poll = provider.get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) poll = provider.get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
if poll.status not in {"closed", "decided", "archived"}: if poll.status == "open":
provider.close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) provider.close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
except PollCapabilityError as exc: except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc raise SchedulingError(str(exc)) from exc
@@ -1350,6 +1647,7 @@ def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]:
"timezone": slot.timezone, "timezone": slot.timezone,
"location": slot.location, "location": slot.location,
"position": slot.position, "position": slot.position,
"revision": scheduling_slot_revision(slot),
"freebusy_checked_at": response_datetime(slot.freebusy_checked_at), "freebusy_checked_at": response_datetime(slot.freebusy_checked_at),
"freebusy_status": slot.freebusy_status, "freebusy_status": slot.freebusy_status,
"freebusy_conflicts": slot.freebusy_conflicts or [], "freebusy_conflicts": slot.freebusy_conflicts or [],
@@ -1380,16 +1678,119 @@ def scheduling_participant_response(
} }
_PARTICIPANT_STATUSES = ("draft", "invited", "responded", "declined", "removed")
def _participant_aggregate(participants: list[SchedulingParticipant]) -> dict[str, Any]:
status_counts = {status: 0 for status in _PARTICIPANT_STATUSES}
for participant in participants:
status_counts[participant.status] = status_counts.get(participant.status, 0) + 1
return {"total": len(participants), "status_counts": status_counts}
def _participant_visibility_decision(
request: SchedulingRequest,
*,
participant: SchedulingParticipant | None,
actor_user_id: str | None,
) -> dict[str, Any]:
requested = request.participant_visibility
payload: dict[str, Any] = {
"requested_visibility": requested,
"effective_visibility": requested,
"policy_applied": False,
"reason": None,
"source_path": [],
"details": {},
}
if participant is None:
payload["effective_visibility"] = "aggregates_only"
payload["reason"] = "A unique participant context is required for roster visibility."
return payload
provider = scheduling_participant_privacy_policy(get_registry())
if provider is None:
return payload
payload["policy_applied"] = True
try:
decision = provider.resolve_scheduling_participant_visibility(
object_session(request),
request=SchedulingParticipantPrivacyRequest(
tenant_id=request.tenant_id,
scheduling_request_id=request.id,
participant_id=participant.id,
actor_user_id=actor_user_id,
requested_visibility=requested,
context={
"request_status": request.status,
"organizer_user_id": request.organizer_user_id,
},
),
)
except Exception:
payload["effective_visibility"] = "aggregates_only"
payload["reason"] = "The participant privacy policy could not be evaluated."
payload["details"] = {"policy_error": True}
return payload
if not isinstance(decision, SchedulingParticipantPrivacyDecision):
payload["effective_visibility"] = "aggregates_only"
payload["reason"] = "The participant privacy policy returned an invalid decision."
payload["details"] = {"invalid_policy_decision": True}
return payload
decision_payload = decision.to_dict()
resolved = decision.effective_visibility
if requested == "aggregates_only" or resolved not in {"aggregates_only", "names_and_statuses"}:
resolved = "aggregates_only"
payload.update(
effective_visibility=resolved,
reason=decision.reason,
source_path=decision_payload["source_path"],
details=decision_payload["details"],
)
return payload
def scheduling_request_response( def scheduling_request_response(
request: SchedulingRequest, request: SchedulingRequest,
*, *,
invitation_tokens: dict[str, str] | None = None, invitation_tokens: dict[str, str] | None = None,
actor_ids: tuple[str, ...] | None = None, actor_ids: tuple[str, ...] | None = None,
actor_user_id: str | None = None,
can_manage: bool = False, can_manage: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
invitation_tokens = invitation_tokens or {} invitation_tokens = invitation_tokens or {}
ids = _actor_ids(actor_ids or ()) ids = _actor_ids(actor_ids or ())
full_roster = actor_ids is None or can_manage or request.organizer_user_id in ids full_roster = actor_ids is None or can_manage or request.organizer_user_id in ids
active_participants = _active_participants(request)
own_participants = [
participant
for participant in active_participants
if participant.respondent_id in ids or participant.email in ids
]
if full_roster:
visibility_decision = {
"requested_visibility": request.participant_visibility,
"effective_visibility": "names_and_statuses",
"policy_applied": False,
"reason": "Full participant roster access is granted to organizers and scheduling managers.",
"source_path": [],
"details": {"management_access": True},
}
projected_participants = active_participants
else:
visibility_decision = _participant_visibility_decision(
request,
participant=own_participants[0] if len(own_participants) == 1 else None,
actor_user_id=actor_user_id,
)
projected_participants = (
active_participants
if visibility_decision["effective_visibility"] == "names_and_statuses"
else own_participants
)
if not full_roster: if not full_roster:
invitation_tokens = {} invitation_tokens = {}
return { return {
@@ -1407,6 +1808,10 @@ def scheduling_request_response(
"allow_external_participants": request.allow_external_participants, "allow_external_participants": request.allow_external_participants,
"allow_participant_updates": request.allow_participant_updates, "allow_participant_updates": request.allow_participant_updates,
"result_visibility": request.result_visibility, "result_visibility": request.result_visibility,
"participant_visibility": request.participant_visibility,
"effective_participant_visibility": visibility_decision["effective_visibility"],
"participant_aggregate": _participant_aggregate(active_participants),
"participant_visibility_decision": visibility_decision,
"calendar_integration_enabled": request.calendar_integration_enabled, "calendar_integration_enabled": request.calendar_integration_enabled,
"calendar_id": request.calendar_id, "calendar_id": request.calendar_id,
"calendar_freebusy_enabled": request.calendar_freebusy_enabled, "calendar_freebusy_enabled": request.calendar_freebusy_enabled,
@@ -1427,7 +1832,7 @@ def scheduling_request_response(
and participant.respondent_id not in ids and participant.respondent_id not in ids
and participant.email not in ids, and participant.email not in ids,
) )
for participant in _active_participants(request) for participant in projected_participants
], ],
} }

View File

@@ -16,20 +16,27 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertNotIn("access", manifest.dependencies) self.assertNotIn("access", manifest.dependencies)
self.assertIn("access", manifest.optional_dependencies) self.assertIn("access", manifest.optional_dependencies)
self.assertIn("addresses", 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",), manifest.required_capabilities)
self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("poll.scheduling", manifest.required_capabilities) self.assertIn("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities) self.assertIn("calendar.scheduling", manifest.optional_capabilities)
self.assertIn("policy.schedulingParticipantPrivacy", manifest.optional_capabilities)
self.assertIn("evaluation", manifest.optional_dependencies) self.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend) self.assertIsNotNone(manifest.frontend)
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces}) 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.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.signed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("notifications.dispatch", {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("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("calendar.scheduling", {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)
if __name__ == "__main__": if __name__ == "__main__":

224
tests/test_migrations.py Normal file
View File

@@ -0,0 +1,224 @@
from __future__ import annotations
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, func, inspect, select, text
from sqlalchemy.orm import Session
from govoplan_core.db.migrations import migrate_database
from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest
from govoplan_scheduling.backend.db.models import (
SchedulingCandidateSlot,
SchedulingNotification,
SchedulingParticipant,
SchedulingRequest,
)
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
_SCHEDULING_HEAD = "9c2f4a7d1e6b"
_ENABLED_MODULES = ("poll", "scheduling")
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
class SchedulingMigrationTests(unittest.TestCase):
def _migrate(self, url: str) -> None:
migrate_database(
database_url=url,
enabled_modules=_ENABLED_MODULES,
manifest_factories=_MANIFEST_FACTORIES,
)
@staticmethod
def _remove_scheduling_revision(
connection, *, remove_privacy_column: bool = True
) -> None:
connection.execute(
text("DELETE FROM alembic_version WHERE version_num = :revision"),
{"revision": _SCHEDULING_HEAD},
)
if remove_privacy_column:
connection.execute(
text(
"ALTER TABLE scheduling_requests DROP COLUMN participant_visibility"
)
)
@staticmethod
def _seed_scheduling_rows(engine) -> None:
with Session(engine) as session:
request = SchedulingRequest(
id="request-1",
tenant_id="tenant-1",
title="Migration adoption probe",
)
request.slots.append(
SchedulingCandidateSlot(
id="slot-1",
tenant_id="tenant-1",
label="First slot",
start_at=datetime(2026, 7, 21, 8, 0),
end_at=datetime(2026, 7, 21, 9, 0),
)
)
request.participants.append(
SchedulingParticipant(
id="participant-1",
tenant_id="tenant-1",
display_name="Ada",
email="ada@example.test",
)
)
session.add(request)
session.flush()
session.add(
SchedulingNotification(
id="notification-1",
tenant_id="tenant-1",
request_id=request.id,
event_kind="invitation",
recipient="ada@example.test",
)
)
session.commit()
def test_adopts_complete_unversioned_v018_schema_and_preserves_rows(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-scheduling-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
self._migrate(url)
engine = create_engine(url)
try:
self._seed_scheduling_rows(engine)
with engine.begin() as connection:
self._remove_scheduling_revision(connection)
self._migrate(url)
with engine.connect() as connection:
heads = set(
MigrationContext.configure(connection).get_current_heads()
)
columns = {
item["name"]
for item in inspect(connection).get_columns(
"scheduling_requests"
)
}
visibility = connection.execute(
text(
"SELECT participant_visibility FROM scheduling_requests "
"WHERE id = 'request-1'"
)
).scalar_one()
counts = {
model.__tablename__: connection.execute(
select(func.count()).select_from(model)
).scalar_one()
for model in (
SchedulingRequest,
SchedulingCandidateSlot,
SchedulingParticipant,
SchedulingNotification,
)
}
self.assertIn(_SCHEDULING_HEAD, heads)
self.assertIn("participant_visibility", columns)
self.assertEqual(visibility, "aggregates_only")
self.assertEqual(set(counts.values()), {1})
finally:
engine.dispose()
def test_adopts_already_present_compatible_privacy_column(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-scheduling-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
self._migrate(url)
engine = create_engine(url)
try:
with engine.begin() as connection:
self._remove_scheduling_revision(
connection, remove_privacy_column=False
)
self._migrate(url)
with engine.connect() as connection:
heads = set(
MigrationContext.configure(connection).get_current_heads()
)
matching_columns = [
item
for item in inspect(connection).get_columns(
"scheduling_requests"
)
if item["name"] == "participant_visibility"
]
self.assertIn(_SCHEDULING_HEAD, heads)
self.assertEqual(len(matching_columns), 1)
finally:
engine.dispose()
def test_rejects_incomplete_existing_baseline(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-scheduling-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
self._migrate(url)
engine = create_engine(url)
try:
with engine.begin() as connection:
self._remove_scheduling_revision(connection)
connection.execute(text("DROP INDEX ix_scheduling_requests_status"))
with self.assertRaisesRegex(
RuntimeError,
"scheduling_requests missing indexes: ix_scheduling_requests_status",
):
self._migrate(url)
with engine.connect() as connection:
heads = set(
MigrationContext.configure(connection).get_current_heads()
)
self.assertNotIn(_SCHEDULING_HEAD, heads)
finally:
engine.dispose()
def test_rejects_partial_existing_calendar_extension(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-scheduling-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'scheduling.db'}"
self._migrate(url)
engine = create_engine(url)
try:
with engine.begin() as connection:
self._remove_scheduling_revision(connection)
connection.execute(text("DROP TABLE scheduling_notifications"))
with self.assertRaisesRegex(
RuntimeError,
"missing table: scheduling_notifications",
):
self._migrate(url)
with engine.connect() as connection:
heads = set(
MigrationContext.configure(connection).get_current_heads()
)
self.assertNotIn(_SCHEDULING_HEAD, heads)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,244 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.policy import (
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
PolicySourceStep,
SchedulingParticipantPrivacyDecision,
SchedulingParticipantPrivacyRequest,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.base import Base
from govoplan_scheduling.backend.db.models import (
SchedulingCandidateSlot,
SchedulingParticipant,
SchedulingRequest,
)
from govoplan_scheduling.backend.runtime import configure_runtime
from govoplan_scheduling.backend.schemas import (
SchedulingCandidateSlotInput,
SchedulingRequestCreateRequest,
SchedulingRequestResponse,
SchedulingRequestUpdateRequest,
)
from govoplan_scheduling.backend.service import scheduling_request_response
class _PrivacyPolicy:
def __init__(self, effective_visibility: str) -> None:
self.effective_visibility = effective_visibility
self.requests: list[SchedulingParticipantPrivacyRequest] = []
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
self.requests.append(request)
return SchedulingParticipantPrivacyDecision(
effective_visibility=self.effective_visibility, # type: ignore[arg-type]
reason="Tenant participant privacy policy",
source_path=(
PolicySourceStep(
scope_type="tenant",
scope_id=request.tenant_id,
label="Tenant",
applied_fields=("scheduling_participant_visibility",),
),
),
details={"provider_session_available": session is not None},
)
class SchedulingParticipantPrivacyTests(unittest.TestCase):
def setUp(self) -> None:
configure_runtime(registry=PlatformRegistry())
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
SchedulingRequest.__table__,
SchedulingCandidateSlot.__table__,
SchedulingParticipant.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[
SchedulingParticipant.__table__,
SchedulingCandidateSlot.__table__,
SchedulingRequest.__table__,
],
)
self.engine.dispose()
configure_runtime(registry=PlatformRegistry())
def _request(self, *, participant_visibility: str | None = None) -> SchedulingRequest:
values = {
"tenant_id": "tenant-1",
"title": "Privacy review",
"status": "collecting",
"organizer_user_id": "organizer-1",
}
if participant_visibility is not None:
values["participant_visibility"] = participant_visibility
request = SchedulingRequest(**values)
request.participants = [
SchedulingParticipant(
tenant_id="tenant-1",
respondent_id="alice-id",
display_name="Alice",
email="alice@example.test",
participant_type="internal",
status="responded",
metadata_={"private": "alice"},
),
SchedulingParticipant(
tenant_id="tenant-1",
respondent_id="bob-id",
display_name="Bob",
email="bob@example.test",
participant_type="external",
status="invited",
metadata_={"private": "bob"},
),
]
self.session.add(request)
self.session.flush()
return request
@staticmethod
def _participant_projection(request: SchedulingRequest) -> dict[str, object]:
return scheduling_request_response(
request,
actor_ids=("alice-id", "alice@example.test"),
actor_user_id="alice-account",
)
@staticmethod
def _configure_policy(provider: _PrivacyPolicy) -> None:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="policy_test",
name="Policy test",
version="test",
capability_factories={
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: lambda context: provider,
},
)
)
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
configure_runtime(registry=registry)
def test_secure_default_returns_own_row_and_aggregate_counts(self) -> None:
request = self._request()
payload = self._participant_projection(request)
response = SchedulingRequestResponse.model_validate(payload)
self.assertEqual(request.participant_visibility, "aggregates_only")
self.assertEqual(response.participant_visibility, "aggregates_only")
self.assertEqual(response.effective_participant_visibility, "aggregates_only")
self.assertEqual([participant.display_name for participant in response.participants], ["Alice"])
self.assertEqual(response.participants[0].email, "alice@example.test")
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)
def test_configured_roster_returns_other_names_and_statuses_with_sensitive_fields_redacted(self) -> None:
request = self._request(participant_visibility="names_and_statuses")
response = SchedulingRequestResponse.model_validate(self._participant_projection(request))
self.assertEqual(response.effective_participant_visibility, "names_and_statuses")
own = next(participant for participant in response.participants if participant.display_name == "Alice")
other = next(participant for participant in response.participants if participant.display_name == "Bob")
self.assertEqual(own.respondent_id, "alice-id")
self.assertEqual(own.email, "alice@example.test")
self.assertEqual(other.status, "invited")
self.assertIsNone(other.respondent_id)
self.assertIsNone(other.email)
self.assertIsNone(other.poll_invitation_id)
self.assertEqual(other.metadata, {})
def test_manager_and_organizer_bypass_participant_roster_restriction(self) -> None:
request = self._request()
manager = SchedulingRequestResponse.model_validate(
scheduling_request_response(
request,
actor_ids=("manager-1",),
actor_user_id="manager-1",
can_manage=True,
)
)
organizer = SchedulingRequestResponse.model_validate(
scheduling_request_response(
request,
actor_ids=("organizer-1",),
actor_user_id="organizer-1",
)
)
for response in (manager, organizer):
self.assertEqual(response.effective_participant_visibility, "names_and_statuses")
self.assertEqual({participant.email for participant in response.participants}, {
"alice@example.test",
"bob@example.test",
})
self.assertTrue(response.participant_visibility_decision.details["management_access"])
def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
restricting_policy = _PrivacyPolicy("aggregates_only")
self._configure_policy(restricting_policy)
visible_request = self._request(participant_visibility="names_and_statuses")
reduced = SchedulingRequestResponse.model_validate(self._participant_projection(visible_request))
self.assertEqual(reduced.effective_participant_visibility, "aggregates_only")
self.assertEqual([participant.display_name for participant in reduced.participants], ["Alice"])
self.assertTrue(reduced.participant_visibility_decision.policy_applied)
self.assertEqual(reduced.participant_visibility_decision.reason, "Tenant participant privacy policy")
self.assertEqual(reduced.participant_visibility_decision.source_path[0]["path"], "tenant:tenant-1")
self.assertTrue(reduced.participant_visibility_decision.details["provider_session_available"])
self.assertEqual(restricting_policy.requests[0].actor_user_id, "alice-account")
widening_policy = _PrivacyPolicy("names_and_statuses")
self._configure_policy(widening_policy)
private_request = self._request()
clamped = SchedulingRequestResponse.model_validate(self._participant_projection(private_request))
self.assertEqual(clamped.effective_participant_visibility, "aggregates_only")
self.assertEqual([participant.display_name for participant in clamped.participants], ["Alice"])
self.assertEqual(widening_policy.requests[0].requested_visibility, "aggregates_only")
def test_create_and_update_schemas_expose_the_configuration(self) -> None:
slot = SchedulingCandidateSlotInput.model_validate(
{
"start_at": "2026-07-20T09:00:00Z",
"end_at": "2026-07-20T10:00:00Z",
}
)
created = SchedulingRequestCreateRequest(title="Privacy", slots=[slot])
updated = SchedulingRequestUpdateRequest(participant_visibility="names_and_statuses")
self.assertEqual(created.participant_visibility, "aggregates_only")
self.assertEqual(updated.participant_visibility, "names_and_statuses")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,381 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.base import Base
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse
from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest
from govoplan_scheduling.backend.db.models import (
SchedulingCandidateSlot,
SchedulingNotification,
SchedulingParticipant,
SchedulingRequest,
)
from govoplan_scheduling.backend.manifest import RESPOND_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.router import (
api_get_my_scheduling_availability,
api_submit_scheduling_availability,
api_update_scheduling_candidate_slot,
)
from govoplan_scheduling.backend.runtime import configure_runtime
from govoplan_scheduling.backend.schemas import (
SchedulingAvailabilityAnswerInput,
SchedulingAvailabilityResponseRequest,
SchedulingCalendarPreferences,
SchedulingCandidateSlotInput,
SchedulingCandidateSlotUpdateRequest,
SchedulingParticipantInput,
SchedulingRequestCreateRequest,
)
from govoplan_scheduling.backend.service import (
SchedulingError,
cancel_scheduling_request,
create_scheduling_request,
scheduling_request_summary,
scheduling_slot_revision,
update_scheduling_candidate_slot,
)
class SchedulingResponseEditingTests(unittest.TestCase):
def setUp(self) -> None:
registry = PlatformRegistry()
registry.register(get_poll_manifest())
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
configure_runtime(registry=registry)
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
Poll.__table__,
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
SchedulingRequest.__table__,
SchedulingCandidateSlot.__table__,
SchedulingParticipant.__table__,
SchedulingNotification.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[
SchedulingNotification.__table__,
SchedulingParticipant.__table__,
SchedulingCandidateSlot.__table__,
SchedulingRequest.__table__,
PollInvitation.__table__,
PollResponse.__table__,
PollOption.__table__,
Poll.__table__,
],
)
self.engine.dispose()
@staticmethod
def _principal(account_id: str, *, email: str | None, scopes: set[str]) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id=f"membership-{account_id}",
tenant_id="tenant-1",
email=email,
display_name=account_id,
scopes=frozenset(scopes),
),
account=SimpleNamespace(id=account_id),
user=SimpleNamespace(id=f"membership-{account_id}"),
)
def _request(
self,
*,
status: str = "collecting",
allow_participant_updates: bool = True,
) -> SchedulingRequest:
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
request, _tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="organizer-1",
payload=SchedulingRequestCreateRequest(
title="Response editing",
status=status,
allow_participant_updates=allow_participant_updates,
calendar=SchedulingCalendarPreferences(),
slots=[
SchedulingCandidateSlotInput(
label="Monday",
start_at=start,
end_at=start + timedelta(hours=1),
),
SchedulingCandidateSlotInput(
label="Tuesday",
start_at=start + timedelta(days=1),
end_at=start + timedelta(days=1, hours=1),
),
],
participants=[
SchedulingParticipantInput(
display_name="Alice",
email="alice@example.test",
)
],
),
)
return request
def _submit_both(self, request: SchedulingRequest) -> None:
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]),
),
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[1].id,
value="maybe",
option_revision=scheduling_slot_revision(request.slots[1]),
),
]
),
session=self.session,
principal=self._principal(
"alice-account",
email="alice@example.test",
scopes={RESPOND_SCOPE},
),
)
def test_current_participant_response_is_prefilled_and_changed_slot_is_invalidated(self) -> None:
request = self._request()
self._submit_both(request)
alice = self._principal(
"alice-account",
email="alice@example.test",
scopes={RESPOND_SCOPE},
)
before = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=alice,
)
self.assertTrue(before.has_response)
self.assertIsNotNone(before.submitted_at)
self.assertEqual(before.participant_id, request.participants[0].id)
self.assertEqual(
[(answer.slot_id, answer.value) for answer in before.answers],
[(request.slots[0].id, "available"), (request.slots[1].id, "maybe")],
)
api_update_scheduling_candidate_slot(
request.id,
request.slots[0].id,
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
session=self.session,
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
)
after = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=alice,
)
self.assertTrue(after.has_response)
self.assertEqual(
[(answer.slot_id, answer.value) for answer in after.answers],
[(request.slots[1].id, "maybe")],
)
poll_response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one()
self.assertEqual(
[answer["option_id"] for answer in poll_response.answers],
[request.slots[1].poll_option_id],
)
# Re-submit and repeat the exact option snapshot: an idempotent no-op
# must preserve both current answers.
self._submit_both(request)
api_update_scheduling_candidate_slot(
request.id,
request.slots[0].id,
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
session=self.session,
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
)
unchanged = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=alice,
)
self.assertEqual(len(unchanged.answers), 2)
def test_calendar_hold_rejection_leaves_slot_and_answers_unchanged(self) -> None:
request = self._request()
self._submit_both(request)
slot = request.slots[0]
original_start = slot.start_at
slot.tentative_hold_event_id = "event-1"
self.session.flush()
with self.assertRaisesRegex(SchedulingError, "tentative calendar hold"):
update_scheduling_candidate_slot(
self.session,
tenant_id="tenant-1",
request_id=request.id,
slot_id=slot.id,
payload=SchedulingCandidateSlotUpdateRequest(
start_at=original_start.replace(tzinfo=timezone.utc) + timedelta(minutes=15),
),
)
self.assertEqual(slot.start_at, original_start)
response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one()
self.assertEqual(len(response.answers), 2)
def test_cancelling_draft_request_preserves_draft_poll(self) -> None:
request = self._request(status="draft")
cancelled = cancel_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
)
poll = self.session.query(Poll).filter(Poll.id == request.poll_id).one()
self.assertEqual(cancelled.status, "cancelled")
self.assertIsNotNone(cancelled.cancelled_at)
self.assertEqual(poll.status, "draft")
def test_fully_invalidated_response_becomes_unanswered(self) -> None:
request = self._request()
alice = self._principal(
"alice-account",
email="alice@example.test",
scopes={RESPOND_SCOPE},
)
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=alice,
)
api_update_scheduling_candidate_slot(
request.id,
request.slots[0].id,
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
session=self.session,
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
)
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=alice,
)
self.assertFalse(current.has_response)
self.assertEqual(current.answers, [])
self.assertEqual(request.participants[0].status, "invited")
self.assertIsNone(request.participants[0].responded_at)
self.assertEqual(
scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)["response_count"],
0,
)
def test_option_change_is_blocked_when_response_updates_are_disabled(self) -> None:
request = self._request(allow_participant_updates=False)
self._submit_both(request)
original_label = request.slots[0].label
with self.assertRaisesRegex(SchedulingError, "response updates are disabled"):
update_scheduling_candidate_slot(
self.session,
tenant_id="tenant-1",
request_id=request.id,
slot_id=request.slots[0].id,
payload=SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
)
self.assertEqual(request.slots[0].label, original_label)
response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one()
self.assertEqual(len(response.answers), 2)
def test_stale_option_revision_is_rejected_without_writing_a_response(self) -> None:
request = self._request()
stale_revision = scheduling_slot_revision(request.slots[0])
api_update_scheduling_candidate_slot(
request.id,
request.slots[0].id,
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
session=self.session,
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
)
with self.assertRaises(HTTPException) as stale:
api_submit_scheduling_availability(
request.id,
SchedulingAvailabilityResponseRequest(
answers=[
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=stale_revision,
)
]
),
session=self.session,
principal=self._principal(
"alice-account",
email="alice@example.test",
scopes={RESPOND_SCOPE},
),
)
self.assertEqual(stale.exception.status_code, 409)
self.assertEqual(self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).count(), 0)
def test_slot_inputs_require_aware_datetimes_and_known_timezones(self) -> None:
with self.assertRaises(ValidationError):
SchedulingCandidateSlotInput(
start_at=datetime(2026, 7, 20, 9),
end_at=datetime(2026, 7, 20, 10),
)
with self.assertRaisesRegex(ValidationError, "valid IANA timezone"):
SchedulingCandidateSlotInput(
start_at=datetime(2026, 7, 20, 9, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 20, 10, tzinfo=timezone.utc),
timezone="Mars/Olympus_Mons",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -19,7 +19,7 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_access.backend.db.models import Account, User from govoplan_access.backend.db.models import Account, User
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource 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_calendar.backend.manifest import get_manifest as get_calendar_manifest
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest 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.router import api_get_poll, api_list_polls, api_submit_poll_response
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
@@ -44,6 +44,7 @@ from govoplan_scheduling.backend.router import (
api_create_tentative_calendar_holds, api_create_tentative_calendar_holds,
api_decide_scheduling_request, api_decide_scheduling_request,
api_evaluate_calendar_freebusy, api_evaluate_calendar_freebusy,
api_get_my_scheduling_availability,
api_get_scheduling_request, api_get_scheduling_request,
api_list_scheduling_requests, api_list_scheduling_requests,
api_scheduling_summary, api_scheduling_summary,
@@ -66,6 +67,7 @@ from govoplan_scheduling.backend.service import (
open_scheduling_request, open_scheduling_request,
require_visible_scheduling_results, require_visible_scheduling_results,
scheduling_request_summary, scheduling_request_summary,
scheduling_slot_revision,
update_scheduling_request, update_scheduling_request,
) )
from govoplan_scheduling.backend.runtime import configure_runtime from govoplan_scheduling.backend.runtime import configure_runtime
@@ -86,6 +88,7 @@ class SchedulingServiceTests(unittest.TestCase):
PollOption.__table__, PollOption.__table__,
PollResponse.__table__, PollResponse.__table__,
PollInvitation.__table__, PollInvitation.__table__,
PollLifecycleTransition.__table__,
CalendarCollection.__table__, CalendarCollection.__table__,
CalendarEvent.__table__, CalendarEvent.__table__,
CalendarSyncSource.__table__, CalendarSyncSource.__table__,
@@ -119,6 +122,7 @@ class SchedulingServiceTests(unittest.TestCase):
CalendarEvent.__table__, CalendarEvent.__table__,
CalendarCollection.__table__, CalendarCollection.__table__,
PollInvitation.__table__, PollInvitation.__table__,
PollLifecycleTransition.__table__,
PollResponse.__table__, PollResponse.__table__,
PollOption.__table__, PollOption.__table__,
Poll.__table__, Poll.__table__,
@@ -823,7 +827,12 @@ class SchedulingServiceTests(unittest.TestCase):
"attacker", "attacker",
email="alice@example.test", email="alice@example.test",
membership_id="alice-membership", membership_id="alice-membership",
scopes={SCHEDULING_READ_SCOPE, "poll:poll:read", "poll:response:write"}, scopes={
SCHEDULING_READ_SCOPE,
SCHEDULING_RESPOND_SCOPE,
"poll:poll:read",
"poll:response:write",
},
) )
response = api_submit_poll_response( response = api_submit_poll_response(
@@ -840,9 +849,19 @@ class SchedulingServiceTests(unittest.TestCase):
session=self.session, session=self.session,
principal=attacker, principal=attacker,
) )
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=attacker,
)
self.assertNotIn("invitation_id", response.metadata) self.assertNotIn("invitation_id", response.metadata)
self.assertEqual([request.id], [item.id for item in listed.requests]) self.assertEqual([request.id], [item.id for item in listed.requests])
self.assertTrue(current.has_response)
self.assertEqual(
[(answer.slot_id, answer.value) for answer in current.answers],
[(request.slots[0].id, "available")],
)
alice = next(participant for participant in request.participants if participant.display_name == "Alice") alice = next(participant for participant in request.participants if participant.display_name == "Alice")
bob = next(participant for participant in request.participants if participant.display_name == "Bob") bob = next(participant for participant in request.participants if participant.display_name == "Bob")
self.assertEqual(alice.status, "responded") self.assertEqual(alice.status, "responded")
@@ -881,10 +900,12 @@ class SchedulingServiceTests(unittest.TestCase):
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id, slot_id=request.slots[0].id,
value="available", value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
), ),
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[1].id, slot_id=request.slots[1].id,
value="maybe", value="maybe",
option_revision=scheduling_slot_revision(request.slots[1]),
), ),
] ]
), ),
@@ -915,6 +936,7 @@ class SchedulingServiceTests(unittest.TestCase):
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id, slot_id=request.slots[0].id,
value="unavailable", value="unavailable",
option_revision=scheduling_slot_revision(request.slots[0]),
) )
] ]
), ),
@@ -967,10 +989,12 @@ class SchedulingServiceTests(unittest.TestCase):
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id, slot_id=request.slots[0].id,
value="unavailable", value="unavailable",
option_revision=scheduling_slot_revision(request.slots[0]),
), ),
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[1].id, slot_id=request.slots[1].id,
value="maybe", value="maybe",
option_revision=scheduling_slot_revision(request.slots[1]),
), ),
] ]
), ),
@@ -1023,6 +1047,7 @@ class SchedulingServiceTests(unittest.TestCase):
"calendar": SchedulingCalendarPreferences(), "calendar": SchedulingCalendarPreferences(),
"participants": participants, "participants": participants,
"result_visibility": "public", "result_visibility": "public",
"participant_visibility": "names_and_statuses",
} }
) )
request, _tokens = create_scheduling_request( request, _tokens = create_scheduling_request(

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/scheduling-webui", "name": "@govoplan/scheduling-webui",
"version": "0.1.8", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs" "test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -11,6 +11,8 @@ assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calend
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/); assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/); assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/); 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.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
assert.match(page, /className="scheduling-full-editor"/); assert.match(page, /className="scheduling-full-editor"/);
assert.match(page, /className="scheduling-page-actions"/); assert.match(page, /className="scheduling-page-actions"/);
@@ -19,16 +21,51 @@ const editorActions = page.slice(
page.indexOf('</div>', 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.ok(editorActions.indexOf("I18N.discard") < editorActions.indexOf("I18N.save"));
assert.match(page, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.addRequest\}/); 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, /title=\{I18N\.myRequests\}/); assert.match(page, /title=\{I18N\.myRequests\}/);
assert.match(page, /title=\{I18N\.invitedRequests\}/); assert.match(page, /title=\{I18N\.invitedRequests\}/);
assert.match(page, /className="scheduling-table-actions"/); assert.match(createBranch, /<Card title=\{I18N\.basicInformation\}>/);
assert.match(createBranch, /<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, /id="scheduling-create-candidate-slots-grid"/);
assert.match(page, /id="scheduling-create-participants-grid"/);
assert.match(page, /id="scheduling-candidate-slots-grid"/);
assert.match(page, /id="scheduling-participants-grid"/);
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, /aria-label=\{i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/); assert.match(page, /aria-label=\{i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/); 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, /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.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/);
assert.match(page, /className="scheduling-selected-calendar"/); assert.match(page, /className="scheduling-selected-calendar"/);
assert.match(page, /showPlanningCalendarActions/); assert.match(page, /showPlanningCalendarActions/);
assert.match(page, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/); assert.match(page, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/); assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
console.log("Scheduling page structure satisfies the focused list/create/respond contract."); console.log("Scheduling page structure satisfies the focused list/create/respond contract.");

View File

@@ -1,6 +1,7 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived"; export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses";
export type SchedulingCandidateSlot = { export type SchedulingCandidateSlot = {
id: string; id: string;
@@ -12,6 +13,7 @@ export type SchedulingCandidateSlot = {
timezone: string; timezone: string;
location?: string | null; location?: string | null;
position: number; position: number;
revision: string;
freebusy_checked_at?: string | null; freebusy_checked_at?: string | null;
freebusy_status?: string | null; freebusy_status?: string | null;
freebusy_conflicts: Record<string, unknown>[]; freebusy_conflicts: Record<string, unknown>[];
@@ -34,6 +36,20 @@ export type SchedulingParticipant = {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
export type SchedulingParticipantAggregate = {
total: number;
status_counts: Record<string, number>;
};
export type SchedulingParticipantVisibilityDecision = {
requested_visibility: SchedulingParticipantVisibility;
effective_visibility: SchedulingParticipantVisibility;
policy_applied: boolean;
reason?: string | null;
source_path: Record<string, unknown>[];
details: Record<string, unknown>;
};
export type SchedulingRequest = { export type SchedulingRequest = {
id: string; id: string;
tenant_id: string; tenant_id: string;
@@ -49,6 +65,10 @@ export type SchedulingRequest = {
allow_external_participants: boolean; allow_external_participants: boolean;
allow_participant_updates: boolean; allow_participant_updates: boolean;
result_visibility: string; result_visibility: string;
participant_visibility: SchedulingParticipantVisibility;
effective_participant_visibility: SchedulingParticipantVisibility;
participant_aggregate: SchedulingParticipantAggregate;
participant_visibility_decision: SchedulingParticipantVisibilityDecision;
calendar_integration_enabled: boolean; calendar_integration_enabled: boolean;
calendar_id?: string | null; calendar_id?: string | null;
calendar_freebusy_enabled: boolean; calendar_freebusy_enabled: boolean;
@@ -96,6 +116,7 @@ export type SchedulingRequestCreatePayload = {
allow_external_participants?: boolean; allow_external_participants?: boolean;
allow_participant_updates?: boolean; allow_participant_updates?: boolean;
result_visibility?: "organizer" | "after_response" | "after_close" | "public"; result_visibility?: "organizer" | "after_response" | "after_close" | "public";
participant_visibility?: SchedulingParticipantVisibility;
calendar?: { calendar?: {
enabled?: boolean; enabled?: boolean;
calendar_id?: string | null; calendar_id?: string | null;
@@ -118,6 +139,18 @@ export type SchedulingDecisionPayload = {
export type SchedulingAvailabilityValue = "available" | "maybe" | "unavailable"; export type SchedulingAvailabilityValue = "available" | "maybe" | "unavailable";
export type SchedulingAvailabilityPayload = { export type SchedulingAvailabilityPayload = {
answers: Array<{
slot_id: string;
value: SchedulingAvailabilityValue;
option_revision: string;
}>;
};
export type SchedulingAvailabilityResponse = {
request_id: string;
participant_id: string;
has_response: boolean;
submitted_at?: string | null;
answers: Array<{ answers: Array<{
slot_id: string; slot_id: string;
value: SchedulingAvailabilityValue; value: SchedulingAvailabilityValue;
@@ -218,6 +251,16 @@ export function submitSchedulingAvailability(
); );
} }
export function getSchedulingAvailabilityResponse(
settings: ApiSettings,
requestId: string
): Promise<SchedulingAvailabilityResponse> {
return apiFetch<SchedulingAvailabilityResponse>(
settings,
`/api/v1/scheduling/requests/${requestId}/responses/me`
);
}
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> { export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({})); return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
} }

File diff suppressed because it is too large Load Diff

View File

@@ -59,6 +59,9 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.option_value.3f643dc0": "Option {value0}", "i18n:govoplan-scheduling.option_value.3f643dc0": "Option {value0}",
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Other scheduling requests", "i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Other scheduling requests",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "Participant email", "i18n:govoplan-scheduling.participant_email.2cadfd9e": "Participant email",
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Participant names and statuses are hidden. Aggregate counts remain visible.",
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Share participant names and response statuses",
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "When enabled, participants can see other participants' names and response statuses. Email addresses and invitation details remain private.",
"i18n:govoplan-scheduling.participants.cd56e083": "Participants", "i18n:govoplan-scheduling.participants.cd56e083": "Participants",
"i18n:govoplan-scheduling.past.405c12fb": "Past", "i18n:govoplan-scheduling.past.405c12fb": "Past",
"i18n:govoplan-scheduling.pending.96f608c1": "Pending", "i18n:govoplan-scheduling.pending.96f608c1": "Pending",
@@ -88,6 +91,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Sent", "i18n:govoplan-scheduling.sent.35f49dcf": "Sent",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped", "i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped",
"i18n:govoplan-scheduling.start.952f3754": "Start", "i18n:govoplan-scheduling.start.952f3754": "Start",
"i18n:govoplan-scheduling.status.bae7d5be": "Status",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Submit response", "i18n:govoplan-scheduling.submit_response.a5f0c053": "Submit response",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Tentative holds create one provisional calendar event per candidate slot.", "i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Tentative holds create one provisional calendar event per candidate slot.",
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "The final event is created only for the selected slot.", "i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "The final event is created only for the selected slot.",
@@ -163,6 +167,9 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.option_value.3f643dc0": "Vorschlag {value0}", "i18n:govoplan-scheduling.option_value.3f643dc0": "Vorschlag {value0}",
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Andere Terminanfragen", "i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Andere Terminanfragen",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "E-Mail der teilnehmenden Person", "i18n:govoplan-scheduling.participant_email.2cadfd9e": "E-Mail der teilnehmenden Person",
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Namen und Antwortstatus der Teilnehmenden sind ausgeblendet. Zusammengefasste Anzahlen bleiben sichtbar.",
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Namen und Antwortstatus der Teilnehmenden freigeben",
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "Wenn aktiviert, sehen Teilnehmende die Namen und Antwortstatus anderer Teilnehmender. E-Mail-Adressen und Einladungsdetails bleiben privat.",
"i18n:govoplan-scheduling.participants.cd56e083": "Teilnehmende", "i18n:govoplan-scheduling.participants.cd56e083": "Teilnehmende",
"i18n:govoplan-scheduling.past.405c12fb": "Vergangen", "i18n:govoplan-scheduling.past.405c12fb": "Vergangen",
"i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend", "i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend",
@@ -192,6 +199,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet", "i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen", "i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen",
"i18n:govoplan-scheduling.start.952f3754": "Beginn", "i18n:govoplan-scheduling.start.952f3754": "Beginn",
"i18n:govoplan-scheduling.status.bae7d5be": "Status",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Antwort senden", "i18n:govoplan-scheduling.submit_response.a5f0c053": "Antwort senden",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Vorläufige Reservierungen erstellen je Terminvorschlag einen provisorischen Kalendereintrag.", "i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Vorläufige Reservierungen erstellen je Terminvorschlag einen provisorischen Kalendereintrag.",
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "Der endgültige Kalendereintrag wird nur für den ausgewählten Termin erstellt.", "i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "Der endgültige Kalendereintrag wird nur für den ausgewählten Termin erstellt.",

View File

@@ -13,64 +13,61 @@
box-sizing: border-box; box-sizing: border-box;
} }
.scheduling-shell {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.scheduling-sidebar,
.scheduling-workspace, .scheduling-workspace,
.scheduling-full-editor { .scheduling-full-editor {
width: 100%;
height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
border: var(--border-line);
background: var(--bg);
} }
.scheduling-sidebar {
border-right: var(--border-line);
background: var(--panel-soft);
}
.scheduling-sidebar-bar,
.scheduling-topbar,
.scheduling-page-header { .scheduling-page-header {
min-height: 58px; min-height: 58px;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px; padding: 10px 14px;
border-bottom: var(--border-line); border-bottom: var(--border-line);
background: var(--panel-header); background: var(--panel-header);
} }
.scheduling-sidebar-bar, .scheduling-page-title,
.scheduling-topbar,
.scheduling-page-header,
.scheduling-section-heading,
.scheduling-sidebar-actions,
.scheduling-page-actions, .scheduling-page-actions,
.scheduling-card-actions, .scheduling-card-actions,
.scheduling-actions, .scheduling-actions,
.scheduling-title-line,
.scheduling-page-title,
.scheduling-status-row { .scheduling-status-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.scheduling-sidebar-bar, .scheduling-page-title {
.scheduling-topbar, min-width: 0;
.scheduling-page-header, }
.scheduling-section-heading {
justify-content: space-between; .scheduling-page-title > div {
min-width: 0;
}
.scheduling-page-title h1 {
margin: 0;
color: var(--text-strong);
font-size: 19px;
}
.scheduling-page-title p {
margin: 3px 0 0;
color: var(--muted);
font-size: 12px;
} }
.scheduling-sidebar-actions,
.scheduling-page-actions, .scheduling-page-actions,
.scheduling-card-actions, .scheduling-card-actions,
.scheduling-actions { .scheduling-actions {
@@ -78,12 +75,14 @@
justify-content: flex-end; justify-content: flex-end;
} }
.scheduling-sidebar-actions .btn, .scheduling-page-actions {
margin-left: auto;
}
.scheduling-page-actions .btn, .scheduling-page-actions .btn,
.scheduling-card-actions .btn, .scheduling-card-actions .btn,
.scheduling-actions .btn, .scheduling-actions .btn,
.scheduling-section-heading .btn, .scheduling-response-card + .btn {
.scheduling-response-card .btn {
min-height: 34px; min-height: 34px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -99,25 +98,51 @@
justify-content: center; justify-content: center;
} }
.scheduling-list { .scheduling-alert,
.scheduling-success {
margin: 10px 14px 0;
padding: 9px 11px;
border: var(--border-line);
border-radius: 7px;
}
.scheduling-alert {
border-color: var(--danger);
color: var(--danger);
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 {
min-width: 0;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: 8px; padding: 14px;
} }
.scheduling-list-group + .scheduling-list-group { .scheduling-detail,
margin-top: 14px; .scheduling-editor-scroll {
padding-top: 12px; display: grid;
border-top: var(--border-line); align-content: start;
gap: 12px;
} }
.scheduling-list-group h2 { .scheduling-request-groups {
margin: 0 8px 6px; display: grid;
color: var(--muted); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
font-size: 12px; gap: 12px;
font-weight: 800; }
letter-spacing: .025em;
text-transform: uppercase; .scheduling-list-group {
min-height: 52px;
max-height: 270px;
overflow: auto;
} }
.scheduling-list-item { .scheduling-list-item {
@@ -146,14 +171,8 @@
background: color-mix(in srgb, var(--accent) 10%, var(--panel)); background: color-mix(in srgb, var(--accent) 10%, var(--panel));
} }
.scheduling-list-item > span,
.scheduling-title-line > div,
.scheduling-page-title > div,
.scheduling-section-heading > div {
min-width: 0;
}
.scheduling-list-item > span { .scheduling-list-item > span {
min-width: 0;
display: grid; display: grid;
gap: 3px; gap: 3px;
} }
@@ -168,9 +187,6 @@
} }
.scheduling-list-item small, .scheduling-list-item small,
.scheduling-title-line small,
.scheduling-page-title p,
.scheduling-section-heading p,
.scheduling-note, .scheduling-note,
.scheduling-list-empty, .scheduling-list-empty,
.scheduling-compact-row small { .scheduling-compact-row small {
@@ -179,95 +195,15 @@
} }
.scheduling-list-status { .scheduling-list-status {
max-width: 110px; max-width: 120px;
padding: 3px 7px; padding: 3px 7px;
border-radius: 999px; border-radius: 999px;
background: var(--panel); background: var(--panel-soft);
font-weight: 700; font-weight: 700;
} }
.scheduling-list-empty { .scheduling-list-empty {
margin: 3px 8px 8px; margin: 3px 0 8px;
}
.scheduling-workspace {
overflow: hidden;
}
.scheduling-title-line {
min-width: 0;
}
.scheduling-title-line > div {
display: grid;
gap: 2px;
}
.scheduling-alert,
.scheduling-success {
margin: 10px 14px 0;
padding: 9px 11px;
border: var(--border-line);
border-radius: 7px;
}
.scheduling-alert {
border-color: var(--danger);
color: var(--danger);
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 {
min-height: 0;
overflow: auto;
padding: 14px;
}
.scheduling-detail {
display: grid;
align-content: start;
gap: 12px;
}
.scheduling-card {
width: 100%;
min-width: 0;
padding: 14px;
border: var(--border-line);
border-radius: 8px;
background: var(--panel);
}
.scheduling-card h2,
.scheduling-page-header h1,
.scheduling-section-heading h2 {
margin: 0;
color: var(--text-strong);
}
.scheduling-card h2,
.scheduling-section-heading h2 {
font-size: 15px;
}
.scheduling-page-header h1 {
font-size: 19px;
}
.scheduling-page-title p,
.scheduling-section-heading p {
margin: 3px 0 0;
}
.scheduling-summary-card > p:last-child {
margin-bottom: 0;
} }
.scheduling-status-row { .scheduling-status-row {
@@ -288,65 +224,10 @@
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--accent) 18%, transparent);
} }
.scheduling-table-card { .scheduling-slot-title {
padding: 0; min-width: 0;
overflow: hidden; display: grid;
} justify-items: start;
.scheduling-table-card > .scheduling-section-heading {
min-height: 52px;
padding: 9px 14px;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.scheduling-table-scroll {
width: 100%;
overflow: auto;
}
.scheduling-table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
.scheduling-table th,
.scheduling-table td {
padding: 9px 10px;
border-bottom: var(--border-line);
text-align: left;
vertical-align: middle;
}
.scheduling-table tr:last-child td {
border-bottom: 0;
}
.scheduling-table th {
color: var(--muted);
background: var(--panel-soft);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.scheduling-table td {
font-size: 13px;
}
.scheduling-table td:first-child {
min-width: 190px;
}
.scheduling-table td > strong {
display: block;
}
.scheduling-table-actions {
width: 1%;
text-align: right !important;
white-space: nowrap;
} }
.scheduling-freebusy { .scheduling-freebusy {
@@ -393,6 +274,10 @@
gap: 12px; gap: 12px;
} }
.scheduling-response-card > p {
margin: 0;
}
.scheduling-response-grid { .scheduling-response-grid {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -425,7 +310,7 @@
.scheduling-response-grid select, .scheduling-response-grid select,
.scheduling-field input, .scheduling-field input,
.scheduling-field textarea, .scheduling-field textarea,
.scheduling-edit-table input:not([type="checkbox"]) { .scheduling-grid-input {
width: 100%; width: 100%;
min-height: 36px; min-height: 36px;
padding: 7px 9px; padding: 7px 9px;
@@ -436,13 +321,8 @@
font: inherit; font: inherit;
} }
.scheduling-action-help summary { .scheduling-action-help {
cursor: pointer; margin: 0;
font-weight: 700;
}
.scheduling-action-help ul {
margin-bottom: 0;
} }
.scheduling-empty { .scheduling-empty {
@@ -455,26 +335,22 @@
background: var(--panel); background: var(--panel);
} }
.scheduling-full-editor { .scheduling-editor-layout {
width: 100%; min-width: 0;
height: 100%; min-height: 0;
overflow: hidden; flex: 1 1 auto;
border: var(--border-line);
background: var(--bg);
}
.scheduling-page-header {
flex: 0 0 auto;
}
.scheduling-page-actions {
margin-left: auto;
}
.scheduling-editor-scroll {
display: grid; display: grid;
align-content: start; grid-template-columns: 198px minmax(0, 1fr);
gap: 12px; overflow: hidden;
}
.scheduling-create-subnav {
min-height: 0;
}
.scheduling-create-section {
min-width: 0;
scroll-margin-top: 14px;
} }
.scheduling-form-grid { .scheduling-form-grid {
@@ -483,7 +359,6 @@
gap: 12px; gap: 12px;
} }
.scheduling-form-grid h2,
.scheduling-field-wide { .scheduling-field-wide {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -499,35 +374,19 @@
font-weight: 800; font-weight: 800;
} }
.scheduling-toggle {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 700;
}
.scheduling-capability-note { .scheduling-capability-note {
margin: 10px 0 0; margin: 10px 0 0;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
} }
.scheduling-edit-table input[type="checkbox"] { .scheduling-selected-calendar {
width: 18px; max-width: 520px;
height: 18px;
} }
@media (max-width: 1050px) { @media (max-width: 900px) {
.scheduling-shell { .scheduling-editor-layout {
grid-template-columns: minmax(260px, 310px) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
}
.scheduling-sidebar-bar {
align-items: flex-start;
}
.scheduling-sidebar-actions {
align-items: flex-end;
} }
} }
@@ -538,25 +397,12 @@
overflow: auto; overflow: auto;
} }
.scheduling-shell {
height: auto;
grid-template-columns: minmax(0, 1fr);
overflow: visible;
}
.scheduling-sidebar {
max-height: 46vh;
border-right: 0;
border-bottom: var(--border-line);
}
.scheduling-workspace, .scheduling-workspace,
.scheduling-full-editor { .scheduling-full-editor,
overflow: visible; .scheduling-editor-layout,
}
.scheduling-detail, .scheduling-detail,
.scheduling-editor-scroll { .scheduling-editor-scroll {
height: auto;
overflow: visible; overflow: visible;
} }
@@ -565,9 +411,7 @@
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.scheduling-page-header, .scheduling-page-header {
.scheduling-topbar,
.scheduling-section-heading {
align-items: flex-start; align-items: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
} }

View File

@@ -34,6 +34,19 @@ function request(
allow_external_participants: true, allow_external_participants: true,
allow_participant_updates: true, allow_participant_updates: true,
result_visibility: "after_close", result_visibility: "after_close",
participant_visibility: "aggregates_only",
effective_participant_visibility: "aggregates_only",
participant_aggregate: {
total: options.participantStatus ? 1 : 0,
status_counts: options.participantStatus ? { [options.participantStatus]: 1 } : {}
},
participant_visibility_decision: {
requested_visibility: "aggregates_only",
effective_visibility: "aggregates_only",
policy_applied: false,
source_path: [],
details: {}
},
calendar_integration_enabled: false, calendar_integration_enabled: false,
calendar_freebusy_enabled: false, calendar_freebusy_enabled: false,
calendar_hold_enabled: false, calendar_hold_enabled: false,