feat(scheduling): enforce governed response policies

This commit is contained in:
2026-07-21 21:17:26 +02:00
parent 886579942f
commit b1725b8f59
12 changed files with 3546 additions and 181 deletions

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
@@ -25,6 +25,24 @@ def _known_timezone(value: str | None) -> str | None:
return value
def _participant_email(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().casefold()
if not normalized:
return None
local, separator, domain = normalized.partition("@")
if (
separator != "@"
or not local
or not domain
or "@" in domain
or any(character.isspace() for character in normalized)
):
raise ValueError("participant_email must be a valid email address")
return normalized
class SchedulingCalendarPreferences(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -91,6 +109,30 @@ class SchedulingParticipantInput(BaseModel):
required: bool = True
metadata: dict[str, Any] = Field(default_factory=dict)
_validate_email = field_validator("email")(_participant_email)
class SchedulingCandidateSlotReconcileInput(SchedulingCandidateSlotInput):
id: str | None = Field(default=None, max_length=36)
revision: str | None = Field(
default=None,
min_length=64,
max_length=64,
pattern=r"^[0-9a-f]{64}$",
)
@model_validator(mode="after")
def validate_existing_revision(self) -> "SchedulingCandidateSlotReconcileInput":
if self.id is not None and self.revision is None:
raise ValueError("revision is required for an existing scheduling slot")
if self.id is None and self.revision is not None:
raise ValueError("revision can only be supplied for an existing scheduling slot")
return self
class SchedulingParticipantReconcileInput(SchedulingParticipantInput):
id: str | None = Field(default=None, max_length=36)
class SchedulingRequestCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -105,6 +147,14 @@ class SchedulingRequestCreateRequest(BaseModel):
allow_participant_updates: bool = True
result_visibility: SchedulingResultVisibility = "after_close"
participant_visibility: SchedulingParticipantVisibility = "aggregates_only"
notify_on_answers: bool = True
single_choice: bool = False
max_participants_per_option: int | None = Field(default=None, ge=1)
allow_maybe: bool = True
allow_comments: bool = False
participant_email_required: bool = False
anonymous_password_protection_enabled: bool = False
anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024)
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
@@ -113,6 +163,14 @@ class SchedulingRequestCreateRequest(BaseModel):
_validate_timezone = field_validator("timezone")(_known_timezone)
@model_validator(mode="after")
def validate_anonymous_password(self) -> "SchedulingRequestCreateRequest":
if self.anonymous_password_protection_enabled and self.anonymous_password is None:
raise ValueError("anonymous_password is required when password protection is enabled")
if not self.anonymous_password_protection_enabled and self.anonymous_password is not None:
raise ValueError("anonymous_password requires password protection to be enabled")
return self
class SchedulingRequestUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -125,9 +183,40 @@ class SchedulingRequestUpdateRequest(BaseModel):
allow_participant_updates: bool | None = None
result_visibility: SchedulingResultVisibility | None = None
participant_visibility: SchedulingParticipantVisibility | None = None
notify_on_answers: bool | None = None
single_choice: bool | None = None
max_participants_per_option: int | None = Field(default=None, ge=1)
allow_maybe: bool | None = None
allow_comments: bool | None = None
participant_email_required: bool | None = None
anonymous_password_protection_enabled: bool | None = None
anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024)
calendar: SchedulingCalendarPreferences | None = None
slots: list[SchedulingCandidateSlotReconcileInput] | None = Field(
default=None,
min_length=1,
)
participants: list[SchedulingParticipantReconcileInput] | None = None
create_participant_invitations: bool = True
metadata: dict[str, Any] | None = None
@model_validator(mode="after")
def validate_anonymous_password(self) -> "SchedulingRequestUpdateRequest":
if self.anonymous_password_protection_enabled is False and self.anonymous_password is not None:
raise ValueError("anonymous_password cannot be set while password protection is disabled")
return self
@model_validator(mode="after")
def validate_reconciliation_ids(self) -> "SchedulingRequestUpdateRequest":
for field_name in ("slots", "participants"):
values = getattr(self, field_name)
if values is None:
continue
ids = [value.id for value in values if value.id is not None]
if len(ids) != len(set(ids)):
raise ValueError(f"Duplicate ids are not allowed in {field_name}")
return self
class SchedulingCandidateSlotResponse(BaseModel):
id: str
@@ -149,11 +238,12 @@ class SchedulingCandidateSlotResponse(BaseModel):
class SchedulingParticipantResponse(BaseModel):
id: str
is_current_participant: bool = False
respondent_id: str | None = None
display_name: str | None = None
email: str | None = None
participant_type: str
required: bool
participant_type: str | None = None
required: bool | None = None
status: str
poll_invitation_id: str | None = None
invitation_token: str | None = None
@@ -178,7 +268,7 @@ class SchedulingParticipantVisibilityDecisionResponse(BaseModel):
class SchedulingRequestResponse(BaseModel):
id: str
tenant_id: str
tenant_id: str | None = None
title: str
description: str | None = None
location: str | None = None
@@ -192,14 +282,23 @@ class SchedulingRequestResponse(BaseModel):
allow_participant_updates: bool
result_visibility: str
participant_visibility: SchedulingParticipantVisibility
notify_on_answers: bool
single_choice: bool
max_participants_per_option: int | None = None
allow_maybe: bool
allow_comments: bool
participant_email_required: bool
anonymous_password_protection_enabled: bool
public_participation_policy_enforcement_available: bool | None = None
public_participation_policy_enforcement_reason: str | None = None
effective_participant_visibility: SchedulingParticipantVisibility
participant_aggregate: SchedulingParticipantAggregateResponse
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
calendar_integration_enabled: bool
calendar_integration_enabled: bool | None = None
calendar_id: str | None = None
calendar_freebusy_enabled: bool
calendar_hold_enabled: bool
create_calendar_event_on_decision: bool
calendar_freebusy_enabled: bool | None = None
calendar_hold_enabled: bool | None = None
create_calendar_event_on_decision: bool | None = None
calendar_event_id: str | None = None
handed_off_at: datetime | None = None
cancelled_at: datetime | None = None
@@ -238,6 +337,7 @@ class SchedulingAvailabilityResponseRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
comment: str | None = Field(default=None, max_length=4000)
@model_validator(mode="after")
def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest":
@@ -258,6 +358,70 @@ class SchedulingAvailabilityResponse(BaseModel):
has_response: bool = False
submitted_at: datetime | None = None
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
comment: str | None = None
class SchedulingPublicParticipationAccessRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
participant_email: str | None = Field(default=None, max_length=320)
password: SecretStr | None = Field(default=None, max_length=1024)
_validate_participant_email = field_validator("participant_email")(_participant_email)
class SchedulingPublicParticipationSubmitRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
participant_email: str | None = Field(default=None, max_length=320)
password: SecretStr | None = Field(default=None, max_length=1024)
comment: str | None = Field(default=None, max_length=4000)
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
_validate_participant_email = field_validator("participant_email")(_participant_email)
@model_validator(mode="after")
def validate_unique_slots(self) -> "SchedulingPublicParticipationSubmitRequest":
slot_ids = [answer.slot_id for answer in self.answers]
if len(slot_ids) != len(set(slot_ids)):
raise ValueError("Each scheduling slot can be answered only once")
return self
class SchedulingPublicCandidateSlotResponse(BaseModel):
id: str
label: str
description: str | None = None
start_at: datetime
end_at: datetime
timezone: str
location: str | None = None
position: int
revision: str
class SchedulingPublicParticipationResponse(BaseModel):
request_id: str
title: str
description: str | None = None
location: str | None = None
timezone: str
status: str
deadline_at: datetime | None = None
participant_email_required: bool
anonymous_password_required: bool
single_choice: bool
max_participants_per_option: int | None = None
allow_maybe: bool
allow_comments: bool
allow_participant_updates: bool
has_response: bool = False
submitted_at: datetime | None = None
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
comment: str | None = None
replayed: bool = False
slots: list[SchedulingPublicCandidateSlotResponse] = Field(default_factory=list)
class SchedulingPollOptionResultResponse(BaseModel):