Compare commits
10 Commits
887e064ae9
...
4ddac65367
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ddac65367 | |||
| 2570192e06 | |||
| 297e3ad96e | |||
| 705ac823ce | |||
| 6141f39bf8 | |||
| d0b95eee58 | |||
| bbf503a7d7 | |||
| 0f33e25c83 | |||
| 6f298c36fe | |||
| ec538f524d |
@@ -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]
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.8"
|
||||
__version__ = "0.1.9"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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="[]"))
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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
224
tests/test_migrations.py
Normal 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()
|
||||
244
tests/test_participant_privacy.py
Normal file
244
tests/test_participant_privacy.py
Normal 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()
|
||||
381
tests/test_response_editing.py
Normal file
381
tests/test_response_editing.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user