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]
name = "govoplan-scheduling"
version = "0.1.8"
version = "0.1.9"
description = "GovOPlaN meeting scheduling and Terminfindung module seed."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-poll>=0.1.8",
"govoplan-core>=0.1.9",
"govoplan-poll>=0.1.9",
]
[tool.setuptools.packages.find]

View File

@@ -2,4 +2,4 @@
__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_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
participant_visibility: Mapped[str] = mapped_column(String(32), default="aggregates_only", nullable=False)
calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View File

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

View File

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

View File

@@ -16,7 +16,128 @@ branch_labels = None
depends_on = None
_CALENDAR_SLOT_COLUMNS = {
"freebusy_checked_at",
"freebusy_status",
"freebusy_conflicts",
"tentative_hold_event_id",
}
_CALENDAR_SLOT_INDEXES = {
"ix_scheduling_candidate_slots_freebusy_status",
"ix_scheduling_candidate_slots_tentative_hold_event_id",
}
_NOTIFICATION_COLUMNS = {
"id",
"tenant_id",
"request_id",
"participant_id",
"event_kind",
"channel",
"recipient",
"status",
"payload",
"error",
"sent_at",
"metadata",
"created_at",
"updated_at",
}
_NOTIFICATION_INDEXES = {
"ix_scheduling_notifications_channel",
"ix_scheduling_notifications_event_kind",
"ix_scheduling_notifications_participant_id",
"ix_scheduling_notifications_recipient",
"ix_scheduling_notifications_request_id",
"ix_scheduling_notifications_request_status",
"ix_scheduling_notifications_status",
"ix_scheduling_notifications_tenant_id",
"ix_scheduling_notifications_tenant_status",
}
def _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:
if _adopt_existing_calendar_notifications():
return
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_checked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_status", sa.String(length=40), nullable=True))
op.add_column("scheduling_candidate_slots", sa.Column("freebusy_conflicts", sa.JSON(), nullable=False, server_default="[]"))

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_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
SchedulingAvailabilityResponseRequest,
SchedulingAddressLookupCandidate,
SchedulingAddressLookupResponse,
SchedulingAvailabilityResponse,
SchedulingAvailabilityResponseRequest,
SchedulingCalendarActionResponse,
SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest,
SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse,
@@ -29,6 +31,7 @@ from govoplan_scheduling.backend.schemas import (
)
from govoplan_scheduling.backend.runtime import get_registry
from govoplan_scheduling.backend.service import (
SchedulingConflictError,
SchedulingError,
SchedulingPermissionError,
cancel_scheduling_request,
@@ -39,6 +42,7 @@ from govoplan_scheduling.backend.service import (
create_tentative_calendar_holds,
decide_scheduling_request,
evaluate_calendar_freebusy,
get_scheduling_availability_response,
get_visible_scheduling_request,
list_visible_scheduling_notifications,
list_visible_scheduling_requests,
@@ -49,6 +53,7 @@ from govoplan_scheduling.backend.service import (
scheduling_request_response,
scheduling_request_summary,
submit_scheduling_availability,
update_scheduling_candidate_slot,
update_scheduling_request,
)
@@ -111,6 +116,8 @@ def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
if isinstance(exc, SchedulingConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, SchedulingPermissionError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
if str(exc) == "Scheduling request not found":
@@ -131,6 +138,7 @@ def _request_response(
request,
invitation_tokens=invitation_tokens,
actor_ids=_principal_actor_ids(principal),
actor_user_id=principal.account_id,
can_manage=_can_manage_scheduling(principal),
)
)
@@ -232,6 +240,26 @@ def api_submit_scheduling_availability(
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)
def api_get_scheduling_request(
request_id: str,
@@ -281,6 +309,30 @@ def api_update_scheduling_request(
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)
def api_open_scheduling_request(
request_id: str,

View File

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

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, object_session
from govoplan_core.core.calendar import (
CalendarCapabilityError,
@@ -17,7 +19,9 @@ from govoplan_core.core.poll import (
PollCapabilityError,
PollCreateCommand,
PollInvitationCommand,
PollOptionUpdateCommand,
PollOptionRequest,
PollResponseRef,
PollResponseSubmissionProvider,
PollSchedulingProvider,
PollSubmitResponseCommand,
@@ -25,10 +29,16 @@ from govoplan_core.core.poll import (
poll_response_submission_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_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.schemas import (
SchedulingAvailabilityResponseRequest,
SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest,
SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest,
@@ -44,6 +54,10 @@ class SchedulingPermissionError(SchedulingError):
pass
class SchedulingConflictError(SchedulingError):
pass
WORKFLOW_DRAFT = "draft"
WORKFLOW_COLLECTING = "collecting_availability"
WORKFLOW_CLOSED = "availability_closed"
@@ -72,6 +86,27 @@ def _jsonable(value: Any) -> Any:
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:
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]
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]:
options: list[PollOptionRequest] = []
for slot in _active_slots(request):
@@ -703,21 +792,22 @@ def refresh_participant_response_state(
*,
request: SchedulingRequest,
actor_ids: tuple[str, ...] = (),
reset_missing: bool = False,
) -> None:
if request.poll_id is None:
return
participants = _active_participants(request)
by_invitation_id = {
participant.poll_invitation_id: participant
for participant in _active_participants(request)
for participant in participants
if participant.poll_invitation_id is not None
}
by_respondent_id = {
participant.respondent_id: participant
for participant in _active_participants(request)
if participant.respondent_id is not None
}
if not by_invitation_id and not by_respondent_id:
return
by_respondent_id: dict[str, SchedulingParticipant] = {}
for participant in participants:
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
for identity in (participant.respondent_id, stored_identity):
if isinstance(identity, str) and identity:
by_respondent_id[identity] = participant
try:
responses = _poll_provider().list_responses(
session,
@@ -730,9 +820,11 @@ def refresh_participant_response_state(
ids = _actor_ids(actor_ids)
actor_participants = [
participant
for participant in _active_participants(request)
for participant in participants
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:
participant = by_invitation_id.get(response.invitation_id)
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
):
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.responded_at = response_datetime(response.submitted_at)
_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}.",
)
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:
session.flush()
@@ -786,6 +908,7 @@ def create_scheduling_request(
allow_external_participants=payload.allow_external_participants,
allow_participant_updates=payload.allow_participant_updates,
result_visibility=payload.result_visibility,
participant_visibility=payload.participant_visibility,
metadata_=payload.metadata,
)
_set_calendar_preferences(request, payload)
@@ -1034,27 +1157,29 @@ def submit_scheduling_availability(
raise SchedulingError("Scheduling request has no backing poll")
if respondent_id is None:
raise SchedulingPermissionError("An authenticated account is required to respond")
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"
)
participant = participants[0]
canonical_respondent_id = participant.respondent_id or (
f"invitation:{participant.poll_invitation_id}"
if participant.poll_invitation_id
else respondent_id
participant = _participant_for_actor(request, actor_ids=actor_ids)
canonical_respondent_id = _participant_respondent_id(
participant,
fallback_respondent_id=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] = []
for answer in payload.answers:
slot = _selected_slot(request, slot_id=answer.slot_id)
if slot.poll_option_id is None:
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(
PollAnswerRequest(
option_id=slot.poll_option_id,
@@ -1085,6 +1210,11 @@ def submit_scheduling_availability(
first_response = participant.status != "responded"
participant.status = "responded"
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:
_emit_scheduling_center_notification(
session,
@@ -1101,6 +1231,61 @@ def submit_scheduling_availability(
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(
session: Session,
*,
@@ -1140,7 +1325,16 @@ def update_scheduling_request(
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.status in {"decided", "handed_off", "cancelled", "archived"}:
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)
if value is not None:
setattr(request, field, value)
@@ -1170,6 +1364,109 @@ def update_scheduling_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:
request = _lock_scheduling_request(
session,
@@ -1303,7 +1600,7 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
try:
provider = _poll_provider()
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)
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
@@ -1350,6 +1647,7 @@ def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]:
"timezone": slot.timezone,
"location": slot.location,
"position": slot.position,
"revision": scheduling_slot_revision(slot),
"freebusy_checked_at": response_datetime(slot.freebusy_checked_at),
"freebusy_status": slot.freebusy_status,
"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(
request: SchedulingRequest,
*,
invitation_tokens: dict[str, str] | None = None,
actor_ids: tuple[str, ...] | None = None,
actor_user_id: str | None = None,
can_manage: bool = False,
) -> dict[str, Any]:
invitation_tokens = invitation_tokens or {}
ids = _actor_ids(actor_ids or ())
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:
invitation_tokens = {}
return {
@@ -1407,6 +1808,10 @@ def scheduling_request_response(
"allow_external_participants": request.allow_external_participants,
"allow_participant_updates": request.allow_participant_updates,
"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_id": request.calendar_id,
"calendar_freebusy_enabled": request.calendar_freebusy_enabled,
@@ -1427,7 +1832,7 @@ def scheduling_request_response(
and participant.respondent_id 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.assertIn("access", manifest.optional_dependencies)
self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("policy", manifest.optional_dependencies)
self.assertEqual(("poll.scheduling",), manifest.required_capabilities)
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
self.assertIn("policy.schedulingParticipantPrivacy", manifest.optional_capabilities)
self.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.response_collection", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces}
self.assertEqual("0.1.9", required_interfaces["poll.availability_matrix"].version_min)
self.assertEqual("0.1.9", required_interfaces["poll.response_collection"].version_min)
self.assertEqual("0.1.9", required_interfaces["poll.workflow_context"].version_min)
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_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
from govoplan_poll.backend.db.models import Poll, PollInvitation, 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.router import api_get_poll, api_list_polls, api_submit_poll_response
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
@@ -44,6 +44,7 @@ from govoplan_scheduling.backend.router import (
api_create_tentative_calendar_holds,
api_decide_scheduling_request,
api_evaluate_calendar_freebusy,
api_get_my_scheduling_availability,
api_get_scheduling_request,
api_list_scheduling_requests,
api_scheduling_summary,
@@ -66,6 +67,7 @@ from govoplan_scheduling.backend.service import (
open_scheduling_request,
require_visible_scheduling_results,
scheduling_request_summary,
scheduling_slot_revision,
update_scheduling_request,
)
from govoplan_scheduling.backend.runtime import configure_runtime
@@ -86,6 +88,7 @@ class SchedulingServiceTests(unittest.TestCase):
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
CalendarCollection.__table__,
CalendarEvent.__table__,
CalendarSyncSource.__table__,
@@ -119,6 +122,7 @@ class SchedulingServiceTests(unittest.TestCase):
CalendarEvent.__table__,
CalendarCollection.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
PollResponse.__table__,
PollOption.__table__,
Poll.__table__,
@@ -823,7 +827,12 @@ class SchedulingServiceTests(unittest.TestCase):
"attacker",
email="alice@example.test",
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(
@@ -840,9 +849,19 @@ class SchedulingServiceTests(unittest.TestCase):
session=self.session,
principal=attacker,
)
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=attacker,
)
self.assertNotIn("invitation_id", response.metadata)
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")
bob = next(participant for participant in request.participants if participant.display_name == "Bob")
self.assertEqual(alice.status, "responded")
@@ -881,10 +900,12 @@ class SchedulingServiceTests(unittest.TestCase):
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]),
),
]
),
@@ -915,6 +936,7 @@ class SchedulingServiceTests(unittest.TestCase):
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="unavailable",
option_revision=scheduling_slot_revision(request.slots[0]),
)
]
),
@@ -967,10 +989,12 @@ class SchedulingServiceTests(unittest.TestCase):
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="unavailable",
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]),
),
]
),
@@ -1023,6 +1047,7 @@ class SchedulingServiceTests(unittest.TestCase):
"calendar": SchedulingCalendarPreferences(),
"participants": participants,
"result_visibility": "public",
"participant_visibility": "names_and_statuses",
}
)
request, _tokens = create_scheduling_request(

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/scheduling-webui",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^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, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*ModuleSubnav,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
assert.match(page, /className="scheduling-full-editor"/);
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">'))
);
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\.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, /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.match(page, /className="scheduling-selected-calendar"/);
assert.match(page, /showPlanningCalendarActions/);
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\/me/);
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";
export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses";
export type SchedulingCandidateSlot = {
id: string;
@@ -12,6 +13,7 @@ export type SchedulingCandidateSlot = {
timezone: string;
location?: string | null;
position: number;
revision: string;
freebusy_checked_at?: string | null;
freebusy_status?: string | null;
freebusy_conflicts: Record<string, unknown>[];
@@ -34,6 +36,20 @@ export type SchedulingParticipant = {
metadata?: Record<string, unknown>;
};
export type SchedulingParticipantAggregate = {
total: number;
status_counts: Record<string, number>;
};
export type SchedulingParticipantVisibilityDecision = {
requested_visibility: SchedulingParticipantVisibility;
effective_visibility: SchedulingParticipantVisibility;
policy_applied: boolean;
reason?: string | null;
source_path: Record<string, unknown>[];
details: Record<string, unknown>;
};
export type SchedulingRequest = {
id: string;
tenant_id: string;
@@ -49,6 +65,10 @@ export type SchedulingRequest = {
allow_external_participants: boolean;
allow_participant_updates: boolean;
result_visibility: string;
participant_visibility: SchedulingParticipantVisibility;
effective_participant_visibility: SchedulingParticipantVisibility;
participant_aggregate: SchedulingParticipantAggregate;
participant_visibility_decision: SchedulingParticipantVisibilityDecision;
calendar_integration_enabled: boolean;
calendar_id?: string | null;
calendar_freebusy_enabled: boolean;
@@ -96,6 +116,7 @@ export type SchedulingRequestCreatePayload = {
allow_external_participants?: boolean;
allow_participant_updates?: boolean;
result_visibility?: "organizer" | "after_response" | "after_close" | "public";
participant_visibility?: SchedulingParticipantVisibility;
calendar?: {
enabled?: boolean;
calendar_id?: string | null;
@@ -118,6 +139,18 @@ export type SchedulingDecisionPayload = {
export type SchedulingAvailabilityValue = "available" | "maybe" | "unavailable";
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<{
slot_id: string;
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> {
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.other_scheduling_requests.5cb6eb30": "Other scheduling requests",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "Participant email",
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Participant names and statuses are hidden. Aggregate counts remain visible.",
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Share participant names and response statuses",
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "When enabled, participants can see other participants' names and response statuses. Email addresses and invitation details remain private.",
"i18n:govoplan-scheduling.participants.cd56e083": "Participants",
"i18n:govoplan-scheduling.past.405c12fb": "Past",
"i18n:govoplan-scheduling.pending.96f608c1": "Pending",
@@ -88,6 +91,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Sent",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped",
"i18n:govoplan-scheduling.start.952f3754": "Start",
"i18n:govoplan-scheduling.status.bae7d5be": "Status",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Submit response",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Tentative holds create one provisional calendar event per candidate slot.",
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "The final event is created only for the selected slot.",
@@ -163,6 +167,9 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.option_value.3f643dc0": "Vorschlag {value0}",
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Andere Terminanfragen",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "E-Mail der teilnehmenden Person",
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Namen und Antwortstatus der Teilnehmenden sind ausgeblendet. Zusammengefasste Anzahlen bleiben sichtbar.",
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Namen und Antwortstatus der Teilnehmenden freigeben",
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "Wenn aktiviert, sehen Teilnehmende die Namen und Antwortstatus anderer Teilnehmender. E-Mail-Adressen und Einladungsdetails bleiben privat.",
"i18n:govoplan-scheduling.participants.cd56e083": "Teilnehmende",
"i18n:govoplan-scheduling.past.405c12fb": "Vergangen",
"i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend",
@@ -192,6 +199,7 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet",
"i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen",
"i18n:govoplan-scheduling.start.952f3754": "Beginn",
"i18n:govoplan-scheduling.status.bae7d5be": "Status",
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Antwort senden",
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Vorläufige Reservierungen erstellen je Terminvorschlag einen provisorischen Kalendereintrag.",
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "Der endgültige Kalendereintrag wird nur für den ausgewählten Termin erstellt.",

View File

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

View File

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