feat(scheduling): enforce governed response policies
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user