intermittent commit
This commit is contained in:
@@ -114,9 +114,11 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _scheduling_router(_context: ModuleContext):
|
def _scheduling_router(context: ModuleContext):
|
||||||
|
from govoplan_scheduling.backend.runtime import configure_runtime
|
||||||
from govoplan_scheduling.backend.router import router
|
from govoplan_scheduling.backend.router import router
|
||||||
|
|
||||||
|
configure_runtime(registry=context.registry, settings=context.settings)
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ manifest = ModuleManifest(
|
|||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("poll",),
|
dependencies=("poll",),
|
||||||
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations"),
|
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),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
|
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
|
||||||
@@ -136,14 +138,16 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.8", version_max_exclusive="0.2.0"),
|
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.8", version_max_exclusive="0.2.0"),
|
||||||
ModuleInterfaceRequirement(name="poll.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
ModuleInterfaceRequirement(name="poll.signed_participation", 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="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),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
|
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
package_name="@govoplan/scheduling-webui",
|
package_name="@govoplan/scheduling-webui",
|
||||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
|
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
|
||||||
),
|
),
|
||||||
route_factory=_scheduling_router,
|
route_factory=_scheduling_router,
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -8,6 +11,8 @@ from govoplan_core.db.session import get_session
|
|||||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
||||||
from govoplan_scheduling.backend.manifest import READ_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 (
|
||||||
|
SchedulingAddressLookupCandidate,
|
||||||
|
SchedulingAddressLookupResponse,
|
||||||
SchedulingCalendarActionResponse,
|
SchedulingCalendarActionResponse,
|
||||||
SchedulingDecisionRequest,
|
SchedulingDecisionRequest,
|
||||||
SchedulingNotificationCreateRequest,
|
SchedulingNotificationCreateRequest,
|
||||||
@@ -20,6 +25,7 @@ from govoplan_scheduling.backend.schemas import (
|
|||||||
SchedulingStatusResponse,
|
SchedulingStatusResponse,
|
||||||
SchedulingSummaryResponse,
|
SchedulingSummaryResponse,
|
||||||
)
|
)
|
||||||
|
from govoplan_scheduling.backend.runtime import get_registry
|
||||||
from govoplan_scheduling.backend.service import (
|
from govoplan_scheduling.backend.service import (
|
||||||
SchedulingError,
|
SchedulingError,
|
||||||
cancel_scheduling_request,
|
cancel_scheduling_request,
|
||||||
@@ -42,6 +48,39 @@ from govoplan_scheduling.backend.service import (
|
|||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/scheduling", tags=["scheduling"])
|
router = APIRouter(prefix="/scheduling", tags=["scheduling"])
|
||||||
|
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||||
|
|
||||||
|
|
||||||
|
def _capability_payload(value: object) -> dict[str, Any]:
|
||||||
|
if dataclasses.is_dataclass(value):
|
||||||
|
return dataclasses.asdict(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return dict(value)
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
for key in (
|
||||||
|
"contact_id",
|
||||||
|
"address_book_id",
|
||||||
|
"display_name",
|
||||||
|
"email",
|
||||||
|
"email_label",
|
||||||
|
"organization",
|
||||||
|
"role_title",
|
||||||
|
"tags",
|
||||||
|
"source_kind",
|
||||||
|
"source_ref",
|
||||||
|
"source_revision",
|
||||||
|
"provenance",
|
||||||
|
):
|
||||||
|
if hasattr(value, key):
|
||||||
|
payload[key] = getattr(value, key)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_capability(name: str) -> object | None:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
||||||
@@ -61,6 +100,24 @@ def _request_response(request, *, invitation_tokens: dict[str, str] | None = Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
|
||||||
|
def api_lookup_scheduling_addresses(
|
||||||
|
query: str = Query(min_length=1),
|
||||||
|
limit: int = Query(default=25, ge=1, le=100),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> SchedulingAddressLookupResponse:
|
||||||
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
|
||||||
|
if capability is None or not hasattr(capability, "lookup"):
|
||||||
|
return SchedulingAddressLookupResponse(available=False, candidates=[])
|
||||||
|
candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit)
|
||||||
|
return SchedulingAddressLookupResponse(
|
||||||
|
available=True,
|
||||||
|
candidates=[SchedulingAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests", response_model=SchedulingRequestListResponse)
|
@router.get("/requests", response_model=SchedulingRequestListResponse)
|
||||||
def api_list_scheduling_requests(
|
def api_list_scheduling_requests(
|
||||||
status_filter: str | None = Query(default=None, alias="status"),
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
|
|||||||
20
src/govoplan_scheduling/backend/runtime.py
Normal file
20
src/govoplan_scheduling/backend/runtime.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
_runtime_registry: object | None = None
|
||||||
|
_runtime_settings: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
|
||||||
|
global _runtime_registry, _runtime_settings
|
||||||
|
if registry is not None:
|
||||||
|
_runtime_registry = registry
|
||||||
|
if settings is not None:
|
||||||
|
_runtime_settings = settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> object | None:
|
||||||
|
return _runtime_registry
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> object | None:
|
||||||
|
return _runtime_settings
|
||||||
@@ -202,3 +202,23 @@ class SchedulingNotificationCreateRequest(BaseModel):
|
|||||||
event_kind: Literal["invitation", "reminder", "decision", "cancellation"]
|
event_kind: Literal["invitation", "reminder", "decision", "cancellation"]
|
||||||
channel: str = Field(default="mail", max_length=40)
|
channel: str = Field(default="mail", max_length=40)
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulingAddressLookupCandidate(BaseModel):
|
||||||
|
contact_id: str
|
||||||
|
address_book_id: str
|
||||||
|
display_name: str
|
||||||
|
email: str | None = None
|
||||||
|
email_label: str | None = None
|
||||||
|
organization: str | None = None
|
||||||
|
role_title: str | None = None
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
source_kind: str = "local"
|
||||||
|
source_ref: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulingAddressLookupResponse(BaseModel):
|
||||||
|
available: bool = False
|
||||||
|
candidates: list[SchedulingAddressLookupCandidate] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_poll.backend.schemas import PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput
|
from govoplan_poll.backend.schemas import PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput
|
||||||
from govoplan_poll.backend.db.models import PollResponse
|
from govoplan_poll.backend.db.models import PollResponse
|
||||||
@@ -24,6 +25,7 @@ from govoplan_scheduling.backend.schemas import (
|
|||||||
SchedulingRequestCreateRequest,
|
SchedulingRequestCreateRequest,
|
||||||
SchedulingRequestUpdateRequest,
|
SchedulingRequestUpdateRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_scheduling.backend.runtime import get_registry
|
||||||
|
|
||||||
|
|
||||||
class SchedulingError(ValueError):
|
class SchedulingError(ValueError):
|
||||||
@@ -348,6 +350,7 @@ def create_scheduling_notification_jobs(
|
|||||||
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
|
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
|
||||||
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||||
notifications: list[SchedulingNotification] = []
|
notifications: list[SchedulingNotification] = []
|
||||||
|
base_metadata = metadata or {}
|
||||||
for participant in _active_participants(request):
|
for participant in _active_participants(request):
|
||||||
recipient = participant.email or participant.respondent_id
|
recipient = participant.email or participant.respondent_id
|
||||||
status = "pending" if recipient else "skipped"
|
status = "pending" if recipient else "skipped"
|
||||||
@@ -367,16 +370,178 @@ def create_scheduling_notification_jobs(
|
|||||||
"selected_slot_id": request.selected_slot_id,
|
"selected_slot_id": request.selected_slot_id,
|
||||||
"calendar_event_id": request.calendar_event_id,
|
"calendar_event_id": request.calendar_event_id,
|
||||||
},
|
},
|
||||||
metadata_=metadata or {},
|
metadata_=dict(base_metadata),
|
||||||
)
|
)
|
||||||
if status == "skipped":
|
if status == "skipped":
|
||||||
notification.error = "Participant has no deliverable recipient address or id"
|
notification.error = "Participant has no deliverable recipient address or id"
|
||||||
session.add(notification)
|
session.add(notification)
|
||||||
notifications.append(notification)
|
notifications.append(notification)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
_mirror_scheduling_notifications_to_center(session, request=request, notifications=notifications)
|
||||||
return notifications
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
|
def _mirror_scheduling_notifications_to_center(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
notifications: list[SchedulingNotification],
|
||||||
|
) -> None:
|
||||||
|
provider = notification_dispatch_provider(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
|
participants_by_id = {participant.id: participant for participant in _active_participants(request)}
|
||||||
|
for notification in notifications:
|
||||||
|
if notification.status == "skipped":
|
||||||
|
continue
|
||||||
|
participant = participants_by_id.get(notification.participant_id or "")
|
||||||
|
try:
|
||||||
|
response = provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
_notification_dispatch_request(request=request, participant=participant, notification=notification),
|
||||||
|
enqueue_delivery=True,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow.
|
||||||
|
notification.status = "failed"
|
||||||
|
notification.error = f"Notification center enqueue failed: {exc}"
|
||||||
|
continue
|
||||||
|
notification.metadata_ = {
|
||||||
|
**(notification.metadata_ or {}),
|
||||||
|
"notification_id": response.get("id"),
|
||||||
|
"notification_status": response.get("status"),
|
||||||
|
}
|
||||||
|
if response.get("status") in {"queued", "sending", "sent"}:
|
||||||
|
notification.status = str(response["status"])
|
||||||
|
elif response.get("status") == "skipped":
|
||||||
|
notification.status = "skipped"
|
||||||
|
notification.error = str(response.get("last_error") or "Notification center skipped delivery")
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_dispatch_request(
|
||||||
|
*,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
participant: SchedulingParticipant | None,
|
||||||
|
notification: SchedulingNotification,
|
||||||
|
) -> NotificationDispatchRequest:
|
||||||
|
payload = {
|
||||||
|
**(notification.payload or {}),
|
||||||
|
"scheduling_notification_id": notification.id,
|
||||||
|
"participant": {
|
||||||
|
"id": participant.id,
|
||||||
|
"display_name": participant.display_name,
|
||||||
|
"email": participant.email,
|
||||||
|
"required": participant.required,
|
||||||
|
"participant_type": participant.participant_type,
|
||||||
|
}
|
||||||
|
if participant
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
return NotificationDispatchRequest(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
source_module="scheduling",
|
||||||
|
source_resource_type="request",
|
||||||
|
source_resource_id=request.id,
|
||||||
|
event_kind=notification.event_kind,
|
||||||
|
channel=notification.channel,
|
||||||
|
recipient=notification.recipient,
|
||||||
|
recipient_type="scheduling_participant",
|
||||||
|
recipient_id=participant.id if participant else notification.participant_id,
|
||||||
|
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}",
|
||||||
|
payload=_jsonable(payload),
|
||||||
|
metadata={
|
||||||
|
**(notification.metadata_ or {}),
|
||||||
|
"scheduling_notification_id": notification.id,
|
||||||
|
"scheduling_request_id": request.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_subject(request: SchedulingRequest, event_kind: str) -> str:
|
||||||
|
labels = {
|
||||||
|
"invitation": "Scheduling invitation",
|
||||||
|
"reminder": "Scheduling reminder",
|
||||||
|
"decision": "Scheduling decision",
|
||||||
|
"cancellation": "Scheduling cancelled",
|
||||||
|
}
|
||||||
|
return f"{labels.get(event_kind, 'Scheduling update')}: {request.title}"
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_body_text(
|
||||||
|
request: SchedulingRequest,
|
||||||
|
participant: SchedulingParticipant | None,
|
||||||
|
event_kind: str,
|
||||||
|
) -> str:
|
||||||
|
greeting = f"Hello {participant.display_name}," if participant and participant.display_name else "Hello,"
|
||||||
|
lines = [greeting, "", _notification_body_intro(request, event_kind)]
|
||||||
|
selected_slot = next((slot for slot in _active_slots(request) if slot.id == request.selected_slot_id), None)
|
||||||
|
if selected_slot is not None:
|
||||||
|
lines.append(f"Selected slot: {_slot_label(selected_slot.start_at, selected_slot.end_at, timezone_name=selected_slot.timezone)}")
|
||||||
|
if request.location:
|
||||||
|
lines.append(f"Location: {request.location}")
|
||||||
|
lines.append(f"Scheduling request: {request.title}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_body_intro(request: SchedulingRequest, event_kind: str) -> str:
|
||||||
|
if event_kind == "invitation":
|
||||||
|
return f"You are invited to respond to the scheduling poll for {request.title}."
|
||||||
|
if event_kind == "reminder":
|
||||||
|
return f"This is a reminder to respond to the scheduling poll for {request.title}."
|
||||||
|
if event_kind == "decision":
|
||||||
|
return f"A final slot was selected for {request.title}."
|
||||||
|
if event_kind == "cancellation":
|
||||||
|
return f"The scheduling request {request.title} was cancelled."
|
||||||
|
return f"There is an update for {request.title}."
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_scheduling_center_notification(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
event_kind: str,
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
participant: SchedulingParticipant | None = None,
|
||||||
|
priority: int = 0,
|
||||||
|
) -> None:
|
||||||
|
provider = notification_dispatch_provider(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
source_module="scheduling",
|
||||||
|
source_resource_type="request",
|
||||||
|
source_resource_id=request.id,
|
||||||
|
event_kind=event_kind,
|
||||||
|
channel="inbox",
|
||||||
|
recipient_type="user" if request.organizer_user_id else None,
|
||||||
|
recipient_id=request.organizer_user_id,
|
||||||
|
subject=subject,
|
||||||
|
body_text=body_text,
|
||||||
|
action_url=f"/scheduling?request_id={request.id}",
|
||||||
|
priority=priority,
|
||||||
|
payload=_jsonable(
|
||||||
|
{
|
||||||
|
"request_id": request.id,
|
||||||
|
"poll_id": request.poll_id,
|
||||||
|
"participant_id": participant.id if participant else None,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
metadata={"scheduling_request_id": request.id},
|
||||||
|
),
|
||||||
|
enqueue_delivery=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def list_scheduling_notifications(
|
def list_scheduling_notifications(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -414,6 +579,14 @@ def refresh_participant_response_state(session: Session, *, request: SchedulingR
|
|||||||
if participant is not None and participant.status != "responded":
|
if participant is not None and participant.status != "responded":
|
||||||
participant.status = "responded"
|
participant.status = "responded"
|
||||||
participant.responded_at = response_datetime(response.submitted_at)
|
participant.responded_at = response_datetime(response.submitted_at)
|
||||||
|
_emit_scheduling_center_notification(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
participant=participant,
|
||||||
|
event_kind="scheduling.participant_responded",
|
||||||
|
subject=f"Scheduling response: {request.title}",
|
||||||
|
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.",
|
||||||
|
)
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class SchedulingManifestTests(unittest.TestCase):
|
|||||||
self.assertIn("poll", manifest.dependencies)
|
self.assertIn("poll", manifest.dependencies)
|
||||||
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.assertFalse(manifest.required_capabilities)
|
self.assertFalse(manifest.required_capabilities)
|
||||||
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
|
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
|
||||||
self.assertIn("evaluation", manifest.optional_dependencies)
|
self.assertIn("evaluation", manifest.optional_dependencies)
|
||||||
@@ -24,6 +25,8 @@ class SchedulingManifestTests(unittest.TestCase):
|
|||||||
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
|
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
|
||||||
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
|
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
|
||||||
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})
|
self.assertIn("poll.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})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -161,8 +161,33 @@ export type SchedulingSummaryResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SchedulingAddressLookupCandidate = {
|
||||||
|
contact_id: string;
|
||||||
|
address_book_id: string;
|
||||||
|
display_name: string;
|
||||||
|
email?: string | null;
|
||||||
|
email_label?: string | null;
|
||||||
|
organization?: string | null;
|
||||||
|
role_title?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
source_kind: string;
|
||||||
|
source_ref?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SchedulingAddressLookupResponse = {
|
||||||
|
available: boolean;
|
||||||
|
candidates: SchedulingAddressLookupCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) });
|
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) });
|
||||||
|
|
||||||
|
export function lookupSchedulingAddresses(settings: ApiSettings, query: string, limit = 25): Promise<SchedulingAddressLookupResponse> {
|
||||||
|
const params = new URLSearchParams({ query, limit: String(limit) });
|
||||||
|
return apiFetch<SchedulingAddressLookupResponse>(settings, `/api/v1/scheduling/address-lookup?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> {
|
export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> {
|
||||||
const query = status ? `?status=${encodeURIComponent(status)}` : "";
|
const query = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||||
return apiFetch<SchedulingRequestListResponse>(settings, `/api/v1/scheduling/requests${query}`);
|
return apiFetch<SchedulingRequestListResponse>(settings, `/api/v1/scheduling/requests${query}`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
import { Bell, CalendarCheck, Check, Clock, Plus, RefreshCw, Send, XCircle } from "lucide-react";
|
import { Bell, CalendarCheck, Check, Clock, Plus, RefreshCw, Send, XCircle } from "lucide-react";
|
||||||
import { Button, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
|
import { Button, EmailAddressInput, hasScope, type ApiSettings, type AuthInfo, type MailboxAddress } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
closeSchedulingRequest,
|
closeSchedulingRequest,
|
||||||
createSchedulingCalendarEvent,
|
createSchedulingCalendarEvent,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
evaluateSchedulingFreeBusy,
|
evaluateSchedulingFreeBusy,
|
||||||
listSchedulingNotifications,
|
listSchedulingNotifications,
|
||||||
listSchedulingRequests,
|
listSchedulingRequests,
|
||||||
|
lookupSchedulingAddresses,
|
||||||
openSchedulingRequest,
|
openSchedulingRequest,
|
||||||
schedulingSummary,
|
schedulingSummary,
|
||||||
type SchedulingNotification,
|
type SchedulingNotification,
|
||||||
@@ -51,6 +52,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
{ label: "Option 1", start_at: localValue(defaultStart), end_at: localValue(defaultEnd) }
|
{ label: "Option 1", start_at: localValue(defaultStart), end_at: localValue(defaultEnd) }
|
||||||
]);
|
]);
|
||||||
const [participants, setParticipants] = useState<ParticipantDraft[]>([{ display_name: "", email: "", required: true }]);
|
const [participants, setParticipants] = useState<ParticipantDraft[]>([{ display_name: "", email: "", required: true }]);
|
||||||
|
const [addressLookupQuery, setAddressLookupQuery] = useState("");
|
||||||
|
const [addressSuggestions, setAddressSuggestions] = useState<MailboxAddress[]>([]);
|
||||||
|
|
||||||
const canWrite = hasScope(auth, "scheduling:schedule:write");
|
const canWrite = hasScope(auth, "scheduling:schedule:write");
|
||||||
const selected = useMemo(() => requests.find((item) => item.id === selectedId) || requests[0] || null, [requests, selectedId]);
|
const selected = useMemo(() => requests.find((item) => item.id === selectedId) || requests[0] || null, [requests, selectedId]);
|
||||||
@@ -73,6 +76,37 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
void loadDetails(selected.id);
|
void loadDetails(selected.id);
|
||||||
}, [selected?.id]);
|
}, [selected?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = addressLookupQuery.trim();
|
||||||
|
if (!canWrite || query.length < 2) {
|
||||||
|
setAddressSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void lookupSchedulingAddresses(settings, query).
|
||||||
|
then((response) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!response.available) {
|
||||||
|
setAddressSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddressSuggestions(
|
||||||
|
response.candidates
|
||||||
|
.map((candidate) => candidate.email ? { name: candidate.display_name || undefined, email: candidate.email } : null)
|
||||||
|
.filter((address): address is MailboxAddress => Boolean(address?.email))
|
||||||
|
);
|
||||||
|
}).
|
||||||
|
catch(() => {
|
||||||
|
if (!cancelled) setAddressSuggestions([]);
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [addressLookupQuery, canWrite, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
async function loadRequests() {
|
async function loadRequests() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -258,7 +292,22 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
{participants.map((participant, index) => (
|
{participants.map((participant, index) => (
|
||||||
<div className="scheduling-participant-draft" key={index}>
|
<div className="scheduling-participant-draft" key={index}>
|
||||||
<input placeholder="Name" value={participant.display_name} onChange={(event) => updateParticipant(index, "display_name", event.target.value)} />
|
<input placeholder="Name" value={participant.display_name} onChange={(event) => updateParticipant(index, "display_name", event.target.value)} />
|
||||||
<input placeholder="Email" value={participant.email} onChange={(event) => updateParticipant(index, "email", event.target.value)} />
|
<EmailAddressInput
|
||||||
|
value={participant.email ? [{ name: participant.display_name || undefined, email: participant.email }] : []}
|
||||||
|
suggestions={addressSuggestions}
|
||||||
|
onSuggestionQueryChange={setAddressLookupQuery}
|
||||||
|
allowMultiple={false}
|
||||||
|
disabled={!canWrite}
|
||||||
|
compact
|
||||||
|
addLabel="Add participant"
|
||||||
|
emptyText="Participant email"
|
||||||
|
onChange={(addresses) => {
|
||||||
|
const address = addresses[0];
|
||||||
|
updateParticipantDraft(index, {
|
||||||
|
display_name: address?.name ?? participant.display_name,
|
||||||
|
email: address?.email ?? ""
|
||||||
|
});
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -333,6 +382,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
function updateParticipant(index: number, field: keyof ParticipantDraft, value: string) {
|
function updateParticipant(index: number, field: keyof ParticipantDraft, value: string) {
|
||||||
setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)));
|
setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateParticipantDraft(index: number, patch: Partial<ParticipantDraft>) {
|
||||||
|
setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRequestEnvelope(value: unknown): value is { request: SchedulingRequest } {
|
function isRequestEnvelope(value: unknown): value is { request: SchedulingRequest } {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export const schedulingModule: PlatformWebModule = {
|
|||||||
label: "Scheduling",
|
label: "Scheduling",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["poll"],
|
dependencies: ["poll"],
|
||||||
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments"],
|
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
|
||||||
translations: generatedTranslations,
|
translations: generatedTranslations,
|
||||||
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar", anyOf: scheduleRead, order: 56 }],
|
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
|
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user