feat(scheduling): enforce governed response policies
This commit is contained in:
@@ -37,6 +37,14 @@ class SchedulingRequest(Base, TimestampMixin):
|
|||||||
allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
|
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
|
||||||
participant_visibility: Mapped[str] = mapped_column(String(32), default="aggregates_only", nullable=False)
|
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_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
@@ -106,8 +114,10 @@ class SchedulingParticipant(Base, TimestampMixin):
|
|||||||
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(40), default="invited", nullable=False, index=True)
|
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)
|
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)
|
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)
|
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)
|
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)
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ from govoplan_core.core.modules import (
|
|||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
|
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
|
||||||
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
|
from govoplan_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
|
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
|
||||||
|
|
||||||
MODULE_ID = "scheduling"
|
MODULE_ID = "scheduling"
|
||||||
MODULE_NAME = "Scheduling"
|
MODULE_NAME = "Scheduling"
|
||||||
MODULE_VERSION = "0.1.9"
|
MODULE_VERSION = "0.1.10"
|
||||||
READ_SCOPE = "scheduling:schedule:read"
|
READ_SCOPE = "scheduling:schedule:read"
|
||||||
WRITE_SCOPE = "scheduling:schedule:write"
|
WRITE_SCOPE = "scheduling:schedule:write"
|
||||||
ADMIN_SCOPE = "scheduling:schedule:admin"
|
ADMIN_SCOPE = "scheduling:schedule:admin"
|
||||||
@@ -47,8 +48,8 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
|||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View scheduling", "Read scheduling polls, proposals, participant state, and selected outcomes."),
|
_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(WRITE_SCOPE, "Manage own scheduling", "Create scheduling polls and manage requests for which the account is the organizer."),
|
||||||
_permission(ADMIN_SCOPE, "Administer scheduling", "Configure tenant-level scheduling policies, external participation, and retention defaults."),
|
_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."),
|
_permission(RESPOND_SCOPE, "Respond to scheduling polls", "Submit and update own scheduling availability responses."),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ ROLE_TEMPLATES = (
|
|||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="scheduling_manager",
|
slug="scheduling_manager",
|
||||||
name="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),
|
permissions=(READ_SCOPE, WRITE_SCOPE, RESPOND_SCOPE),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
@@ -137,16 +138,19 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_CALENDAR_SCHEDULING,
|
CAPABILITY_CALENDAR_SCHEDULING,
|
||||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||||
),
|
),
|
||||||
required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
|
required_capabilities=(
|
||||||
|
CAPABILITY_POLL_SCHEDULING,
|
||||||
|
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||||
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.9"),
|
ModuleInterfaceProvider(name="scheduling.candidate_slots", version=MODULE_VERSION),
|
||||||
ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.9"),
|
ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.9", version_max_exclusive="0.2.0"),
|
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.9", 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.9", 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.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
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="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="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),
|
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
from typing import Any
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
@@ -26,6 +26,9 @@ from govoplan_scheduling.backend.schemas import (
|
|||||||
SchedulingRequestResponse,
|
SchedulingRequestResponse,
|
||||||
SchedulingRequestUpdateRequest,
|
SchedulingRequestUpdateRequest,
|
||||||
SchedulingPollSummaryResponse,
|
SchedulingPollSummaryResponse,
|
||||||
|
SchedulingPublicParticipationAccessRequest,
|
||||||
|
SchedulingPublicParticipationResponse,
|
||||||
|
SchedulingPublicParticipationSubmitRequest,
|
||||||
SchedulingStatusResponse,
|
SchedulingStatusResponse,
|
||||||
SchedulingSummaryResponse,
|
SchedulingSummaryResponse,
|
||||||
)
|
)
|
||||||
@@ -34,6 +37,7 @@ from govoplan_scheduling.backend.service import (
|
|||||||
SchedulingConflictError,
|
SchedulingConflictError,
|
||||||
SchedulingError,
|
SchedulingError,
|
||||||
SchedulingPermissionError,
|
SchedulingPermissionError,
|
||||||
|
SchedulingPublicParticipationError,
|
||||||
cancel_scheduling_request,
|
cancel_scheduling_request,
|
||||||
close_scheduling_request,
|
close_scheduling_request,
|
||||||
create_final_calendar_event,
|
create_final_calendar_event,
|
||||||
@@ -42,7 +46,9 @@ from govoplan_scheduling.backend.service import (
|
|||||||
create_tentative_calendar_holds,
|
create_tentative_calendar_holds,
|
||||||
decide_scheduling_request,
|
decide_scheduling_request,
|
||||||
evaluate_calendar_freebusy,
|
evaluate_calendar_freebusy,
|
||||||
|
get_scheduling_request,
|
||||||
get_scheduling_availability_response,
|
get_scheduling_availability_response,
|
||||||
|
get_public_scheduling_participation,
|
||||||
get_visible_scheduling_request,
|
get_visible_scheduling_request,
|
||||||
list_visible_scheduling_notifications,
|
list_visible_scheduling_notifications,
|
||||||
list_visible_scheduling_requests,
|
list_visible_scheduling_requests,
|
||||||
@@ -53,8 +59,9 @@ from govoplan_scheduling.backend.service import (
|
|||||||
scheduling_request_response,
|
scheduling_request_response,
|
||||||
scheduling_request_summary,
|
scheduling_request_summary,
|
||||||
submit_scheduling_availability,
|
submit_scheduling_availability,
|
||||||
|
submit_public_scheduling_participation,
|
||||||
update_scheduling_candidate_slot,
|
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:
|
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:
|
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))
|
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(
|
def _request_response(
|
||||||
request,
|
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)
|
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
|
||||||
def api_lookup_scheduling_addresses(
|
def api_lookup_scheduling_addresses(
|
||||||
query: str = Query(min_length=1),
|
query: str = Query(min_length=1),
|
||||||
@@ -151,7 +262,7 @@ def api_lookup_scheduling_addresses(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingAddressLookupResponse:
|
) -> SchedulingAddressLookupResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scheduling_writer(principal)
|
||||||
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
|
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
|
||||||
if capability is None or not hasattr(capability, "lookup"):
|
if capability is None or not hasattr(capability, "lookup"):
|
||||||
return SchedulingAddressLookupResponse(available=False, candidates=[])
|
return SchedulingAddressLookupResponse(available=False, candidates=[])
|
||||||
@@ -198,7 +309,7 @@ def api_create_scheduling_request(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingRequestResponse:
|
) -> SchedulingRequestResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scheduling_writer(principal)
|
||||||
try:
|
try:
|
||||||
request, invitation_tokens = create_scheduling_request(
|
request, invitation_tokens = create_scheduling_request(
|
||||||
session,
|
session,
|
||||||
@@ -294,9 +405,13 @@ def api_update_scheduling_request(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingRequestResponse:
|
) -> SchedulingRequestResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
request = update_scheduling_request(
|
request, invitation_tokens = update_scheduling_request_with_invitation_tokens(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@@ -304,7 +419,11 @@ def api_update_scheduling_request(
|
|||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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()
|
session.commit()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -317,7 +436,11 @@ def api_update_scheduling_candidate_slot(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingRequestResponse:
|
) -> SchedulingRequestResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
request = update_scheduling_candidate_slot(
|
request = update_scheduling_candidate_slot(
|
||||||
session,
|
session,
|
||||||
@@ -339,7 +462,7 @@ def api_open_scheduling_request(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingStatusResponse:
|
) -> SchedulingStatusResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||||
try:
|
try:
|
||||||
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
@@ -355,7 +478,7 @@ def api_close_scheduling_request(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingStatusResponse:
|
) -> SchedulingStatusResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||||
try:
|
try:
|
||||||
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
@@ -372,7 +495,7 @@ def api_decide_scheduling_request(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingStatusResponse:
|
) -> SchedulingStatusResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||||
try:
|
try:
|
||||||
request = decide_scheduling_request(
|
request = decide_scheduling_request(
|
||||||
session,
|
session,
|
||||||
@@ -398,7 +521,7 @@ def api_evaluate_calendar_freebusy(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingCalendarActionResponse:
|
) -> SchedulingCalendarActionResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||||
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
|
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
|
||||||
try:
|
try:
|
||||||
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
|
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),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingCalendarActionResponse:
|
) -> SchedulingCalendarActionResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
||||||
@@ -446,7 +569,7 @@ def api_create_final_calendar_event(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingCalendarActionResponse:
|
) -> SchedulingCalendarActionResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
request, event_id, warnings = create_final_calendar_event(
|
request, event_id, warnings = create_final_calendar_event(
|
||||||
@@ -472,7 +595,7 @@ def api_cancel_scheduling_request(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingStatusResponse:
|
) -> SchedulingStatusResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||||
try:
|
try:
|
||||||
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
@@ -547,7 +670,7 @@ def api_create_scheduling_notifications(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingNotificationListResponse:
|
) -> SchedulingNotificationListResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||||
try:
|
try:
|
||||||
notifications = create_scheduling_notification_jobs(
|
notifications = create_scheduling_notification_jobs(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
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"]
|
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
|
||||||
@@ -25,6 +25,24 @@ def _known_timezone(value: str | None) -> str | None:
|
|||||||
return value
|
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):
|
class SchedulingCalendarPreferences(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -91,6 +109,30 @@ class SchedulingParticipantInput(BaseModel):
|
|||||||
required: bool = True
|
required: bool = True
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class SchedulingRequestCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
@@ -105,6 +147,14 @@ class SchedulingRequestCreateRequest(BaseModel):
|
|||||||
allow_participant_updates: bool = True
|
allow_participant_updates: bool = True
|
||||||
result_visibility: SchedulingResultVisibility = "after_close"
|
result_visibility: SchedulingResultVisibility = "after_close"
|
||||||
participant_visibility: SchedulingParticipantVisibility = "aggregates_only"
|
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)
|
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
|
||||||
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
|
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
|
||||||
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
|
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
|
||||||
@@ -113,6 +163,14 @@ class SchedulingRequestCreateRequest(BaseModel):
|
|||||||
|
|
||||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
_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):
|
class SchedulingRequestUpdateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
@@ -125,9 +183,40 @@ class SchedulingRequestUpdateRequest(BaseModel):
|
|||||||
allow_participant_updates: bool | None = None
|
allow_participant_updates: bool | None = None
|
||||||
result_visibility: SchedulingResultVisibility | None = None
|
result_visibility: SchedulingResultVisibility | None = None
|
||||||
participant_visibility: SchedulingParticipantVisibility | None = None
|
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
|
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
|
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):
|
class SchedulingCandidateSlotResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -149,11 +238,12 @@ class SchedulingCandidateSlotResponse(BaseModel):
|
|||||||
|
|
||||||
class SchedulingParticipantResponse(BaseModel):
|
class SchedulingParticipantResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
is_current_participant: bool = False
|
||||||
respondent_id: str | None = None
|
respondent_id: str | None = None
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
participant_type: str
|
participant_type: str | None = None
|
||||||
required: bool
|
required: bool | None = None
|
||||||
status: str
|
status: str
|
||||||
poll_invitation_id: str | None = None
|
poll_invitation_id: str | None = None
|
||||||
invitation_token: str | None = None
|
invitation_token: str | None = None
|
||||||
@@ -178,7 +268,7 @@ class SchedulingParticipantVisibilityDecisionResponse(BaseModel):
|
|||||||
|
|
||||||
class SchedulingRequestResponse(BaseModel):
|
class SchedulingRequestResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str
|
tenant_id: str | None = None
|
||||||
title: str
|
title: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
location: str | None = None
|
location: str | None = None
|
||||||
@@ -192,14 +282,23 @@ class SchedulingRequestResponse(BaseModel):
|
|||||||
allow_participant_updates: bool
|
allow_participant_updates: bool
|
||||||
result_visibility: str
|
result_visibility: str
|
||||||
participant_visibility: SchedulingParticipantVisibility
|
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
|
effective_participant_visibility: SchedulingParticipantVisibility
|
||||||
participant_aggregate: SchedulingParticipantAggregateResponse
|
participant_aggregate: SchedulingParticipantAggregateResponse
|
||||||
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
|
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
|
||||||
calendar_integration_enabled: bool
|
calendar_integration_enabled: bool | None = None
|
||||||
calendar_id: str | None = None
|
calendar_id: str | None = None
|
||||||
calendar_freebusy_enabled: bool
|
calendar_freebusy_enabled: bool | None = None
|
||||||
calendar_hold_enabled: bool
|
calendar_hold_enabled: bool | None = None
|
||||||
create_calendar_event_on_decision: bool
|
create_calendar_event_on_decision: bool | None = None
|
||||||
calendar_event_id: str | None = None
|
calendar_event_id: str | None = None
|
||||||
handed_off_at: datetime | None = None
|
handed_off_at: datetime | None = None
|
||||||
cancelled_at: datetime | None = None
|
cancelled_at: datetime | None = None
|
||||||
@@ -238,6 +337,7 @@ class SchedulingAvailabilityResponseRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
|
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
|
||||||
|
comment: str | None = Field(default=None, max_length=4000)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest":
|
def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest":
|
||||||
@@ -258,6 +358,70 @@ class SchedulingAvailabilityResponse(BaseModel):
|
|||||||
has_response: bool = False
|
has_response: bool = False
|
||||||
submitted_at: datetime | None = None
|
submitted_at: datetime | None = None
|
||||||
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
|
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):
|
class SchedulingPollOptionResultResponse(BaseModel):
|
||||||
|
|||||||
61
src/govoplan_scheduling/backend/security.py
Normal file
61
src/govoplan_scheduling/backend/security.py
Normal 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
@@ -17,7 +17,10 @@ class SchedulingManifestTests(unittest.TestCase):
|
|||||||
self.assertIn("access", manifest.optional_dependencies)
|
self.assertIn("access", manifest.optional_dependencies)
|
||||||
self.assertIn("addresses", manifest.optional_dependencies)
|
self.assertIn("addresses", manifest.optional_dependencies)
|
||||||
self.assertIn("policy", manifest.optional_dependencies)
|
self.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("auth.principalResolver", manifest.optional_capabilities)
|
||||||
self.assertIn("poll.scheduling", manifest.required_capabilities)
|
self.assertIn("poll.scheduling", manifest.required_capabilities)
|
||||||
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
|
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
|
||||||
@@ -29,14 +32,18 @@ class SchedulingManifestTests(unittest.TestCase):
|
|||||||
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
|
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.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.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("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
|
||||||
self.assertIn("addresses.lookup", {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})
|
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
|
||||||
required_interfaces = {interface.name: interface 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)
|
for interface_name in (
|
||||||
self.assertEqual("0.1.9", required_interfaces["poll.response_collection"].version_min)
|
"poll.availability_matrix",
|
||||||
self.assertEqual("0.1.9", required_interfaces["poll.workflow_context"].version_min)
|
"poll.response_collection",
|
||||||
|
"poll.workflow_context",
|
||||||
|
"poll.governed_participation",
|
||||||
|
):
|
||||||
|
self.assertEqual("0.1.10", required_interfaces[interface_name].version_min)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from govoplan_scheduling.backend.db.models import (
|
|||||||
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
|
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
|
||||||
|
|
||||||
|
|
||||||
_SCHEDULING_HEAD = "9c2f4a7d1e6b"
|
_SCHEDULING_HEAD = "ad7e3c9b2f10"
|
||||||
_ENABLED_MODULES = ("poll", "scheduling")
|
_ENABLED_MODULES = ("poll", "scheduling")
|
||||||
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
|
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
|
||||||
|
|
||||||
@@ -110,12 +110,27 @@ class SchedulingMigrationTests(unittest.TestCase):
|
|||||||
"scheduling_requests"
|
"scheduling_requests"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
participant_columns = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspect(connection).get_columns(
|
||||||
|
"scheduling_participants"
|
||||||
|
)
|
||||||
|
}
|
||||||
visibility = connection.execute(
|
visibility = connection.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT participant_visibility FROM scheduling_requests "
|
"SELECT participant_visibility FROM scheduling_requests "
|
||||||
"WHERE id = 'request-1'"
|
"WHERE id = 'request-1'"
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).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 = {
|
counts = {
|
||||||
model.__tablename__: connection.execute(
|
model.__tablename__: connection.execute(
|
||||||
select(func.count()).select_from(model)
|
select(func.count()).select_from(model)
|
||||||
@@ -130,7 +145,11 @@ class SchedulingMigrationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn(_SCHEDULING_HEAD, heads)
|
self.assertIn(_SCHEDULING_HEAD, heads)
|
||||||
self.assertIn("participant_visibility", columns)
|
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(visibility, "aggregates_only")
|
||||||
|
self.assertEqual(tuple(response_defaults), (1, 0, None, 1, 0, 0, 0, None))
|
||||||
self.assertEqual(set(counts.values()), {1})
|
self.assertEqual(set(counts.values()), {1})
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
@@ -219,6 +238,74 @@ class SchedulingMigrationTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -89,11 +90,46 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
|||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"title": "Privacy review",
|
"title": "Privacy review",
|
||||||
"status": "collecting",
|
"status": "collecting",
|
||||||
|
"poll_id": "poll-internal",
|
||||||
"organizer_user_id": "organizer-1",
|
"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:
|
if participant_visibility is not None:
|
||||||
values["participant_visibility"] = participant_visibility
|
values["participant_visibility"] = participant_visibility
|
||||||
request = SchedulingRequest(**values)
|
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 = [
|
request.participants = [
|
||||||
SchedulingParticipant(
|
SchedulingParticipant(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -102,6 +138,9 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
|||||||
email="alice@example.test",
|
email="alice@example.test",
|
||||||
participant_type="internal",
|
participant_type="internal",
|
||||||
status="responded",
|
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"},
|
metadata_={"private": "alice"},
|
||||||
),
|
),
|
||||||
SchedulingParticipant(
|
SchedulingParticipant(
|
||||||
@@ -152,10 +191,43 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.participant_visibility, "aggregates_only")
|
self.assertEqual(response.participant_visibility, "aggregates_only")
|
||||||
self.assertEqual(response.effective_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([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.total, 2)
|
||||||
self.assertEqual(response.participant_aggregate.status_counts["responded"], 1)
|
self.assertEqual(response.participant_aggregate.status_counts["responded"], 1)
|
||||||
self.assertEqual(response.participant_aggregate.status_counts["invited"], 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:
|
def test_configured_roster_returns_other_names_and_statuses_with_sensitive_fields_redacted(self) -> None:
|
||||||
request = self._request(participant_visibility="names_and_statuses")
|
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")
|
self.assertEqual(response.effective_participant_visibility, "names_and_statuses")
|
||||||
own = next(participant for participant in response.participants if participant.display_name == "Alice")
|
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")
|
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(own.email, "alice@example.test")
|
||||||
self.assertEqual(other.status, "invited")
|
self.assertEqual(other.status, "invited")
|
||||||
|
self.assertFalse(other.is_current_participant)
|
||||||
self.assertIsNone(other.respondent_id)
|
self.assertIsNone(other.respondent_id)
|
||||||
self.assertIsNone(other.email)
|
self.assertIsNone(other.email)
|
||||||
|
self.assertIsNone(other.participant_type)
|
||||||
|
self.assertIsNone(other.required)
|
||||||
self.assertIsNone(other.poll_invitation_id)
|
self.assertIsNone(other.poll_invitation_id)
|
||||||
self.assertEqual(other.metadata, {})
|
self.assertEqual(other.metadata, {})
|
||||||
|
|
||||||
@@ -199,6 +275,18 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
|||||||
"bob@example.test",
|
"bob@example.test",
|
||||||
})
|
})
|
||||||
self.assertTrue(response.participant_visibility_decision.details["management_access"])
|
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:
|
def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
|
||||||
restricting_policy = _PrivacyPolicy("aggregates_only")
|
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.assertEqual([participant.display_name for participant in reduced.participants], ["Alice"])
|
||||||
self.assertTrue(reduced.participant_visibility_decision.policy_applied)
|
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.reason, "Tenant participant privacy policy")
|
||||||
self.assertEqual(reduced.participant_visibility_decision.source_path[0]["path"], "tenant:tenant-1")
|
self.assertEqual(reduced.participant_visibility_decision.source_path, [])
|
||||||
self.assertTrue(reduced.participant_visibility_decision.details["provider_session_available"])
|
self.assertEqual(reduced.participant_visibility_decision.details, {})
|
||||||
self.assertEqual(restricting_policy.requests[0].actor_user_id, "alice-account")
|
self.assertEqual(restricting_policy.requests[0].actor_user_id, "alice-account")
|
||||||
|
|
||||||
widening_policy = _PrivacyPolicy("names_and_statuses")
|
widening_policy = _PrivacyPolicy("names_and_statuses")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,13 +19,21 @@ from govoplan_core.core.registry import PlatformRegistry
|
|||||||
from govoplan_access.backend.db.models import Account, User
|
from govoplan_access.backend.db.models import Account, User
|
||||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource
|
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_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.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.router import api_get_poll, api_list_polls, api_submit_poll_response
|
||||||
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
|
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.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 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 RESPOND_SCOPE as SCHEDULING_RESPOND_SCOPE
|
||||||
from govoplan_scheduling.backend.manifest import WRITE_SCOPE as SCHEDULING_WRITE_SCOPE
|
from govoplan_scheduling.backend.manifest import WRITE_SCOPE as SCHEDULING_WRITE_SCOPE
|
||||||
from govoplan_scheduling.backend.schemas import (
|
from govoplan_scheduling.backend.schemas import (
|
||||||
@@ -35,6 +43,7 @@ from govoplan_scheduling.backend.schemas import (
|
|||||||
SchedulingCandidateSlotInput,
|
SchedulingCandidateSlotInput,
|
||||||
SchedulingDecisionRequest,
|
SchedulingDecisionRequest,
|
||||||
SchedulingParticipantInput,
|
SchedulingParticipantInput,
|
||||||
|
SchedulingPublicParticipationSubmitRequest,
|
||||||
SchedulingRequestCreateRequest,
|
SchedulingRequestCreateRequest,
|
||||||
SchedulingRequestUpdateRequest,
|
SchedulingRequestUpdateRequest,
|
||||||
)
|
)
|
||||||
@@ -68,6 +77,7 @@ from govoplan_scheduling.backend.service import (
|
|||||||
require_visible_scheduling_results,
|
require_visible_scheduling_results,
|
||||||
scheduling_request_summary,
|
scheduling_request_summary,
|
||||||
scheduling_slot_revision,
|
scheduling_slot_revision,
|
||||||
|
submit_public_scheduling_participation,
|
||||||
update_scheduling_request,
|
update_scheduling_request,
|
||||||
)
|
)
|
||||||
from govoplan_scheduling.backend.runtime import configure_runtime
|
from govoplan_scheduling.backend.runtime import configure_runtime
|
||||||
@@ -88,6 +98,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
PollOption.__table__,
|
PollOption.__table__,
|
||||||
PollResponse.__table__,
|
PollResponse.__table__,
|
||||||
PollInvitation.__table__,
|
PollInvitation.__table__,
|
||||||
|
PollParticipationSubmission.__table__,
|
||||||
PollLifecycleTransition.__table__,
|
PollLifecycleTransition.__table__,
|
||||||
CalendarCollection.__table__,
|
CalendarCollection.__table__,
|
||||||
CalendarEvent.__table__,
|
CalendarEvent.__table__,
|
||||||
@@ -121,6 +132,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
CalendarSyncSource.__table__,
|
CalendarSyncSource.__table__,
|
||||||
CalendarEvent.__table__,
|
CalendarEvent.__table__,
|
||||||
CalendarCollection.__table__,
|
CalendarCollection.__table__,
|
||||||
|
PollParticipationSubmission.__table__,
|
||||||
PollInvitation.__table__,
|
PollInvitation.__table__,
|
||||||
PollLifecycleTransition.__table__,
|
PollLifecycleTransition.__table__,
|
||||||
PollResponse.__table__,
|
PollResponse.__table__,
|
||||||
@@ -215,6 +227,82 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(tokens), 2)
|
self.assertEqual(len(tokens), 2)
|
||||||
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants))
|
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:
|
def test_create_route_persists_after_request_session_closes(self) -> None:
|
||||||
response = api_create_scheduling_request(
|
response = api_create_scheduling_request(
|
||||||
self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()}),
|
self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()}),
|
||||||
@@ -244,15 +332,25 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
first_slot = request.slots[0]
|
first_slot = request.slots[0]
|
||||||
second_slot = request.slots[1]
|
second_slot = request.slots[1]
|
||||||
|
|
||||||
submit_poll_response_with_token(
|
submit_public_scheduling_participation(
|
||||||
self.session,
|
self.session,
|
||||||
|
request_id=request.id,
|
||||||
token=tokens[first_participant.id],
|
token=tokens[first_participant.id],
|
||||||
payload=PollSubmitResponseRequest(
|
payload=SchedulingPublicParticipationSubmitRequest(
|
||||||
answers=[
|
answers=[
|
||||||
PollAnswerInput(option_id=first_slot.poll_option_id, value="available"),
|
SchedulingAvailabilityAnswerInput(
|
||||||
PollAnswerInput(option_id=second_slot.poll_option_id, value="maybe"),
|
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)
|
summary = scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)
|
||||||
|
|
||||||
@@ -550,6 +648,142 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
).id,
|
).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:
|
def test_result_visibility_follows_existing_after_close_policy(self) -> None:
|
||||||
request, _tokens = create_scheduling_request(
|
request, _tokens = create_scheduling_request(
|
||||||
self.session,
|
self.session,
|
||||||
@@ -800,7 +1034,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
organizer = self._principal("user-1", scopes={"poll:poll:read"})
|
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)
|
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 = [
|
participants = [
|
||||||
SchedulingParticipantInput(
|
SchedulingParticipantInput(
|
||||||
respondent_id="alice-membership",
|
respondent_id="alice-membership",
|
||||||
@@ -835,10 +1069,16 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
response = api_submit_poll_response(
|
with self.assertRaises(HTTPException) as direct_response:
|
||||||
|
api_submit_poll_response(
|
||||||
request.poll_id,
|
request.poll_id,
|
||||||
PollSubmitResponseRequest(
|
PollSubmitResponseRequest(
|
||||||
answers=[PollAnswerInput(option_id=request.slots[0].poll_option_id, value="available")],
|
answers=[
|
||||||
|
PollAnswerInput(
|
||||||
|
option_id=request.slots[0].poll_option_id,
|
||||||
|
value="available",
|
||||||
|
)
|
||||||
|
],
|
||||||
metadata={"invitation_id": target.poll_invitation_id},
|
metadata={"invitation_id": target.poll_invitation_id},
|
||||||
),
|
),
|
||||||
session=self.session,
|
session=self.session,
|
||||||
@@ -855,8 +1095,29 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
principal=attacker,
|
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.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.assertTrue(current.has_response)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[(answer.slot_id, answer.value) for answer in current.answers],
|
[(answer.slot_id, answer.value) for answer in current.answers],
|
||||||
@@ -964,17 +1225,20 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
participant = request.participants[0]
|
participant = request.participants[0]
|
||||||
submit_poll_response_with_token(
|
submit_public_scheduling_participation(
|
||||||
self.session,
|
self.session,
|
||||||
|
request_id=request.id,
|
||||||
token=tokens[participant.id],
|
token=tokens[participant.id],
|
||||||
payload=PollSubmitResponseRequest(
|
payload=SchedulingPublicParticipationSubmitRequest(
|
||||||
answers=[
|
answers=[
|
||||||
PollAnswerInput(
|
SchedulingAvailabilityAnswerInput(
|
||||||
option_id=request.slots[0].poll_option_id,
|
slot_id=request.slots[0].id,
|
||||||
value="available",
|
value="available",
|
||||||
|
option_revision=scheduling_slot_revision(request.slots[0]),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
client_address="127.0.0.1",
|
||||||
)
|
)
|
||||||
alice = self._principal(
|
alice = self._principal(
|
||||||
"alice-account",
|
"alice-account",
|
||||||
@@ -1010,7 +1274,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(responses), 1)
|
self.assertEqual(len(responses), 1)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
responses[0].respondent_id,
|
responses[0].respondent_id,
|
||||||
f"invitation:{participant.poll_invitation_id}",
|
participant.respondent_id,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
responses[0].metadata_["invitation_id"],
|
responses[0].metadata_["invitation_id"],
|
||||||
@@ -1071,13 +1335,17 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
for response in responses:
|
for response in responses:
|
||||||
own = next(item for item in response.participants if item.display_name == "Alice")
|
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")
|
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.email, "alice@example.test")
|
||||||
self.assertEqual(own.metadata, {"private": "alice"})
|
self.assertEqual(own.metadata, {})
|
||||||
self.assertIsNotNone(own.poll_invitation_id)
|
self.assertIsNone(own.poll_invitation_id)
|
||||||
self.assertIsNone(own.invitation_token)
|
self.assertIsNone(own.invitation_token)
|
||||||
|
self.assertFalse(other.is_current_participant)
|
||||||
self.assertIsNone(other.respondent_id)
|
self.assertIsNone(other.respondent_id)
|
||||||
self.assertIsNone(other.email)
|
self.assertIsNone(other.email)
|
||||||
|
self.assertIsNone(other.participant_type)
|
||||||
|
self.assertIsNone(other.required)
|
||||||
self.assertIsNone(other.poll_invitation_id)
|
self.assertIsNone(other.poll_invitation_id)
|
||||||
self.assertIsNone(other.last_invited_at)
|
self.assertIsNone(other.last_invited_at)
|
||||||
self.assertIsNone(other.responded_at)
|
self.assertIsNone(other.responded_at)
|
||||||
@@ -1114,13 +1382,13 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
self.assertIsNone(request.selected_slot_id)
|
self.assertIsNone(request.selected_slot_id)
|
||||||
|
|
||||||
availability_reader = self._principal(
|
availability_reader = self._principal(
|
||||||
"reader",
|
"user-1",
|
||||||
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_AVAILABILITY_READ_SCOPE},
|
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_AVAILABILITY_READ_SCOPE},
|
||||||
)
|
)
|
||||||
freebusy = api_evaluate_calendar_freebusy(request.id, session=self.session, principal=availability_reader)
|
freebusy = api_evaluate_calendar_freebusy(request.id, session=self.session, principal=availability_reader)
|
||||||
self.assertTrue(freebusy.updated_slot_ids)
|
self.assertTrue(freebusy.updated_slot_ids)
|
||||||
event_writer = self._principal(
|
event_writer = self._principal(
|
||||||
"event-writer",
|
"user-1",
|
||||||
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_EVENT_WRITE_SCOPE},
|
scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_EVENT_WRITE_SCOPE},
|
||||||
)
|
)
|
||||||
holds = api_create_tentative_calendar_holds(request.id, session=self.session, principal=event_writer)
|
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()
|
self.session.flush()
|
||||||
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
|
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
|
||||||
scheduling_writer = self._principal(
|
scheduling_writer = self._principal(
|
||||||
"writer",
|
"user-1",
|
||||||
scopes={SCHEDULING_WRITE_SCOPE},
|
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.recipient_id for item in provider.requests}, {"alice-id", "bob-id"})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{item.action_url for item in provider.requests},
|
{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(
|
local_notifications = list_scheduling_notifications(
|
||||||
self.session,
|
self.session,
|
||||||
|
|||||||
Reference in New Issue
Block a user