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

@@ -37,6 +37,14 @@ class SchedulingRequest(Base, TimestampMixin):
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)
notify_on_answers: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
single_choice: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
max_participants_per_option: Mapped[int | None] = mapped_column(Integer, nullable=True)
allow_maybe: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_comments: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
participant_email_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
anonymous_password_protection_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
anonymous_password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True)
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)
@@ -106,8 +114,10 @@ class SchedulingParticipant(Base, TimestampMixin):
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
status: Mapped[str] = mapped_column(String(40), default="invited", nullable=False, index=True)
poll_invitation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
participation_gateway: Mapped[str | None] = mapped_column(String(40), nullable=True)
last_invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
response_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)

View File

@@ -20,11 +20,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_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
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.9"
MODULE_VERSION = "0.1.10"
READ_SCOPE = "scheduling:schedule:read"
WRITE_SCOPE = "scheduling:schedule:write"
ADMIN_SCOPE = "scheduling:schedule:admin"
@@ -47,8 +48,8 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = (
_permission(READ_SCOPE, "View scheduling", "Read scheduling polls, proposals, participant state, and selected outcomes."),
_permission(WRITE_SCOPE, "Manage scheduling", "Create and update scheduling polls, candidate slots, reminders, and decision handoff."),
_permission(ADMIN_SCOPE, "Administer scheduling", "Configure tenant-level scheduling policies, external participation, and retention defaults."),
_permission(WRITE_SCOPE, "Manage own scheduling", "Create scheduling polls and manage requests for which the account is the organizer."),
_permission(ADMIN_SCOPE, "Administer scheduling", "Manage every tenant scheduling request and configure scheduling policies, external participation, and retention defaults."),
_permission(RESPOND_SCOPE, "Respond to scheduling polls", "Submit and update own scheduling availability responses."),
)
@@ -56,7 +57,7 @@ ROLE_TEMPLATES = (
RoleTemplate(
slug="scheduling_manager",
name="Scheduling manager",
description="Create scheduling polls, manage candidate slots, and decide outcomes.",
description="Create scheduling polls and manage candidate slots and outcomes for requests the account organizes.",
permissions=(READ_SCOPE, WRITE_SCOPE, RESPOND_SCOPE),
),
RoleTemplate(
@@ -137,16 +138,19 @@ manifest = ModuleManifest(
CAPABILITY_CALENDAR_SCHEDULING,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
),
required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
required_capabilities=(
CAPABILITY_POLL_SCHEDULING,
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
),
provides_interfaces=(
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.9"),
ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.9"),
ModuleInterfaceProvider(name="scheduling.candidate_slots", version=MODULE_VERSION),
ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION),
),
requires_interfaces=(
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="poll.availability_matrix", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.10", version_max_exclusive="0.2.0"),
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),
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),

View File

@@ -0,0 +1,168 @@
"""v0.1.10 scheduling response settings
Revision ID: ad7e3c9b2f10
Revises: 9c2f4a7d1e6b
Create Date: 2026-07-21 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad7e3c9b2f10"
down_revision = "9c2f4a7d1e6b"
branch_labels = None
depends_on = None
_REQUEST_COLUMNS = (
"notify_on_answers",
"single_choice",
"max_participants_per_option",
"allow_maybe",
"allow_comments",
"participant_email_required",
"anonymous_password_protection_enabled",
"anonymous_password_hash",
)
def _adopted_columns(inspector: sa.Inspector, table_name: str, names: tuple[str, ...]) -> bool:
columns = {item["name"]: item for item in inspector.get_columns(table_name)}
present = [name for name in names if name in columns]
if not present:
return False
if len(present) != len(names):
missing = sorted(set(names) - set(present))
raise RuntimeError(
f"Cannot adopt partial {table_name} scheduling response settings; missing columns: "
+ ", ".join(missing)
)
return True
def _require_compatible_adopted_schema(inspector: sa.Inspector) -> None:
request_columns = {
item["name"]: item
for item in inspector.get_columns("scheduling_requests")
}
for name in (
"notify_on_answers",
"single_choice",
"allow_maybe",
"allow_comments",
"participant_email_required",
"anonymous_password_protection_enabled",
):
column = request_columns[name]
if column.get("nullable") or not isinstance(column["type"], sa.Boolean):
raise RuntimeError(
f"Cannot adopt scheduling_requests.{name} because its schema is unexpected"
)
capacity = request_columns["max_participants_per_option"]
if not capacity.get("nullable") or not isinstance(capacity["type"], sa.Integer):
raise RuntimeError(
"Cannot adopt scheduling_requests.max_participants_per_option because its schema is unexpected"
)
password_hash = request_columns["anonymous_password_hash"]
if (
not password_hash.get("nullable")
or not isinstance(password_hash["type"], sa.String)
or password_hash["type"].length != 500
):
raise RuntimeError(
"Cannot adopt scheduling_requests.anonymous_password_hash because its schema is unexpected"
)
participant_columns = {
item["name"]: item
for item in inspector.get_columns("scheduling_participants")
}
comment = participant_columns["response_comment"]
if not comment.get("nullable") or not isinstance(comment["type"], sa.Text):
raise RuntimeError(
"Cannot adopt scheduling_participants.response_comment because its schema is unexpected"
)
gateway = participant_columns["participation_gateway"]
if (
not gateway.get("nullable")
or not isinstance(gateway["type"], sa.String)
or gateway["type"].length != 40
):
raise RuntimeError(
"Cannot adopt scheduling_participants.participation_gateway because its schema is unexpected"
)
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
request_columns_present = _adopted_columns(
inspector,
"scheduling_requests",
_REQUEST_COLUMNS,
)
participant_columns_present = _adopted_columns(
inspector,
"scheduling_participants",
("response_comment", "participation_gateway"),
)
if request_columns_present != participant_columns_present:
raise RuntimeError(
"Cannot adopt partial scheduling response settings across request and participant tables"
)
if request_columns_present:
_require_compatible_adopted_schema(inspector)
return
op.add_column(
"scheduling_requests",
sa.Column("notify_on_answers", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"scheduling_requests",
sa.Column("single_choice", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"scheduling_requests",
sa.Column("max_participants_per_option", sa.Integer(), nullable=True),
)
op.add_column(
"scheduling_requests",
sa.Column("allow_maybe", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"scheduling_requests",
sa.Column("allow_comments", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"scheduling_requests",
sa.Column("participant_email_required", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
"scheduling_requests",
sa.Column(
"anonymous_password_protection_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.add_column(
"scheduling_requests",
sa.Column("anonymous_password_hash", sa.String(length=500), nullable=True),
)
op.add_column(
"scheduling_participants",
sa.Column("response_comment", sa.Text(), nullable=True),
)
op.add_column(
"scheduling_participants",
sa.Column("participation_gateway", sa.String(length=40), nullable=True),
)
def downgrade() -> None:
op.drop_column("scheduling_participants", "participation_gateway")
op.drop_column("scheduling_participants", "response_comment")
for name in reversed(_REQUEST_COLUMNS):
op.drop_column("scheduling_requests", name)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import dataclasses
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
@@ -26,6 +26,9 @@ from govoplan_scheduling.backend.schemas import (
SchedulingRequestResponse,
SchedulingRequestUpdateRequest,
SchedulingPollSummaryResponse,
SchedulingPublicParticipationAccessRequest,
SchedulingPublicParticipationResponse,
SchedulingPublicParticipationSubmitRequest,
SchedulingStatusResponse,
SchedulingSummaryResponse,
)
@@ -34,6 +37,7 @@ from govoplan_scheduling.backend.service import (
SchedulingConflictError,
SchedulingError,
SchedulingPermissionError,
SchedulingPublicParticipationError,
cancel_scheduling_request,
close_scheduling_request,
create_final_calendar_event,
@@ -42,7 +46,9 @@ from govoplan_scheduling.backend.service import (
create_tentative_calendar_holds,
decide_scheduling_request,
evaluate_calendar_freebusy,
get_scheduling_request,
get_scheduling_availability_response,
get_public_scheduling_participation,
get_visible_scheduling_request,
list_visible_scheduling_notifications,
list_visible_scheduling_requests,
@@ -53,8 +59,9 @@ from govoplan_scheduling.backend.service import (
scheduling_request_response,
scheduling_request_summary,
submit_scheduling_availability,
submit_public_scheduling_participation,
update_scheduling_candidate_slot,
update_scheduling_request,
update_scheduling_request_with_invitation_tokens,
)
@@ -112,7 +119,40 @@ def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE)
return has_scope(principal, ADMIN_SCOPE)
def _require_scheduling_writer(principal: ApiPrincipal) -> None:
if has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing scope: {WRITE_SCOPE} or {ADMIN_SCOPE}",
)
def _require_request_editor(
session: Session,
*,
principal: ApiPrincipal,
request_id: str,
) -> None:
_require_scheduling_writer(principal)
if has_scope(principal, ADMIN_SCOPE):
return
try:
request = get_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
if request.organizer_user_id not in _principal_actor_ids(principal):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the organizer or a scheduling administrator can edit this request",
)
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
@@ -127,6 +167,25 @@ def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _public_participation_http_error(
exc: SchedulingPublicParticipationError,
) -> HTTPException:
if exc.retry_after_seconds:
return HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=str(exc),
headers={"Retry-After": str(exc.retry_after_seconds)},
)
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
def _client_address(request: Request) -> str | None:
return request.client.host if request.client is not None else None
def _request_response(
request,
*,
@@ -144,6 +203,58 @@ def _request_response(
)
@router.post(
"/public/{request_id}/{token}",
response_model=SchedulingPublicParticipationResponse,
)
def api_get_public_scheduling_participation(
request_id: str,
token: str,
payload: SchedulingPublicParticipationAccessRequest,
request: Request,
session: Session = Depends(get_session),
) -> SchedulingPublicParticipationResponse:
try:
response = get_public_scheduling_participation(
session,
request_id=request_id,
token=token,
payload=payload,
client_address=_client_address(request),
)
except SchedulingPublicParticipationError as exc:
raise _public_participation_http_error(exc) from exc
return SchedulingPublicParticipationResponse.model_validate(response)
@router.post(
"/public/{request_id}/{token}/responses",
response_model=SchedulingPublicParticipationResponse,
)
def api_submit_public_scheduling_participation(
request_id: str,
token: str,
payload: SchedulingPublicParticipationSubmitRequest,
request: Request,
session: Session = Depends(get_session),
) -> SchedulingPublicParticipationResponse:
try:
response = submit_public_scheduling_participation(
session,
request_id=request_id,
token=token,
payload=payload,
client_address=_client_address(request),
)
except SchedulingPublicParticipationError as exc:
raise _public_participation_http_error(exc) from exc
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
validated = SchedulingPublicParticipationResponse.model_validate(response)
session.commit()
return validated
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
def api_lookup_scheduling_addresses(
query: str = Query(min_length=1),
@@ -151,7 +262,7 @@ def api_lookup_scheduling_addresses(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingAddressLookupResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scheduling_writer(principal)
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
if capability is None or not hasattr(capability, "lookup"):
return SchedulingAddressLookupResponse(available=False, candidates=[])
@@ -198,7 +309,7 @@ def api_create_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
_require_scheduling_writer(principal)
try:
request, invitation_tokens = create_scheduling_request(
session,
@@ -294,9 +405,13 @@ def api_update_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(
session,
principal=principal,
request_id=request_id,
)
try:
request = update_scheduling_request(
request, invitation_tokens = update_scheduling_request_with_invitation_tokens(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
@@ -304,7 +419,11 @@ def api_update_scheduling_request(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal)
response = _request_response(
request,
principal=principal,
invitation_tokens=invitation_tokens,
)
session.commit()
return response
@@ -317,7 +436,11 @@ def api_update_scheduling_candidate_slot(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(
session,
principal=principal,
request_id=request_id,
)
try:
request = update_scheduling_candidate_slot(
session,
@@ -339,7 +462,7 @@ def api_open_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
@@ -355,7 +478,7 @@ def api_close_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
@@ -372,7 +495,7 @@ def api_decide_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = decide_scheduling_request(
session,
@@ -398,7 +521,7 @@ def api_evaluate_calendar_freebusy(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try:
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
@@ -419,7 +542,7 @@ def api_create_tentative_calendar_holds(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
request, created_event_ids, warnings = create_tentative_calendar_holds(
@@ -446,7 +569,7 @@ def api_create_final_calendar_event(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingCalendarActionResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
request, event_id, warnings = create_final_calendar_event(
@@ -472,7 +595,7 @@ def api_cancel_scheduling_request(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingStatusResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
except SchedulingError as exc:
@@ -547,7 +670,7 @@ def api_create_scheduling_notifications(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingNotificationListResponse:
_require_scope(principal, WRITE_SCOPE)
_require_request_editor(session, principal=principal, request_id=request_id)
try:
notifications = create_scheduling_notification_jobs(
session,

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):

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
_ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16
def hash_participant_password(
password: str,
*,
iterations: int = _DEFAULT_ITERATIONS,
) -> str:
"""Hash a public-participant access password for durable storage."""
salt = os.urandom(_SALT_BYTES)
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
)
return "$".join(
(
_ALGORITHM,
str(iterations),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(digest).decode("ascii"),
)
)
def verify_participant_password(password: str, encoded: str | None) -> bool:
"""Verify a participant password without exposing the stored hash."""
if not encoded:
return False
try:
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
if algorithm != _ALGORITHM:
return False
iterations = int(iterations_text)
salt = base64.b64decode(salt_b64.encode("ascii"), validate=True)
expected = base64.b64decode(digest_b64.encode("ascii"), validate=True)
except (TypeError, ValueError):
return False
actual = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
)
return hmac.compare_digest(actual, expected)
__all__ = ["hash_participant_password", "verify_participant_password"]

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,10 @@ class SchedulingManifestTests(unittest.TestCase):
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.assertEqual(
("poll.scheduling", "poll.participation_gateway"),
manifest.required_capabilities,
)
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIn("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
@@ -29,14 +32,18 @@ class SchedulingManifestTests(unittest.TestCase):
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("poll.governed_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)
for interface_name in (
"poll.availability_matrix",
"poll.response_collection",
"poll.workflow_context",
"poll.governed_participation",
):
self.assertEqual("0.1.10", required_interfaces[interface_name].version_min)
if __name__ == "__main__":

View File

@@ -20,7 +20,7 @@ from govoplan_scheduling.backend.db.models import (
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
_SCHEDULING_HEAD = "9c2f4a7d1e6b"
_SCHEDULING_HEAD = "ad7e3c9b2f10"
_ENABLED_MODULES = ("poll", "scheduling")
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
@@ -110,12 +110,27 @@ class SchedulingMigrationTests(unittest.TestCase):
"scheduling_requests"
)
}
participant_columns = {
item["name"]
for item in inspect(connection).get_columns(
"scheduling_participants"
)
}
visibility = connection.execute(
text(
"SELECT participant_visibility FROM scheduling_requests "
"WHERE id = 'request-1'"
)
).scalar_one()
response_defaults = connection.execute(
text(
"SELECT notify_on_answers, single_choice, "
"max_participants_per_option, allow_maybe, allow_comments, "
"participant_email_required, anonymous_password_protection_enabled, "
"anonymous_password_hash FROM scheduling_requests "
"WHERE id = 'request-1'"
)
).one()
counts = {
model.__tablename__: connection.execute(
select(func.count()).select_from(model)
@@ -130,7 +145,11 @@ class SchedulingMigrationTests(unittest.TestCase):
self.assertIn(_SCHEDULING_HEAD, heads)
self.assertIn("participant_visibility", columns)
self.assertIn("max_participants_per_option", columns)
self.assertIn("response_comment", participant_columns)
self.assertIn("participation_gateway", participant_columns)
self.assertEqual(visibility, "aggregates_only")
self.assertEqual(tuple(response_defaults), (1, 0, None, 1, 0, 0, 0, None))
self.assertEqual(set(counts.values()), {1})
finally:
engine.dispose()
@@ -219,6 +238,74 @@ class SchedulingMigrationTests(unittest.TestCase):
finally:
engine.dispose()
def test_rejects_partial_existing_response_settings(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,
)
connection.execute(
text(
"ALTER TABLE scheduling_requests "
"DROP COLUMN allow_comments"
)
)
with self.assertRaisesRegex(
RuntimeError,
"partial scheduling_requests scheduling response settings",
):
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_participant_response_settings(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,
)
connection.execute(
text(
"ALTER TABLE scheduling_participants "
"DROP COLUMN participation_gateway"
)
)
with self.assertRaisesRegex(
RuntimeError,
"partial scheduling_participants scheduling response settings",
):
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()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -89,11 +90,46 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
"tenant_id": "tenant-1",
"title": "Privacy review",
"status": "collecting",
"poll_id": "poll-internal",
"organizer_user_id": "organizer-1",
"calendar_integration_enabled": True,
"calendar_id": "calendar-internal",
"calendar_freebusy_enabled": True,
"calendar_hold_enabled": True,
"create_calendar_event_on_decision": True,
"calendar_event_id": "event-internal",
"metadata_": {"connector_secret_ref": "secret-internal"},
}
if participant_visibility is not None:
values["participant_visibility"] = participant_visibility
request = SchedulingRequest(**values)
request.slots = [
SchedulingCandidateSlot(
tenant_id="tenant-1",
poll_option_id="poll-option-internal",
label="Monday morning",
start_at=datetime(2026, 7, 27, 9, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 27, 10, tzinfo=timezone.utc),
timezone="Europe/Berlin",
position=0,
freebusy_checked_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc),
freebusy_status="busy",
freebusy_conflicts=[
{
"calendar_id": "calendar-internal",
"event_id": "event-internal",
"uid": "connector-uid-internal",
"recurrence_id": "recurrence-internal",
"start_at": "2026-07-27T09:30:00+00:00",
"end_at": "2026-07-27T09:45:00+00:00",
"status": "CONFIRMED",
},
{"error": "connector-internal failure"},
],
tentative_hold_event_id="hold-internal",
metadata_={"provider_ref": "provider-internal"},
)
]
request.participants = [
SchedulingParticipant(
tenant_id="tenant-1",
@@ -102,6 +138,9 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
email="alice@example.test",
participant_type="internal",
status="responded",
poll_invitation_id="invitation-alice-internal",
last_invited_at=datetime(2026, 7, 20, 12, tzinfo=timezone.utc),
responded_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc),
metadata_={"private": "alice"},
),
SchedulingParticipant(
@@ -152,10 +191,43 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
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")
own = response.participants[0]
self.assertTrue(own.is_current_participant)
self.assertEqual(own.email, "alice@example.test")
self.assertIsNone(own.respondent_id)
self.assertIsNone(own.participant_type)
self.assertIsNone(own.required)
self.assertIsNone(own.poll_invitation_id)
self.assertEqual(own.metadata, {})
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)
self.assertIsNone(response.tenant_id)
self.assertIsNone(response.poll_id)
self.assertIsNone(response.organizer_user_id)
self.assertIsNone(response.calendar_integration_enabled)
self.assertIsNone(response.calendar_id)
self.assertIsNone(response.calendar_freebusy_enabled)
self.assertIsNone(response.calendar_hold_enabled)
self.assertIsNone(response.create_calendar_event_on_decision)
self.assertIsNone(response.calendar_event_id)
self.assertIsNone(response.public_participation_policy_enforcement_available)
self.assertEqual(response.metadata, {})
slot = response.slots[0]
self.assertIsNone(slot.poll_option_id)
self.assertEqual(slot.freebusy_status, "busy")
self.assertEqual(
slot.freebusy_conflicts,
[
{
"start_at": "2026-07-27T09:30:00+00:00",
"end_at": "2026-07-27T09:45:00+00:00",
"status": "CONFIRMED",
}
],
)
self.assertIsNone(slot.tentative_hold_event_id)
self.assertEqual(slot.metadata, {})
def test_configured_roster_returns_other_names_and_statuses_with_sensitive_fields_redacted(self) -> None:
request = self._request(participant_visibility="names_and_statuses")
@@ -165,11 +237,15 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
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.assertTrue(own.is_current_participant)
self.assertIsNone(own.respondent_id)
self.assertEqual(own.email, "alice@example.test")
self.assertEqual(other.status, "invited")
self.assertFalse(other.is_current_participant)
self.assertIsNone(other.respondent_id)
self.assertIsNone(other.email)
self.assertIsNone(other.participant_type)
self.assertIsNone(other.required)
self.assertIsNone(other.poll_invitation_id)
self.assertEqual(other.metadata, {})
@@ -199,6 +275,18 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
"bob@example.test",
})
self.assertTrue(response.participant_visibility_decision.details["management_access"])
self.assertEqual(response.tenant_id, "tenant-1")
self.assertEqual(response.poll_id, "poll-internal")
self.assertEqual(response.organizer_user_id, "organizer-1")
self.assertEqual(response.calendar_id, "calendar-internal")
self.assertEqual(response.calendar_event_id, "event-internal")
self.assertEqual(response.metadata["connector_secret_ref"], "secret-internal")
self.assertEqual(response.slots[0].poll_option_id, "poll-option-internal")
self.assertEqual(
response.slots[0].freebusy_conflicts[0]["uid"],
"connector-uid-internal",
)
self.assertEqual(response.slots[0].tentative_hold_event_id, "hold-internal")
def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
restricting_policy = _PrivacyPolicy("aggregates_only")
@@ -211,8 +299,8 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
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(reduced.participant_visibility_decision.source_path, [])
self.assertEqual(reduced.participant_visibility_decision.details, {})
self.assertEqual(restricting_policy.requests[0].actor_user_id, "alice-account")
widening_policy = _PrivacyPolicy("names_and_statuses")

File diff suppressed because it is too large Load Diff

View File

@@ -19,13 +19,21 @@ 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, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollOption,
PollParticipationSubmission,
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
from govoplan_poll.backend.service import get_poll, submit_poll_response_with_token
from govoplan_poll.backend.service import get_poll
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.manifest import READ_SCOPE as SCHEDULING_READ_SCOPE
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE as SCHEDULING_ADMIN_SCOPE
from govoplan_scheduling.backend.manifest import RESPOND_SCOPE as SCHEDULING_RESPOND_SCOPE
from govoplan_scheduling.backend.manifest import WRITE_SCOPE as SCHEDULING_WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
@@ -35,6 +43,7 @@ from govoplan_scheduling.backend.schemas import (
SchedulingCandidateSlotInput,
SchedulingDecisionRequest,
SchedulingParticipantInput,
SchedulingPublicParticipationSubmitRequest,
SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest,
)
@@ -68,6 +77,7 @@ from govoplan_scheduling.backend.service import (
require_visible_scheduling_results,
scheduling_request_summary,
scheduling_slot_revision,
submit_public_scheduling_participation,
update_scheduling_request,
)
from govoplan_scheduling.backend.runtime import configure_runtime
@@ -88,6 +98,7 @@ class SchedulingServiceTests(unittest.TestCase):
PollOption.__table__,
PollResponse.__table__,
PollInvitation.__table__,
PollParticipationSubmission.__table__,
PollLifecycleTransition.__table__,
CalendarCollection.__table__,
CalendarEvent.__table__,
@@ -121,6 +132,7 @@ class SchedulingServiceTests(unittest.TestCase):
CalendarSyncSource.__table__,
CalendarEvent.__table__,
CalendarCollection.__table__,
PollParticipationSubmission.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
PollResponse.__table__,
@@ -215,6 +227,82 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual(len(tokens), 2)
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants))
def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None:
class RejectingNotificationProvider:
def enqueue_notification(self, *_args, **_kwargs):
raise AssertionError("A draft must not enqueue invitation delivery")
with patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=RejectingNotificationProvider(),
):
request, tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(update={"status": "draft"}),
)
self.assertEqual(tokens, {})
self.assertEqual(
self.session.query(PollInvitation).filter(
PollInvitation.poll_id == request.poll_id
).count(),
0,
)
self.assertTrue(
all(
participant.status == "draft"
and participant.poll_invitation_id is None
for participant in request.participants
)
)
self.assertEqual(
self.session.query(SchedulingNotification).filter(
SchedulingNotification.request_id == request.id
).count(),
0,
)
open_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
)
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=self._principal(
"alice-account",
email="alice@example.test",
scopes={SCHEDULING_RESPOND_SCOPE},
),
)
alice = next(
participant
for participant in request.participants
if participant.email == "alice@example.test"
)
self.assertEqual(alice.status, "responded")
self.assertEqual(alice.participation_gateway, "scheduling")
self.assertIsNotNone(alice.poll_invitation_id)
self.assertEqual(
self.session.query(SchedulingNotification).filter(
SchedulingNotification.request_id == request.id,
SchedulingNotification.event_kind == "invitation",
).count(),
0,
)
def test_create_route_persists_after_request_session_closes(self) -> None:
response = api_create_scheduling_request(
self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()}),
@@ -244,15 +332,25 @@ class SchedulingServiceTests(unittest.TestCase):
first_slot = request.slots[0]
second_slot = request.slots[1]
submit_poll_response_with_token(
submit_public_scheduling_participation(
self.session,
request_id=request.id,
token=tokens[first_participant.id],
payload=PollSubmitResponseRequest(
payload=SchedulingPublicParticipationSubmitRequest(
answers=[
PollAnswerInput(option_id=first_slot.poll_option_id, value="available"),
PollAnswerInput(option_id=second_slot.poll_option_id, value="maybe"),
SchedulingAvailabilityAnswerInput(
slot_id=first_slot.id,
value="available",
option_revision=scheduling_slot_revision(first_slot),
),
SchedulingAvailabilityAnswerInput(
slot_id=second_slot.id,
value="maybe",
option_revision=scheduling_slot_revision(second_slot),
),
]
),
client_address="127.0.0.1",
)
summary = scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)
@@ -550,6 +648,142 @@ class SchedulingServiceTests(unittest.TestCase):
).id,
)
ordinary_writer = self._principal(
"unrelated-writer",
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE},
)
administrator = self._principal(
"scheduling-admin",
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_ADMIN_SCOPE},
)
self.assertEqual(
[],
api_list_scheduling_requests(
status_filter=None,
session=self.session,
principal=ordinary_writer,
).requests,
)
self.assertEqual(
[request.id],
[
item.id
for item in api_list_scheduling_requests(
status_filter=None,
session=self.session,
principal=administrator,
).requests
],
)
def test_participant_list_and_get_redact_management_and_connector_internals(self) -> None:
request, _tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
)
request.calendar_event_id = "calendar-event-internal"
request.metadata_ = {"connector_ref": "connector-internal"}
slot = request.slots[0]
slot.freebusy_status = "busy"
slot.freebusy_conflicts = [
{
"calendar_id": "calendar-1",
"event_id": "busy-event-internal",
"uid": "busy-uid-internal",
"recurrence_id": "busy-recurrence-internal",
"start_at": "2026-07-20T09:15:00+00:00",
"end_at": "2026-07-20T09:45:00+00:00",
"status": "CONFIRMED",
}
]
slot.tentative_hold_event_id = "hold-event-internal"
slot.metadata_ = {"provider_ref": "provider-internal"}
participant = request.participants[0]
participant.respondent_id = "alice-identity-internal"
participant.metadata_ = {"directory_ref": "directory-internal"}
self.session.flush()
principal = self._principal(
"alice-account",
email="alice@example.test",
scopes={SCHEDULING_READ_SCOPE},
)
listed = api_list_scheduling_requests(
status_filter=None,
session=self.session,
principal=principal,
).requests
fetched = api_get_scheduling_request(
request.id,
session=self.session,
principal=principal,
)
self.assertEqual(len(listed), 1)
for response in (listed[0], fetched):
self.assertIsNone(response.tenant_id)
self.assertIsNone(response.poll_id)
self.assertIsNone(response.organizer_user_id)
self.assertIsNone(response.calendar_integration_enabled)
self.assertIsNone(response.calendar_id)
self.assertIsNone(response.calendar_freebusy_enabled)
self.assertIsNone(response.calendar_hold_enabled)
self.assertIsNone(response.create_calendar_event_on_decision)
self.assertIsNone(response.calendar_event_id)
self.assertIsNone(
response.public_participation_policy_enforcement_available
)
self.assertEqual(response.metadata, {})
self.assertEqual(response.participant_aggregate.total, 2)
self.assertEqual(len(response.participants), 1)
own = response.participants[0]
self.assertTrue(own.is_current_participant)
self.assertEqual(own.email, "alice@example.test")
self.assertIsNone(own.respondent_id)
self.assertIsNone(own.poll_invitation_id)
self.assertEqual(own.metadata, {})
projected_slot = response.slots[0]
self.assertIsNone(projected_slot.poll_option_id)
self.assertEqual(projected_slot.freebusy_status, "busy")
self.assertEqual(
projected_slot.freebusy_conflicts,
[
{
"start_at": "2026-07-20T09:15:00+00:00",
"end_at": "2026-07-20T09:45:00+00:00",
"status": "CONFIRMED",
}
],
)
self.assertIsNone(projected_slot.tentative_hold_event_id)
self.assertEqual(projected_slot.metadata, {})
organizer = self._principal(
"user-1",
scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE},
)
management = api_get_scheduling_request(
request.id,
session=self.session,
principal=organizer,
)
self.assertEqual(management.tenant_id, "tenant-1")
self.assertEqual(management.poll_id, request.poll_id)
self.assertEqual(management.calendar_id, "calendar-1")
self.assertEqual(management.calendar_event_id, "calendar-event-internal")
self.assertEqual(management.metadata["connector_ref"], "connector-internal")
self.assertEqual(management.slots[0].poll_option_id, slot.poll_option_id)
self.assertEqual(
management.slots[0].freebusy_conflicts[0]["uid"],
"busy-uid-internal",
)
self.assertEqual(
management.slots[0].tentative_hold_event_id,
"hold-event-internal",
)
def test_result_visibility_follows_existing_after_close_policy(self) -> None:
request, _tokens = create_scheduling_request(
self.session,
@@ -800,7 +1034,7 @@ class SchedulingServiceTests(unittest.TestCase):
organizer = self._principal("user-1", scopes={"poll:poll:read"})
self.assertEqual(api_get_poll(request.poll_id, session=self.session, principal=organizer).id, request.poll_id)
def test_authenticated_response_reconciles_only_the_current_participant(self) -> None:
def test_module_owned_poll_rejects_direct_response_and_uses_current_participant(self) -> None:
participants = [
SchedulingParticipantInput(
respondent_id="alice-membership",
@@ -835,15 +1069,21 @@ class SchedulingServiceTests(unittest.TestCase):
},
)
response = api_submit_poll_response(
request.poll_id,
PollSubmitResponseRequest(
answers=[PollAnswerInput(option_id=request.slots[0].poll_option_id, value="available")],
metadata={"invitation_id": target.poll_invitation_id},
),
session=self.session,
principal=attacker,
)
with self.assertRaises(HTTPException) as direct_response:
api_submit_poll_response(
request.poll_id,
PollSubmitResponseRequest(
answers=[
PollAnswerInput(
option_id=request.slots[0].poll_option_id,
value="available",
)
],
metadata={"invitation_id": target.poll_invitation_id},
),
session=self.session,
principal=attacker,
)
listed = api_list_scheduling_requests(
status_filter=None,
session=self.session,
@@ -855,8 +1095,29 @@ class SchedulingServiceTests(unittest.TestCase):
principal=attacker,
)
self.assertNotIn("invitation_id", response.metadata)
self.assertEqual(direct_response.exception.status_code, 400)
self.assertEqual([request.id], [item.id for item in listed.requests])
self.assertFalse(current.has_response)
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=attacker,
)
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=attacker,
)
self.assertTrue(current.has_response)
self.assertEqual(
[(answer.slot_id, answer.value) for answer in current.answers],
@@ -964,17 +1225,20 @@ class SchedulingServiceTests(unittest.TestCase):
payload=payload,
)
participant = request.participants[0]
submit_poll_response_with_token(
submit_public_scheduling_participation(
self.session,
request_id=request.id,
token=tokens[participant.id],
payload=PollSubmitResponseRequest(
payload=SchedulingPublicParticipationSubmitRequest(
answers=[
PollAnswerInput(
option_id=request.slots[0].poll_option_id,
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
)
]
),
client_address="127.0.0.1",
)
alice = self._principal(
"alice-account",
@@ -1010,7 +1274,7 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual(len(responses), 1)
self.assertEqual(
responses[0].respondent_id,
f"invitation:{participant.poll_invitation_id}",
participant.respondent_id,
)
self.assertEqual(
responses[0].metadata_["invitation_id"],
@@ -1071,13 +1335,17 @@ class SchedulingServiceTests(unittest.TestCase):
for response in responses:
own = next(item for item in response.participants if item.display_name == "Alice")
other = next(item for item in response.participants if item.display_name == "Bob")
self.assertEqual(own.respondent_id, "alice-id")
self.assertTrue(own.is_current_participant)
self.assertIsNone(own.respondent_id)
self.assertEqual(own.email, "alice@example.test")
self.assertEqual(own.metadata, {"private": "alice"})
self.assertIsNotNone(own.poll_invitation_id)
self.assertEqual(own.metadata, {})
self.assertIsNone(own.poll_invitation_id)
self.assertIsNone(own.invitation_token)
self.assertFalse(other.is_current_participant)
self.assertIsNone(other.respondent_id)
self.assertIsNone(other.email)
self.assertIsNone(other.participant_type)
self.assertIsNone(other.required)
self.assertIsNone(other.poll_invitation_id)
self.assertIsNone(other.last_invited_at)
self.assertIsNone(other.responded_at)
@@ -1114,13 +1382,13 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertIsNone(request.selected_slot_id)
availability_reader = self._principal(
"reader",
"user-1",
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_AVAILABILITY_READ_SCOPE},
)
freebusy = api_evaluate_calendar_freebusy(request.id, session=self.session, principal=availability_reader)
self.assertTrue(freebusy.updated_slot_ids)
event_writer = self._principal(
"event-writer",
"user-1",
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_EVENT_WRITE_SCOPE},
)
holds = api_create_tentative_calendar_holds(request.id, session=self.session, principal=event_writer)
@@ -1139,7 +1407,7 @@ class SchedulingServiceTests(unittest.TestCase):
self.session.flush()
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
scheduling_writer = self._principal(
"writer",
"user-1",
scopes={SCHEDULING_WRITE_SCOPE},
)
@@ -1209,7 +1477,10 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"})
self.assertEqual(
{item.action_url for item in provider.requests},
{f"/poll/public/{token}" for token in tokens.values()},
{
f"/scheduling/public/{request.id}/{token}"
for token in tokens.values()
},
)
local_notifications = list_scheduling_notifications(
self.session,