diff --git a/src/govoplan_scheduling/backend/db/models.py b/src/govoplan_scheduling/backend/db/models.py index 987cd55..ecc2b80 100644 --- a/src/govoplan_scheduling/backend/db/models.py +++ b/src/govoplan_scheduling/backend/db/models.py @@ -37,6 +37,14 @@ class SchedulingRequest(Base, TimestampMixin): allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False) participant_visibility: Mapped[str] = mapped_column(String(32), default="aggregates_only", nullable=False) + notify_on_answers: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + single_choice: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + max_participants_per_option: Mapped[int | None] = mapped_column(Integer, nullable=True) + allow_maybe: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allow_comments: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + participant_email_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + anonymous_password_protection_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + anonymous_password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True) calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @@ -106,8 +114,10 @@ class SchedulingParticipant(Base, TimestampMixin): required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) status: Mapped[str] = mapped_column(String(40), default="invited", nullable=False, index=True) poll_invitation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + participation_gateway: Mapped[str | None] = mapped_column(String(40), nullable=True) last_invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + response_comment: Mapped[str | None] = mapped_column(Text, nullable=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index de3cea4..4f600dd 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -20,11 +20,12 @@ from govoplan_core.core.modules import ( from govoplan_core.db.base import Base from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY +from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata MODULE_ID = "scheduling" MODULE_NAME = "Scheduling" -MODULE_VERSION = "0.1.9" +MODULE_VERSION = "0.1.10" READ_SCOPE = "scheduling:schedule:read" WRITE_SCOPE = "scheduling:schedule:write" ADMIN_SCOPE = "scheduling:schedule:admin" @@ -47,8 +48,8 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio PERMISSIONS = ( _permission(READ_SCOPE, "View scheduling", "Read scheduling polls, proposals, participant state, and selected outcomes."), - _permission(WRITE_SCOPE, "Manage scheduling", "Create and update scheduling polls, candidate slots, reminders, and decision handoff."), - _permission(ADMIN_SCOPE, "Administer scheduling", "Configure tenant-level scheduling policies, external participation, and retention defaults."), + _permission(WRITE_SCOPE, "Manage own scheduling", "Create scheduling polls and manage requests for which the account is the organizer."), + _permission(ADMIN_SCOPE, "Administer scheduling", "Manage every tenant scheduling request and configure scheduling policies, external participation, and retention defaults."), _permission(RESPOND_SCOPE, "Respond to scheduling polls", "Submit and update own scheduling availability responses."), ) @@ -56,7 +57,7 @@ ROLE_TEMPLATES = ( RoleTemplate( slug="scheduling_manager", name="Scheduling manager", - description="Create scheduling polls, manage candidate slots, and decide outcomes.", + description="Create scheduling polls and manage candidate slots and outcomes for requests the account organizes.", permissions=(READ_SCOPE, WRITE_SCOPE, RESPOND_SCOPE), ), RoleTemplate( @@ -137,16 +138,19 @@ manifest = ModuleManifest( CAPABILITY_CALENDAR_SCHEDULING, CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY, ), - required_capabilities=(CAPABILITY_POLL_SCHEDULING,), + required_capabilities=( + CAPABILITY_POLL_SCHEDULING, + CAPABILITY_POLL_PARTICIPATION_GATEWAY, + ), provides_interfaces=( - ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.9"), - ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.9"), + ModuleInterfaceProvider(name="scheduling.candidate_slots", version=MODULE_VERSION), + ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION), ), requires_interfaces=( - ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.9", version_max_exclusive="0.2.0"), - ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.9", version_max_exclusive="0.2.0"), - ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.9", version_max_exclusive="0.2.0"), - ModuleInterfaceRequirement(name="poll.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), + ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.10", version_max_exclusive="0.2.0"), + ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.10", version_max_exclusive="0.2.0"), + ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.10", version_max_exclusive="0.2.0"), + ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.10", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), diff --git a/src/govoplan_scheduling/backend/migrations/versions/ad7e3c9b2f10_v0110_scheduling_response_settings.py b/src/govoplan_scheduling/backend/migrations/versions/ad7e3c9b2f10_v0110_scheduling_response_settings.py new file mode 100644 index 0000000..4ccfc24 --- /dev/null +++ b/src/govoplan_scheduling/backend/migrations/versions/ad7e3c9b2f10_v0110_scheduling_response_settings.py @@ -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) diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 0ee4c33..80114dd 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -3,7 +3,7 @@ from __future__ import annotations import dataclasses from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope @@ -26,6 +26,9 @@ from govoplan_scheduling.backend.schemas import ( SchedulingRequestResponse, SchedulingRequestUpdateRequest, SchedulingPollSummaryResponse, + SchedulingPublicParticipationAccessRequest, + SchedulingPublicParticipationResponse, + SchedulingPublicParticipationSubmitRequest, SchedulingStatusResponse, SchedulingSummaryResponse, ) @@ -34,6 +37,7 @@ from govoplan_scheduling.backend.service import ( SchedulingConflictError, SchedulingError, SchedulingPermissionError, + SchedulingPublicParticipationError, cancel_scheduling_request, close_scheduling_request, create_final_calendar_event, @@ -42,7 +46,9 @@ from govoplan_scheduling.backend.service import ( create_tentative_calendar_holds, decide_scheduling_request, evaluate_calendar_freebusy, + get_scheduling_request, get_scheduling_availability_response, + get_public_scheduling_participation, get_visible_scheduling_request, list_visible_scheduling_notifications, list_visible_scheduling_requests, @@ -53,8 +59,9 @@ from govoplan_scheduling.backend.service import ( scheduling_request_response, scheduling_request_summary, submit_scheduling_availability, + submit_public_scheduling_participation, update_scheduling_candidate_slot, - update_scheduling_request, + update_scheduling_request_with_invitation_tokens, ) @@ -112,7 +119,40 @@ def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]: def _can_manage_scheduling(principal: ApiPrincipal) -> bool: - return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE) + return has_scope(principal, ADMIN_SCOPE) + + +def _require_scheduling_writer(principal: ApiPrincipal) -> None: + if has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing scope: {WRITE_SCOPE} or {ADMIN_SCOPE}", + ) + + +def _require_request_editor( + session: Session, + *, + principal: ApiPrincipal, + request_id: str, +) -> None: + _require_scheduling_writer(principal) + if has_scope(principal, ADMIN_SCOPE): + return + try: + request = get_scheduling_request( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + ) + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + if request.organizer_user_id not in _principal_actor_ids(principal): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the organizer or a scheduling administrator can edit this request", + ) def _scheduling_http_error(exc: SchedulingError) -> HTTPException: @@ -127,6 +167,25 @@ def _scheduling_http_error(exc: SchedulingError) -> HTTPException: return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) +def _public_participation_http_error( + exc: SchedulingPublicParticipationError, +) -> HTTPException: + if exc.retry_after_seconds: + return HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=str(exc), + headers={"Retry-After": str(exc.retry_after_seconds)}, + ) + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) + + +def _client_address(request: Request) -> str | None: + return request.client.host if request.client is not None else None + + def _request_response( request, *, @@ -144,6 +203,58 @@ def _request_response( ) +@router.post( + "/public/{request_id}/{token}", + response_model=SchedulingPublicParticipationResponse, +) +def api_get_public_scheduling_participation( + request_id: str, + token: str, + payload: SchedulingPublicParticipationAccessRequest, + request: Request, + session: Session = Depends(get_session), +) -> SchedulingPublicParticipationResponse: + try: + response = get_public_scheduling_participation( + session, + request_id=request_id, + token=token, + payload=payload, + client_address=_client_address(request), + ) + except SchedulingPublicParticipationError as exc: + raise _public_participation_http_error(exc) from exc + return SchedulingPublicParticipationResponse.model_validate(response) + + +@router.post( + "/public/{request_id}/{token}/responses", + response_model=SchedulingPublicParticipationResponse, +) +def api_submit_public_scheduling_participation( + request_id: str, + token: str, + payload: SchedulingPublicParticipationSubmitRequest, + request: Request, + session: Session = Depends(get_session), +) -> SchedulingPublicParticipationResponse: + try: + response = submit_public_scheduling_participation( + session, + request_id=request_id, + token=token, + payload=payload, + client_address=_client_address(request), + ) + except SchedulingPublicParticipationError as exc: + raise _public_participation_http_error(exc) from exc + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + validated = SchedulingPublicParticipationResponse.model_validate(response) + session.commit() + return validated + + @router.get("/address-lookup", response_model=SchedulingAddressLookupResponse) def api_lookup_scheduling_addresses( query: str = Query(min_length=1), @@ -151,7 +262,7 @@ def api_lookup_scheduling_addresses( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingAddressLookupResponse: - _require_scope(principal, WRITE_SCOPE) + _require_scheduling_writer(principal) capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP) if capability is None or not hasattr(capability, "lookup"): return SchedulingAddressLookupResponse(available=False, candidates=[]) @@ -198,7 +309,7 @@ def api_create_scheduling_request( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestResponse: - _require_scope(principal, WRITE_SCOPE) + _require_scheduling_writer(principal) try: request, invitation_tokens = create_scheduling_request( session, @@ -294,9 +405,13 @@ def api_update_scheduling_request( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor( + session, + principal=principal, + request_id=request_id, + ) try: - request = update_scheduling_request( + request, invitation_tokens = update_scheduling_request_with_invitation_tokens( session, tenant_id=principal.tenant_id, request_id=request_id, @@ -304,7 +419,11 @@ def api_update_scheduling_request( ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - response = _request_response(request, principal=principal) + response = _request_response( + request, + principal=principal, + invitation_tokens=invitation_tokens, + ) session.commit() return response @@ -317,7 +436,11 @@ def api_update_scheduling_candidate_slot( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor( + session, + principal=principal, + request_id=request_id, + ) try: request = update_scheduling_candidate_slot( session, @@ -339,7 +462,7 @@ def api_open_scheduling_request( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingStatusResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor(session, principal=principal, request_id=request_id) try: request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id) except SchedulingError as exc: @@ -355,7 +478,7 @@ def api_close_scheduling_request( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingStatusResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor(session, principal=principal, request_id=request_id) try: request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id) except SchedulingError as exc: @@ -372,7 +495,7 @@ def api_decide_scheduling_request( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingStatusResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor(session, principal=principal, request_id=request_id) try: request = decide_scheduling_request( session, @@ -398,7 +521,7 @@ def api_evaluate_calendar_freebusy( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingCalendarActionResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor(session, principal=principal, request_id=request_id) _require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE) try: request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id) @@ -419,7 +542,7 @@ def api_create_tentative_calendar_holds( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingCalendarActionResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor(session, principal=principal, request_id=request_id) _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE) try: request, created_event_ids, warnings = create_tentative_calendar_holds( @@ -446,7 +569,7 @@ def api_create_final_calendar_event( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingCalendarActionResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor(session, principal=principal, request_id=request_id) _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE) try: request, event_id, warnings = create_final_calendar_event( @@ -472,7 +595,7 @@ def api_cancel_scheduling_request( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingStatusResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor(session, principal=principal, request_id=request_id) try: request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id) except SchedulingError as exc: @@ -547,7 +670,7 @@ def api_create_scheduling_notifications( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingNotificationListResponse: - _require_scope(principal, WRITE_SCOPE) + _require_request_editor(session, principal=principal, request_id=request_id) try: notifications = create_scheduling_notification_jobs( session, diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index 82067c8..7a95b57 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Any, Literal from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"] @@ -25,6 +25,24 @@ def _known_timezone(value: str | None) -> str | None: return value +def _participant_email(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().casefold() + if not normalized: + return None + local, separator, domain = normalized.partition("@") + if ( + separator != "@" + or not local + or not domain + or "@" in domain + or any(character.isspace() for character in normalized) + ): + raise ValueError("participant_email must be a valid email address") + return normalized + + class SchedulingCalendarPreferences(BaseModel): model_config = ConfigDict(extra="forbid") @@ -91,6 +109,30 @@ class SchedulingParticipantInput(BaseModel): required: bool = True metadata: dict[str, Any] = Field(default_factory=dict) + _validate_email = field_validator("email")(_participant_email) + + +class SchedulingCandidateSlotReconcileInput(SchedulingCandidateSlotInput): + id: str | None = Field(default=None, max_length=36) + revision: str | None = Field( + default=None, + min_length=64, + max_length=64, + pattern=r"^[0-9a-f]{64}$", + ) + + @model_validator(mode="after") + def validate_existing_revision(self) -> "SchedulingCandidateSlotReconcileInput": + if self.id is not None and self.revision is None: + raise ValueError("revision is required for an existing scheduling slot") + if self.id is None and self.revision is not None: + raise ValueError("revision can only be supplied for an existing scheduling slot") + return self + + +class SchedulingParticipantReconcileInput(SchedulingParticipantInput): + id: str | None = Field(default=None, max_length=36) + class SchedulingRequestCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -105,6 +147,14 @@ class SchedulingRequestCreateRequest(BaseModel): allow_participant_updates: bool = True result_visibility: SchedulingResultVisibility = "after_close" participant_visibility: SchedulingParticipantVisibility = "aggregates_only" + notify_on_answers: bool = True + single_choice: bool = False + max_participants_per_option: int | None = Field(default=None, ge=1) + allow_maybe: bool = True + allow_comments: bool = False + participant_email_required: bool = False + anonymous_password_protection_enabled: bool = False + anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024) calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences) slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1) participants: list[SchedulingParticipantInput] = Field(default_factory=list) @@ -113,6 +163,14 @@ class SchedulingRequestCreateRequest(BaseModel): _validate_timezone = field_validator("timezone")(_known_timezone) + @model_validator(mode="after") + def validate_anonymous_password(self) -> "SchedulingRequestCreateRequest": + if self.anonymous_password_protection_enabled and self.anonymous_password is None: + raise ValueError("anonymous_password is required when password protection is enabled") + if not self.anonymous_password_protection_enabled and self.anonymous_password is not None: + raise ValueError("anonymous_password requires password protection to be enabled") + return self + class SchedulingRequestUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -125,9 +183,40 @@ class SchedulingRequestUpdateRequest(BaseModel): allow_participant_updates: bool | None = None result_visibility: SchedulingResultVisibility | None = None participant_visibility: SchedulingParticipantVisibility | None = None + notify_on_answers: bool | None = None + single_choice: bool | None = None + max_participants_per_option: int | None = Field(default=None, ge=1) + allow_maybe: bool | None = None + allow_comments: bool | None = None + participant_email_required: bool | None = None + anonymous_password_protection_enabled: bool | None = None + anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024) calendar: SchedulingCalendarPreferences | None = None + slots: list[SchedulingCandidateSlotReconcileInput] | None = Field( + default=None, + min_length=1, + ) + participants: list[SchedulingParticipantReconcileInput] | None = None + create_participant_invitations: bool = True metadata: dict[str, Any] | None = None + @model_validator(mode="after") + def validate_anonymous_password(self) -> "SchedulingRequestUpdateRequest": + if self.anonymous_password_protection_enabled is False and self.anonymous_password is not None: + raise ValueError("anonymous_password cannot be set while password protection is disabled") + return self + + @model_validator(mode="after") + def validate_reconciliation_ids(self) -> "SchedulingRequestUpdateRequest": + for field_name in ("slots", "participants"): + values = getattr(self, field_name) + if values is None: + continue + ids = [value.id for value in values if value.id is not None] + if len(ids) != len(set(ids)): + raise ValueError(f"Duplicate ids are not allowed in {field_name}") + return self + class SchedulingCandidateSlotResponse(BaseModel): id: str @@ -149,11 +238,12 @@ class SchedulingCandidateSlotResponse(BaseModel): class SchedulingParticipantResponse(BaseModel): id: str + is_current_participant: bool = False respondent_id: str | None = None display_name: str | None = None email: str | None = None - participant_type: str - required: bool + participant_type: str | None = None + required: bool | None = None status: str poll_invitation_id: str | None = None invitation_token: str | None = None @@ -178,7 +268,7 @@ class SchedulingParticipantVisibilityDecisionResponse(BaseModel): class SchedulingRequestResponse(BaseModel): id: str - tenant_id: str + tenant_id: str | None = None title: str description: str | None = None location: str | None = None @@ -192,14 +282,23 @@ class SchedulingRequestResponse(BaseModel): allow_participant_updates: bool result_visibility: str participant_visibility: SchedulingParticipantVisibility + notify_on_answers: bool + single_choice: bool + max_participants_per_option: int | None = None + allow_maybe: bool + allow_comments: bool + participant_email_required: bool + anonymous_password_protection_enabled: bool + public_participation_policy_enforcement_available: bool | None = None + public_participation_policy_enforcement_reason: str | None = None effective_participant_visibility: SchedulingParticipantVisibility participant_aggregate: SchedulingParticipantAggregateResponse participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse - calendar_integration_enabled: bool + calendar_integration_enabled: bool | None = None calendar_id: str | None = None - calendar_freebusy_enabled: bool - calendar_hold_enabled: bool - create_calendar_event_on_decision: bool + calendar_freebusy_enabled: bool | None = None + calendar_hold_enabled: bool | None = None + create_calendar_event_on_decision: bool | None = None calendar_event_id: str | None = None handed_off_at: datetime | None = None cancelled_at: datetime | None = None @@ -238,6 +337,7 @@ class SchedulingAvailabilityResponseRequest(BaseModel): model_config = ConfigDict(extra="forbid") answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1) + comment: str | None = Field(default=None, max_length=4000) @model_validator(mode="after") def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest": @@ -258,6 +358,70 @@ class SchedulingAvailabilityResponse(BaseModel): has_response: bool = False submitted_at: datetime | None = None answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list) + comment: str | None = None + + +class SchedulingPublicParticipationAccessRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + participant_email: str | None = Field(default=None, max_length=320) + password: SecretStr | None = Field(default=None, max_length=1024) + + _validate_participant_email = field_validator("participant_email")(_participant_email) + + +class SchedulingPublicParticipationSubmitRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1) + participant_email: str | None = Field(default=None, max_length=320) + password: SecretStr | None = Field(default=None, max_length=1024) + comment: str | None = Field(default=None, max_length=4000) + idempotency_key: str | None = Field(default=None, min_length=1, max_length=255) + + _validate_participant_email = field_validator("participant_email")(_participant_email) + + @model_validator(mode="after") + def validate_unique_slots(self) -> "SchedulingPublicParticipationSubmitRequest": + slot_ids = [answer.slot_id for answer in self.answers] + if len(slot_ids) != len(set(slot_ids)): + raise ValueError("Each scheduling slot can be answered only once") + return self + + +class SchedulingPublicCandidateSlotResponse(BaseModel): + id: str + label: str + description: str | None = None + start_at: datetime + end_at: datetime + timezone: str + location: str | None = None + position: int + revision: str + + +class SchedulingPublicParticipationResponse(BaseModel): + request_id: str + title: str + description: str | None = None + location: str | None = None + timezone: str + status: str + deadline_at: datetime | None = None + participant_email_required: bool + anonymous_password_required: bool + single_choice: bool + max_participants_per_option: int | None = None + allow_maybe: bool + allow_comments: bool + allow_participant_updates: bool + has_response: bool = False + submitted_at: datetime | None = None + answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list) + comment: str | None = None + replayed: bool = False + slots: list[SchedulingPublicCandidateSlotResponse] = Field(default_factory=list) class SchedulingPollOptionResultResponse(BaseModel): diff --git a/src/govoplan_scheduling/backend/security.py b/src/govoplan_scheduling/backend/security.py new file mode 100644 index 0000000..5b3d081 --- /dev/null +++ b/src/govoplan_scheduling/backend/security.py @@ -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"] diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 0ed4c19..9a14101 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -4,6 +4,7 @@ import hashlib import json from dataclasses import dataclass from datetime import datetime, timezone +from functools import lru_cache from typing import Any, cast from sqlalchemy.orm import Session, object_session @@ -19,15 +20,11 @@ from govoplan_core.core.poll import ( PollAnswerRequest, PollCapabilityError, PollCreateCommand, - PollInvitationCommand, PollOptionUpdateCommand, PollOptionRequest, PollResponseRef, - PollResponseSubmissionProvider, PollSchedulingProvider, - PollSubmitResponseCommand, PollUpdateCommand, - poll_response_submission_provider, poll_scheduling_provider, ) from govoplan_core.core.policy import ( @@ -35,16 +32,41 @@ from govoplan_core.core.policy import ( SchedulingParticipantPrivacyRequest, scheduling_participant_privacy_policy, ) +from govoplan_core.core.throttling import ( + FixedWindowThrottle, + ThrottleDimension, + build_fixed_window_throttle, +) from govoplan_core.db.base import utcnow from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest from govoplan_scheduling.backend.schemas import ( SchedulingAvailabilityResponseRequest, + SchedulingCandidateSlotReconcileInput, SchedulingCandidateSlotUpdateRequest, SchedulingDecisionRequest, + SchedulingParticipantReconcileInput, + SchedulingPublicParticipationAccessRequest, + SchedulingPublicParticipationSubmitRequest, SchedulingRequestCreateRequest, SchedulingRequestUpdateRequest, ) -from govoplan_scheduling.backend.runtime import get_registry +from govoplan_scheduling.backend.runtime import get_registry, get_settings +from govoplan_scheduling.backend.security import ( + hash_participant_password, + verify_participant_password, +) +from govoplan_poll.backend.participation import ( + ANONYMOUS_PASSWORD_REQUIREMENT, + PollGovernedInvitationCommand, + PollGovernedResponseCommand, + PollGovernedResponseRef, + PollParticipationContextRef, + PollParticipationGatewayProvider, + PollParticipationPolicy, + PollResponseGatewayRef, + participation_token_fingerprint, + poll_participation_gateway_provider, +) class SchedulingError(ValueError): @@ -59,6 +81,12 @@ class SchedulingConflictError(SchedulingError): pass +class SchedulingPublicParticipationError(SchedulingError): + def __init__(self, *, retry_after_seconds: int = 0) -> None: + super().__init__("Scheduling participation link or credentials are invalid") + self.retry_after_seconds = max(0, retry_after_seconds) + + WORKFLOW_DRAFT = "draft" WORKFLOW_COLLECTING = "collecting_availability" WORKFLOW_CLOSED = "availability_closed" @@ -66,6 +94,14 @@ WORKFLOW_DECIDED = "slot_decided" WORKFLOW_HANDED_OFF = "handed_off" WORKFLOW_CANCELLED = "cancelled" NOTIFICATION_KINDS = {"invitation", "reminder", "decision", "cancellation"} +PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON = ( + "Poll signed-participation policy enforcement is unavailable; restricted " + "public links are disabled until the Poll gateway contract is installed." +) +SCHEDULING_PARTICIPATION_GATEWAY = "scheduling" +PARTICIPATION_PASSWORD_ATTEMPT_LIMIT = 10 +PARTICIPATION_PASSWORD_REQUEST_LIMIT = 100 +PARTICIPATION_PASSWORD_WINDOW_SECONDS = 15 * 60 def response_datetime(value: datetime | None) -> datetime | None: @@ -153,6 +189,15 @@ def _active_participants(request: SchedulingRequest) -> list[SchedulingParticipa return [participant for participant in request.participants if participant.deleted_at is None] +def _stable_participant_respondent_id( + participant: SchedulingParticipant, +) -> str: + if participant.respondent_id: + return participant.respondent_id + participant.respondent_id = f"scheduling-participant:{participant.id}" + return participant.respondent_id + + def _participant_for_actor( request: SchedulingRequest, *, @@ -162,7 +207,7 @@ def _participant_for_actor( participants = [ participant for participant in _active_participants(request) - if participant.respondent_id in ids or participant.email in ids + if _participant_matches_actor(participant, ids) ] if len(participants) != 1: raise SchedulingPermissionError( @@ -265,11 +310,79 @@ def _poll_provider() -> PollSchedulingProvider: return provider -def _poll_response_provider() -> PollResponseSubmissionProvider: - provider = poll_response_submission_provider(get_registry()) - if provider is None: - raise SchedulingError("Poll response submission capability is unavailable") - return provider +def _poll_participation_provider() -> PollParticipationGatewayProvider | None: + return poll_participation_gateway_provider(get_registry()) + + +def _public_participation_gateway(request_id: str) -> PollResponseGatewayRef: + return PollResponseGatewayRef( + module_id="scheduling", + resource_type="scheduling_request", + resource_id=request_id, + ) + + +def _public_participation_policy(request: SchedulingRequest) -> PollParticipationPolicy: + return PollParticipationPolicy( + single_choice=request.single_choice, + allow_maybe=request.allow_maybe, + max_participants_per_option=request.max_participants_per_option, + allow_comments=request.allow_comments, + participant_email_required=request.participant_email_required, + anonymous_password_required=request.anonymous_password_protection_enabled, + ) + + +@lru_cache(maxsize=8) +def _configured_participation_password_throttle( + redis_url: str | None, +) -> FixedWindowThrottle: + return build_fixed_window_throttle( + redis_url=redis_url, + window_seconds=PARTICIPATION_PASSWORD_WINDOW_SECONDS, + key_prefix="govoplan:scheduling:participation-password:v1", + ) + + +def _participation_password_throttle() -> FixedWindowThrottle: + settings = get_settings() + configured_url = getattr(settings, "redis_url", None) + redis_url = configured_url if isinstance(configured_url, str) else None + return _configured_participation_password_throttle(redis_url) + + +def _participation_password_dimensions( + request: SchedulingRequest, + *, + token: str, + client_address: str | None, +) -> tuple[ThrottleDimension, ThrottleDimension]: + token_subject = ":".join( + ( + request.tenant_id, + request.id, + participation_token_fingerprint(token), + ) + ) + request_subject = ":".join( + ( + request.tenant_id, + request.id, + client_address or "unknown-client", + ) + ) + return ( + ThrottleDimension( + namespace="poll-participation-password", + subject=token_subject, + limit=PARTICIPATION_PASSWORD_ATTEMPT_LIMIT, + ), + ThrottleDimension( + namespace="poll-participation-password-request", + subject=request_subject, + limit=PARTICIPATION_PASSWORD_REQUEST_LIMIT, + ), + ) def _set_calendar_preferences(request: SchedulingRequest, payload) -> None: @@ -632,7 +745,12 @@ def _notification_dispatch_request( subject=_notification_subject(request, notification.event_kind), body_text=_notification_body_text(request, participant, notification.event_kind), action_url=( - f"/poll/public/{invitation_token}" + ( + f"/scheduling/public/{request.id}/{invitation_token}" + if participant is not None + and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY + else f"/poll/public/{invitation_token}" + ) if notification.event_kind == "invitation" and invitation_token else f"/scheduling?request_id={request.id}" ), @@ -780,9 +898,9 @@ def list_visible_scheduling_notifications( (item for item in _active_participants(request) if item.id == notification.participant_id), None, ) - if notification.recipient in ids or ( + if _identity_matches_actor(notification.recipient, ids) or ( participant is not None - and (participant.respondent_id in ids or participant.email in ids) + and _participant_matches_actor(participant, ids) ): visible.append(notification) return visible @@ -840,18 +958,24 @@ def _apply_participant_response( "poll_response_respondent_id": response.respondent_id, } changed = True - if participant.status == "responded": + submitted_at = response_datetime(response.submitted_at) + response_changed = ( + participant.status != "responded" + or response_datetime(participant.responded_at) != submitted_at + ) + if not response_changed: return changed participant.status = "responded" - participant.responded_at = response_datetime(response.submitted_at) - _emit_scheduling_center_notification( - session, - request=request, - participant=participant, - event_kind="scheduling.participant_responded", - subject=f"Scheduling response: {request.title}", - body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.", - ) + participant.responded_at = submitted_at + if request.notify_on_answers: + _emit_scheduling_center_notification( + session, + request=request, + participant=participant, + event_kind="scheduling.participant_responded", + subject=f"Scheduling response: {request.title}", + body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.", + ) return True @@ -872,6 +996,7 @@ def _reset_missing_participant_responses( continue participant.status = "invited" if participant.poll_invitation_id else "draft" participant.responded_at = None + participant.response_comment = None if isinstance(stored_identity, str): participant.metadata_ = { key: value @@ -906,7 +1031,7 @@ def refresh_participant_response_state( actor_participants = [ participant for participant in participants - if participant.respondent_id in ids or participant.email in ids + if _participant_matches_actor(participant, ids) ] matched_participant_ids: set[str] = set() unmatched_response_count = 0 @@ -945,6 +1070,12 @@ def create_scheduling_request( user_id: str | None, payload: SchedulingRequestCreateRequest, ) -> tuple[SchedulingRequest, dict[str, str]]: + participation_provider = _poll_participation_provider() + issue_public_invitations = ( + payload.create_participant_invitations and payload.status != "draft" + ) + if issue_public_invitations and participation_provider is None: + raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON) if not payload.allow_external_participants: for participant_input in payload.participants: if participant_input.participant_type == "external": @@ -962,6 +1093,18 @@ def create_scheduling_request( allow_participant_updates=payload.allow_participant_updates, result_visibility=payload.result_visibility, participant_visibility=payload.participant_visibility, + notify_on_answers=payload.notify_on_answers, + single_choice=payload.single_choice, + max_participants_per_option=payload.max_participants_per_option, + allow_maybe=payload.allow_maybe, + allow_comments=payload.allow_comments, + participant_email_required=payload.participant_email_required, + anonymous_password_protection_enabled=payload.anonymous_password_protection_enabled, + anonymous_password_hash=( + hash_participant_password(payload.anonymous_password.get_secret_value()) + if payload.anonymous_password is not None + else None + ), metadata_=payload.metadata, ) _set_calendar_preferences(request, payload) @@ -992,7 +1135,7 @@ def create_scheduling_request( email=participant_input.email, participant_type=participant_input.participant_type, required=participant_input.required, - status="draft" if payload.status == "draft" else "invited", + status="invited" if issue_public_invitations else "draft", metadata_=participant_input.metadata, ) session.add(participant) @@ -1022,7 +1165,18 @@ def create_scheduling_request( opens_at=None, closes_at=payload.deadline_at, options=tuple(_poll_option_inputs(request)), - metadata={"scheduling_request_id": request.id, "calendar": payload.calendar.model_dump()}, + metadata={ + "scheduling_request_id": request.id, + "calendar": payload.calendar.model_dump(), + "scheduling_response_settings": { + "single_choice": payload.single_choice, + "max_participants_per_option": payload.max_participants_per_option, + "allow_maybe": payload.allow_maybe, + "allow_comments": payload.allow_comments, + "participant_email_required": payload.participant_email_required, + "anonymous_password_protection_enabled": payload.anonymous_password_protection_enabled, + }, + }, ), ) except PollCapabilityError as exc: @@ -1033,21 +1187,30 @@ def create_scheduling_request( slot.poll_option_id = options_by_position.get(slot.position) invitation_tokens: dict[str, str] = {} - if payload.create_participant_invitations: + if issue_public_invitations: + if participation_provider is None: # Guarded above; narrows the type. + raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON) for participant in _active_participants(request): try: - invitation = _poll_provider().create_invitation( + respondent_id = _stable_participant_respondent_id(participant) + invitation = participation_provider.create_governed_invitation( session, tenant_id=tenant_id, poll_id=poll.id, - command=PollInvitationCommand( - respondent_id=participant.respondent_id, + command=PollGovernedInvitationCommand( + gateway=_public_participation_gateway(request.id), + policy=_public_participation_policy(request), + respondent_id=respondent_id, respondent_label=participant.display_name, email=participant.email, expires_at=payload.deadline_at, - metadata={"scheduling_request_id": request.id, "scheduling_participant_id": participant.id}, + metadata={ + "scheduling_request_id": request.id, + "scheduling_participant_id": participant.id, + }, ), ) + participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc participant.poll_invitation_id = invitation.id @@ -1074,7 +1237,36 @@ def list_scheduling_requests(session: Session, *, tenant_id: str, status: str | def _actor_ids(values: tuple[str, ...]) -> tuple[str, ...]: - return tuple(dict.fromkeys(str(value) for value in values if value)) + aliases: list[str] = [] + for value in values: + if not value: + continue + identity = str(value) + aliases.append(identity) + if "@" in identity: + aliases.append(identity.strip().casefold()) + return tuple(dict.fromkeys(aliases)) + + +def _identity_matches_actor( + value: str | None, + actor_ids: set[str] | tuple[str, ...], +) -> bool: + if not value: + return False + if value in actor_ids: + return True + return "@" in value and value.strip().casefold() in actor_ids + + +def _participant_matches_actor( + participant: SchedulingParticipant, + actor_ids: set[str] | tuple[str, ...], +) -> bool: + return _identity_matches_actor( + participant.respondent_id, + actor_ids, + ) or _identity_matches_actor(participant.email, actor_ids) def scheduling_request_is_visible( @@ -1091,7 +1283,7 @@ def scheduling_request_is_visible( if request.organizer_user_id in ids: return True return any( - participant.respondent_id in ids or participant.email in ids + _participant_matches_actor(participant, ids) for participant in _active_participants(request) ) @@ -1128,6 +1320,7 @@ def _lock_scheduling_request( tenant_id: str, request_id: str, lock_slots: bool = False, + lock_participants: bool = False, ) -> SchedulingRequest: """Serialize lifecycle and Calendar handoff checks for one request.""" @@ -1156,6 +1349,18 @@ def _lock_scheduling_request( .with_for_update() .all() ) + if lock_participants: + ( + session.query(SchedulingParticipant) + .filter( + SchedulingParticipant.tenant_id == tenant_id, + SchedulingParticipant.request_id == request.id, + ) + .order_by(SchedulingParticipant.id.asc()) + .populate_existing() + .with_for_update() + .all() + ) return request @@ -1188,6 +1393,20 @@ def get_visible_scheduling_request( return request +def _require_scheduling_response_collection_open( + request: SchedulingRequest, +) -> None: + """Enforce Scheduling's lifecycle independently of Poll's projection.""" + + if request.status != "collecting": + raise SchedulingError("Scheduling request is not collecting availability") + if ( + request.deadline_at is not None + and response_datetime(request.deadline_at) <= _now() + ): + raise SchedulingError("Scheduling response deadline has passed") + + def submit_scheduling_availability( session: Session, *, @@ -1198,14 +1417,14 @@ def submit_scheduling_availability( respondent_label: str | None, payload: SchedulingAvailabilityResponseRequest, ) -> SchedulingRequest: - request = get_visible_scheduling_request( + request = _lock_scheduling_request( session, tenant_id=tenant_id, request_id=request_id, - actor_ids=actor_ids, ) - if request.status != "collecting": - raise SchedulingError("Scheduling request is not collecting availability") + if not scheduling_request_is_visible(request, actor_ids=actor_ids): + raise SchedulingError("Scheduling request not found") + _require_scheduling_response_collection_open(request) if request.poll_id is None: raise SchedulingError("Scheduling request has no backing poll") if respondent_id is None: @@ -1215,15 +1434,19 @@ def submit_scheduling_availability( participant, fallback_respondent_id=respondent_id, ) - existing_response = _participant_poll_response( - session, - request=request, - participant=participant, - actor_ids=actor_ids, - canonical_respondent_id=canonical_respondent_id, - ) - if existing_response is not None and existing_response.respondent_id: - canonical_respondent_id = existing_response.respondent_id + if not ( + participant.poll_invitation_id is not None + and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY + ): + existing_response = _participant_poll_response( + session, + request=request, + participant=participant, + actor_ids=actor_ids, + canonical_respondent_id=canonical_respondent_id, + ) + if existing_response is not None and existing_response.respondent_id: + canonical_respondent_id = existing_response.respondent_id answers: list[PollAnswerRequest] = [] for answer in payload.answers: slot = _selected_slot(request, slot_id=answer.slot_id) @@ -1239,36 +1462,57 @@ def submit_scheduling_availability( value=answer.value, ) ) + provider = _ensure_authenticated_participation_invitation( + session, + request=request, + participant=participant, + respondent_id=canonical_respondent_id, + ) + gateway = _public_participation_gateway(request.id) try: - response = _poll_response_provider().submit_response( + provider.resolve_authenticated_participation( session, tenant_id=tenant_id, poll_id=request.poll_id, - command=PollSubmitResponseCommand( + invitation_id=cast(str, participant.poll_invitation_id), + gateway=gateway, + respondent_id=canonical_respondent_id, + ) + response = provider.submit_authenticated_response( + session, + tenant_id=tenant_id, + poll_id=request.poll_id, + invitation_id=cast(str, participant.poll_invitation_id), + gateway=gateway, + respondent_id=canonical_respondent_id, + command=PollGovernedResponseCommand( respondent_id=canonical_respondent_id, respondent_label=respondent_label, + participant_email=participant.email, + participant_is_authenticated=True, answers=tuple(answers), + comment=payload.comment, metadata={ "scheduling_participant_id": participant.id, - **( - {"invitation_id": participant.poll_invitation_id} - if participant.poll_invitation_id - else {} - ), }, ), ) except PollCapabilityError as exc: - raise SchedulingError(str(exc)) from exc - first_response = participant.status != "responded" + message = str(exc) + if "Participant limit reached" in message: + raise SchedulingConflictError(message) from exc + raise SchedulingError(message) from exc participant.status = "responded" - participant.responded_at = response_datetime(response.submitted_at) - if response.respondent_id: + participant.responded_at = response_datetime(response.response.submitted_at) + participant.response_comment = response.comment + if participant.respondent_id is None: + participant.respondent_id = canonical_respondent_id + if response.response.respondent_id: participant.metadata_ = { **(participant.metadata_ or {}), - "poll_response_respondent_id": response.respondent_id, + "poll_response_respondent_id": response.response.respondent_id, } - if first_response: + if request.notify_on_answers and not response.replayed: _emit_scheduling_center_notification( session, request=request, @@ -1284,6 +1528,62 @@ def submit_scheduling_availability( return request +def _ensure_authenticated_participation_invitation( + session: Session, + *, + request: SchedulingRequest, + participant: SchedulingParticipant, + respondent_id: str, +) -> PollParticipationGatewayProvider: + provider = _poll_participation_provider() + if provider is None: + raise SchedulingError( + "Poll governed participation capability is unavailable" + ) + if ( + participant.poll_invitation_id is not None + and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY + ): + return provider + if request.poll_id is None: + raise SchedulingError("Scheduling request has no backing poll") + try: + if participant.poll_invitation_id is not None: + provider.revoke_invitation( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + invitation_id=participant.poll_invitation_id, + ) + invitation = provider.create_governed_invitation( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + command=PollGovernedInvitationCommand( + gateway=_public_participation_gateway(request.id), + policy=_public_participation_policy(request), + respondent_id=respondent_id, + respondent_label=participant.display_name, + email=participant.email, + expires_at=request.deadline_at, + metadata={ + "scheduling_request_id": request.id, + "scheduling_participant_id": participant.id, + "public_link_issued": False, + }, + ), + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + # The token is deliberately discarded. Authenticated callers use the + # in-process invitation-id contract and no public link has been issued. + participant.poll_invitation_id = invitation.id + participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY + if participant.respondent_id is None: + participant.respondent_id = respondent_id + return provider + + def get_scheduling_availability_response( session: Session, *, @@ -1307,13 +1607,41 @@ def get_scheduling_availability_response( participant, fallback_respondent_id=respondent_id, ) - response = _participant_poll_response( - session, - request=request, - participant=participant, - actor_ids=actor_ids, - canonical_respondent_id=canonical_respondent_id, - ) + response: PollResponseRef | None = None + if not ( + participant.poll_invitation_id is not None + and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY + ): + response = _participant_poll_response( + session, + request=request, + participant=participant, + actor_ids=actor_ids, + canonical_respondent_id=canonical_respondent_id, + ) + if response is not None and response.respondent_id: + canonical_respondent_id = response.respondent_id + if ( + participant.poll_invitation_id is not None + and participant.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY + ): + provider = _poll_participation_provider() + if provider is None: + raise SchedulingError( + "Poll governed participation capability is unavailable" + ) + try: + context = provider.resolve_authenticated_participation( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + invitation_id=participant.poll_invitation_id, + gateway=_public_participation_gateway(request.id), + respondent_id=canonical_respondent_id, + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + response = context.response.response if context.response is not None else None slot_by_option_id = { slot.poll_option_id: slot for slot in _active_slots(request) @@ -1336,9 +1664,325 @@ def get_scheduling_availability_response( "has_response": response is not None, "submitted_at": response_datetime(response.submitted_at) if response is not None else None, "answers": answers, + "comment": participant.response_comment if request.allow_comments else None, } +def _resolve_public_scheduling_participation( + session: Session, + *, + request_id: str, + token: str, + payload: SchedulingPublicParticipationAccessRequest, + client_address: str | None, + lock_for_submission: bool = False, +) -> tuple[ + SchedulingRequest, + SchedulingParticipant, + PollParticipationContextRef, +]: + request = ( + session.query(SchedulingRequest) + .filter( + SchedulingRequest.id == request_id, + SchedulingRequest.deleted_at.is_(None), + ) + .one_or_none() + ) + if request is None: + raise SchedulingPublicParticipationError() + + if lock_for_submission: + # Scheduling mutations acquire their request/participant locks before + # entering Poll. Public response submission must use the same order: + # otherwise a participant reconciliation (Scheduling -> Poll) can + # deadlock with a response (Poll -> Scheduling), and two first + # responses can both observe an unbound participant email. + try: + request = _lock_scheduling_request( + session, + tenant_id=request.tenant_id, + request_id=request.id, + lock_participants=True, + ) + except SchedulingError as exc: + raise SchedulingPublicParticipationError() from exc + try: + _require_scheduling_response_collection_open(request) + except SchedulingError as exc: + # Public participation deliberately does not disclose whether a + # token, lifecycle state, or deadline caused rejection. + raise SchedulingPublicParticipationError() from exc + + password_dimensions: tuple[ThrottleDimension, ...] = () + if request.anonymous_password_protection_enabled: + password_dimensions = _participation_password_dimensions( + request, + token=token, + client_address=client_address, + ) + throttle = _participation_password_throttle() + decision = throttle.check(password_dimensions) + if not decision.allowed: + raise SchedulingPublicParticipationError( + retry_after_seconds=decision.retry_after_seconds, + ) + supplied_password = ( + payload.password.get_secret_value() + if payload.password is not None + else "" + ) + if not verify_participant_password( + supplied_password, + request.anonymous_password_hash, + ): + decision = throttle.record(password_dimensions) + raise SchedulingPublicParticipationError( + retry_after_seconds=( + decision.retry_after_seconds + if not decision.allowed + else 0 + ), + ) + + provider = _poll_participation_provider() + if provider is None: + raise SchedulingPublicParticipationError() + gateway = _public_participation_gateway(request.id) + try: + context = provider.resolve_participation( + session, + token=token, + gateway=gateway, + participant_email=payload.participant_email, + participant_is_authenticated=False, + verified_requirements=( + frozenset({ANONYMOUS_PASSWORD_REQUIREMENT}) + if request.anonymous_password_protection_enabled + else frozenset() + ), + ) + except PollCapabilityError as exc: + raise SchedulingPublicParticipationError() from exc + if ( + context.tenant_id != request.tenant_id + or context.poll_id != request.poll_id + or context.gateway != gateway + ): + raise SchedulingPublicParticipationError() + participant = next( + ( + item + for item in _active_participants(request) + if item.poll_invitation_id == context.invitation_id + and item.participation_gateway == SCHEDULING_PARTICIPATION_GATEWAY + ), + None, + ) + if participant is None: + raise SchedulingPublicParticipationError() + if ( + participant.email is not None + and payload.participant_email is not None + and participant.email.strip().casefold() + != payload.participant_email.strip().casefold() + ): + raise SchedulingPublicParticipationError() + if password_dimensions: + # Clear only the token-specific bucket. The broader request/client + # dimension deliberately retains failures to resist token rotation. + _participation_password_throttle().reset(password_dimensions[:1]) + return request, participant, context + + +def _public_scheduling_participation_response( + request: SchedulingRequest, + context: PollParticipationContextRef, + *, + response: PollGovernedResponseRef | None = None, +) -> dict[str, Any]: + current_response = response or context.response + slot_by_option_id = { + slot.poll_option_id: slot + for slot in _active_slots(request) + if slot.poll_option_id is not None + } + slot_by_option_key = { + f"slot-{slot.position + 1}": slot + for slot in _active_slots(request) + } + answers: list[dict[str, str]] = [] + if current_response is not None: + for answer in current_response.response.answers: + slot = ( + slot_by_option_id.get(answer.option_id) + or slot_by_option_key.get(answer.option_key) + ) + if slot is None or answer.value not in { + "available", + "maybe", + "unavailable", + }: + continue + answers.append({"slot_id": slot.id, "value": str(answer.value)}) + return { + "request_id": request.id, + "title": request.title, + "description": request.description, + "location": request.location, + "timezone": request.timezone, + "status": request.status, + "deadline_at": response_datetime(request.deadline_at), + "participant_email_required": context.policy.participant_email_required, + "anonymous_password_required": context.policy.anonymous_password_required, + "single_choice": context.policy.single_choice, + "max_participants_per_option": context.policy.max_participants_per_option, + "allow_maybe": context.policy.allow_maybe, + "allow_comments": context.policy.allow_comments, + "allow_participant_updates": request.allow_participant_updates, + "has_response": current_response is not None, + "submitted_at": ( + response_datetime(current_response.response.submitted_at) + if current_response is not None + else None + ), + "answers": answers, + "comment": current_response.comment if current_response is not None else None, + "replayed": current_response.replayed if current_response is not None else False, + "slots": [ + { + "id": slot.id, + "label": slot.label, + "description": slot.description, + "start_at": response_datetime(slot.start_at), + "end_at": response_datetime(slot.end_at), + "timezone": slot.timezone, + "location": slot.location, + "position": slot.position, + "revision": scheduling_slot_revision(slot), + } + for slot in _active_slots(request) + ], + } + + +def get_public_scheduling_participation( + session: Session, + *, + request_id: str, + token: str, + payload: SchedulingPublicParticipationAccessRequest, + client_address: str | None, +) -> dict[str, Any]: + request, _participant, context = _resolve_public_scheduling_participation( + session, + request_id=request_id, + token=token, + payload=payload, + client_address=client_address, + ) + return _public_scheduling_participation_response(request, context) + + +def submit_public_scheduling_participation( + session: Session, + *, + request_id: str, + token: str, + payload: SchedulingPublicParticipationSubmitRequest, + client_address: str | None, +) -> dict[str, Any]: + access_payload = SchedulingPublicParticipationAccessRequest( + participant_email=payload.participant_email, + password=payload.password, + ) + request, participant, context = _resolve_public_scheduling_participation( + session, + request_id=request_id, + token=token, + payload=access_payload, + client_address=client_address, + lock_for_submission=True, + ) + answers: list[PollAnswerRequest] = [] + for answer in payload.answers: + slot = _selected_slot(request, slot_id=answer.slot_id) + if slot.poll_option_id is None: + raise SchedulingError("Scheduling slot has no backing poll option") + if answer.option_revision != scheduling_slot_revision(slot): + raise SchedulingConflictError( + "Scheduling options changed after this response form was loaded; reload before responding" + ) + answers.append( + PollAnswerRequest( + option_id=slot.poll_option_id, + value=answer.value, + ) + ) + verified_requirements = ( + frozenset({ANONYMOUS_PASSWORD_REQUIREMENT}) + if request.anonymous_password_protection_enabled + else frozenset() + ) + provider = _poll_participation_provider() + if provider is None: + raise SchedulingPublicParticipationError() + try: + governed_response = provider.submit_governed_response( + session, + token=token, + gateway=_public_participation_gateway(request.id), + command=PollGovernedResponseCommand( + respondent_label=participant.display_name, + participant_email=payload.participant_email, + participant_is_authenticated=False, + answers=tuple(answers), + comment=payload.comment, + verified_requirements=verified_requirements, + idempotency_key=payload.idempotency_key, + metadata={"scheduling_participant_id": participant.id}, + ), + ) + except PollCapabilityError as exc: + message = str(exc) + if message == "Poll invitation not found": + raise SchedulingPublicParticipationError() from exc + if "Participant limit reached" in message or "Idempotency key" in message: + raise SchedulingConflictError(message) from exc + raise SchedulingError(message) from exc + + participant.status = "responded" + participant.responded_at = response_datetime( + governed_response.response.submitted_at + ) + participant.response_comment = governed_response.comment + if governed_response.response.respondent_id: + participant.metadata_ = { + **(participant.metadata_ or {}), + "poll_response_respondent_id": governed_response.response.respondent_id, + } + if participant.email is None and governed_response.participant_email is not None: + participant.email = governed_response.participant_email + if request.notify_on_answers and not governed_response.replayed: + _emit_scheduling_center_notification( + session, + request=request, + participant=participant, + event_kind="scheduling.participant_responded", + subject=f"Scheduling response: {request.title}", + body_text=( + f"{participant.display_name or participant.email or 'A participant'} " + f"responded to {request.title}." + ), + ) + session.flush() + return _public_scheduling_participation_response( + request, + context, + response=governed_response, + ) + + def require_visible_scheduling_results( session: Session, *, @@ -1361,23 +2005,74 @@ def require_visible_scheduling_results( refresh_participant_response_state(session, request=request) if any( participant.status == "responded" - and (participant.respondent_id in ids or participant.email in ids) + and _participant_matches_actor(participant, ids) for participant in _active_participants(request) ): return raise SchedulingError("Scheduling results are not visible") -def update_scheduling_request( +def _update_scheduling_request_with_invitation_tokens( session: Session, *, tenant_id: str, request_id: str, payload: SchedulingRequestUpdateRequest, -) -> SchedulingRequest: - request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) +) -> tuple[SchedulingRequest, dict[str, str]]: + request = _lock_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + lock_slots=payload.slots is not None, + lock_participants=( + payload.participants is not None + or "deadline_at" in payload.model_fields_set + ), + ) if request.status in {"decided", "handed_off", "cancelled", "archived"}: raise SchedulingError("Decided, handed off, cancelled, or archived scheduling requests cannot be edited") + deadline_changed = ( + "deadline_at" in payload.model_fields_set + and response_datetime(payload.deadline_at) + != response_datetime(request.deadline_at) + ) + invitation_ids_before_deadline_change = { + participant.id: participant.poll_invitation_id + for participant in _active_participants(request) + if participant.poll_invitation_id is not None + } + if ( + deadline_changed + and invitation_ids_before_deadline_change + and _poll_participation_provider() is None + ): + raise SchedulingError( + "Poll participation invitation rotation capability is unavailable" + ) + policy_changed = any( + field in payload.model_fields_set + and getattr(payload, field) is not None + and getattr(payload, field) != getattr(request, field) + for field in ( + "single_choice", + "allow_maybe", + "allow_comments", + "participant_email_required", + "anonymous_password_protection_enabled", + ) + ) or ( + "max_participants_per_option" in payload.model_fields_set + and payload.max_participants_per_option + != request.max_participants_per_option + ) + if ( + policy_changed + and any(participant.poll_invitation_id for participant in _active_participants(request)) + ): + raise SchedulingError( + "Public participation policy cannot change after invitation links are issued" + ) + nullable_fields = {"description", "location", "deadline_at"} for field in ( "title", "description", @@ -1387,14 +2082,91 @@ def update_scheduling_request( "allow_participant_updates", "result_visibility", "participant_visibility", + "notify_on_answers", + "single_choice", + "allow_maybe", + "allow_comments", + "participant_email_required", ): + if field not in payload.model_fields_set: + continue value = getattr(payload, field) - if value is not None: - setattr(request, field, value) + if value is None and field not in nullable_fields: + raise SchedulingError(f"{field} cannot be null") + setattr(request, field, value) + if "max_participants_per_option" in payload.model_fields_set: + request.max_participants_per_option = payload.max_participants_per_option + if ( + "anonymous_password_protection_enabled" in payload.model_fields_set + and payload.anonymous_password_protection_enabled is not None + ): + if payload.anonymous_password_protection_enabled: + request.anonymous_password_protection_enabled = True + else: + request.anonymous_password_protection_enabled = False + request.anonymous_password_hash = None + if payload.anonymous_password is not None: + if not request.anonymous_password_protection_enabled: + raise SchedulingError( + "Password protection must be enabled before setting an anonymous participant password" + ) + request.anonymous_password_hash = hash_participant_password( + payload.anonymous_password.get_secret_value() + ) + if request.anonymous_password_protection_enabled and not request.anonymous_password_hash: + raise SchedulingError( + "anonymous_password is required when password protection is enabled" + ) if payload.calendar is not None: _set_calendar_preferences(request, payload) if payload.metadata is not None: request.metadata_ = payload.metadata + invitation_tokens: dict[str, str] = {} + slots_changed = False + if payload.slots is not None: + slots_changed = _reconcile_scheduling_slots( + session, + request=request, + supplied_slots=payload.slots, + ) + if payload.participants is not None: + invitation_tokens = _reconcile_scheduling_participants( + session, + request=request, + supplied_participants=payload.participants, + create_invitations=( + payload.create_participant_invitations + and request.status != "draft" + ), + ) + if deadline_changed: + for participant in _active_participants(request): + previous_invitation_id = invitation_ids_before_deadline_change.get( + participant.id + ) + if ( + previous_invitation_id is None + or participant.poll_invitation_id != previous_invitation_id + ): + # Removed participants were revoked by reconciliation, while + # newly added participants already use the new deadline. + continue + _update_participant_invitation_expiry( + session, + request=request, + participant=participant, + invitation_id=previous_invitation_id, + ) + if ( + not request.allow_external_participants + and any( + participant.participant_type == "external" + for participant in _active_participants(request) + ) + ): + raise SchedulingError( + "External participants are not allowed for this scheduling request" + ) if request.poll_id is not None: try: _poll_provider().update_poll( @@ -1413,10 +2185,49 @@ def update_scheduling_request( ) except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc + if slots_changed: + refresh_participant_response_state( + session, + request=request, + reset_missing=True, + ) session.flush() + return request, invitation_tokens + + +def update_scheduling_request( + session: Session, + *, + tenant_id: str, + request_id: str, + payload: SchedulingRequestUpdateRequest, +) -> SchedulingRequest: + request, _invitation_tokens = _update_scheduling_request_with_invitation_tokens( + session, + tenant_id=tenant_id, + request_id=request_id, + payload=payload, + ) return request +def update_scheduling_request_with_invitation_tokens( + session: Session, + *, + tenant_id: str, + request_id: str, + payload: SchedulingRequestUpdateRequest, +) -> tuple[SchedulingRequest, dict[str, str]]: + """Update a request and return any one-time tokens issued for new rows.""" + + return _update_scheduling_request_with_invitation_tokens( + session, + tenant_id=tenant_id, + request_id=request_id, + payload=payload, + ) + + @dataclass(frozen=True, slots=True) class _CandidateSlotChanges: label: str @@ -1532,6 +2343,445 @@ def _apply_candidate_slot_changes( slot.freebusy_conflicts = [] +def _candidate_slot_changes_from_reconcile( + request: SchedulingRequest, + payload: SchedulingCandidateSlotReconcileInput, +) -> _CandidateSlotChanges: + timezone_name = payload.timezone or request.timezone + normalized_start = cast(datetime, response_datetime(payload.start_at)) + normalized_end = cast(datetime, response_datetime(payload.end_at)) + label = payload.label or _slot_label( + payload.start_at, + payload.end_at, + timezone_name=timezone_name, + ) + return _CandidateSlotChanges( + label=label, + description=payload.description, + start_at=payload.start_at, + end_at=payload.end_at, + timezone=timezone_name, + location=payload.location if payload.location is not None else request.location, + metadata=dict(payload.metadata), + normalized_start=normalized_start, + normalized_end=normalized_end, + ) + + +def _validate_slot_reconciliation_order( + current_slots: list[SchedulingCandidateSlot], + supplied_slots: list[SchedulingCandidateSlotReconcileInput], +) -> None: + supplied_existing_ids = [item.id for item in supplied_slots if item.id is not None] + retained_ids = { + item.id + for item in supplied_slots + if item.id is not None + } + expected_existing_ids = [ + slot.id + for slot in current_slots + if slot.id in retained_ids + ] + if supplied_existing_ids != expected_existing_ids: + raise SchedulingError( + "Existing candidate slots cannot be reordered; retain their order and append new slots" + ) + encountered_new = False + for item in supplied_slots: + if item.id is None: + encountered_new = True + elif encountered_new: + raise SchedulingError( + "New candidate slots must be appended after existing slots" + ) + + +def _reconcile_scheduling_slots( + session: Session, + *, + request: SchedulingRequest, + supplied_slots: list[SchedulingCandidateSlotReconcileInput], +) -> bool: + """Apply a complete slot collection while keeping Poll answers coherent.""" + + if request.status not in {"draft", "collecting"}: + raise SchedulingError( + "Only draft or collecting scheduling request slots can be edited" + ) + if request.poll_id is None: + raise SchedulingError("Scheduling request has no backing poll") + current_slots = _active_slots(request) + current_by_id = {slot.id: slot for slot in current_slots} + unknown_ids = sorted( + item.id + for item in supplied_slots + if item.id is not None and item.id not in current_by_id + ) + if unknown_ids: + raise SchedulingError("Unknown scheduling candidate slot") + _validate_slot_reconciliation_order(current_slots, supplied_slots) + + supplied_ids = { + item.id + for item in supplied_slots + if item.id is not None + } + removed_slots = [slot for slot in current_slots if slot.id not in supplied_ids] + new_inputs = [item for item in supplied_slots if item.id is None] + if (removed_slots or new_inputs) and _poll_participation_provider() is None: + raise SchedulingError( + "Poll participation option mutation capability is unavailable" + ) + for slot in removed_slots: + if slot.tentative_hold_event_id: + raise SchedulingError( + "A candidate slot with a tentative calendar hold cannot be removed" + ) + if slot.poll_option_id is None: + raise SchedulingError("Scheduling slot has no backing poll option") + + planned_updates: list[ + tuple[SchedulingCandidateSlot, _CandidateSlotChanges, bool] + ] = [] + for item in supplied_slots: + if item.id is None: + continue + slot = current_by_id[item.id] + if item.revision != scheduling_slot_revision(slot): + raise SchedulingConflictError( + "Scheduling options changed after this edit form was loaded; reload before saving" + ) + changes = _candidate_slot_changes_from_reconcile(request, item) + changed, temporal_or_location_changed = _candidate_slot_change_flags( + slot, + changes, + ) + if ( + changed + and temporal_or_location_changed + and slot.tentative_hold_event_id + ): + raise SchedulingError( + "A slot with a tentative calendar hold cannot change time, timezone, or location" + ) + if changed: + if slot.poll_option_id is None: + raise SchedulingError("Scheduling slot has no backing poll option") + planned_updates.append((slot, changes, temporal_or_location_changed)) + + for slot, changes, temporal_or_location_changed in planned_updates: + _update_candidate_poll_option( + session, + tenant_id=request.tenant_id, + request=request, + slot=slot, + changes=changes, + ) + _apply_candidate_slot_changes( + slot, + changes, + temporal_or_location_changed=temporal_or_location_changed, + ) + + participation_provider = _poll_participation_provider() + for item in new_inputs: + changes = _candidate_slot_changes_from_reconcile(request, item) + slot = SchedulingCandidateSlot( + tenant_id=request.tenant_id, + request=request, + label=changes.label, + description=changes.description, + start_at=changes.start_at, + end_at=changes.end_at, + timezone=changes.timezone, + location=changes.location, + position=max((stored.position for stored in request.slots), default=-1) + 1, + metadata_=changes.metadata, + ) + session.add(slot) + session.flush() + if participation_provider is None: # Guarded above; keeps type narrowing explicit. + raise SchedulingError( + "Poll participation option mutation capability is unavailable" + ) + try: + created = participation_provider.add_option( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + command=PollOptionRequest( + key=f"slot-{slot.id}", + label=slot.label, + description=slot.description, + value={ + "slot_id": slot.id, + "start_at": changes.normalized_start.isoformat(), + "end_at": changes.normalized_end.isoformat(), + "timezone": slot.timezone, + "location": slot.location, + }, + metadata=slot.metadata_ or {}, + ), + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + slot.poll_option_id = created.id + slot.position = created.position + + for slot in removed_slots: + if participation_provider is None: # Guarded above; keeps type narrowing explicit. + raise SchedulingError( + "Poll participation option mutation capability is unavailable" + ) + try: + participation_provider.remove_option( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + option_id=cast(str, slot.poll_option_id), + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + slot.deleted_at = _now() + + return bool(planned_updates or new_inputs or removed_slots) + + +def _normalized_participant_identity(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + return None + return normalized.casefold() if "@" in normalized else normalized + + +def _participant_identity_values( + participant: SchedulingParticipant | SchedulingParticipantReconcileInput, +) -> tuple[str, ...]: + identities = ( + _normalized_participant_identity(participant.respondent_id), + _normalized_participant_identity(participant.email), + ) + return tuple(dict.fromkeys(value for value in identities if value is not None)) + + +def _validate_participant_reconciliation( + request: SchedulingRequest, + current_by_id: dict[str, SchedulingParticipant], + supplied_participants: list[SchedulingParticipantReconcileInput], +) -> None: + unknown_ids = sorted( + item.id + for item in supplied_participants + if item.id is not None and item.id not in current_by_id + ) + if unknown_ids: + raise SchedulingError("Unknown scheduling participant") + if not request.allow_external_participants and any( + item.participant_type == "external" for item in supplied_participants + ): + raise SchedulingError( + "External participants are not allowed for this scheduling request" + ) + assigned_identities: dict[str, int] = {} + for index, item in enumerate(supplied_participants): + for identity in _participant_identity_values(item): + previous = assigned_identities.setdefault(identity, index) + if previous != index: + raise SchedulingError( + "A scheduling participant identity or email can occur only once" + ) + for item in supplied_participants: + if item.id is None: + continue + participant = current_by_id[item.id] + if participant.poll_invitation_id is None: + continue + identity_changed = ( + _normalized_participant_identity(item.respondent_id) + != _normalized_participant_identity(participant.respondent_id) + or _normalized_participant_identity(item.email) + != _normalized_participant_identity(participant.email) + or item.display_name != participant.display_name + ) + if identity_changed: + raise SchedulingError( + "An invited participant's identity, name, and email cannot be changed; remove and add the participant instead" + ) + + +def _create_reconciled_participant_invitation( + session: Session, + *, + request: SchedulingRequest, + participant: SchedulingParticipant, +) -> str: + if request.poll_id is None: + raise SchedulingError("Scheduling request has no backing poll") + participation_provider = _poll_participation_provider() + if participation_provider is None: + raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON) + try: + respondent_id = _stable_participant_respondent_id(participant) + invitation = participation_provider.create_governed_invitation( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + command=PollGovernedInvitationCommand( + gateway=_public_participation_gateway(request.id), + policy=_public_participation_policy(request), + respondent_id=respondent_id, + respondent_label=participant.display_name, + email=participant.email, + expires_at=request.deadline_at, + metadata={ + "scheduling_request_id": request.id, + "scheduling_participant_id": participant.id, + }, + ), + ) + participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + participant.poll_invitation_id = invitation.id + participant.last_invited_at = _now() + participant.status = "invited" + return invitation.token + + +def _update_participant_invitation_expiry( + session: Session, + *, + request: SchedulingRequest, + participant: SchedulingParticipant, + invitation_id: str, +) -> None: + """Align one retained participant link with the request deadline. + + Poll owns invitation rows and performs the owner- and gateway-bound expiry + mutation under Poll -> invitation row locks. The invitation identity and + token remain unchanged across future, past, extended, and cleared + deadlines, so no raw replacement token has to cross the API boundary. + """ + + if request.poll_id is None: + raise SchedulingError("Scheduling request has no backing poll") + participation_provider = _poll_participation_provider() + if participation_provider is None: + raise SchedulingError( + "Poll participation invitation rotation capability is unavailable" + ) + try: + result = participation_provider.update_invitation_expiry( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + invitation_id=invitation_id, + gateway=_public_participation_gateway(request.id), + expires_at=request.deadline_at, + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + if result.id != invitation_id: + raise SchedulingError("Poll invitation expiry update returned a different invitation") + participant.poll_invitation_id = invitation_id + participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY + + +def _reconcile_scheduling_participants( + session: Session, + *, + request: SchedulingRequest, + supplied_participants: list[SchedulingParticipantReconcileInput], + create_invitations: bool, +) -> dict[str, str]: + """Apply a complete participant collection and revoke removed links.""" + + current_participants = _active_participants(request) + current_by_id = {participant.id: participant for participant in current_participants} + _validate_participant_reconciliation( + request, + current_by_id, + supplied_participants, + ) + supplied_ids = { + item.id + for item in supplied_participants + if item.id is not None + } + removed_participants = [ + participant + for participant in current_participants + if participant.id not in supplied_ids + ] + if ( + any(participant.poll_invitation_id for participant in removed_participants) + and _poll_participation_provider() is None + ): + raise SchedulingError( + "Poll participation invitation revocation capability is unavailable" + ) + + for item in supplied_participants: + if item.id is None: + continue + participant = current_by_id[item.id] + if participant.poll_invitation_id is None: + participant.respondent_id = item.respondent_id + participant.display_name = item.display_name + participant.email = item.email + participant.participant_type = item.participant_type + participant.required = item.required + participant.metadata_ = dict(item.metadata) + + invitation_tokens: dict[str, str] = {} + for item in supplied_participants: + if item.id is not None: + continue + participant = SchedulingParticipant( + tenant_id=request.tenant_id, + request=request, + respondent_id=item.respondent_id, + display_name=item.display_name, + email=item.email, + participant_type=item.participant_type, + required=item.required, + status="draft", + metadata_=dict(item.metadata), + ) + session.add(participant) + session.flush() + if create_invitations: + invitation_tokens[participant.id] = _create_reconciled_participant_invitation( + session, + request=request, + participant=participant, + ) + + participation_provider = _poll_participation_provider() + for participant in removed_participants: + if participant.poll_invitation_id is not None: + if participation_provider is None: # Guarded above. + raise SchedulingError( + "Poll participation invitation revocation capability is unavailable" + ) + try: + participation_provider.revoke_invitation( + session, + tenant_id=request.tenant_id, + poll_id=cast(str, request.poll_id), + invitation_id=participant.poll_invitation_id, + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + participant.status = "removed" + participant.deleted_at = _now() + + return invitation_tokens + + def update_scheduling_candidate_slot( session: Session, *, @@ -1757,10 +3007,44 @@ def scheduling_request_summary(session: Session, *, tenant_id: str, request_id: return dict(summary) -def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]: +_PARTICIPANT_FREEBUSY_FIELDS = frozenset( + { + "start_at", + "end_at", + "all_day", + "transparency", + "status", + } +) + + +def _participant_freebusy_conflicts( + conflicts: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Return useful busy intervals without connector or event identities.""" + + return [ + { + key: value + for key, value in conflict.items() + if key in _PARTICIPANT_FREEBUSY_FIELDS + } + for conflict in conflicts + if any(key in _PARTICIPANT_FREEBUSY_FIELDS for key in conflict) + ] + + +def scheduling_slot_response( + slot: SchedulingCandidateSlot, + *, + include_management_fields: bool = True, +) -> dict[str, Any]: + conflicts = slot.freebusy_conflicts or [] return { "id": slot.id, - "poll_option_id": slot.poll_option_id, + "poll_option_id": ( + slot.poll_option_id if include_management_fields else None + ), "label": slot.label, "description": slot.description, "start_at": response_datetime(slot.start_at), @@ -1771,9 +3055,15 @@ def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]: "revision": scheduling_slot_revision(slot), "freebusy_checked_at": response_datetime(slot.freebusy_checked_at), "freebusy_status": slot.freebusy_status, - "freebusy_conflicts": slot.freebusy_conflicts or [], - "tentative_hold_event_id": slot.tentative_hold_event_id, - "metadata": slot.metadata_ or {}, + "freebusy_conflicts": ( + conflicts + if include_management_fields + else _participant_freebusy_conflicts(conflicts) + ), + "tentative_hold_event_id": ( + slot.tentative_hold_event_id if include_management_fields else None + ), + "metadata": (slot.metadata_ or {}) if include_management_fields else {}, } @@ -1782,20 +3072,51 @@ def scheduling_participant_response( *, invitation_token: str | None = None, redact_sensitive: bool = False, + include_management_fields: bool = True, + is_current_participant: bool = False, ) -> dict[str, Any]: + expose_email = include_management_fields or is_current_participant return { "id": participant.id, - "respondent_id": None if redact_sensitive else participant.respondent_id, + "is_current_participant": is_current_participant, + "respondent_id": ( + participant.respondent_id + if include_management_fields and not redact_sensitive + else None + ), "display_name": participant.display_name, - "email": None if redact_sensitive else participant.email, - "participant_type": participant.participant_type, - "required": participant.required, + "email": participant.email if expose_email and not redact_sensitive else None, + "participant_type": ( + participant.participant_type if include_management_fields else None + ), + "required": participant.required if include_management_fields else None, "status": participant.status, - "poll_invitation_id": None if redact_sensitive else participant.poll_invitation_id, - "invitation_token": None if redact_sensitive else invitation_token, - "last_invited_at": None if redact_sensitive else response_datetime(participant.last_invited_at), - "responded_at": None if redact_sensitive else response_datetime(participant.responded_at), - "metadata": {} if redact_sensitive else participant.metadata_ or {}, + "poll_invitation_id": ( + participant.poll_invitation_id + if include_management_fields and not redact_sensitive + else None + ), + "invitation_token": ( + invitation_token + if include_management_fields and not redact_sensitive + else None + ), + "last_invited_at": ( + response_datetime(participant.last_invited_at) + if include_management_fields and not redact_sensitive + else None + ), + "responded_at": ( + response_datetime(participant.responded_at) + if (include_management_fields or is_current_participant) + and not redact_sensitive + else None + ), + "metadata": ( + (participant.metadata_ or {}) + if include_management_fields and not redact_sensitive + else {} + ), } @@ -1889,7 +3210,7 @@ def scheduling_request_response( own_participants = [ participant for participant in active_participants - if participant.respondent_id in ids or participant.email in ids + if _participant_matches_actor(participant, ids) ] if full_roster: visibility_decision = { @@ -1914,44 +3235,88 @@ def scheduling_request_response( ) if not full_roster: invitation_tokens = {} + visibility_decision = { + **visibility_decision, + # Policy provenance may contain tenant, organisational-unit, or + # provider internals. Participants need the effective outcome and + # its user-facing reason, not the enforcement topology. + "source_path": [], + "details": {}, + } + public_policy_available = _poll_participation_provider() is not None return { "id": request.id, - "tenant_id": request.tenant_id, + "tenant_id": request.tenant_id if full_roster else None, "title": request.title, "description": request.description, "location": request.location, "timezone": request.timezone, "status": request.status, - "poll_id": request.poll_id, + "poll_id": request.poll_id if full_roster else None, "selected_slot_id": request.selected_slot_id, - "organizer_user_id": request.organizer_user_id, + "organizer_user_id": request.organizer_user_id if full_roster else None, "deadline_at": response_datetime(request.deadline_at), "allow_external_participants": request.allow_external_participants, "allow_participant_updates": request.allow_participant_updates, "result_visibility": request.result_visibility, "participant_visibility": request.participant_visibility, + "notify_on_answers": request.notify_on_answers, + "single_choice": request.single_choice, + "max_participants_per_option": request.max_participants_per_option, + "allow_maybe": request.allow_maybe, + "allow_comments": request.allow_comments, + "participant_email_required": request.participant_email_required, + "anonymous_password_protection_enabled": request.anonymous_password_protection_enabled, + "public_participation_policy_enforcement_available": ( + public_policy_available if full_roster else None + ), + "public_participation_policy_enforcement_reason": ( + None + if public_policy_available or not full_roster + else PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON + ), "effective_participant_visibility": visibility_decision["effective_visibility"], "participant_aggregate": _participant_aggregate(active_participants), "participant_visibility_decision": visibility_decision, - "calendar_integration_enabled": request.calendar_integration_enabled, - "calendar_id": request.calendar_id, - "calendar_freebusy_enabled": request.calendar_freebusy_enabled, - "calendar_hold_enabled": request.calendar_hold_enabled, - "create_calendar_event_on_decision": request.create_calendar_event_on_decision, - "calendar_event_id": request.calendar_event_id, + "calendar_integration_enabled": ( + request.calendar_integration_enabled if full_roster else None + ), + "calendar_id": request.calendar_id if full_roster else None, + "calendar_freebusy_enabled": ( + request.calendar_freebusy_enabled if full_roster else None + ), + "calendar_hold_enabled": ( + request.calendar_hold_enabled if full_roster else None + ), + "create_calendar_event_on_decision": ( + request.create_calendar_event_on_decision if full_roster else None + ), + "calendar_event_id": request.calendar_event_id if full_roster else None, "handed_off_at": response_datetime(request.handed_off_at), "cancelled_at": response_datetime(request.cancelled_at), "created_at": response_datetime(request.created_at), "updated_at": response_datetime(request.updated_at), - "metadata": request.metadata_ or {}, - "slots": [scheduling_slot_response(slot) for slot in _active_slots(request)], + "metadata": (request.metadata_ or {}) if full_roster else {}, + "slots": [ + scheduling_slot_response( + slot, + include_management_fields=full_roster, + ) + for slot in _active_slots(request) + ], "participants": [ scheduling_participant_response( participant, invitation_token=invitation_tokens.get(participant.id), - redact_sensitive=not full_roster - and participant.respondent_id not in ids - and participant.email not in ids, + include_management_fields=full_roster, + is_current_participant=_participant_matches_actor( + participant, + ids, + ), + redact_sensitive=( + not full_roster + and not _participant_matches_actor(participant, ids) + ), ) for participant in projected_participants ], diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 9077d05..680197d 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -17,7 +17,10 @@ class SchedulingManifestTests(unittest.TestCase): self.assertIn("access", manifest.optional_dependencies) self.assertIn("addresses", manifest.optional_dependencies) self.assertIn("policy", manifest.optional_dependencies) - self.assertEqual(("poll.scheduling",), manifest.required_capabilities) + self.assertEqual( + ("poll.scheduling", "poll.participation_gateway"), + manifest.required_capabilities, + ) self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("poll.scheduling", manifest.required_capabilities) self.assertIn("calendar.scheduling", manifest.optional_capabilities) @@ -29,14 +32,18 @@ class SchedulingManifestTests(unittest.TestCase): self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.response_collection", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces}) - self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces}) + self.assertIn("poll.governed_participation", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces}) required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces} - self.assertEqual("0.1.9", required_interfaces["poll.availability_matrix"].version_min) - self.assertEqual("0.1.9", required_interfaces["poll.response_collection"].version_min) - self.assertEqual("0.1.9", required_interfaces["poll.workflow_context"].version_min) + for interface_name in ( + "poll.availability_matrix", + "poll.response_collection", + "poll.workflow_context", + "poll.governed_participation", + ): + self.assertEqual("0.1.10", required_interfaces[interface_name].version_min) if __name__ == "__main__": diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 8cdd798..86ad9b9 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -20,7 +20,7 @@ from govoplan_scheduling.backend.db.models import ( from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest -_SCHEDULING_HEAD = "9c2f4a7d1e6b" +_SCHEDULING_HEAD = "ad7e3c9b2f10" _ENABLED_MODULES = ("poll", "scheduling") _MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest) @@ -110,12 +110,27 @@ class SchedulingMigrationTests(unittest.TestCase): "scheduling_requests" ) } + participant_columns = { + item["name"] + for item in inspect(connection).get_columns( + "scheduling_participants" + ) + } visibility = connection.execute( text( "SELECT participant_visibility FROM scheduling_requests " "WHERE id = 'request-1'" ) ).scalar_one() + response_defaults = connection.execute( + text( + "SELECT notify_on_answers, single_choice, " + "max_participants_per_option, allow_maybe, allow_comments, " + "participant_email_required, anonymous_password_protection_enabled, " + "anonymous_password_hash FROM scheduling_requests " + "WHERE id = 'request-1'" + ) + ).one() counts = { model.__tablename__: connection.execute( select(func.count()).select_from(model) @@ -130,7 +145,11 @@ class SchedulingMigrationTests(unittest.TestCase): self.assertIn(_SCHEDULING_HEAD, heads) self.assertIn("participant_visibility", columns) + self.assertIn("max_participants_per_option", columns) + self.assertIn("response_comment", participant_columns) + self.assertIn("participation_gateway", participant_columns) self.assertEqual(visibility, "aggregates_only") + self.assertEqual(tuple(response_defaults), (1, 0, None, 1, 0, 0, 0, None)) self.assertEqual(set(counts.values()), {1}) finally: engine.dispose() @@ -219,6 +238,74 @@ class SchedulingMigrationTests(unittest.TestCase): finally: engine.dispose() + def test_rejects_partial_existing_response_settings(self) -> None: + with tempfile.TemporaryDirectory( + prefix="govoplan-scheduling-migration-" + ) as directory: + url = f"sqlite:///{Path(directory) / 'scheduling.db'}" + self._migrate(url) + engine = create_engine(url) + try: + with engine.begin() as connection: + self._remove_scheduling_revision( + connection, + remove_privacy_column=False, + ) + connection.execute( + text( + "ALTER TABLE scheduling_requests " + "DROP COLUMN allow_comments" + ) + ) + + with self.assertRaisesRegex( + RuntimeError, + "partial scheduling_requests scheduling response settings", + ): + self._migrate(url) + + with engine.connect() as connection: + heads = set( + MigrationContext.configure(connection).get_current_heads() + ) + self.assertNotIn(_SCHEDULING_HEAD, heads) + finally: + engine.dispose() + + def test_rejects_partial_existing_participant_response_settings(self) -> None: + with tempfile.TemporaryDirectory( + prefix="govoplan-scheduling-migration-" + ) as directory: + url = f"sqlite:///{Path(directory) / 'scheduling.db'}" + self._migrate(url) + engine = create_engine(url) + try: + with engine.begin() as connection: + self._remove_scheduling_revision( + connection, + remove_privacy_column=False, + ) + connection.execute( + text( + "ALTER TABLE scheduling_participants " + "DROP COLUMN participation_gateway" + ) + ) + + with self.assertRaisesRegex( + RuntimeError, + "partial scheduling_participants scheduling response settings", + ): + self._migrate(url) + + with engine.connect() as connection: + heads = set( + MigrationContext.configure(connection).get_current_heads() + ) + self.assertNotIn(_SCHEDULING_HEAD, heads) + finally: + engine.dispose() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_participant_privacy.py b/tests/test_participant_privacy.py index cbf87a2..3a1247c 100644 --- a/tests/test_participant_privacy.py +++ b/tests/test_participant_privacy.py @@ -1,6 +1,7 @@ from __future__ import annotations import unittest +from datetime import datetime, timezone from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -89,11 +90,46 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase): "tenant_id": "tenant-1", "title": "Privacy review", "status": "collecting", + "poll_id": "poll-internal", "organizer_user_id": "organizer-1", + "calendar_integration_enabled": True, + "calendar_id": "calendar-internal", + "calendar_freebusy_enabled": True, + "calendar_hold_enabled": True, + "create_calendar_event_on_decision": True, + "calendar_event_id": "event-internal", + "metadata_": {"connector_secret_ref": "secret-internal"}, } if participant_visibility is not None: values["participant_visibility"] = participant_visibility request = SchedulingRequest(**values) + request.slots = [ + SchedulingCandidateSlot( + tenant_id="tenant-1", + poll_option_id="poll-option-internal", + label="Monday morning", + start_at=datetime(2026, 7, 27, 9, tzinfo=timezone.utc), + end_at=datetime(2026, 7, 27, 10, tzinfo=timezone.utc), + timezone="Europe/Berlin", + position=0, + freebusy_checked_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc), + freebusy_status="busy", + freebusy_conflicts=[ + { + "calendar_id": "calendar-internal", + "event_id": "event-internal", + "uid": "connector-uid-internal", + "recurrence_id": "recurrence-internal", + "start_at": "2026-07-27T09:30:00+00:00", + "end_at": "2026-07-27T09:45:00+00:00", + "status": "CONFIRMED", + }, + {"error": "connector-internal failure"}, + ], + tentative_hold_event_id="hold-internal", + metadata_={"provider_ref": "provider-internal"}, + ) + ] request.participants = [ SchedulingParticipant( tenant_id="tenant-1", @@ -102,6 +138,9 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase): email="alice@example.test", participant_type="internal", status="responded", + poll_invitation_id="invitation-alice-internal", + last_invited_at=datetime(2026, 7, 20, 12, tzinfo=timezone.utc), + responded_at=datetime(2026, 7, 21, 12, tzinfo=timezone.utc), metadata_={"private": "alice"}, ), SchedulingParticipant( @@ -152,10 +191,43 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase): self.assertEqual(response.participant_visibility, "aggregates_only") self.assertEqual(response.effective_participant_visibility, "aggregates_only") self.assertEqual([participant.display_name for participant in response.participants], ["Alice"]) - self.assertEqual(response.participants[0].email, "alice@example.test") + own = response.participants[0] + self.assertTrue(own.is_current_participant) + self.assertEqual(own.email, "alice@example.test") + self.assertIsNone(own.respondent_id) + self.assertIsNone(own.participant_type) + self.assertIsNone(own.required) + self.assertIsNone(own.poll_invitation_id) + self.assertEqual(own.metadata, {}) self.assertEqual(response.participant_aggregate.total, 2) self.assertEqual(response.participant_aggregate.status_counts["responded"], 1) self.assertEqual(response.participant_aggregate.status_counts["invited"], 1) + self.assertIsNone(response.tenant_id) + self.assertIsNone(response.poll_id) + self.assertIsNone(response.organizer_user_id) + self.assertIsNone(response.calendar_integration_enabled) + self.assertIsNone(response.calendar_id) + self.assertIsNone(response.calendar_freebusy_enabled) + self.assertIsNone(response.calendar_hold_enabled) + self.assertIsNone(response.create_calendar_event_on_decision) + self.assertIsNone(response.calendar_event_id) + self.assertIsNone(response.public_participation_policy_enforcement_available) + self.assertEqual(response.metadata, {}) + slot = response.slots[0] + self.assertIsNone(slot.poll_option_id) + self.assertEqual(slot.freebusy_status, "busy") + self.assertEqual( + slot.freebusy_conflicts, + [ + { + "start_at": "2026-07-27T09:30:00+00:00", + "end_at": "2026-07-27T09:45:00+00:00", + "status": "CONFIRMED", + } + ], + ) + self.assertIsNone(slot.tentative_hold_event_id) + self.assertEqual(slot.metadata, {}) def test_configured_roster_returns_other_names_and_statuses_with_sensitive_fields_redacted(self) -> None: request = self._request(participant_visibility="names_and_statuses") @@ -165,11 +237,15 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase): self.assertEqual(response.effective_participant_visibility, "names_and_statuses") own = next(participant for participant in response.participants if participant.display_name == "Alice") other = next(participant for participant in response.participants if participant.display_name == "Bob") - self.assertEqual(own.respondent_id, "alice-id") + self.assertTrue(own.is_current_participant) + self.assertIsNone(own.respondent_id) self.assertEqual(own.email, "alice@example.test") self.assertEqual(other.status, "invited") + self.assertFalse(other.is_current_participant) self.assertIsNone(other.respondent_id) self.assertIsNone(other.email) + self.assertIsNone(other.participant_type) + self.assertIsNone(other.required) self.assertIsNone(other.poll_invitation_id) self.assertEqual(other.metadata, {}) @@ -199,6 +275,18 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase): "bob@example.test", }) self.assertTrue(response.participant_visibility_decision.details["management_access"]) + self.assertEqual(response.tenant_id, "tenant-1") + self.assertEqual(response.poll_id, "poll-internal") + self.assertEqual(response.organizer_user_id, "organizer-1") + self.assertEqual(response.calendar_id, "calendar-internal") + self.assertEqual(response.calendar_event_id, "event-internal") + self.assertEqual(response.metadata["connector_secret_ref"], "secret-internal") + self.assertEqual(response.slots[0].poll_option_id, "poll-option-internal") + self.assertEqual( + response.slots[0].freebusy_conflicts[0]["uid"], + "connector-uid-internal", + ) + self.assertEqual(response.slots[0].tentative_hold_event_id, "hold-internal") def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None: restricting_policy = _PrivacyPolicy("aggregates_only") @@ -211,8 +299,8 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase): self.assertEqual([participant.display_name for participant in reduced.participants], ["Alice"]) self.assertTrue(reduced.participant_visibility_decision.policy_applied) self.assertEqual(reduced.participant_visibility_decision.reason, "Tenant participant privacy policy") - self.assertEqual(reduced.participant_visibility_decision.source_path[0]["path"], "tenant:tenant-1") - self.assertTrue(reduced.participant_visibility_decision.details["provider_session_available"]) + self.assertEqual(reduced.participant_visibility_decision.source_path, []) + self.assertEqual(reduced.participant_visibility_decision.details, {}) self.assertEqual(restricting_policy.requests[0].actor_user_id, "alice-account") widening_policy = _PrivacyPolicy("names_and_statuses") diff --git a/tests/test_response_editing.py b/tests/test_response_editing.py index ff1159e..4ba5071 100644 --- a/tests/test_response_editing.py +++ b/tests/test_response_editing.py @@ -3,6 +3,7 @@ from __future__ import annotations import unittest from datetime import datetime, timedelta, timezone from types import SimpleNamespace +from unittest.mock import patch from fastapi import HTTPException from pydantic import ValidationError @@ -12,21 +13,30 @@ from sqlalchemy.orm import Session, sessionmaker from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import PrincipalRef from govoplan_core.core.modules import ModuleContext +from govoplan_core.core.poll import PollCapabilityError from govoplan_core.core.registry import PlatformRegistry from govoplan_core.db.base import Base -from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse +from govoplan_poll.backend.db.models import ( + Poll, + PollInvitation, + PollOption, + PollParticipationSubmission, + PollResponse, +) from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest +from govoplan_scheduling.backend import service as scheduling_service from govoplan_scheduling.backend.db.models import ( SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest, ) -from govoplan_scheduling.backend.manifest import RESPOND_SCOPE, WRITE_SCOPE +from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, RESPOND_SCOPE, WRITE_SCOPE from govoplan_scheduling.backend.router import ( api_get_my_scheduling_availability, api_submit_scheduling_availability, api_update_scheduling_candidate_slot, + api_update_scheduling_request, ) from govoplan_scheduling.backend.runtime import configure_runtime from govoplan_scheduling.backend.schemas import ( @@ -34,16 +44,26 @@ from govoplan_scheduling.backend.schemas import ( SchedulingAvailabilityResponseRequest, SchedulingCalendarPreferences, SchedulingCandidateSlotInput, + SchedulingCandidateSlotReconcileInput, SchedulingCandidateSlotUpdateRequest, SchedulingParticipantInput, + SchedulingParticipantReconcileInput, + SchedulingPublicParticipationAccessRequest, + SchedulingPublicParticipationSubmitRequest, SchedulingRequestCreateRequest, + SchedulingRequestUpdateRequest, ) +from govoplan_scheduling.backend.security import verify_participant_password from govoplan_scheduling.backend.service import ( SchedulingError, + SchedulingPublicParticipationError, cancel_scheduling_request, create_scheduling_request, + get_public_scheduling_participation, scheduling_request_summary, scheduling_slot_revision, + submit_scheduling_availability, + submit_public_scheduling_participation, update_scheduling_candidate_slot, ) @@ -62,6 +82,7 @@ class SchedulingResponseEditingTests(unittest.TestCase): PollOption.__table__, PollResponse.__table__, PollInvitation.__table__, + PollParticipationSubmission.__table__, SchedulingRequest.__table__, SchedulingCandidateSlot.__table__, SchedulingParticipant.__table__, @@ -80,6 +101,7 @@ class SchedulingResponseEditingTests(unittest.TestCase): SchedulingParticipant.__table__, SchedulingCandidateSlot.__table__, SchedulingRequest.__table__, + PollParticipationSubmission.__table__, PollInvitation.__table__, PollResponse.__table__, PollOption.__table__, @@ -108,9 +130,27 @@ class SchedulingResponseEditingTests(unittest.TestCase): *, status: str = "collecting", allow_participant_updates: bool = True, + participants: list[SchedulingParticipantInput] | None = None, + **settings, ) -> SchedulingRequest: + request, _tokens = self._request_and_tokens( + status=status, + allow_participant_updates=allow_participant_updates, + participants=participants, + **settings, + ) + return request + + def _request_and_tokens( + self, + *, + status: str = "collecting", + allow_participant_updates: bool = True, + participants: list[SchedulingParticipantInput] | None = None, + **settings, + ) -> tuple[SchedulingRequest, dict[str, str]]: start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc) - request, _tokens = create_scheduling_request( + return create_scheduling_request( self.session, tenant_id="tenant-1", user_id="organizer-1", @@ -131,6 +171,302 @@ class SchedulingResponseEditingTests(unittest.TestCase): end_at=start + timedelta(days=1, hours=1), ), ], + participants=( + participants + if participants is not None + else [ + SchedulingParticipantInput( + display_name="Alice", + email="alice@example.test", + ) + ] + ), + **settings, + ), + ) + + def _answer( + self, + request: SchedulingRequest, + *, + account_id: str, + email: str, + values: tuple[str, ...], + comment: str | None = None, + ) -> None: + api_submit_scheduling_availability( + request.id, + SchedulingAvailabilityResponseRequest( + answers=[ + SchedulingAvailabilityAnswerInput( + slot_id=slot.id, + value=value, + option_revision=scheduling_slot_revision(slot), + ) + for slot, value in zip(request.slots, values, strict=True) + ], + comment=comment, + ), + session=self.session, + principal=self._principal(account_id, email=email, scopes={RESPOND_SCOPE}), + ) + + def test_response_settings_are_persisted_and_password_is_write_only(self) -> None: + request = self._request( + notify_on_answers=False, + single_choice=True, + max_participants_per_option=3, + allow_maybe=False, + allow_comments=True, + participant_email_required=True, + anonymous_password_protection_enabled=True, + anonymous_password="correct horse battery staple", + create_participant_invitations=False, + ) + + self.assertFalse(request.notify_on_answers) + self.assertTrue(request.single_choice) + self.assertEqual(request.max_participants_per_option, 3) + self.assertFalse(request.allow_maybe) + self.assertTrue(request.allow_comments) + self.assertTrue(request.participant_email_required) + self.assertTrue(request.anonymous_password_protection_enabled) + self.assertNotIn("correct horse", request.anonymous_password_hash or "") + self.assertTrue( + verify_participant_password( + "correct horse battery staple", + request.anonymous_password_hash, + ) + ) + + response = api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest(title="Response settings, updated"), + session=self.session, + principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}), + ) + serialized = response.model_dump() + self.assertTrue(serialized["anonymous_password_protection_enabled"]) + self.assertTrue(serialized["public_participation_policy_enforcement_available"]) + self.assertIsNone(serialized["public_participation_policy_enforcement_reason"]) + self.assertNotIn("anonymous_password", serialized) + self.assertNotIn("anonymous_password_hash", serialized) + + api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest(anonymous_password_protection_enabled=False), + session=self.session, + principal=self._principal("admin-1", email=None, scopes={ADMIN_SCOPE}), + ) + self.assertFalse(request.anonymous_password_protection_enabled) + self.assertIsNone(request.anonymous_password_hash) + + def test_only_organizer_or_scheduling_admin_can_edit(self) -> None: + request = self._request() + + with self.assertRaises(HTTPException) as raised: + api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest(title="Unauthorized change"), + session=self.session, + principal=self._principal("other-writer", email=None, scopes={WRITE_SCOPE}), + ) + + self.assertEqual(raised.exception.status_code, 403) + self.assertEqual(request.title, "Response editing") + + def test_request_edit_preserves_answers_when_slots_are_unchanged(self) -> None: + request = self._request() + self._submit_both(request) + + response = api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest( + description=None, + location=None, + notify_on_answers=False, + ), + session=self.session, + principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}), + ) + current = api_get_my_scheduling_availability( + request.id, + session=self.session, + principal=self._principal( + "alice-account", + email="alice@example.test", + scopes={RESPOND_SCOPE}, + ), + ) + + self.assertIsNone(response.description) + self.assertIsNone(response.location) + self.assertFalse(response.notify_on_answers) + self.assertEqual(len(current.answers), 2) + + def test_single_choice_maybe_and_comment_settings_are_enforced(self) -> None: + request = self._request( + single_choice=True, + allow_maybe=False, + create_participant_invitations=False, + ) + + with self.assertRaises(HTTPException) as multiple: + self._answer( + request, + account_id="alice-account", + email="alice@example.test", + values=("available", "available"), + ) + self.assertEqual(multiple.exception.status_code, 400) + + with self.assertRaises(HTTPException) as maybe: + self._answer( + request, + account_id="alice-account", + email="alice@example.test", + values=("maybe", "unavailable"), + ) + self.assertEqual(maybe.exception.status_code, 400) + + with self.assertRaises(HTTPException) as comment: + self._answer( + request, + account_id="alice-account", + email="alice@example.test", + values=("available", "unavailable"), + comment="I prefer Monday", + ) + self.assertEqual(comment.exception.status_code, 400) + + def test_comments_round_trip_and_notifications_follow_setting(self) -> None: + request = self._request( + allow_comments=True, + notify_on_answers=False, + create_participant_invitations=False, + ) + with patch("govoplan_scheduling.backend.service._emit_scheduling_center_notification") as emit: + self._answer( + request, + account_id="alice-account", + email="alice@example.test", + values=("available", "unavailable"), + comment=" Monday works best. ", + ) + emit.assert_not_called() + + response = api_get_my_scheduling_availability( + request.id, + session=self.session, + principal=self._principal( + "alice-account", + email="alice@example.test", + scopes={RESPOND_SCOPE}, + ), + ) + self.assertEqual(response.comment, "Monday works best.") + poll_response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one() + self.assertEqual(poll_response.metadata_["comment"], "Monday works best.") + + def test_capacity_is_enforced_but_own_response_can_be_edited(self) -> None: + request = self._request( + max_participants_per_option=1, + create_participant_invitations=False, + participants=[ + SchedulingParticipantInput(display_name="Alice", email="alice@example.test"), + SchedulingParticipantInput(display_name="Bob", email="bob@example.test"), + ], + ) + self._answer( + request, + account_id="alice-account", + email="alice@example.test", + values=("available", "unavailable"), + ) + + with self.assertRaises(HTTPException) as full: + self._answer( + request, + account_id="bob-account", + email="bob@example.test", + values=("available", "unavailable"), + ) + self.assertEqual(full.exception.status_code, 409) + + self._answer( + request, + account_id="alice-account", + email="alice@example.test", + values=("unavailable", "available"), + ) + self._answer( + request, + account_id="bob-account", + email="bob@example.test", + values=("available", "unavailable"), + ) + + summary = scheduling_request_summary( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ) + values = { + item["option_id"]: item["values"] + for item in summary["option_results"] + } + self.assertEqual(values[request.slots[0].poll_option_id]["available"], 1) + self.assertEqual(values[request.slots[1].poll_option_id]["available"], 1) + + def test_restricted_signed_participation_uses_governed_gateway(self) -> None: + request = self._request( + participant_email_required=True, + create_participant_invitations=True, + ) + self.assertEqual(request.participants[0].participation_gateway, "scheduling") + + with self.assertRaises(HTTPException) as update: + api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest(allow_comments=True), + session=self.session, + principal=self._principal( + "organizer-1", + email=None, + scopes={WRITE_SCOPE}, + ), + ) + self.assertEqual(update.exception.status_code, 400) + self.assertFalse(request.allow_comments) + + def test_public_governed_response_is_prefilled_and_idempotent(self) -> None: + start = datetime(2026, 8, 1, 9, tzinfo=timezone.utc) + public_request, tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="organizer-1", + payload=SchedulingRequestCreateRequest( + title="Public governed response", + status="collecting", + single_choice=True, + allow_maybe=False, + allow_comments=True, + participant_email_required=True, + anonymous_password_protection_enabled=True, + anonymous_password="correct horse battery staple", + calendar=SchedulingCalendarPreferences(), + slots=[ + SchedulingCandidateSlotInput( + label="First", + start_at=start, + end_at=start + timedelta(hours=1), + ), + SchedulingCandidateSlotInput( + label="Second", + start_at=start + timedelta(days=1), + end_at=start + timedelta(days=1, hours=1), + ), + ], participants=[ SchedulingParticipantInput( display_name="Alice", @@ -139,7 +475,688 @@ class SchedulingResponseEditingTests(unittest.TestCase): ], ), ) - return request + token = tokens[public_request.participants[0].id] + invitation = self.session.query(PollInvitation).filter( + PollInvitation.id == public_request.participants[0].poll_invitation_id + ).one() + self.assertIsNotNone(invitation.token_hash) + self.assertNotEqual(invitation.token_hash, token) + access = SchedulingPublicParticipationAccessRequest( + participant_email="ALICE@EXAMPLE.TEST", + password="correct horse battery staple", + ) + initial = get_public_scheduling_participation( + self.session, + request_id=public_request.id, + token=token, + payload=access, + client_address="192.0.2.10", + ) + self.assertFalse(initial["has_response"]) + self.assertNotIn("anonymous_password_hash", initial) + + submit_payload = SchedulingPublicParticipationSubmitRequest( + answers=[ + SchedulingAvailabilityAnswerInput( + slot_id=public_request.slots[0].id, + value="available", + option_revision=scheduling_slot_revision(public_request.slots[0]), + ), + SchedulingAvailabilityAnswerInput( + slot_id=public_request.slots[1].id, + value="unavailable", + option_revision=scheduling_slot_revision(public_request.slots[1]), + ), + ], + participant_email="alice@example.test", + password="correct horse battery staple", + comment=" First works. ", + idempotency_key="public-response-1", + ) + submitted = submit_public_scheduling_participation( + self.session, + request_id=public_request.id, + token=token, + payload=submit_payload, + client_address="192.0.2.10", + ) + replayed = submit_public_scheduling_participation( + self.session, + request_id=public_request.id, + token=token, + payload=submit_payload, + client_address="192.0.2.10", + ) + prefilled = get_public_scheduling_participation( + self.session, + request_id=public_request.id, + token=token, + payload=access, + client_address="192.0.2.10", + ) + + self.assertTrue(submitted["has_response"]) + self.assertFalse(submitted["replayed"]) + self.assertTrue(replayed["replayed"]) + self.assertEqual(prefilled["comment"], "First works.") + self.assertEqual(len(prefilled["answers"]), 2) + self.assertEqual( + self.session.query(PollResponse).filter( + PollResponse.poll_id == public_request.poll_id + ).count(), + 1, + ) + + def test_public_password_failures_are_generic_and_throttled_before_verify(self) -> None: + start = datetime(2026, 8, 10, 9, tzinfo=timezone.utc) + request, tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="organizer-1", + payload=SchedulingRequestCreateRequest( + title="Protected response", + status="collecting", + anonymous_password_protection_enabled=True, + anonymous_password="correct horse battery staple", + calendar=SchedulingCalendarPreferences(), + slots=[ + SchedulingCandidateSlotInput( + start_at=start, + end_at=start + timedelta(hours=1), + ) + ], + participants=[SchedulingParticipantInput(display_name="Guest")], + ), + ) + token = tokens[request.participants[0].id] + wrong = SchedulingPublicParticipationAccessRequest(password="wrong password") + + for attempt in range(10): + with self.assertRaises(SchedulingPublicParticipationError) as raised: + get_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=wrong, + client_address="192.0.2.20", + ) + self.assertEqual( + str(raised.exception), + "Scheduling participation link or credentials are invalid", + ) + if attempt < 9: + self.assertEqual(raised.exception.retry_after_seconds, 0) + self.assertGreater(raised.exception.retry_after_seconds, 0) + + with patch( + "govoplan_scheduling.backend.service.verify_participant_password" + ) as verify: + with self.assertRaises(SchedulingPublicParticipationError) as blocked: + get_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=wrong, + client_address="192.0.2.20", + ) + verify.assert_not_called() + self.assertGreater(blocked.exception.retry_after_seconds, 0) + + def test_public_email_is_bound_after_first_anonymous_response(self) -> None: + start = datetime(2026, 8, 12, 9, tzinfo=timezone.utc) + request, tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="organizer-1", + payload=SchedulingRequestCreateRequest( + title="Email-bound response", + status="collecting", + participant_email_required=True, + calendar=SchedulingCalendarPreferences(), + slots=[ + SchedulingCandidateSlotInput( + start_at=start, + end_at=start + timedelta(hours=1), + ) + ], + participants=[SchedulingParticipantInput(display_name="Guest")], + ), + ) + participant = request.participants[0] + token = tokens[participant.id] + answer = SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, + value="available", + option_revision=scheduling_slot_revision(request.slots[0]), + ) + submit_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicParticipationSubmitRequest( + answers=[answer], + participant_email="first@example.test", + ), + client_address="192.0.2.30", + ) + + with self.assertRaises(SchedulingPublicParticipationError): + submit_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicParticipationSubmitRequest( + answers=[answer], + participant_email="second@example.test", + ), + client_address="192.0.2.30", + ) + + self.assertEqual(participant.email, "first@example.test") + self.assertTrue( + participant.respondent_id.startswith("scheduling-participant:") + ) + self.assertEqual( + self.session.query(PollResponse).filter( + PollResponse.poll_id == request.poll_id + ).count(), + 1, + ) + + def test_public_submission_locks_scheduling_before_poll_submission(self) -> None: + request, tokens = self._request_and_tokens() + token = tokens[request.participants[0].id] + provider = scheduling_service._poll_participation_provider() + self.assertIsNotNone(provider) + events: list[str] = [] + original_lock = scheduling_service._lock_scheduling_request + original_submit = provider.submit_governed_response + + def recording_lock(*args, **kwargs): + self.assertTrue(kwargs["lock_participants"]) + events.append("scheduling") + return original_lock(*args, **kwargs) + + def recording_submit(*args, **kwargs): + self.assertEqual(events, ["scheduling"]) + events.append("poll") + return original_submit(*args, **kwargs) + + with ( + patch.object( + scheduling_service, + "_lock_scheduling_request", + side_effect=recording_lock, + ), + patch.object( + provider, + "submit_governed_response", + side_effect=recording_submit, + ), + ): + submit_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicParticipationSubmitRequest( + answers=[ + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, + value="available", + option_revision=scheduling_slot_revision( + request.slots[0] + ), + ) + ], + participant_email="alice@example.test", + ), + client_address="192.0.2.40", + ) + + self.assertEqual(events, ["scheduling", "poll"]) + + def test_scheduling_status_and_deadline_guard_all_governed_submissions(self) -> None: + request, tokens = self._request_and_tokens() + token = tokens[request.participants[0].id] + answer = SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, + value="available", + option_revision=scheduling_slot_revision(request.slots[0]), + ) + authenticated_payload = SchedulingAvailabilityResponseRequest( + answers=[answer] + ) + public_payload = SchedulingPublicParticipationSubmitRequest( + answers=[answer], + participant_email="alice@example.test", + ) + + # Simulate a stale Poll projection that remains open after Scheduling + # has already closed response collection. + request.status = "closed" + with self.assertRaisesRegex( + SchedulingError, + "not collecting availability", + ): + submit_scheduling_availability( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + actor_ids=("alice@example.test",), + respondent_id="alice-account", + respondent_label="Alice", + payload=authenticated_payload, + ) + with self.assertRaises(SchedulingPublicParticipationError): + submit_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=public_payload, + client_address="192.0.2.42", + ) + + # Likewise, invitation expiry drift must not override Scheduling's + # authoritative deadline. + request.status = "collecting" + request.deadline_at = datetime.now(timezone.utc) - timedelta(minutes=1) + invitation = ( + self.session.query(PollInvitation) + .filter(PollInvitation.id == request.participants[0].poll_invitation_id) + .one() + ) + invitation.expires_at = None + with self.assertRaisesRegex(SchedulingError, "deadline has passed"): + submit_scheduling_availability( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + actor_ids=("alice@example.test",), + respondent_id="alice-account", + respondent_label="Alice", + payload=authenticated_payload, + ) + with self.assertRaises(SchedulingPublicParticipationError): + submit_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=public_payload, + client_address="192.0.2.42", + ) + + self.assertEqual( + self.session.query(PollResponse) + .filter(PollResponse.poll_id == request.poll_id) + .count(), + 0, + ) + + def test_deadline_changes_update_same_link_and_preserve_prior_response(self) -> None: + initial_deadline = datetime(2030, 8, 10, 12, tzinfo=timezone.utc) + request, tokens = self._request_and_tokens( + deadline_at=initial_deadline, + allow_comments=True, + ) + participant = request.participants[0] + token = tokens[participant.id] + submit_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicParticipationSubmitRequest( + answers=[ + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, + value="available", + option_revision=scheduling_slot_revision(request.slots[0]), + ) + ], + participant_email="alice@example.test", + comment="Keep this response.", + ), + client_address="192.0.2.41", + ) + self.session.commit() + + invitation_id = participant.poll_invitation_id + invitation = ( + self.session.query(PollInvitation) + .filter(PollInvitation.id == invitation_id) + .one() + ) + token_hash = invitation.token_hash + responded_at = participant.responded_at + deadlines = ( + datetime(2030, 8, 20, 12, tzinfo=timezone.utc), + datetime.now(timezone.utc) - timedelta(minutes=1), + datetime(2030, 8, 15, 12, tzinfo=timezone.utc), + None, + ) + for deadline in deadlines: + response = api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest(deadline_at=deadline), + session=self.session, + principal=self._principal( + "organizer-1", + email=None, + scopes={WRITE_SCOPE}, + ), + ) + projected = response.participants[0] + self.assertEqual(projected.status, "responded") + self.assertIsNone(projected.invitation_token) + self.assertEqual(projected.poll_invitation_id, invitation_id) + participant = ( + self.session.query(SchedulingParticipant) + .filter(SchedulingParticipant.id == participant.id) + .populate_existing() + .one() + ) + invitation = ( + self.session.query(PollInvitation) + .filter(PollInvitation.id == invitation_id) + .populate_existing() + .one() + ) + self.assertEqual(participant.poll_invitation_id, invitation_id) + self.assertEqual(participant.responded_at, responded_at) + self.assertEqual(participant.status, "responded") + self.assertEqual( + scheduling_service.response_datetime(invitation.expires_at), + deadline, + ) + self.assertEqual(invitation.token_hash, token_hash) + self.assertIsNone(invitation.revoked_at) + if deadline is not None and deadline <= datetime.now(timezone.utc): + with self.assertRaises(SchedulingPublicParticipationError): + get_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicParticipationAccessRequest( + participant_email="alice@example.test" + ), + client_address="192.0.2.41", + ) + continue + prefilled = get_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicParticipationAccessRequest( + participant_email="alice@example.test" + ), + client_address="192.0.2.41", + ) + self.assertTrue(prefilled["has_response"]) + self.assertEqual(prefilled["comment"], "Keep this response.") + + self.assertEqual( + self.session.query(PollResponse) + .filter(PollResponse.poll_id == request.poll_id) + .count(), + 1, + ) + + def test_deadline_expiry_update_rolls_back_when_poll_rejects_it(self) -> None: + original_deadline = datetime(2030, 9, 10, 12, tzinfo=timezone.utc) + request, tokens = self._request_and_tokens(deadline_at=original_deadline) + participant = request.participants[0] + token = tokens[participant.id] + invitation_id = participant.poll_invitation_id + request_id = request.id + participant_id = participant.id + self.session.commit() + + provider = scheduling_service._poll_participation_provider() + self.assertIsNotNone(provider) + with patch.object( + provider, + "update_invitation_expiry", + side_effect=PollCapabilityError("Expiry update failed"), + ): + with self.assertRaises(HTTPException): + api_update_scheduling_request( + request_id, + SchedulingRequestUpdateRequest( + deadline_at=datetime( + 2030, + 9, + 20, + 12, + tzinfo=timezone.utc, + ) + ), + session=self.session, + principal=self._principal( + "organizer-1", + email=None, + scopes={WRITE_SCOPE}, + ), + ) + self.session.rollback() + + persisted_request = ( + self.session.query(SchedulingRequest) + .filter(SchedulingRequest.id == request_id) + .populate_existing() + .one() + ) + persisted_participant = ( + self.session.query(SchedulingParticipant) + .filter(SchedulingParticipant.id == participant_id) + .populate_existing() + .one() + ) + persisted_invitation = ( + self.session.query(PollInvitation) + .filter(PollInvitation.id == invitation_id) + .populate_existing() + .one() + ) + self.assertEqual( + scheduling_service.response_datetime(persisted_request.deadline_at), + original_deadline, + ) + self.assertEqual(persisted_participant.poll_invitation_id, invitation_id) + self.assertIsNone(persisted_invitation.revoked_at) + self.assertEqual( + scheduling_service.response_datetime(persisted_invitation.expires_at), + original_deadline, + ) + self.assertEqual( + get_public_scheduling_participation( + self.session, + request_id=request_id, + token=token, + payload=SchedulingPublicParticipationAccessRequest( + participant_email="alice@example.test" + ), + client_address="192.0.2.41", + )["request_id"], + request_id, + ) + + def test_full_edit_reconciles_slots_participants_and_revokes_removed_link(self) -> None: + request = self._request(create_participant_invitations=False) + self._submit_both(request) + first_slot, removed_slot = request.slots + alice = request.participants[0] + new_start = datetime(2026, 7, 23, 9, tzinfo=timezone.utc) + + response = api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest( + slots=[ + SchedulingCandidateSlotReconcileInput( + id=first_slot.id, + revision=scheduling_slot_revision(first_slot), + label=first_slot.label, + description=first_slot.description, + start_at=first_slot.start_at.replace(tzinfo=timezone.utc), + end_at=first_slot.end_at.replace(tzinfo=timezone.utc), + timezone=first_slot.timezone, + location=first_slot.location, + metadata=first_slot.metadata_ or {}, + ), + SchedulingCandidateSlotReconcileInput( + label="Wednesday", + start_at=new_start, + end_at=new_start + timedelta(hours=1), + ), + ], + participants=[ + SchedulingParticipantReconcileInput( + id=alice.id, + respondent_id=alice.respondent_id, + display_name=alice.display_name, + email=alice.email, + participant_type=alice.participant_type, + required=alice.required, + metadata=alice.metadata_ or {}, + ), + SchedulingParticipantReconcileInput( + display_name="Bob", + email="BOB@EXAMPLE.TEST", + ), + ], + create_participant_invitations=True, + ), + session=self.session, + principal=self._principal( + "organizer-1", + email=None, + scopes={WRITE_SCOPE}, + ), + ) + + self.assertEqual([slot.label for slot in response.slots], ["Monday", "Wednesday"]) + self.assertIsNotNone(removed_slot.deleted_at) + bob = next(item for item in response.participants if item.display_name == "Bob") + self.assertEqual(bob.email, "bob@example.test") + self.assertIsNotNone(bob.poll_invitation_id) + self.assertIsNotNone(bob.invitation_token) + current = api_get_my_scheduling_availability( + request.id, + session=self.session, + principal=self._principal( + "alice-account", + email="ALICE@EXAMPLE.TEST", + scopes={RESPOND_SCOPE}, + ), + ) + self.assertEqual( + [(answer.slot_id, answer.value) for answer in current.answers], + [(first_slot.id, "available")], + ) + + retained_slots = [slot for slot in request.slots if slot.deleted_at is None] + api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest( + slots=[ + SchedulingCandidateSlotReconcileInput( + id=slot.id, + revision=scheduling_slot_revision(slot), + label=slot.label, + description=slot.description, + start_at=slot.start_at.replace(tzinfo=timezone.utc), + end_at=slot.end_at.replace(tzinfo=timezone.utc), + timezone=slot.timezone, + location=slot.location, + metadata=slot.metadata_ or {}, + ) + for slot in retained_slots + ], + participants=[ + SchedulingParticipantReconcileInput( + id=alice.id, + respondent_id=alice.respondent_id, + display_name=alice.display_name, + email=alice.email, + participant_type=alice.participant_type, + required=alice.required, + metadata=alice.metadata_ or {}, + ) + ], + create_participant_invitations=False, + ), + session=self.session, + principal=self._principal( + "organizer-1", + email=None, + scopes={WRITE_SCOPE}, + ), + ) + invitation = self.session.query(PollInvitation).filter( + PollInvitation.id == bob.poll_invitation_id + ).one() + self.assertIsNotNone(invitation.revoked_at) + + def test_full_edit_rejects_stale_slot_revision(self) -> None: + request = self._request(create_participant_invitations=False) + slot = request.slots[0] + + with self.assertRaises(HTTPException) as raised: + api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest( + slots=[ + SchedulingCandidateSlotReconcileInput( + id=current.id, + revision=("0" * 64 if current.id == slot.id else scheduling_slot_revision(current)), + label=current.label, + description=current.description, + start_at=current.start_at.replace(tzinfo=timezone.utc), + end_at=current.end_at.replace(tzinfo=timezone.utc), + timezone=current.timezone, + location=current.location, + metadata=current.metadata_ or {}, + ) + for current in request.slots + ] + ), + session=self.session, + principal=self._principal( + "organizer-1", + email=None, + scopes={WRITE_SCOPE}, + ), + ) + + self.assertEqual(raised.exception.status_code, 409) + + def test_draft_edit_does_not_issue_link_for_added_participant(self) -> None: + request = self._request( + status="draft", + participants=[], + create_participant_invitations=False, + ) + response = api_update_scheduling_request( + request.id, + SchedulingRequestUpdateRequest( + participants=[ + SchedulingParticipantReconcileInput( + display_name="Draft participant", + email="draft@example.test", + ) + ], + create_participant_invitations=True, + ), + session=self.session, + principal=self._principal( + "organizer-1", + email=None, + scopes={WRITE_SCOPE}, + ), + ) + + self.assertEqual(len(response.participants), 1) + self.assertEqual(response.participants[0].status, "draft") + self.assertIsNone(response.participants[0].poll_invitation_id) + self.assertIsNone(response.participants[0].invitation_token) def _submit_both(self, request: SchedulingRequest) -> None: api_submit_scheduling_availability( diff --git a/tests/test_service.py b/tests/test_service.py index 210c959..13960dd 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -19,13 +19,21 @@ from govoplan_core.core.registry import PlatformRegistry from govoplan_access.backend.db.models import Account, User from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest -from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse +from govoplan_poll.backend.db.models import ( + Poll, + PollInvitation, + PollLifecycleTransition, + PollOption, + PollParticipationSubmission, + PollResponse, +) from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest from govoplan_poll.backend.router import api_get_poll, api_list_polls, api_submit_poll_response from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest -from govoplan_poll.backend.service import get_poll, submit_poll_response_with_token +from govoplan_poll.backend.service import get_poll from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest from govoplan_scheduling.backend.manifest import READ_SCOPE as SCHEDULING_READ_SCOPE +from govoplan_scheduling.backend.manifest import ADMIN_SCOPE as SCHEDULING_ADMIN_SCOPE from govoplan_scheduling.backend.manifest import RESPOND_SCOPE as SCHEDULING_RESPOND_SCOPE from govoplan_scheduling.backend.manifest import WRITE_SCOPE as SCHEDULING_WRITE_SCOPE from govoplan_scheduling.backend.schemas import ( @@ -35,6 +43,7 @@ from govoplan_scheduling.backend.schemas import ( SchedulingCandidateSlotInput, SchedulingDecisionRequest, SchedulingParticipantInput, + SchedulingPublicParticipationSubmitRequest, SchedulingRequestCreateRequest, SchedulingRequestUpdateRequest, ) @@ -68,6 +77,7 @@ from govoplan_scheduling.backend.service import ( require_visible_scheduling_results, scheduling_request_summary, scheduling_slot_revision, + submit_public_scheduling_participation, update_scheduling_request, ) from govoplan_scheduling.backend.runtime import configure_runtime @@ -88,6 +98,7 @@ class SchedulingServiceTests(unittest.TestCase): PollOption.__table__, PollResponse.__table__, PollInvitation.__table__, + PollParticipationSubmission.__table__, PollLifecycleTransition.__table__, CalendarCollection.__table__, CalendarEvent.__table__, @@ -121,6 +132,7 @@ class SchedulingServiceTests(unittest.TestCase): CalendarSyncSource.__table__, CalendarEvent.__table__, CalendarCollection.__table__, + PollParticipationSubmission.__table__, PollInvitation.__table__, PollLifecycleTransition.__table__, PollResponse.__table__, @@ -215,6 +227,82 @@ class SchedulingServiceTests(unittest.TestCase): self.assertEqual(len(tokens), 2) self.assertTrue(all(participant.poll_invitation_id for participant in request.participants)) + def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None: + class RejectingNotificationProvider: + def enqueue_notification(self, *_args, **_kwargs): + raise AssertionError("A draft must not enqueue invitation delivery") + + with patch( + "govoplan_scheduling.backend.service.notification_dispatch_provider", + return_value=RejectingNotificationProvider(), + ): + request, tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy(update={"status": "draft"}), + ) + + self.assertEqual(tokens, {}) + self.assertEqual( + self.session.query(PollInvitation).filter( + PollInvitation.poll_id == request.poll_id + ).count(), + 0, + ) + self.assertTrue( + all( + participant.status == "draft" + and participant.poll_invitation_id is None + for participant in request.participants + ) + ) + self.assertEqual( + self.session.query(SchedulingNotification).filter( + SchedulingNotification.request_id == request.id + ).count(), + 0, + ) + + open_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ) + api_submit_scheduling_availability( + request.id, + SchedulingAvailabilityResponseRequest( + answers=[ + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, + value="available", + option_revision=scheduling_slot_revision(request.slots[0]), + ) + ] + ), + session=self.session, + principal=self._principal( + "alice-account", + email="alice@example.test", + scopes={SCHEDULING_RESPOND_SCOPE}, + ), + ) + alice = next( + participant + for participant in request.participants + if participant.email == "alice@example.test" + ) + self.assertEqual(alice.status, "responded") + self.assertEqual(alice.participation_gateway, "scheduling") + self.assertIsNotNone(alice.poll_invitation_id) + self.assertEqual( + self.session.query(SchedulingNotification).filter( + SchedulingNotification.request_id == request.id, + SchedulingNotification.event_kind == "invitation", + ).count(), + 0, + ) + def test_create_route_persists_after_request_session_closes(self) -> None: response = api_create_scheduling_request( self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()}), @@ -244,15 +332,25 @@ class SchedulingServiceTests(unittest.TestCase): first_slot = request.slots[0] second_slot = request.slots[1] - submit_poll_response_with_token( + submit_public_scheduling_participation( self.session, + request_id=request.id, token=tokens[first_participant.id], - payload=PollSubmitResponseRequest( + payload=SchedulingPublicParticipationSubmitRequest( answers=[ - PollAnswerInput(option_id=first_slot.poll_option_id, value="available"), - PollAnswerInput(option_id=second_slot.poll_option_id, value="maybe"), + SchedulingAvailabilityAnswerInput( + slot_id=first_slot.id, + value="available", + option_revision=scheduling_slot_revision(first_slot), + ), + SchedulingAvailabilityAnswerInput( + slot_id=second_slot.id, + value="maybe", + option_revision=scheduling_slot_revision(second_slot), + ), ] ), + client_address="127.0.0.1", ) summary = scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id) @@ -550,6 +648,142 @@ class SchedulingServiceTests(unittest.TestCase): ).id, ) + ordinary_writer = self._principal( + "unrelated-writer", + scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE}, + ) + administrator = self._principal( + "scheduling-admin", + scopes={SCHEDULING_READ_SCOPE, SCHEDULING_ADMIN_SCOPE}, + ) + self.assertEqual( + [], + api_list_scheduling_requests( + status_filter=None, + session=self.session, + principal=ordinary_writer, + ).requests, + ) + self.assertEqual( + [request.id], + [ + item.id + for item in api_list_scheduling_requests( + status_filter=None, + session=self.session, + principal=administrator, + ).requests + ], + ) + + def test_participant_list_and_get_redact_management_and_connector_internals(self) -> None: + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + request.calendar_event_id = "calendar-event-internal" + request.metadata_ = {"connector_ref": "connector-internal"} + slot = request.slots[0] + slot.freebusy_status = "busy" + slot.freebusy_conflicts = [ + { + "calendar_id": "calendar-1", + "event_id": "busy-event-internal", + "uid": "busy-uid-internal", + "recurrence_id": "busy-recurrence-internal", + "start_at": "2026-07-20T09:15:00+00:00", + "end_at": "2026-07-20T09:45:00+00:00", + "status": "CONFIRMED", + } + ] + slot.tentative_hold_event_id = "hold-event-internal" + slot.metadata_ = {"provider_ref": "provider-internal"} + participant = request.participants[0] + participant.respondent_id = "alice-identity-internal" + participant.metadata_ = {"directory_ref": "directory-internal"} + self.session.flush() + + principal = self._principal( + "alice-account", + email="alice@example.test", + scopes={SCHEDULING_READ_SCOPE}, + ) + listed = api_list_scheduling_requests( + status_filter=None, + session=self.session, + principal=principal, + ).requests + fetched = api_get_scheduling_request( + request.id, + session=self.session, + principal=principal, + ) + + self.assertEqual(len(listed), 1) + for response in (listed[0], fetched): + self.assertIsNone(response.tenant_id) + self.assertIsNone(response.poll_id) + self.assertIsNone(response.organizer_user_id) + self.assertIsNone(response.calendar_integration_enabled) + self.assertIsNone(response.calendar_id) + self.assertIsNone(response.calendar_freebusy_enabled) + self.assertIsNone(response.calendar_hold_enabled) + self.assertIsNone(response.create_calendar_event_on_decision) + self.assertIsNone(response.calendar_event_id) + self.assertIsNone( + response.public_participation_policy_enforcement_available + ) + self.assertEqual(response.metadata, {}) + self.assertEqual(response.participant_aggregate.total, 2) + self.assertEqual(len(response.participants), 1) + own = response.participants[0] + self.assertTrue(own.is_current_participant) + self.assertEqual(own.email, "alice@example.test") + self.assertIsNone(own.respondent_id) + self.assertIsNone(own.poll_invitation_id) + self.assertEqual(own.metadata, {}) + projected_slot = response.slots[0] + self.assertIsNone(projected_slot.poll_option_id) + self.assertEqual(projected_slot.freebusy_status, "busy") + self.assertEqual( + projected_slot.freebusy_conflicts, + [ + { + "start_at": "2026-07-20T09:15:00+00:00", + "end_at": "2026-07-20T09:45:00+00:00", + "status": "CONFIRMED", + } + ], + ) + self.assertIsNone(projected_slot.tentative_hold_event_id) + self.assertEqual(projected_slot.metadata, {}) + + organizer = self._principal( + "user-1", + scopes={SCHEDULING_READ_SCOPE, SCHEDULING_WRITE_SCOPE}, + ) + management = api_get_scheduling_request( + request.id, + session=self.session, + principal=organizer, + ) + self.assertEqual(management.tenant_id, "tenant-1") + self.assertEqual(management.poll_id, request.poll_id) + self.assertEqual(management.calendar_id, "calendar-1") + self.assertEqual(management.calendar_event_id, "calendar-event-internal") + self.assertEqual(management.metadata["connector_ref"], "connector-internal") + self.assertEqual(management.slots[0].poll_option_id, slot.poll_option_id) + self.assertEqual( + management.slots[0].freebusy_conflicts[0]["uid"], + "busy-uid-internal", + ) + self.assertEqual( + management.slots[0].tentative_hold_event_id, + "hold-event-internal", + ) + def test_result_visibility_follows_existing_after_close_policy(self) -> None: request, _tokens = create_scheduling_request( self.session, @@ -800,7 +1034,7 @@ class SchedulingServiceTests(unittest.TestCase): organizer = self._principal("user-1", scopes={"poll:poll:read"}) self.assertEqual(api_get_poll(request.poll_id, session=self.session, principal=organizer).id, request.poll_id) - def test_authenticated_response_reconciles_only_the_current_participant(self) -> None: + def test_module_owned_poll_rejects_direct_response_and_uses_current_participant(self) -> None: participants = [ SchedulingParticipantInput( respondent_id="alice-membership", @@ -835,15 +1069,21 @@ class SchedulingServiceTests(unittest.TestCase): }, ) - response = api_submit_poll_response( - request.poll_id, - PollSubmitResponseRequest( - answers=[PollAnswerInput(option_id=request.slots[0].poll_option_id, value="available")], - metadata={"invitation_id": target.poll_invitation_id}, - ), - session=self.session, - principal=attacker, - ) + with self.assertRaises(HTTPException) as direct_response: + api_submit_poll_response( + request.poll_id, + PollSubmitResponseRequest( + answers=[ + PollAnswerInput( + option_id=request.slots[0].poll_option_id, + value="available", + ) + ], + metadata={"invitation_id": target.poll_invitation_id}, + ), + session=self.session, + principal=attacker, + ) listed = api_list_scheduling_requests( status_filter=None, session=self.session, @@ -855,8 +1095,29 @@ class SchedulingServiceTests(unittest.TestCase): principal=attacker, ) - self.assertNotIn("invitation_id", response.metadata) + self.assertEqual(direct_response.exception.status_code, 400) self.assertEqual([request.id], [item.id for item in listed.requests]) + self.assertFalse(current.has_response) + + api_submit_scheduling_availability( + request.id, + SchedulingAvailabilityResponseRequest( + answers=[ + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, + value="available", + option_revision=scheduling_slot_revision(request.slots[0]), + ) + ] + ), + session=self.session, + principal=attacker, + ) + current = api_get_my_scheduling_availability( + request.id, + session=self.session, + principal=attacker, + ) self.assertTrue(current.has_response) self.assertEqual( [(answer.slot_id, answer.value) for answer in current.answers], @@ -964,17 +1225,20 @@ class SchedulingServiceTests(unittest.TestCase): payload=payload, ) participant = request.participants[0] - submit_poll_response_with_token( + submit_public_scheduling_participation( self.session, + request_id=request.id, token=tokens[participant.id], - payload=PollSubmitResponseRequest( + payload=SchedulingPublicParticipationSubmitRequest( answers=[ - PollAnswerInput( - option_id=request.slots[0].poll_option_id, + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, value="available", + option_revision=scheduling_slot_revision(request.slots[0]), ) ] ), + client_address="127.0.0.1", ) alice = self._principal( "alice-account", @@ -1010,7 +1274,7 @@ class SchedulingServiceTests(unittest.TestCase): self.assertEqual(len(responses), 1) self.assertEqual( responses[0].respondent_id, - f"invitation:{participant.poll_invitation_id}", + participant.respondent_id, ) self.assertEqual( responses[0].metadata_["invitation_id"], @@ -1071,13 +1335,17 @@ class SchedulingServiceTests(unittest.TestCase): for response in responses: own = next(item for item in response.participants if item.display_name == "Alice") other = next(item for item in response.participants if item.display_name == "Bob") - self.assertEqual(own.respondent_id, "alice-id") + self.assertTrue(own.is_current_participant) + self.assertIsNone(own.respondent_id) self.assertEqual(own.email, "alice@example.test") - self.assertEqual(own.metadata, {"private": "alice"}) - self.assertIsNotNone(own.poll_invitation_id) + self.assertEqual(own.metadata, {}) + self.assertIsNone(own.poll_invitation_id) self.assertIsNone(own.invitation_token) + self.assertFalse(other.is_current_participant) self.assertIsNone(other.respondent_id) self.assertIsNone(other.email) + self.assertIsNone(other.participant_type) + self.assertIsNone(other.required) self.assertIsNone(other.poll_invitation_id) self.assertIsNone(other.last_invited_at) self.assertIsNone(other.responded_at) @@ -1114,13 +1382,13 @@ class SchedulingServiceTests(unittest.TestCase): self.assertIsNone(request.selected_slot_id) availability_reader = self._principal( - "reader", + "user-1", scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_AVAILABILITY_READ_SCOPE}, ) freebusy = api_evaluate_calendar_freebusy(request.id, session=self.session, principal=availability_reader) self.assertTrue(freebusy.updated_slot_ids) event_writer = self._principal( - "event-writer", + "user-1", scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_EVENT_WRITE_SCOPE}, ) holds = api_create_tentative_calendar_holds(request.id, session=self.session, principal=event_writer) @@ -1139,7 +1407,7 @@ class SchedulingServiceTests(unittest.TestCase): self.session.flush() close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id) scheduling_writer = self._principal( - "writer", + "user-1", scopes={SCHEDULING_WRITE_SCOPE}, ) @@ -1209,7 +1477,10 @@ class SchedulingServiceTests(unittest.TestCase): self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"}) self.assertEqual( {item.action_url for item in provider.requests}, - {f"/poll/public/{token}" for token in tokens.values()}, + { + f"/scheduling/public/{request.id}/{token}" + for token in tokens.values() + }, ) local_notifications = list_scheduling_notifications( self.session,