feat(scheduling): enforce participant roster privacy
This commit is contained in:
@@ -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,6 +19,7 @@ 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"
|
||||
@@ -129,11 +130,12 @@ 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=(
|
||||
|
||||
@@ -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")
|
||||
@@ -138,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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ 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:
|
||||
@@ -103,6 +104,7 @@ 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)
|
||||
@@ -122,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
|
||||
|
||||
@@ -159,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
|
||||
@@ -174,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
|
||||
|
||||
@@ -5,7 +5,7 @@ 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,
|
||||
@@ -29,6 +29,11 @@ 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 (
|
||||
@@ -903,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)
|
||||
@@ -1319,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)
|
||||
@@ -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(
|
||||
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 {
|
||||
@@ -1690,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,
|
||||
@@ -1710,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,10 +16,12 @@ 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)
|
||||
|
||||
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()
|
||||
@@ -1047,6 +1047,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
"calendar": SchedulingCalendarPreferences(),
|
||||
"participants": participants,
|
||||
"result_visibility": "public",
|
||||
"participant_visibility": "names_and_statuses",
|
||||
}
|
||||
)
|
||||
request, _tokens = create_scheduling_request(
|
||||
|
||||
Reference in New Issue
Block a user