feat(scheduling): harden poll-backed request flows
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
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.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
@@ -17,6 +18,7 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
|
||||||
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
|
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
|
||||||
|
|
||||||
MODULE_ID = "scheduling"
|
MODULE_ID = "scheduling"
|
||||||
@@ -128,7 +130,12 @@ manifest = ModuleManifest(
|
|||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("poll",),
|
dependencies=("poll",),
|
||||||
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
|
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=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
|
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
|
||||||
ModuleInterfaceProvider(name="scheduling.decision_handoff", 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="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||||
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||||
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
|
ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
||||||
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
|
|
||||||
from govoplan_scheduling.backend.schemas import (
|
from govoplan_scheduling.backend.schemas import (
|
||||||
|
SchedulingAvailabilityResponseRequest,
|
||||||
SchedulingAddressLookupCandidate,
|
SchedulingAddressLookupCandidate,
|
||||||
SchedulingAddressLookupResponse,
|
SchedulingAddressLookupResponse,
|
||||||
SchedulingCalendarActionResponse,
|
SchedulingCalendarActionResponse,
|
||||||
@@ -22,12 +23,14 @@ from govoplan_scheduling.backend.schemas import (
|
|||||||
SchedulingRequestListResponse,
|
SchedulingRequestListResponse,
|
||||||
SchedulingRequestResponse,
|
SchedulingRequestResponse,
|
||||||
SchedulingRequestUpdateRequest,
|
SchedulingRequestUpdateRequest,
|
||||||
|
SchedulingPollSummaryResponse,
|
||||||
SchedulingStatusResponse,
|
SchedulingStatusResponse,
|
||||||
SchedulingSummaryResponse,
|
SchedulingSummaryResponse,
|
||||||
)
|
)
|
||||||
from govoplan_scheduling.backend.runtime import get_registry
|
from govoplan_scheduling.backend.runtime import get_registry
|
||||||
from govoplan_scheduling.backend.service import (
|
from govoplan_scheduling.backend.service import (
|
||||||
SchedulingError,
|
SchedulingError,
|
||||||
|
SchedulingPermissionError,
|
||||||
cancel_scheduling_request,
|
cancel_scheduling_request,
|
||||||
close_scheduling_request,
|
close_scheduling_request,
|
||||||
create_final_calendar_event,
|
create_final_calendar_event,
|
||||||
@@ -36,13 +39,16 @@ from govoplan_scheduling.backend.service import (
|
|||||||
create_tentative_calendar_holds,
|
create_tentative_calendar_holds,
|
||||||
decide_scheduling_request,
|
decide_scheduling_request,
|
||||||
evaluate_calendar_freebusy,
|
evaluate_calendar_freebusy,
|
||||||
get_scheduling_request,
|
get_visible_scheduling_request,
|
||||||
list_scheduling_notifications,
|
list_visible_scheduling_notifications,
|
||||||
list_scheduling_requests,
|
list_visible_scheduling_requests,
|
||||||
open_scheduling_request,
|
open_scheduling_request,
|
||||||
|
refresh_participant_response_state,
|
||||||
|
require_visible_scheduling_results,
|
||||||
scheduling_notification_response,
|
scheduling_notification_response,
|
||||||
scheduling_request_response,
|
scheduling_request_response,
|
||||||
scheduling_request_summary,
|
scheduling_request_summary,
|
||||||
|
submit_scheduling_availability,
|
||||||
update_scheduling_request,
|
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}")
|
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:
|
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":
|
if str(exc) == "Scheduling request not found":
|
||||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
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))
|
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(
|
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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingRequestListResponse:
|
) -> SchedulingRequestListResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
requests = list_scheduling_requests(session, tenant_id=principal.tenant_id, status=status_filter)
|
requests = list_visible_scheduling_requests(
|
||||||
return SchedulingRequestListResponse(requests=[_request_response(request) for request in 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)
|
@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:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@router.get("/requests/{request_id}", response_model=SchedulingRequestResponse)
|
||||||
@@ -156,9 +240,23 @@ def api_get_scheduling_request(
|
|||||||
) -> SchedulingRequestResponse:
|
) -> SchedulingRequestResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
try:
|
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:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse)
|
||||||
@@ -170,11 +268,17 @@ def api_update_scheduling_request(
|
|||||||
) -> SchedulingRequestResponse:
|
) -> SchedulingRequestResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
return _request_response(
|
request = update_scheduling_request(
|
||||||
update_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
request_id=request_id,
|
||||||
|
payload=payload,
|
||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
|
response = _request_response(request, principal=principal)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse)
|
@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)
|
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@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)
|
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@router.post("/requests/{request_id}/decide", response_model=SchedulingStatusResponse)
|
||||||
@@ -220,10 +328,16 @@ def api_decide_scheduling_request(
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
user_id=principal.account_id,
|
user_id=principal.account_id,
|
||||||
|
allow_calendar_handoff=has_scope(
|
||||||
|
principal,
|
||||||
|
CALENDAR_EVENT_WRITE_SCOPE,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingCalendarActionResponse:
|
) -> SchedulingCalendarActionResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
|
||||||
try:
|
try:
|
||||||
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
|
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingCalendarActionResponse(
|
response = SchedulingCalendarActionResponse(
|
||||||
request=_request_response(request),
|
request=_request_response(request, principal=principal),
|
||||||
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
|
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse)
|
@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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingCalendarActionResponse:
|
) -> SchedulingCalendarActionResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
||||||
session,
|
session,
|
||||||
@@ -260,12 +378,14 @@ def api_create_tentative_calendar_holds(
|
|||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingCalendarActionResponse(
|
response = SchedulingCalendarActionResponse(
|
||||||
request=_request_response(request),
|
request=_request_response(request, principal=principal),
|
||||||
created_event_ids=created_event_ids,
|
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],
|
updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids],
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse)
|
@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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingCalendarActionResponse:
|
) -> SchedulingCalendarActionResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
request, event_id, warnings = create_final_calendar_event(
|
request, event_id, warnings = create_final_calendar_event(
|
||||||
session,
|
session,
|
||||||
@@ -284,11 +405,13 @@ def api_create_final_calendar_event(
|
|||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingCalendarActionResponse(
|
response = SchedulingCalendarActionResponse(
|
||||||
request=_request_response(request),
|
request=_request_response(request, principal=principal),
|
||||||
created_event_ids=[event_id] if event_id else [],
|
created_event_ids=[event_id] if event_id else [],
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
|
@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)
|
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@router.get("/requests/{request_id}/summary", response_model=SchedulingSummaryResponse)
|
||||||
@@ -313,14 +438,30 @@ def api_scheduling_summary(
|
|||||||
) -> SchedulingSummaryResponse:
|
) -> SchedulingSummaryResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
try:
|
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)
|
summary = scheduling_request_summary(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingSummaryResponse(
|
response = SchedulingSummaryResponse(
|
||||||
request=_request_response(request),
|
request=_request_response(request, principal=principal),
|
||||||
poll_summary=PollResultSummaryResponse.model_validate(summary),
|
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)
|
@router.get("/notifications", response_model=SchedulingNotificationListResponse)
|
||||||
@@ -331,9 +472,11 @@ def api_list_scheduling_notifications(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingNotificationListResponse:
|
) -> SchedulingNotificationListResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
notifications = list_scheduling_notifications(
|
notifications = list_visible_scheduling_notifications(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
|
actor_ids=_principal_actor_ids(principal),
|
||||||
|
can_manage=_can_manage_scheduling(principal),
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
status=status_filter,
|
status=status_filter,
|
||||||
)
|
)
|
||||||
@@ -364,9 +507,11 @@ def api_create_scheduling_notifications(
|
|||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingNotificationListResponse(
|
response = SchedulingNotificationListResponse(
|
||||||
notifications=[
|
notifications=[
|
||||||
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
|
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
|
||||||
for notification in notifications
|
for notification in notifications
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
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"]
|
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
|
||||||
SchedulingParticipantType = Literal["internal", "external", "resource"]
|
SchedulingParticipantType = Literal["internal", "external", "resource"]
|
||||||
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
|
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
|
||||||
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
||||||
|
SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"]
|
||||||
|
|
||||||
|
|
||||||
class SchedulingCalendarPreferences(BaseModel):
|
class SchedulingCalendarPreferences(BaseModel):
|
||||||
@@ -164,9 +163,48 @@ class SchedulingDecisionRequest(BaseModel):
|
|||||||
handoff_to_calendar: bool | None = None
|
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):
|
class SchedulingSummaryResponse(BaseModel):
|
||||||
request: SchedulingRequestResponse
|
request: SchedulingRequestResponse
|
||||||
poll_summary: PollResultSummaryResponse
|
poll_summary: SchedulingPollSummaryResponse
|
||||||
|
|
||||||
|
|
||||||
class SchedulingCalendarActionResponse(BaseModel):
|
class SchedulingCalendarActionResponse(BaseModel):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,10 @@ class SchedulingManifestTests(unittest.TestCase):
|
|||||||
self.assertNotIn("access", manifest.dependencies)
|
self.assertNotIn("access", manifest.dependencies)
|
||||||
self.assertIn("access", manifest.optional_dependencies)
|
self.assertIn("access", manifest.optional_dependencies)
|
||||||
self.assertIn("addresses", manifest.optional_dependencies)
|
self.assertIn("addresses", manifest.optional_dependencies)
|
||||||
self.assertFalse(manifest.required_capabilities)
|
self.assertEqual(("poll.scheduling",), manifest.required_capabilities)
|
||||||
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
|
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
|
||||||
|
self.assertIn("poll.scheduling", manifest.required_capabilities)
|
||||||
|
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
|
||||||
self.assertIn("evaluation", manifest.optional_dependencies)
|
self.assertIn("evaluation", manifest.optional_dependencies)
|
||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
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("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("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
|
||||||
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
|
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
|
||||||
|
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user