diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index 9fb223e..fa56f88 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER +from govoplan_core.core.calendar import CAPABILITY_CALENDAR_SCHEDULING from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import ( DocumentationTopic, @@ -17,6 +18,7 @@ from govoplan_core.core.modules import ( RoleTemplate, ) from govoplan_core.db.base import Base +from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata MODULE_ID = "scheduling" @@ -128,7 +130,12 @@ manifest = ModuleManifest( version=MODULE_VERSION, dependencies=("poll",), optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"), - optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + optional_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_CALENDAR_SCHEDULING, + ), + required_capabilities=(CAPABILITY_POLL_SCHEDULING,), provides_interfaces=( ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"), ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.8"), @@ -140,6 +147,7 @@ manifest = ModuleManifest( 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), + ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 7bb4740..3bd82d3 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -7,10 +7,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE from govoplan_core.db.session import get_session -from govoplan_poll.backend.schemas import PollResultSummaryResponse -from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE +from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE from govoplan_scheduling.backend.schemas import ( + SchedulingAvailabilityResponseRequest, SchedulingAddressLookupCandidate, SchedulingAddressLookupResponse, SchedulingCalendarActionResponse, @@ -22,12 +23,14 @@ from govoplan_scheduling.backend.schemas import ( SchedulingRequestListResponse, SchedulingRequestResponse, SchedulingRequestUpdateRequest, + SchedulingPollSummaryResponse, SchedulingStatusResponse, SchedulingSummaryResponse, ) from govoplan_scheduling.backend.runtime import get_registry from govoplan_scheduling.backend.service import ( SchedulingError, + SchedulingPermissionError, cancel_scheduling_request, close_scheduling_request, create_final_calendar_event, @@ -36,13 +39,16 @@ from govoplan_scheduling.backend.service import ( create_tentative_calendar_holds, decide_scheduling_request, evaluate_calendar_freebusy, - get_scheduling_request, - list_scheduling_notifications, - list_scheduling_requests, + get_visible_scheduling_request, + list_visible_scheduling_notifications, + list_visible_scheduling_requests, open_scheduling_request, + refresh_participant_response_state, + require_visible_scheduling_results, scheduling_notification_response, scheduling_request_response, scheduling_request_summary, + submit_scheduling_availability, update_scheduling_request, ) @@ -88,15 +94,45 @@ def _require_scope(principal: ApiPrincipal, scope: str) -> None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") +def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]: + candidates = ( + principal.account_id, + principal.membership_id, + getattr(principal.user, "id", None), + principal.identity_id, + principal.principal.service_account_id, + principal.email, + ) + return tuple(dict.fromkeys(str(value) for value in candidates if value)) + + +def _can_manage_scheduling(principal: ApiPrincipal) -> bool: + return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE) + + def _scheduling_http_error(exc: SchedulingError) -> HTTPException: + if isinstance(exc, SchedulingPermissionError): + return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) if str(exc) == "Scheduling request not found": return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) + if str(exc) == "Scheduling results are not visible": + return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) -def _request_response(request, *, invitation_tokens: dict[str, str] | None = None) -> SchedulingRequestResponse: +def _request_response( + request, + *, + principal: ApiPrincipal, + invitation_tokens: dict[str, str] | None = None, +) -> SchedulingRequestResponse: return SchedulingRequestResponse.model_validate( - scheduling_request_response(request, invitation_tokens=invitation_tokens) + scheduling_request_response( + request, + invitation_tokens=invitation_tokens, + actor_ids=_principal_actor_ids(principal), + can_manage=_can_manage_scheduling(principal), + ) ) @@ -125,8 +161,27 @@ def api_list_scheduling_requests( principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestListResponse: _require_scope(principal, READ_SCOPE) - requests = list_scheduling_requests(session, tenant_id=principal.tenant_id, status=status_filter) - return SchedulingRequestListResponse(requests=[_request_response(request) for request in requests]) + requests = list_visible_scheduling_requests( + session, + tenant_id=principal.tenant_id, + actor_ids=_principal_actor_ids(principal), + can_manage=_can_manage_scheduling(principal), + status=status_filter, + ) + actor_ids = _principal_actor_ids(principal) + for request in requests: + refresh_participant_response_state( + session, + request=request, + actor_ids=actor_ids, + ) + response = SchedulingRequestListResponse( + requests=[_request_response(request, principal=principal) for request in requests] + ) + # Poll responses are authoritative, while Scheduling keeps a durable + # participant projection used by its task-oriented list. + session.commit() + return response @router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED) @@ -145,7 +200,36 @@ def api_create_scheduling_request( ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return _request_response(request, invitation_tokens=invitation_tokens) + response = _request_response(request, principal=principal, invitation_tokens=invitation_tokens) + session.commit() + return response + + +@router.post("/requests/{request_id}/responses", response_model=SchedulingStatusResponse) +def api_submit_scheduling_availability( + request_id: str, + payload: SchedulingAvailabilityResponseRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> SchedulingStatusResponse: + _require_scope(principal, RESPOND_SCOPE) + try: + request = submit_scheduling_availability( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + actor_ids=_principal_actor_ids(principal), + respondent_id=principal.account_id, + respondent_label=principal.display_name or principal.email, + payload=payload, + ) + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + response = SchedulingStatusResponse( + request=_request_response(request, principal=principal) + ) + session.commit() + return response @router.get("/requests/{request_id}", response_model=SchedulingRequestResponse) @@ -156,9 +240,23 @@ def api_get_scheduling_request( ) -> SchedulingRequestResponse: _require_scope(principal, READ_SCOPE) try: - return _request_response(get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)) + request = get_visible_scheduling_request( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + actor_ids=_principal_actor_ids(principal), + can_manage=_can_manage_scheduling(principal), + ) + refresh_participant_response_state( + session, + request=request, + actor_ids=_principal_actor_ids(principal), + ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc + response = _request_response(request, principal=principal) + session.commit() + return response @router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse) @@ -170,11 +268,17 @@ def api_update_scheduling_request( ) -> SchedulingRequestResponse: _require_scope(principal, WRITE_SCOPE) try: - return _request_response( - update_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload) + request = update_scheduling_request( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + payload=payload, ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc + response = _request_response(request, principal=principal) + session.commit() + return response @router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse) @@ -188,7 +292,9 @@ def api_open_scheduling_request( request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return SchedulingStatusResponse(request=_request_response(request)) + response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) + session.commit() + return response @router.post("/requests/{request_id}/close", response_model=SchedulingStatusResponse) @@ -202,7 +308,9 @@ def api_close_scheduling_request( request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return SchedulingStatusResponse(request=_request_response(request)) + response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) + session.commit() + return response @router.post("/requests/{request_id}/decide", response_model=SchedulingStatusResponse) @@ -220,10 +328,16 @@ def api_decide_scheduling_request( request_id=request_id, payload=payload, user_id=principal.account_id, + allow_calendar_handoff=has_scope( + principal, + CALENDAR_EVENT_WRITE_SCOPE, + ), ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return SchedulingStatusResponse(request=_request_response(request)) + response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) + session.commit() + return response @router.post("/requests/{request_id}/calendar/freebusy", response_model=SchedulingCalendarActionResponse) @@ -233,15 +347,18 @@ def api_evaluate_calendar_freebusy( principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingCalendarActionResponse: _require_scope(principal, WRITE_SCOPE) + _require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE) try: request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return SchedulingCalendarActionResponse( - request=_request_response(request), + response = SchedulingCalendarActionResponse( + request=_request_response(request, principal=principal), updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None], warnings=warnings, ) + session.commit() + return response @router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse) @@ -251,6 +368,7 @@ def api_create_tentative_calendar_holds( principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingCalendarActionResponse: _require_scope(principal, WRITE_SCOPE) + _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE) try: request, created_event_ids, warnings = create_tentative_calendar_holds( session, @@ -260,12 +378,14 @@ def api_create_tentative_calendar_holds( ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return SchedulingCalendarActionResponse( - request=_request_response(request), + response = SchedulingCalendarActionResponse( + request=_request_response(request, principal=principal), created_event_ids=created_event_ids, updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids], warnings=warnings, ) + session.commit() + return response @router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse) @@ -275,6 +395,7 @@ def api_create_final_calendar_event( principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingCalendarActionResponse: _require_scope(principal, WRITE_SCOPE) + _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE) try: request, event_id, warnings = create_final_calendar_event( session, @@ -284,11 +405,13 @@ def api_create_final_calendar_event( ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return SchedulingCalendarActionResponse( - request=_request_response(request), + response = SchedulingCalendarActionResponse( + request=_request_response(request, principal=principal), created_event_ids=[event_id] if event_id else [], warnings=warnings, ) + session.commit() + return response @router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse) @@ -302,7 +425,9 @@ def api_cancel_scheduling_request( request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return SchedulingStatusResponse(request=_request_response(request)) + response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) + session.commit() + return response @router.get("/requests/{request_id}/summary", response_model=SchedulingSummaryResponse) @@ -313,14 +438,30 @@ def api_scheduling_summary( ) -> SchedulingSummaryResponse: _require_scope(principal, READ_SCOPE) try: - request = get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id) + request = get_visible_scheduling_request( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + actor_ids=_principal_actor_ids(principal), + can_manage=_can_manage_scheduling(principal), + ) + require_visible_scheduling_results( + session, + request=request, + actor_ids=_principal_actor_ids(principal), + can_manage=_can_manage_scheduling(principal), + ) summary = scheduling_request_summary(session, tenant_id=principal.tenant_id, request_id=request_id) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return SchedulingSummaryResponse( - request=_request_response(request), - poll_summary=PollResultSummaryResponse.model_validate(summary), + response = SchedulingSummaryResponse( + request=_request_response(request, principal=principal), + poll_summary=SchedulingPollSummaryResponse.model_validate(summary), ) + # Refreshing the summary synchronizes participant response state and can + # enqueue response notifications, so persist that work before teardown. + session.commit() + return response @router.get("/notifications", response_model=SchedulingNotificationListResponse) @@ -331,9 +472,11 @@ def api_list_scheduling_notifications( principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingNotificationListResponse: _require_scope(principal, READ_SCOPE) - notifications = list_scheduling_notifications( + notifications = list_visible_scheduling_notifications( session, tenant_id=principal.tenant_id, + actor_ids=_principal_actor_ids(principal), + can_manage=_can_manage_scheduling(principal), request_id=request_id, status=status_filter, ) @@ -364,9 +507,11 @@ def api_create_scheduling_notifications( ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - return SchedulingNotificationListResponse( + response = SchedulingNotificationListResponse( notifications=[ SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification)) for notification in notifications ] ) + session.commit() + return response diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index 2e3918c..0b31663 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -5,13 +5,12 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator -from govoplan_poll.backend.schemas import PollResultSummaryResponse - SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"] SchedulingParticipantType = Literal["internal", "external", "resource"] SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"] SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"] +SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"] class SchedulingCalendarPreferences(BaseModel): @@ -164,9 +163,48 @@ class SchedulingDecisionRequest(BaseModel): handoff_to_calendar: bool | None = None +class SchedulingAvailabilityAnswerInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + slot_id: str + value: SchedulingAvailabilityValue + + +class SchedulingAvailabilityResponseRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1) + + @model_validator(mode="after") + def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest": + 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 SchedulingPollOptionResultResponse(BaseModel): + option_id: str + option_key: str + label: str + count: int = 0 + score: int = 0 + values: dict[str, int] = Field(default_factory=dict) + ranks: dict[int, int] = Field(default_factory=dict) + + +class SchedulingPollSummaryResponse(BaseModel): + poll_id: str + kind: str + status: str + response_count: int + option_results: list[SchedulingPollOptionResultResponse] = Field(default_factory=list) + leading_option_ids: list[str] = Field(default_factory=list) + + class SchedulingSummaryResponse(BaseModel): request: SchedulingRequestResponse - poll_summary: PollResultSummaryResponse + poll_summary: SchedulingPollSummaryResponse class SchedulingCalendarActionResponse(BaseModel): diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 8ada1a6..b6a9fb9 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -5,22 +5,30 @@ from typing import Any from sqlalchemy.orm import Session -from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider -from govoplan_core.db.base import utcnow -from govoplan_poll.backend.schemas import PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput -from govoplan_poll.backend.db.models import PollResponse -from govoplan_poll.backend.service import ( - PollError, - close_poll, - create_poll, - create_poll_invitation, - decide_poll, - get_poll, - open_poll, - poll_result_summary_by_id, +from govoplan_core.core.calendar import ( + CalendarCapabilityError, + CalendarEventRequest, + CalendarSchedulingProvider, + calendar_scheduling_provider, ) +from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider +from govoplan_core.core.poll import ( + PollAnswerRequest, + PollCapabilityError, + PollCreateCommand, + PollInvitationCommand, + PollOptionRequest, + PollResponseSubmissionProvider, + PollSchedulingProvider, + PollSubmitResponseCommand, + PollUpdateCommand, + poll_response_submission_provider, + poll_scheduling_provider, +) +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, SchedulingDecisionRequest, SchedulingRequestCreateRequest, SchedulingRequestUpdateRequest, @@ -32,6 +40,10 @@ class SchedulingError(ValueError): pass +class SchedulingPermissionError(SchedulingError): + pass + + WORKFLOW_DRAFT = "draft" WORKFLOW_COLLECTING = "collecting_availability" WORKFLOW_CLOSED = "availability_closed" @@ -55,7 +67,7 @@ def _jsonable(value: Any) -> Any: return normalized.isoformat() if normalized else None if isinstance(value, dict): return {str(key): _jsonable(item) for key, item in value.items()} - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [_jsonable(item) for item in value] return value @@ -105,11 +117,11 @@ def _active_participants(request: SchedulingRequest) -> list[SchedulingParticipa return [participant for participant in request.participants if participant.deleted_at is None] -def _poll_option_inputs(request: SchedulingRequest) -> list[PollOptionInput]: - options: list[PollOptionInput] = [] +def _poll_option_inputs(request: SchedulingRequest) -> list[PollOptionRequest]: + options: list[PollOptionRequest] = [] for slot in _active_slots(request): options.append( - PollOptionInput( + PollOptionRequest( key=f"slot-{slot.position + 1}", label=slot.label, description=slot.description, @@ -140,13 +152,34 @@ def _poll_workflow_state(request: SchedulingRequest) -> str: def _sync_poll_workflow(session: Session, *, tenant_id: str, request: SchedulingRequest) -> None: if request.poll_id is None: return - poll = get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) state = _poll_workflow_state(request) - poll.workflow_state = state - poll.workflow_steps = _workflow_steps(state) - poll.context_module = "scheduling" - poll.context_resource_type = "scheduling_request" - poll.context_resource_id = request.id + try: + _poll_provider().set_workflow_context( + session, + tenant_id=tenant_id, + poll_id=request.poll_id, + workflow_state=state, + workflow_steps=_workflow_steps(state), + context_module="scheduling", + context_resource_type="scheduling_request", + context_resource_id=request.id, + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + + +def _poll_provider() -> PollSchedulingProvider: + provider = poll_scheduling_provider(get_registry()) + if provider is None: + raise SchedulingError("Poll scheduling capability is unavailable") + 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 _set_calendar_preferences(request: SchedulingRequest, payload) -> None: @@ -162,13 +195,11 @@ def _set_calendar_preferences(request: SchedulingRequest, payload) -> None: request.create_calendar_event_on_decision = False -def _calendar_api(): - try: - from govoplan_calendar.backend.schemas import CalendarEventCreateRequest - from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy - except ModuleNotFoundError as exc: - raise SchedulingError("Calendar module is not installed") from exc - return CalendarEventCreateRequest, CalendarError, create_event, list_freebusy +def _calendar_provider() -> CalendarSchedulingProvider: + provider = calendar_scheduling_provider(get_registry()) + if provider is None: + raise SchedulingError("Calendar scheduling capability is unavailable") + return provider def _require_calendar_enabled(request: SchedulingRequest) -> None: @@ -178,6 +209,13 @@ def _require_calendar_enabled(request: SchedulingRequest) -> None: raise SchedulingError("Scheduling request has no target calendar") +def _require_calendar_planning_state(request: SchedulingRequest) -> None: + if request.status not in {"draft", "collecting", "closed"} or request.selected_slot_id: + raise SchedulingError( + "Calendar availability checks and tentative holds are only available before a scheduling decision" + ) + + def _attendees_for_calendar_event(request: SchedulingRequest) -> list[dict[str, Any]]: attendees: list[dict[str, Any]] = [] for participant in _active_participants(request): @@ -202,22 +240,22 @@ def _calendar_event_payload( status: str, summary_prefix: str | None = None, ) -> Any: - CalendarEventCreateRequest, _CalendarError, _create_event, _list_freebusy = _calendar_api() summary = f"{summary_prefix}: {request.title}" if summary_prefix else request.title - return CalendarEventCreateRequest( + summary = summary[:500] + return CalendarEventRequest( calendar_id=request.calendar_id, summary=summary, description=request.description, location=slot.location or request.location, status=status, transparency="OPAQUE", - classification="PRIVATE" if status == "TENTATIVE" else "PUBLIC", + classification="PRIVATE", start_at=slot.start_at, end_at=slot.end_at, timezone=slot.timezone, - attendees=_attendees_for_calendar_event(request), - categories=["GovOPlaN", "Scheduling"], - related_to=[{"reltype": "SIBLING", "uid": request.poll_id, "type": "poll"}] if request.poll_id else [], + attendees=tuple(_attendees_for_calendar_event(request)), + categories=("GovOPlaN", "Scheduling"), + related_to=({"reltype": "SIBLING", "uid": request.poll_id, "type": "poll"},) if request.poll_id else (), metadata={ "scheduling_request_id": request.id, "scheduling_slot_id": slot.id, @@ -233,22 +271,28 @@ def evaluate_calendar_freebusy( tenant_id: str, request_id: str, ) -> tuple[SchedulingRequest, list[str]]: - request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + request = _lock_scheduling_calendar_handoff( + session, + tenant_id=tenant_id, + request_id=request_id, + lock_slots=True, + ) + _require_calendar_planning_state(request) _require_calendar_enabled(request) if not request.calendar_freebusy_enabled: raise SchedulingError("Calendar free/busy checks are not enabled for this scheduling request") - _CalendarEventCreateRequest, CalendarError, _create_event, list_freebusy = _calendar_api() + provider = _calendar_provider() warnings: list[str] = [] for slot in _active_slots(request): try: - conflicts = list_freebusy( + conflicts = provider.list_freebusy( session, tenant_id=tenant_id, start_at=slot.start_at, end_at=slot.end_at, calendar_ids=[request.calendar_id] if request.calendar_id else None, ) - except CalendarError as exc: + except CalendarCapabilityError as exc: slot.freebusy_checked_at = _now() slot.freebusy_status = "error" slot.freebusy_conflicts = [{"error": str(exc)}] @@ -268,27 +312,43 @@ def create_tentative_calendar_holds( user_id: str | None, request_id: str, ) -> tuple[SchedulingRequest, list[str], list[str]]: - request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + request = _lock_scheduling_calendar_handoff( + session, + tenant_id=tenant_id, + request_id=request_id, + lock_slots=True, + ) + _require_calendar_planning_state(request) _require_calendar_enabled(request) if not request.calendar_hold_enabled: raise SchedulingError("Tentative calendar holds are not enabled for this scheduling request") - _CalendarEventCreateRequest, CalendarError, create_event, _list_freebusy = _calendar_api() + provider = _calendar_provider() created_event_ids: list[str] = [] warnings: list[str] = [] for slot in _active_slots(request): if slot.tentative_hold_event_id: continue try: - event = create_event( + event = provider.create_event( session, tenant_id=tenant_id, user_id=user_id, - payload=_calendar_event_payload(request, slot, status="TENTATIVE", summary_prefix="Tentative hold"), + request=_calendar_event_payload(request, slot, status="TENTATIVE", summary_prefix="Tentative hold"), ) - except CalendarError as exc: + except CalendarCapabilityError as exc: warnings.append(str(exc)) continue slot.tentative_hold_event_id = event.id + slot.metadata_ = { + **(slot.metadata_ or {}), + "calendar_hold": { + "event_id": event.id, + # This is a creation-time snapshot. Calendar remains the + # authority for later outbox delivery transitions. + "last_known_external_state": event.external_state, + "outbox_operation_id": event.outbox_operation_id, + }, + } created_event_ids.append(event.id) session.flush() return request, created_event_ids, warnings @@ -301,24 +361,32 @@ def create_final_calendar_event( user_id: str | None, request_id: str, ) -> tuple[SchedulingRequest, str | None, list[str]]: - request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + request = _lock_scheduling_calendar_handoff( + session, + tenant_id=tenant_id, + request_id=request_id, + ) _require_calendar_enabled(request) if not request.create_calendar_event_on_decision: raise SchedulingError("Final calendar event creation is not enabled for this scheduling request") + if request.status == "handed_off" and request.selected_slot_id and request.calendar_event_id: + return request, request.calendar_event_id, [] + if request.status != "decided" or not request.selected_slot_id: + raise SchedulingError( + "A final calendar event can only be created for a decided scheduling request with a selected slot" + ) if request.calendar_event_id: return request, request.calendar_event_id, [] - if not request.selected_slot_id: - raise SchedulingError("Scheduling request has no selected slot") slot = _selected_slot(request, slot_id=request.selected_slot_id) - _CalendarEventCreateRequest, CalendarError, create_event, _list_freebusy = _calendar_api() + provider = _calendar_provider() try: - event = create_event( + event = provider.create_event( session, tenant_id=tenant_id, user_id=user_id, - payload=_calendar_event_payload(request, slot, status="CONFIRMED"), + request=_calendar_event_payload(request, slot, status="CONFIRMED"), ) - except CalendarError as exc: + except CalendarCapabilityError as exc: request.metadata_ = { **(request.metadata_ or {}), "calendar_handoff": {"status": "error", "error": str(exc), "slot_id": slot.id, "calendar_id": request.calendar_id}, @@ -330,7 +398,16 @@ def create_final_calendar_event( request.status = "handed_off" request.metadata_ = { **(request.metadata_ or {}), - "calendar_handoff": {"status": "created", "event_id": event.id, "slot_id": slot.id, "calendar_id": request.calendar_id}, + "calendar_handoff": { + "status": "accepted", + # Scheduling stores identifiers plus an explicitly named snapshot; + # consumers must resolve the Calendar event/outbox for live state. + "last_known_external_state": event.external_state, + "outbox_operation_id": event.outbox_operation_id, + "event_id": event.id, + "slot_id": slot.id, + "calendar_id": request.calendar_id, + }, } _sync_poll_workflow(session, tenant_id=tenant_id, request=request) session.flush() @@ -345,6 +422,7 @@ def create_scheduling_notification_jobs( event_kind: str, channel: str = "mail", metadata: dict[str, Any] | None = None, + invitation_tokens: dict[str, str] | None = None, ) -> list[SchedulingNotification]: if event_kind not in NOTIFICATION_KINDS: raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}") @@ -377,7 +455,12 @@ def create_scheduling_notification_jobs( session.add(notification) notifications.append(notification) session.flush() - _mirror_scheduling_notifications_to_center(session, request=request, notifications=notifications) + _mirror_scheduling_notifications_to_center( + session, + request=request, + notifications=notifications, + invitation_tokens=invitation_tokens, + ) return notifications @@ -386,6 +469,7 @@ def _mirror_scheduling_notifications_to_center( *, request: SchedulingRequest, notifications: list[SchedulingNotification], + invitation_tokens: dict[str, str] | None = None, ) -> None: provider = notification_dispatch_provider(get_registry()) if provider is None: @@ -395,10 +479,16 @@ def _mirror_scheduling_notifications_to_center( if notification.status == "skipped": continue participant = participants_by_id.get(notification.participant_id or "") + invitation_token = invitation_tokens.get(participant.id) if invitation_tokens and participant else None try: response = provider.enqueue_notification( session, - _notification_dispatch_request(request=request, participant=participant, notification=notification), + _notification_dispatch_request( + request=request, + participant=participant, + notification=notification, + invitation_token=invitation_token, + ), enqueue_delivery=True, ) except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow. @@ -423,6 +513,7 @@ def _notification_dispatch_request( request: SchedulingRequest, participant: SchedulingParticipant | None, notification: SchedulingNotification, + invitation_token: str | None = None, ) -> NotificationDispatchRequest: payload = { **(notification.payload or {}), @@ -446,11 +537,15 @@ def _notification_dispatch_request( channel=notification.channel, recipient=notification.recipient, recipient_type="scheduling_participant", - recipient_id=participant.id if participant else notification.participant_id, + recipient_id=participant.respondent_id if participant else None, recipient_label=participant.display_name if participant else None, subject=_notification_subject(request, notification.event_kind), body_text=_notification_body_text(request, participant, notification.event_kind), - action_url=f"/scheduling?request_id={request.id}", + action_url=( + f"/poll/public/{invitation_token}" + if notification.event_kind == "invitation" and invitation_token + else f"/scheduling?request_id={request.id}" + ), payload=_jsonable(payload), metadata={ **(notification.metadata_ or {}), @@ -557,7 +652,58 @@ def list_scheduling_notifications( return query.order_by(SchedulingNotification.created_at.desc(), SchedulingNotification.id.asc()).all() -def refresh_participant_response_state(session: Session, *, request: SchedulingRequest) -> None: +def list_visible_scheduling_notifications( + session: Session, + *, + tenant_id: str, + actor_ids: tuple[str, ...], + can_manage: bool = False, + request_id: str | None = None, + status: str | None = None, +) -> list[SchedulingNotification]: + notifications = list_scheduling_notifications( + session, + tenant_id=tenant_id, + request_id=request_id, + status=status, + ) + if can_manage: + return notifications + ids = _actor_ids(actor_ids) + if not ids: + return [] + visible: list[SchedulingNotification] = [] + requests_by_id: dict[str, SchedulingRequest] = {} + for notification in notifications: + request = requests_by_id.get(notification.request_id) + if request is None: + request = get_scheduling_request( + session, + tenant_id=tenant_id, + request_id=notification.request_id, + ) + requests_by_id[notification.request_id] = request + if request.organizer_user_id in ids: + visible.append(notification) + continue + participant = next( + (item for item in _active_participants(request) if item.id == notification.participant_id), + None, + ) + if notification.recipient in ids or ( + participant is not None + and (participant.respondent_id in ids or participant.email in ids) + ): + visible.append(notification) + return visible + + +def refresh_participant_response_state( + session: Session, + *, + request: SchedulingRequest, + actor_ids: tuple[str, ...] = (), +) -> None: if request.poll_id is None: return by_invitation_id = { @@ -565,17 +711,42 @@ def refresh_participant_response_state(session: Session, *, request: SchedulingR for participant in _active_participants(request) if participant.poll_invitation_id is not None } - if not by_invitation_id: + by_respondent_id = { + participant.respondent_id: participant + for participant in _active_participants(request) + if participant.respondent_id is not None + } + if not by_invitation_id and not by_respondent_id: return - responses = ( - session.query(PollResponse) - .filter(PollResponse.tenant_id == request.tenant_id, PollResponse.poll_id == request.poll_id, PollResponse.deleted_at.is_(None)) - .all() - ) + try: + responses = _poll_provider().list_responses( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc changed = False + ids = _actor_ids(actor_ids) + actor_participants = [ + participant + for participant in _active_participants(request) + if participant.respondent_id in ids or participant.email in ids + ] for response in responses: - invitation_id = (response.metadata_ or {}).get("invitation_id") - participant = by_invitation_id.get(invitation_id) + participant = by_invitation_id.get(response.invitation_id) + if participant is None and response.respondent_id is not None: + participant = by_respondent_id.get(response.respondent_id) + # Poll deliberately ignores client-supplied invitation identifiers on + # authenticated submissions. While reconciling for that same actor, + # map the server-authenticated respondent id to their unique Scheduling + # participant row (which may have been invited by email only). + if ( + participant is None + and response.respondent_id in ids + and len(actor_participants) == 1 + ): + participant = actor_participants[0] if participant is not None and participant.status != "responded": participant.status = "responded" participant.responded_at = response_datetime(response.submitted_at) @@ -652,33 +823,33 @@ def create_scheduling_request( session.flush() try: - poll = create_poll( + poll = _poll_provider().create_poll( session, tenant_id=tenant_id, user_id=user_id, - payload=PollCreateRequest( + command=PollCreateCommand( title=payload.title, description=payload.description, kind="availability", status=_poll_status_for_request(payload.status), - visibility="unlisted" if payload.allow_external_participants else "tenant", + visibility="private", result_visibility=payload.result_visibility, context_module="scheduling", context_resource_type="scheduling_request", context_resource_id=request.id, workflow_state=_poll_workflow_state(request), - workflow_steps=_workflow_steps(_poll_workflow_state(request)), + workflow_steps=tuple(_workflow_steps(_poll_workflow_state(request))), allow_anonymous=False, allow_response_update=payload.allow_participant_updates, min_choices=1, max_choices=len(_active_slots(request)), opens_at=None, closes_at=payload.deadline_at, - options=_poll_option_inputs(request), + options=tuple(_poll_option_inputs(request)), metadata={"scheduling_request_id": request.id, "calendar": payload.calendar.model_dump()}, ), ) - except PollError as exc: + except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc request.poll_id = poll.id options_by_position = {option.position: option.id for option in poll.options} @@ -689,11 +860,11 @@ def create_scheduling_request( if payload.create_participant_invitations: for participant in _active_participants(request): try: - invitation, token = create_poll_invitation( + invitation = _poll_provider().create_invitation( session, tenant_id=tenant_id, poll_id=poll.id, - payload=PollInvitationCreateRequest( + command=PollInvitationCommand( respondent_id=participant.respondent_id, respondent_label=participant.display_name, email=participant.email, @@ -701,18 +872,19 @@ def create_scheduling_request( metadata={"scheduling_request_id": request.id, "scheduling_participant_id": participant.id}, ), ) - except PollError as exc: + except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc participant.poll_invitation_id = invitation.id participant.last_invited_at = _now() participant.status = "invited" - invitation_tokens[participant.id] = token + invitation_tokens[participant.id] = invitation.token create_scheduling_notification_jobs( session, tenant_id=tenant_id, request_id=request.id, event_kind="invitation", metadata={"created_with_request": True}, + invitation_tokens=invitation_tokens, ) session.flush() return request, invitation_tokens @@ -725,6 +897,44 @@ def list_scheduling_requests(session: Session, *, tenant_id: str, status: str | return query.order_by(SchedulingRequest.created_at.desc(), SchedulingRequest.title.asc()).all() +def _actor_ids(values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(dict.fromkeys(str(value) for value in values if value)) + + +def scheduling_request_is_visible( + request: SchedulingRequest, + *, + actor_ids: tuple[str, ...], + can_manage: bool = False, +) -> bool: + if can_manage: + return True + ids = _actor_ids(actor_ids) + if not ids: + return False + if request.organizer_user_id in ids: + return True + return any( + participant.respondent_id in ids or participant.email in ids + for participant in _active_participants(request) + ) + + +def list_visible_scheduling_requests( + session: Session, + *, + tenant_id: str, + actor_ids: tuple[str, ...], + can_manage: bool = False, + status: str | None = None, +) -> list[SchedulingRequest]: + return [ + request + for request in list_scheduling_requests(session, tenant_id=tenant_id, status=status) + if scheduling_request_is_visible(request, actor_ids=actor_ids, can_manage=can_manage) + ] + + def get_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest: request = ( session.query(SchedulingRequest) @@ -736,6 +946,190 @@ def get_scheduling_request(session: Session, *, tenant_id: str, request_id: str) return request +def _lock_scheduling_request( + session: Session, + *, + tenant_id: str, + request_id: str, + lock_slots: bool = False, +) -> SchedulingRequest: + """Serialize lifecycle and Calendar handoff checks for one request.""" + + request = ( + session.query(SchedulingRequest) + .filter( + SchedulingRequest.tenant_id == tenant_id, + SchedulingRequest.id == request_id, + SchedulingRequest.deleted_at.is_(None), + ) + .populate_existing() + .with_for_update() + .first() + ) + if request is None: + raise SchedulingError("Scheduling request not found") + if lock_slots: + ( + session.query(SchedulingCandidateSlot) + .filter( + SchedulingCandidateSlot.tenant_id == tenant_id, + SchedulingCandidateSlot.request_id == request.id, + ) + .order_by(SchedulingCandidateSlot.id.asc()) + .populate_existing() + .with_for_update() + .all() + ) + return request + + +def _lock_scheduling_calendar_handoff( + session: Session, + *, + tenant_id: str, + request_id: str, + lock_slots: bool = False, +) -> SchedulingRequest: + return _lock_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + lock_slots=lock_slots, + ) + + +def get_visible_scheduling_request( + session: Session, + *, + tenant_id: str, + request_id: str, + actor_ids: tuple[str, ...], + can_manage: bool = False, +) -> SchedulingRequest: + request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + if not scheduling_request_is_visible(request, actor_ids=actor_ids, can_manage=can_manage): + raise SchedulingError("Scheduling request not found") + return request + + +def submit_scheduling_availability( + session: Session, + *, + tenant_id: str, + request_id: str, + actor_ids: tuple[str, ...], + respondent_id: str | None, + respondent_label: str | None, + payload: SchedulingAvailabilityResponseRequest, +) -> SchedulingRequest: + request = get_visible_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 request.poll_id is None: + raise SchedulingError("Scheduling request has no backing poll") + if respondent_id is None: + raise SchedulingPermissionError("An authenticated account is required to respond") + ids = _actor_ids(actor_ids) + participants = [ + participant + for participant in _active_participants(request) + if participant.respondent_id in ids or participant.email in ids + ] + if len(participants) != 1: + raise SchedulingPermissionError( + "A unique participant assignment is required to respond" + ) + participant = participants[0] + canonical_respondent_id = participant.respondent_id or ( + f"invitation:{participant.poll_invitation_id}" + if participant.poll_invitation_id + else respondent_id + ) + 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") + answers.append( + PollAnswerRequest( + option_id=slot.poll_option_id, + value=answer.value, + ) + ) + try: + response = _poll_response_provider().submit_response( + session, + tenant_id=tenant_id, + poll_id=request.poll_id, + command=PollSubmitResponseCommand( + respondent_id=canonical_respondent_id, + respondent_label=respondent_label, + answers=tuple(answers), + 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" + participant.status = "responded" + participant.responded_at = response_datetime(response.submitted_at) + if first_response: + _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 request + + +def require_visible_scheduling_results( + session: Session, + *, + request: SchedulingRequest, + actor_ids: tuple[str, ...], + can_manage: bool = False, +) -> None: + ids = _actor_ids(actor_ids) + if can_manage or request.organizer_user_id in ids or request.result_visibility == "public": + return + if request.result_visibility == "after_close" and request.status in { + "closed", + "decided", + "handed_off", + "cancelled", + "archived", + }: + return + if request.result_visibility == "after_response": + refresh_participant_response_state(session, request=request) + if any( + participant.status == "responded" + and (participant.respondent_id in ids or participant.email in ids) + for participant in _active_participants(request) + ): + return + raise SchedulingError("Scheduling results are not visible") + + def update_scheduling_request( session: Session, *, @@ -754,17 +1148,43 @@ def update_scheduling_request( _set_calendar_preferences(request, payload) if payload.metadata is not None: request.metadata_ = payload.metadata + if request.poll_id is not None: + try: + _poll_provider().update_poll( + session, + tenant_id=tenant_id, + poll_id=request.poll_id, + command=PollUpdateCommand( + title=request.title, + description=request.description, + visibility="private", + result_visibility=request.result_visibility, + allow_anonymous=False, + allow_response_update=request.allow_participant_updates, + closes_at=request.deadline_at, + ), + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc session.flush() return request def open_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest: - request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + request = _lock_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + ) + if request.status == "collecting": + return request + if request.status != "draft": + raise SchedulingError("Only draft scheduling requests can be opened") if request.poll_id is None: raise SchedulingError("Scheduling request has no backing poll") try: - open_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) - except PollError as exc: + _poll_provider().open_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) + except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc request.status = "collecting" _sync_poll_workflow(session, tenant_id=tenant_id, request=request) @@ -773,12 +1193,20 @@ def open_scheduling_request(session: Session, *, tenant_id: str, request_id: str def close_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest: - request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + request = _lock_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + ) + if request.status == "closed": + return request + if request.status != "collecting": + raise SchedulingError("Only collecting scheduling requests can be closed") if request.poll_id is None: raise SchedulingError("Scheduling request has no backing poll") try: - close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) - except PollError as exc: + _poll_provider().close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) + except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc request.status = "closed" _sync_poll_workflow(session, tenant_id=tenant_id, request=request) @@ -793,25 +1221,49 @@ def decide_scheduling_request( request_id: str, payload: SchedulingDecisionRequest, user_id: str | None = None, + allow_calendar_handoff: bool = False, ) -> SchedulingRequest: - request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + request = _lock_scheduling_calendar_handoff( + session, + tenant_id=tenant_id, + request_id=request_id, + ) if request.poll_id is None: raise SchedulingError("Scheduling request has no backing poll") slot = _selected_slot(request, slot_id=payload.slot_id, poll_option_id=payload.poll_option_id) + if request.status in {"decided", "handed_off"} and request.selected_slot_id: + if request.selected_slot_id != slot.id: + raise SchedulingError( + "A different slot cannot be selected after a scheduling decision; " + "explicit rescheduling semantics are required" + ) + return request + if request.status != "closed": + raise SchedulingError("Only closed scheduling requests can be decided") + should_handoff = payload.handoff_to_calendar or ( + payload.handoff_to_calendar is None + and request.create_calendar_event_on_decision + ) + creates_calendar_event = bool( + should_handoff + and request.calendar_integration_enabled + and request.create_calendar_event_on_decision + ) + if creates_calendar_event and not allow_calendar_handoff: + raise SchedulingPermissionError( + "Calendar event creation requires calendar:event:write" + ) try: - decide_poll( + _poll_provider().decide_poll( session, tenant_id=tenant_id, poll_id=request.poll_id, - payload=PollDecisionRequest(option_id=slot.poll_option_id), + option_id=slot.poll_option_id, ) - except PollError as exc: + except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc request.selected_slot_id = slot.id request.status = "decided" - should_handoff = payload.handoff_to_calendar or ( - payload.handoff_to_calendar is None and request.create_calendar_event_on_decision - ) if should_handoff: if request.calendar_integration_enabled and request.create_calendar_event_on_decision: create_final_calendar_event(session, tenant_id=tenant_id, user_id=user_id, request_id=request.id) @@ -834,15 +1286,26 @@ def decide_scheduling_request( def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest: - request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + request = _lock_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + ) + if request.status == "cancelled": + return request + if request.status not in {"draft", "collecting", "closed"}: + raise SchedulingError( + "Only draft, collecting, or closed scheduling requests can be cancelled" + ) request.status = "cancelled" request.cancelled_at = _now() if request.poll_id is not None: try: - poll = get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) + provider = _poll_provider() + poll = provider.get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) if poll.status not in {"closed", "decided", "archived"}: - close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) - except PollError as exc: + provider.close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) + except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc _sync_poll_workflow(session, tenant_id=tenant_id, request=request) create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="cancellation") @@ -869,11 +1332,11 @@ def scheduling_request_summary(session: Session, *, tenant_id: str, request_id: if request.poll_id is None: raise SchedulingError("Scheduling request has no backing poll") try: - summary = poll_result_summary_by_id(session, tenant_id=tenant_id, poll_id=request.poll_id) - except PollError as exc: + summary = _poll_provider().result_summary(session, tenant_id=tenant_id, poll_id=request.poll_id) + except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc refresh_participant_response_state(session, request=request) - return summary + return dict(summary) def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]: @@ -895,25 +1358,40 @@ def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]: } -def scheduling_participant_response(participant: SchedulingParticipant, *, invitation_token: str | None = None) -> dict[str, Any]: +def scheduling_participant_response( + participant: SchedulingParticipant, + *, + invitation_token: str | None = None, + redact_sensitive: bool = False, +) -> dict[str, Any]: return { "id": participant.id, - "respondent_id": participant.respondent_id, + "respondent_id": None if redact_sensitive else participant.respondent_id, "display_name": participant.display_name, - "email": participant.email, + "email": None if redact_sensitive else participant.email, "participant_type": participant.participant_type, "required": participant.required, "status": participant.status, - "poll_invitation_id": participant.poll_invitation_id, - "invitation_token": invitation_token, - "last_invited_at": response_datetime(participant.last_invited_at), - "responded_at": response_datetime(participant.responded_at), - "metadata": participant.metadata_ or {}, + "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 {}, } -def scheduling_request_response(request: SchedulingRequest, *, invitation_tokens: dict[str, str] | None = None) -> dict[str, Any]: +def scheduling_request_response( + request: SchedulingRequest, + *, + invitation_tokens: dict[str, str] | None = None, + actor_ids: tuple[str, ...] | None = None, + can_manage: bool = False, +) -> dict[str, Any]: invitation_tokens = invitation_tokens or {} + ids = _actor_ids(actor_ids or ()) + full_roster = actor_ids is None or can_manage or request.organizer_user_id in ids + if not full_roster: + invitation_tokens = {} return { "id": request.id, "tenant_id": request.tenant_id, @@ -942,7 +1420,13 @@ def scheduling_request_response(request: SchedulingRequest, *, invitation_tokens "metadata": request.metadata_ or {}, "slots": [scheduling_slot_response(slot) for slot in _active_slots(request)], "participants": [ - scheduling_participant_response(participant, invitation_token=invitation_tokens.get(participant.id)) + 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, + ) for participant in _active_participants(request) ], } diff --git a/tests/test_manifest.py b/tests/test_manifest.py index a578033..c56dbbf 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -16,8 +16,10 @@ class SchedulingManifestTests(unittest.TestCase): self.assertNotIn("access", manifest.dependencies) self.assertIn("access", manifest.optional_dependencies) self.assertIn("addresses", manifest.optional_dependencies) - self.assertFalse(manifest.required_capabilities) + self.assertEqual(("poll.scheduling",), manifest.required_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities) + self.assertIn("poll.scheduling", manifest.required_capabilities) + self.assertIn("calendar.scheduling", manifest.optional_capabilities) self.assertIn("evaluation", manifest.optional_dependencies) self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.migration_spec) @@ -27,6 +29,7 @@ class SchedulingManifestTests(unittest.TestCase): self.assertIn("poll.signed_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}) if __name__ == "__main__": diff --git a/tests/test_service.py b/tests/test_service.py index ae301de..9ecedb2 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -2,27 +2,56 @@ 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 sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE from govoplan_core.db.base import Base from govoplan_core.core.change_sequence import ChangeSequenceEntry +from govoplan_core.core.modules import ModuleContext +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, CalendarSyncSource +from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource +from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, 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_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 RESPOND_SCOPE as SCHEDULING_RESPOND_SCOPE +from govoplan_scheduling.backend.manifest import WRITE_SCOPE as SCHEDULING_WRITE_SCOPE from govoplan_scheduling.backend.schemas import ( + SchedulingAvailabilityAnswerInput, + SchedulingAvailabilityResponseRequest, SchedulingCalendarPreferences, SchedulingCandidateSlotInput, SchedulingDecisionRequest, SchedulingParticipantInput, SchedulingRequestCreateRequest, + SchedulingRequestUpdateRequest, +) +from govoplan_scheduling.backend.router import ( + api_create_final_calendar_event, + api_create_scheduling_request, + api_create_tentative_calendar_holds, + api_decide_scheduling_request, + api_evaluate_calendar_freebusy, + api_get_scheduling_request, + api_list_scheduling_requests, + api_scheduling_summary, + api_submit_scheduling_availability, ) from govoplan_scheduling.backend.service import ( SchedulingError, + cancel_scheduling_request, close_scheduling_request, create_final_calendar_event, create_scheduling_notification_jobs, @@ -30,13 +59,25 @@ from govoplan_scheduling.backend.service import ( create_tentative_calendar_holds, decide_scheduling_request, evaluate_calendar_freebusy, + get_visible_scheduling_request, list_scheduling_notifications, + list_visible_scheduling_notifications, + list_visible_scheduling_requests, + open_scheduling_request, + require_visible_scheduling_results, scheduling_request_summary, + update_scheduling_request, ) +from govoplan_scheduling.backend.runtime import configure_runtime class SchedulingServiceTests(unittest.TestCase): def setUp(self) -> None: + registry = PlatformRegistry() + registry.register(get_poll_manifest()) + registry.register(get_calendar_manifest()) + registry.configure_capability_context(ModuleContext(registry=registry, settings=object())) + configure_runtime(registry=registry) self.engine = create_engine("sqlite:///:memory:") Base.metadata.create_all( self.engine, @@ -48,6 +89,7 @@ class SchedulingServiceTests(unittest.TestCase): CalendarCollection.__table__, CalendarEvent.__table__, CalendarSyncSource.__table__, + CalendarOutboxOperation.__table__, ChangeSequenceEntry.__table__, Account.__table__, User.__table__, @@ -72,6 +114,7 @@ class SchedulingServiceTests(unittest.TestCase): User.__table__, Account.__table__, ChangeSequenceEntry.__table__, + CalendarOutboxOperation.__table__, CalendarSyncSource.__table__, CalendarEvent.__table__, CalendarCollection.__table__, @@ -99,6 +142,28 @@ class SchedulingServiceTests(unittest.TestCase): self.session.flush() return calendar + @staticmethod + def _principal( + account_id: str, + *, + email: str | None = None, + membership_id: str | None = None, + scopes: set[str] | None = None, + ) -> ApiPrincipal: + membership_id = membership_id or f"membership-{account_id}" + return ApiPrincipal( + principal=PrincipalRef( + account_id=account_id, + membership_id=membership_id, + tenant_id="tenant-1", + email=email, + display_name=account_id, + scopes=frozenset(scopes or {SCHEDULING_READ_SCOPE}), + ), + account=SimpleNamespace(id=account_id), + user=SimpleNamespace(id=membership_id), + ) + def _payload(self) -> SchedulingRequestCreateRequest: start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc) return SchedulingRequestCreateRequest( @@ -138,6 +203,7 @@ class SchedulingServiceTests(unittest.TestCase): self.assertEqual(request.calendar_id, "calendar-1") self.assertEqual(poll.kind, "availability") self.assertEqual(poll.status, "open") + self.assertEqual(poll.visibility, "private") self.assertEqual(poll.context_module, "scheduling") self.assertEqual(poll.context_resource_id, request.id) self.assertEqual(len(request.slots), 2) @@ -145,6 +211,23 @@ class SchedulingServiceTests(unittest.TestCase): self.assertEqual(len(tokens), 2) self.assertTrue(all(participant.poll_invitation_id for participant in request.participants)) + def test_create_route_persists_after_request_session_closes(self) -> None: + response = api_create_scheduling_request( + self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()}), + session=self.session, + principal=self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE}), + ) + + self.session.close() + self.session = self.Session() + request = self.session.query(SchedulingRequest).filter(SchedulingRequest.id == response.id).one_or_none() + poll = self.session.query(Poll).filter(Poll.id == response.poll_id).one_or_none() + + self.assertIsNotNone(request) + self.assertIsNotNone(poll) + self.assertEqual(request.title, "Steering group") + self.assertEqual(poll.visibility, "private") + def test_signed_response_summary_and_decision_handoff(self) -> None: payload = self._payload().model_copy(update={"calendar": SchedulingCalendarPreferences()}) request, tokens = create_scheduling_request( @@ -193,7 +276,22 @@ class SchedulingServiceTests(unittest.TestCase): def test_calendar_freebusy_holds_and_final_event_creation(self) -> None: self._calendar() - payload = self._payload() + self.session.add( + CalendarSyncSource( + id="source-1", + tenant_id="tenant-1", + calendar_id="calendar-1", + source_kind="caldav", + collection_url="https://dav.example.test/cal/", + auth_type="none", + sync_enabled=True, + sync_interval_seconds=900, + sync_direction="two_way", + conflict_policy="etag", + metadata_={}, + ) + ) + payload = self._payload().model_copy(update={"title": "x" * 500}) request, _tokens = create_scheduling_request( self.session, tenant_id="tenant-1", @@ -233,15 +331,33 @@ class SchedulingServiceTests(unittest.TestCase): self.assertEqual(request.slots[0].freebusy_status, "busy") self.assertEqual(request.slots[1].freebusy_status, "free") - request, hold_event_ids, warnings = create_tentative_calendar_holds( + with patch("govoplan_calendar.backend.service.caldav_client_for_source") as caldav_client: + request, hold_event_ids, warnings = create_tentative_calendar_holds( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request_id=request.id, + ) + caldav_client.assert_not_called() + self.assertEqual(warnings, []) + self.assertEqual(len(hold_event_ids), 2) + self.assertTrue(all(slot.tentative_hold_event_id for slot in request.slots)) + request, duplicate_hold_ids, warnings = create_tentative_calendar_holds( self.session, tenant_id="tenant-1", user_id="user-1", request_id=request.id, ) self.assertEqual(warnings, []) - self.assertEqual(len(hold_event_ids), 2) - self.assertTrue(all(slot.tentative_hold_event_id for slot in request.slots)) + self.assertEqual(duplicate_hold_ids, []) + + with self.assertRaisesRegex(SchedulingError, "only be created for a decided"): + create_final_calendar_event( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request_id=request.id, + ) close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id) decided = decide_scheduling_request( @@ -251,18 +367,103 @@ class SchedulingServiceTests(unittest.TestCase): payload=SchedulingDecisionRequest(slot_id=request.slots[1].id, handoff_to_calendar=False), user_id="user-1", ) - decided, event_id, warnings = create_final_calendar_event( - self.session, - tenant_id="tenant-1", - user_id="user-1", - request_id=decided.id, - ) + with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"): + evaluate_calendar_freebusy( + self.session, + tenant_id="tenant-1", + request_id=decided.id, + ) + with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"): + create_tentative_calendar_holds( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request_id=decided.id, + ) + with patch("govoplan_calendar.backend.service.caldav_client_for_source") as caldav_client: + decided, event_id, warnings = create_final_calendar_event( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request_id=decided.id, + ) + caldav_client.assert_not_called() self.assertEqual(warnings, []) self.assertIsNotNone(event_id) self.assertEqual(decided.status, "handed_off") self.assertEqual(decided.calendar_event_id, event_id) - self.assertEqual(decided.metadata_["calendar_handoff"]["status"], "created") + self.assertEqual(decided.metadata_["calendar_handoff"]["status"], "accepted") + self.assertEqual( + decided.metadata_["calendar_handoff"]["last_known_external_state"], + "queued", + ) + self.assertIsNotNone(decided.metadata_["calendar_handoff"]["outbox_operation_id"]) + with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"): + evaluate_calendar_freebusy( + self.session, + tenant_id="tenant-1", + request_id=decided.id, + ) + with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"): + create_tentative_calendar_holds( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request_id=decided.id, + ) + self.assertTrue( + all( + slot.metadata_["calendar_hold"]["last_known_external_state"] == "queued" + for slot in request.slots + ) + ) + generated_events = ( + self.session.query(CalendarEvent) + .filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id) + .all() + ) + self.assertTrue(generated_events) + self.assertTrue(all(event.classification == "PRIVATE" for event in generated_events)) + self.assertTrue(all(len(event.summary) <= 500 for event in generated_events)) + event_count = len(generated_events) + decided_again, same_event_id, warnings = create_final_calendar_event( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request_id=decided.id, + ) + self.assertEqual(warnings, []) + self.assertEqual(same_event_id, event_id) + self.assertEqual(decided_again.calendar_event_id, event_id) + self.assertEqual( + self.session.query(CalendarEvent) + .filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id) + .count(), + event_count, + ) + exact_repeat = decide_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=decided.id, + payload=SchedulingDecisionRequest( + slot_id=request.slots[1].id, + handoff_to_calendar=True, + ), + user_id="user-1", + ) + self.assertEqual(exact_repeat.calendar_event_id, event_id) + with self.assertRaisesRegex(SchedulingError, "different slot"): + decide_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=decided.id, + payload=SchedulingDecisionRequest( + slot_id=request.slots[0].id, + handoff_to_calendar=True, + ), + user_id="user-1", + ) def test_notification_outbox_jobs_are_created_and_listed(self) -> None: request, _tokens = create_scheduling_request( @@ -284,6 +485,716 @@ class SchedulingServiceTests(unittest.TestCase): self.assertGreaterEqual(len(all_jobs), 4) self.assertTrue(all(job.status == "pending" for job in reminder_jobs)) + organizer_jobs = list_visible_scheduling_notifications( + self.session, + tenant_id="tenant-1", + request_id=request.id, + actor_ids=("user-1",), + ) + alice_jobs = list_visible_scheduling_notifications( + self.session, + tenant_id="tenant-1", + request_id=request.id, + actor_ids=("alice@example.test",), + ) + outsider_jobs = list_visible_scheduling_notifications( + self.session, + tenant_id="tenant-1", + request_id=request.id, + actor_ids=("outsider@example.test",), + ) + + self.assertEqual(len(all_jobs), len(organizer_jobs)) + self.assertTrue(alice_jobs) + self.assertTrue(all(job.recipient == "alice@example.test" for job in alice_jobs)) + self.assertEqual([], outsider_jobs) + + def test_request_visibility_is_limited_to_organizer_participants_and_managers(self) -> None: + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + + self.assertEqual( + [request.id], + [ + item.id + for item in list_visible_scheduling_requests( + self.session, + tenant_id="tenant-1", + actor_ids=("alice@example.test",), + ) + ], + ) + with self.assertRaisesRegex(SchedulingError, "Scheduling request not found"): + get_visible_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + actor_ids=("outsider@example.test",), + ) + self.assertEqual( + request.id, + get_visible_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + actor_ids=("outsider@example.test",), + can_manage=True, + ).id, + ) + + def test_result_visibility_follows_existing_after_close_policy(self) -> None: + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + + with self.assertRaisesRegex(SchedulingError, "Scheduling results are not visible"): + require_visible_scheduling_results( + self.session, + request=request, + actor_ids=("alice@example.test",), + ) + require_visible_scheduling_results( + self.session, + request=request, + actor_ids=("user-1",), + ) + close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id) + require_visible_scheduling_results( + self.session, + request=request, + actor_ids=("alice@example.test",), + ) + + def test_update_synchronizes_backing_poll_policy(self) -> None: + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + new_deadline = datetime(2026, 7, 30, 12, tzinfo=timezone.utc) + + update_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + payload=SchedulingRequestUpdateRequest( + title="Restricted steering group", + description="Updated private context", + deadline_at=new_deadline, + allow_participant_updates=False, + result_visibility="organizer", + ), + ) + poll = get_poll(self.session, tenant_id="tenant-1", poll_id=request.poll_id) + + self.assertEqual(poll.title, "Restricted steering group") + self.assertEqual(poll.description, "Updated private context") + self.assertEqual(poll.visibility, "private") + self.assertEqual(poll.result_visibility, "organizer") + self.assertFalse(poll.allow_anonymous) + self.assertFalse(poll.allow_response_update) + self.assertEqual(poll.closes_at.replace(tzinfo=timezone.utc), new_deadline) + + def test_lifecycle_guards_and_terminal_actions_are_idempotent(self) -> None: + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences()} + ), + ) + self.assertIs(open_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ), request) + close_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ) + closed_at = get_poll( + self.session, + tenant_id="tenant-1", + poll_id=request.poll_id, + ).closed_at + self.assertIs(close_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ), request) + self.assertEqual( + get_poll(self.session, tenant_id="tenant-1", poll_id=request.poll_id).closed_at, + closed_at, + ) + with self.assertRaisesRegex(SchedulingError, "Only draft"): + open_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ) + + decided = decide_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + payload=SchedulingDecisionRequest( + slot_id=request.slots[0].id, + handoff_to_calendar=False, + ), + ) + decision_job_count = ( + self.session.query(SchedulingNotification) + .filter( + SchedulingNotification.request_id == request.id, + SchedulingNotification.event_kind == "decision", + ) + .count() + ) + self.assertIs( + decide_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + payload=SchedulingDecisionRequest( + slot_id=request.slots[0].id, + handoff_to_calendar=False, + ), + ), + decided, + ) + self.assertEqual( + self.session.query(SchedulingNotification) + .filter( + SchedulingNotification.request_id == request.id, + SchedulingNotification.event_kind == "decision", + ) + .count(), + decision_job_count, + ) + with self.assertRaisesRegex(SchedulingError, "different slot"): + decide_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + payload=SchedulingDecisionRequest( + slot_id=request.slots[1].id, + handoff_to_calendar=False, + ), + ) + for callback in (open_scheduling_request, close_scheduling_request, cancel_scheduling_request): + with self.assertRaises(SchedulingError): + callback( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ) + + cancelled_request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={ + "title": "Cancelled request", + "calendar": SchedulingCalendarPreferences(), + } + ), + ) + cancelled = cancel_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=cancelled_request.id, + ) + cancellation_job_count = ( + self.session.query(SchedulingNotification) + .filter( + SchedulingNotification.request_id == cancelled_request.id, + SchedulingNotification.event_kind == "cancellation", + ) + .count() + ) + self.assertIs( + cancel_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=cancelled_request.id, + ), + cancelled, + ) + self.assertEqual( + self.session.query(SchedulingNotification) + .filter( + SchedulingNotification.request_id == cancelled_request.id, + SchedulingNotification.event_kind == "cancellation", + ) + .count(), + cancellation_job_count, + ) + with self.assertRaisesRegex(SchedulingError, "Only draft"): + open_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=cancelled_request.id, + ) + with self.assertRaisesRegex(SchedulingError, "Only collecting"): + close_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=cancelled_request.id, + ) + with self.assertRaisesRegex(SchedulingError, "Only closed"): + decide_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=cancelled_request.id, + payload=SchedulingDecisionRequest( + slot_id=cancelled_request.slots[0].id, + handoff_to_calendar=False, + ), + ) + + def test_backing_poll_is_hidden_from_unrelated_poll_reader(self) -> None: + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + outsider = self._principal( + "outsider", + scopes={"poll:poll:read", "poll:response:write"}, + ) + + self.assertEqual( + api_list_polls(status_filter=None, kind=None, session=self.session, principal=outsider).polls, + [], + ) + with self.assertRaises(HTTPException) as hidden: + api_get_poll(request.poll_id, session=self.session, principal=outsider) + self.assertEqual(hidden.exception.status_code, 404) + with self.assertRaises(HTTPException) as blocked_vote: + api_submit_poll_response( + request.poll_id, + PollSubmitResponseRequest( + answers=[PollAnswerInput(option_id=request.slots[0].poll_option_id, value="available")] + ), + session=self.session, + principal=outsider, + ) + self.assertEqual(blocked_vote.exception.status_code, 404) + + 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: + participants = [ + SchedulingParticipantInput( + respondent_id="alice-membership", + display_name="Alice", + email="alice@example.test", + ), + SchedulingParticipantInput( + respondent_id="bob-membership", + display_name="Bob", + email="bob@example.test", + ), + ] + payload = self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences(), "participants": participants} + ) + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=payload, + ) + target = request.participants[1] + attacker = self._principal( + "attacker", + email="alice@example.test", + membership_id="alice-membership", + scopes={SCHEDULING_READ_SCOPE, "poll:poll:read", "poll:response:write"}, + ) + + 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, + principal=attacker, + ) + + self.assertNotIn("invitation_id", response.metadata) + self.assertEqual([request.id], [item.id for item in listed.requests]) + alice = next(participant for participant in request.participants if participant.display_name == "Alice") + bob = next(participant for participant in request.participants if participant.display_name == "Bob") + self.assertEqual(alice.status, "responded") + self.assertIsNotNone(alice.responded_at) + self.assertEqual(bob.status, "invited") + + def test_scheduling_participant_scope_can_respond_without_poll_scope(self) -> None: + payload = self._payload().model_copy( + update={ + "calendar": SchedulingCalendarPreferences(), + "participants": [ + SchedulingParticipantInput( + respondent_id="alice-account", + display_name="Alice", + email="alice@example.test", + ) + ], + } + ) + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=payload, + ) + alice = self._principal( + "alice-account", + email="alice@example.test", + scopes={SCHEDULING_READ_SCOPE, SCHEDULING_RESPOND_SCOPE}, + ) + + result = api_submit_scheduling_availability( + request.id, + SchedulingAvailabilityResponseRequest( + answers=[ + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, + value="available", + ), + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[1].id, + value="maybe", + ), + ] + ), + session=self.session, + principal=alice, + ) + + self.assertEqual(result.request.id, request.id) + self.assertEqual(request.participants[0].status, "responded") + self.assertIsNotNone(request.participants[0].responded_at) + summary = scheduling_request_summary( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ) + self.assertEqual(summary["response_count"], 1) + + outsider = self._principal( + "outsider-account", + email="outsider@example.test", + scopes={SCHEDULING_READ_SCOPE, SCHEDULING_RESPOND_SCOPE}, + ) + with self.assertRaises(HTTPException) as hidden: + api_submit_scheduling_availability( + request.id, + SchedulingAvailabilityResponseRequest( + answers=[ + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, + value="unavailable", + ) + ] + ), + session=self.session, + principal=outsider, + ) + self.assertEqual(hidden.exception.status_code, 404) + + def test_signed_link_and_in_module_response_update_the_same_poll_response(self) -> None: + payload = self._payload().model_copy( + update={ + "calendar": SchedulingCalendarPreferences(), + "participants": [ + SchedulingParticipantInput( + display_name="Alice", + email="alice@example.test", + ) + ], + } + ) + request, tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=payload, + ) + participant = request.participants[0] + submit_poll_response_with_token( + self.session, + token=tokens[participant.id], + payload=PollSubmitResponseRequest( + answers=[ + PollAnswerInput( + option_id=request.slots[0].poll_option_id, + value="available", + ) + ] + ), + ) + alice = self._principal( + "alice-account", + email="alice@example.test", + scopes={SCHEDULING_READ_SCOPE, SCHEDULING_RESPOND_SCOPE}, + ) + + api_submit_scheduling_availability( + request.id, + SchedulingAvailabilityResponseRequest( + answers=[ + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[0].id, + value="unavailable", + ), + SchedulingAvailabilityAnswerInput( + slot_id=request.slots[1].id, + value="maybe", + ), + ] + ), + session=self.session, + principal=alice, + ) + + responses = ( + self.session.query(PollResponse) + .filter(PollResponse.poll_id == request.poll_id) + .all() + ) + self.assertEqual(len(responses), 1) + self.assertEqual( + responses[0].respondent_id, + f"invitation:{participant.poll_invitation_id}", + ) + self.assertEqual( + responses[0].metadata_["invitation_id"], + participant.poll_invitation_id, + ) + self.assertEqual( + [answer["value"] for answer in responses[0].answers], + ["unavailable", "maybe"], + ) + summary = scheduling_request_summary( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ) + self.assertEqual(summary["response_count"], 1) + + def test_participant_request_projection_redacts_other_participant_details(self) -> None: + participants = [ + SchedulingParticipantInput( + respondent_id="alice-id", + display_name="Alice", + email="alice@example.test", + metadata={"private": "alice"}, + ), + SchedulingParticipantInput( + respondent_id="bob-id", + display_name="Bob", + email="bob@example.test", + metadata={"private": "bob"}, + ), + ] + payload = self._payload().model_copy( + update={ + "calendar": SchedulingCalendarPreferences(), + "participants": participants, + "result_visibility": "public", + } + ) + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=payload, + ) + alice = self._principal( + "alice-account", + email="alice@example.test", + membership_id="alice-id", + scopes={SCHEDULING_READ_SCOPE}, + ) + + responses = [ + api_list_scheduling_requests(status_filter=None, session=self.session, principal=alice).requests[0], + api_get_scheduling_request(request.id, session=self.session, principal=alice), + api_scheduling_summary(request.id, session=self.session, principal=alice).request, + ] + 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.assertEqual(own.email, "alice@example.test") + self.assertEqual(own.metadata, {"private": "alice"}) + self.assertIsNotNone(own.poll_invitation_id) + self.assertIsNone(own.invitation_token) + self.assertIsNone(other.respondent_id) + self.assertIsNone(other.email) + self.assertIsNone(other.poll_invitation_id) + self.assertIsNone(other.last_invited_at) + self.assertIsNone(other.responded_at) + self.assertEqual(other.metadata, {}) + self.assertIsNone(other.invitation_token) + + def test_calendar_side_effect_routes_require_calendar_scopes(self) -> None: + self._calendar() + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + scheduling_writer = self._principal("writer", scopes={SCHEDULING_WRITE_SCOPE}) + + for callback in ( + api_evaluate_calendar_freebusy, + api_create_tentative_calendar_holds, + api_create_final_calendar_event, + ): + with self.assertRaises(HTTPException) as denied: + callback(request.id, session=self.session, principal=scheduling_writer) + self.assertEqual(denied.exception.status_code, 403) + close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id) + with self.assertRaises(HTTPException) as denied_decision: + api_decide_scheduling_request( + request.id, + SchedulingDecisionRequest(slot_id=request.slots[0].id), + session=self.session, + principal=scheduling_writer, + ) + self.assertEqual(denied_decision.exception.status_code, 403) + self.assertIsNone(request.selected_slot_id) + + availability_reader = self._principal( + "reader", + 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", + scopes={SCHEDULING_WRITE_SCOPE, CALENDAR_EVENT_WRITE_SCOPE}, + ) + holds = api_create_tentative_calendar_holds(request.id, session=self.session, principal=event_writer) + self.assertTrue(holds.created_event_ids) + + def test_decision_rechecks_calendar_scope_after_locked_state_refresh(self) -> None: + self._calendar() + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + request.calendar_integration_enabled = False + request.create_calendar_event_on_decision = False + self.session.flush() + close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id) + scheduling_writer = self._principal( + "writer", + scopes={SCHEDULING_WRITE_SCOPE}, + ) + + def refreshed_locked_request(*_args, **_kwargs): + # Simulate a concurrent preference update committed after an old + # router preflight but before the service obtains its row lock. + request.calendar_integration_enabled = True + request.create_calendar_event_on_decision = True + return request + + with ( + patch( + "govoplan_scheduling.backend.service._lock_scheduling_calendar_handoff", + side_effect=refreshed_locked_request, + ), + self.assertRaises(HTTPException) as denied, + ): + api_decide_scheduling_request( + request.id, + SchedulingDecisionRequest(slot_id=request.slots[0].id), + session=self.session, + principal=scheduling_writer, + ) + + self.assertEqual(denied.exception.status_code, 403) + self.assertIsNone(request.selected_slot_id) + self.assertIsNone(request.calendar_event_id) + + def test_initial_invitation_notifications_use_signed_poll_link_and_verified_recipient_id(self) -> None: + class CapturingNotificationProvider: + def __init__(self) -> None: + self.requests = [] + + def enqueue_notification(self, _session, request, *, enqueue_delivery): + self.requests.append(request) + return {"id": f"notification-{len(self.requests)}", "status": "queued"} + + provider = CapturingNotificationProvider() + participants = [ + SchedulingParticipantInput( + respondent_id="alice-id", + display_name="Alice", + email="alice@example.test", + ), + SchedulingParticipantInput( + respondent_id="bob-id", + display_name="Bob", + email="bob@example.test", + ), + ] + payload = self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences(), "participants": participants} + ) + + with patch( + "govoplan_scheduling.backend.service.notification_dispatch_provider", + return_value=provider, + ): + request, tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=payload, + ) + + self.assertEqual(len(provider.requests), 2) + 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()}, + ) + local_notifications = list_scheduling_notifications( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ) + for notification in local_notifications: + serialized = repr({"payload": notification.payload, "metadata": notification.metadata_}) + self.assertTrue(all(token not in serialized for token in tokens.values())) + def test_external_participants_can_be_rejected(self) -> None: payload = self._payload().model_copy(update={"allow_external_participants": False})