feat(scheduling): enforce participant roster privacy

This commit is contained in:
2026-07-20 18:44:28 +02:00
parent d0b95eee58
commit 6141f39bf8
9 changed files with 430 additions and 4 deletions

View File

@@ -36,6 +36,7 @@ class SchedulingRequest(Base, TimestampMixin):
allow_external_participants: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) allow_external_participants: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False) result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
participant_visibility: Mapped[str] = mapped_column(String(32), default="aggregates_only", nullable=False)
calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View File

@@ -19,6 +19,7 @@ from govoplan_core.core.modules import (
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
MODULE_ID = "scheduling" MODULE_ID = "scheduling"
@@ -129,11 +130,12 @@ manifest = ModuleManifest(
name=MODULE_NAME, name=MODULE_NAME,
version=MODULE_VERSION, version=MODULE_VERSION,
dependencies=("poll",), dependencies=("poll",),
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"), optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "policy", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
optional_capabilities=( optional_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_CALENDAR_SCHEDULING, CAPABILITY_CALENDAR_SCHEDULING,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
), ),
required_capabilities=(CAPABILITY_POLL_SCHEDULING,), required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
provides_interfaces=( provides_interfaces=(

View File

@@ -0,0 +1,32 @@
"""v0.1.9 scheduling participant visibility
Revision ID: 5e6f7a8b9c0d
Revises: 4d5e6f7a8b9c
Create Date: 2026-07-20 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5e6f7a8b9c0d"
down_revision = "4d5e6f7a8b9c"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"scheduling_requests",
sa.Column(
"participant_visibility",
sa.String(length=32),
nullable=False,
server_default="aggregates_only",
),
)
def downgrade() -> None:
op.drop_column("scheduling_requests", "participant_visibility")

View File

@@ -138,6 +138,7 @@ def _request_response(
request, request,
invitation_tokens=invitation_tokens, invitation_tokens=invitation_tokens,
actor_ids=_principal_actor_ids(principal), actor_ids=_principal_actor_ids(principal),
actor_user_id=principal.account_id,
can_manage=_can_manage_scheduling(principal), can_manage=_can_manage_scheduling(principal),
) )
) )

View File

@@ -12,6 +12,7 @@ SchedulingParticipantType = Literal["internal", "external", "resource"]
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"] SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"] SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"] SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
def _known_timezone(value: str | None) -> str | None: def _known_timezone(value: str | None) -> str | None:
@@ -103,6 +104,7 @@ class SchedulingRequestCreateRequest(BaseModel):
allow_external_participants: bool = True allow_external_participants: bool = True
allow_participant_updates: bool = True allow_participant_updates: bool = True
result_visibility: SchedulingResultVisibility = "after_close" result_visibility: SchedulingResultVisibility = "after_close"
participant_visibility: SchedulingParticipantVisibility = "aggregates_only"
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences) calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1) slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
participants: list[SchedulingParticipantInput] = Field(default_factory=list) participants: list[SchedulingParticipantInput] = Field(default_factory=list)
@@ -122,6 +124,7 @@ class SchedulingRequestUpdateRequest(BaseModel):
allow_external_participants: bool | None = None allow_external_participants: bool | None = None
allow_participant_updates: bool | None = None allow_participant_updates: bool | None = None
result_visibility: SchedulingResultVisibility | None = None result_visibility: SchedulingResultVisibility | None = None
participant_visibility: SchedulingParticipantVisibility | None = None
calendar: SchedulingCalendarPreferences | None = None calendar: SchedulingCalendarPreferences | None = None
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
@@ -159,6 +162,20 @@ class SchedulingParticipantResponse(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingParticipantAggregateResponse(BaseModel):
total: int = 0
status_counts: dict[str, int] = Field(default_factory=dict)
class SchedulingParticipantVisibilityDecisionResponse(BaseModel):
requested_visibility: SchedulingParticipantVisibility
effective_visibility: SchedulingParticipantVisibility
policy_applied: bool = False
reason: str | None = None
source_path: list[dict[str, Any]] = Field(default_factory=list)
details: dict[str, Any] = Field(default_factory=dict)
class SchedulingRequestResponse(BaseModel): class SchedulingRequestResponse(BaseModel):
id: str id: str
tenant_id: str tenant_id: str
@@ -174,6 +191,10 @@ class SchedulingRequestResponse(BaseModel):
allow_external_participants: bool allow_external_participants: bool
allow_participant_updates: bool allow_participant_updates: bool
result_visibility: str result_visibility: str
participant_visibility: SchedulingParticipantVisibility
effective_participant_visibility: SchedulingParticipantVisibility
participant_aggregate: SchedulingParticipantAggregateResponse
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
calendar_integration_enabled: bool calendar_integration_enabled: bool
calendar_id: str | None = None calendar_id: str | None = None
calendar_freebusy_enabled: bool calendar_freebusy_enabled: bool

View File

@@ -5,7 +5,7 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, object_session
from govoplan_core.core.calendar import ( from govoplan_core.core.calendar import (
CalendarCapabilityError, CalendarCapabilityError,
@@ -29,6 +29,11 @@ from govoplan_core.core.poll import (
poll_response_submission_provider, poll_response_submission_provider,
poll_scheduling_provider, poll_scheduling_provider,
) )
from govoplan_core.core.policy import (
SchedulingParticipantPrivacyDecision,
SchedulingParticipantPrivacyRequest,
scheduling_participant_privacy_policy,
)
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.schemas import ( from govoplan_scheduling.backend.schemas import (
@@ -903,6 +908,7 @@ def create_scheduling_request(
allow_external_participants=payload.allow_external_participants, allow_external_participants=payload.allow_external_participants,
allow_participant_updates=payload.allow_participant_updates, allow_participant_updates=payload.allow_participant_updates,
result_visibility=payload.result_visibility, result_visibility=payload.result_visibility,
participant_visibility=payload.participant_visibility,
metadata_=payload.metadata, metadata_=payload.metadata,
) )
_set_calendar_preferences(request, payload) _set_calendar_preferences(request, payload)
@@ -1319,7 +1325,16 @@ def update_scheduling_request(
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
if request.status in {"decided", "handed_off", "cancelled", "archived"}: if request.status in {"decided", "handed_off", "cancelled", "archived"}:
raise SchedulingError("Decided, handed off, cancelled, or archived scheduling requests cannot be edited") raise SchedulingError("Decided, handed off, cancelled, or archived scheduling requests cannot be edited")
for field in ("title", "description", "location", "deadline_at", "allow_external_participants", "allow_participant_updates", "result_visibility"): for field in (
"title",
"description",
"location",
"deadline_at",
"allow_external_participants",
"allow_participant_updates",
"result_visibility",
"participant_visibility",
):
value = getattr(payload, field) value = getattr(payload, field)
if value is not None: if value is not None:
setattr(request, field, value) setattr(request, field, value)
@@ -1663,16 +1678,119 @@ def scheduling_participant_response(
} }
_PARTICIPANT_STATUSES = ("draft", "invited", "responded", "declined", "removed")
def _participant_aggregate(participants: list[SchedulingParticipant]) -> dict[str, Any]:
status_counts = {status: 0 for status in _PARTICIPANT_STATUSES}
for participant in participants:
status_counts[participant.status] = status_counts.get(participant.status, 0) + 1
return {"total": len(participants), "status_counts": status_counts}
def _participant_visibility_decision(
request: SchedulingRequest,
*,
participant: SchedulingParticipant | None,
actor_user_id: str | None,
) -> dict[str, Any]:
requested = request.participant_visibility
payload: dict[str, Any] = {
"requested_visibility": requested,
"effective_visibility": requested,
"policy_applied": False,
"reason": None,
"source_path": [],
"details": {},
}
if participant is None:
payload["effective_visibility"] = "aggregates_only"
payload["reason"] = "A unique participant context is required for roster visibility."
return payload
provider = scheduling_participant_privacy_policy(get_registry())
if provider is None:
return payload
payload["policy_applied"] = True
try:
decision = provider.resolve_scheduling_participant_visibility(
object_session(request),
request=SchedulingParticipantPrivacyRequest(
tenant_id=request.tenant_id,
scheduling_request_id=request.id,
participant_id=participant.id,
actor_user_id=actor_user_id,
requested_visibility=requested,
context={
"request_status": request.status,
"organizer_user_id": request.organizer_user_id,
},
),
)
except Exception:
payload["effective_visibility"] = "aggregates_only"
payload["reason"] = "The participant privacy policy could not be evaluated."
payload["details"] = {"policy_error": True}
return payload
if not isinstance(decision, SchedulingParticipantPrivacyDecision):
payload["effective_visibility"] = "aggregates_only"
payload["reason"] = "The participant privacy policy returned an invalid decision."
payload["details"] = {"invalid_policy_decision": True}
return payload
decision_payload = decision.to_dict()
resolved = decision.effective_visibility
if requested == "aggregates_only" or resolved not in {"aggregates_only", "names_and_statuses"}:
resolved = "aggregates_only"
payload.update(
effective_visibility=resolved,
reason=decision.reason,
source_path=decision_payload["source_path"],
details=decision_payload["details"],
)
return payload
def scheduling_request_response( def scheduling_request_response(
request: SchedulingRequest, request: SchedulingRequest,
*, *,
invitation_tokens: dict[str, str] | None = None, invitation_tokens: dict[str, str] | None = None,
actor_ids: tuple[str, ...] | None = None, actor_ids: tuple[str, ...] | None = None,
actor_user_id: str | None = None,
can_manage: bool = False, can_manage: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
invitation_tokens = invitation_tokens or {} invitation_tokens = invitation_tokens or {}
ids = _actor_ids(actor_ids or ()) ids = _actor_ids(actor_ids or ())
full_roster = actor_ids is None or can_manage or request.organizer_user_id in ids full_roster = actor_ids is None or can_manage or request.organizer_user_id in ids
active_participants = _active_participants(request)
own_participants = [
participant
for participant in active_participants
if participant.respondent_id in ids or participant.email in ids
]
if full_roster:
visibility_decision = {
"requested_visibility": request.participant_visibility,
"effective_visibility": "names_and_statuses",
"policy_applied": False,
"reason": "Full participant roster access is granted to organizers and scheduling managers.",
"source_path": [],
"details": {"management_access": True},
}
projected_participants = active_participants
else:
visibility_decision = _participant_visibility_decision(
request,
participant=own_participants[0] if len(own_participants) == 1 else None,
actor_user_id=actor_user_id,
)
projected_participants = (
active_participants
if visibility_decision["effective_visibility"] == "names_and_statuses"
else own_participants
)
if not full_roster: if not full_roster:
invitation_tokens = {} invitation_tokens = {}
return { return {
@@ -1690,6 +1808,10 @@ def scheduling_request_response(
"allow_external_participants": request.allow_external_participants, "allow_external_participants": request.allow_external_participants,
"allow_participant_updates": request.allow_participant_updates, "allow_participant_updates": request.allow_participant_updates,
"result_visibility": request.result_visibility, "result_visibility": request.result_visibility,
"participant_visibility": request.participant_visibility,
"effective_participant_visibility": visibility_decision["effective_visibility"],
"participant_aggregate": _participant_aggregate(active_participants),
"participant_visibility_decision": visibility_decision,
"calendar_integration_enabled": request.calendar_integration_enabled, "calendar_integration_enabled": request.calendar_integration_enabled,
"calendar_id": request.calendar_id, "calendar_id": request.calendar_id,
"calendar_freebusy_enabled": request.calendar_freebusy_enabled, "calendar_freebusy_enabled": request.calendar_freebusy_enabled,
@@ -1710,7 +1832,7 @@ def scheduling_request_response(
and participant.respondent_id not in ids and participant.respondent_id not in ids
and participant.email not in ids, and participant.email not in ids,
) )
for participant in _active_participants(request) for participant in projected_participants
], ],
} }

View File

@@ -16,10 +16,12 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertNotIn("access", manifest.dependencies) self.assertNotIn("access", manifest.dependencies)
self.assertIn("access", manifest.optional_dependencies) self.assertIn("access", manifest.optional_dependencies)
self.assertIn("addresses", manifest.optional_dependencies) self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("policy", manifest.optional_dependencies)
self.assertEqual(("poll.scheduling",), manifest.required_capabilities) self.assertEqual(("poll.scheduling",), manifest.required_capabilities)
self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("poll.scheduling", manifest.required_capabilities) self.assertIn("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities) self.assertIn("calendar.scheduling", manifest.optional_capabilities)
self.assertIn("policy.schedulingParticipantPrivacy", manifest.optional_capabilities)
self.assertIn("evaluation", manifest.optional_dependencies) self.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)

View File

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

View File

@@ -1047,6 +1047,7 @@ class SchedulingServiceTests(unittest.TestCase):
"calendar": SchedulingCalendarPreferences(), "calendar": SchedulingCalendarPreferences(),
"participants": participants, "participants": participants,
"result_visibility": "public", "result_visibility": "public",
"participant_visibility": "names_and_statuses",
} }
) )
request, _tokens = create_scheduling_request( request, _tokens = create_scheduling_request(