feat(scheduling): use central people picker

This commit is contained in:
2026-07-22 03:17:13 +02:00
parent 2cb86c90dc
commit ea2f721377
13 changed files with 529 additions and 231 deletions

View File

@@ -20,6 +20,10 @@ from govoplan_core.core.modules import (
)
from govoplan_core.db.base import Base
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.people import (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
)
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
@@ -138,6 +142,8 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_CALENDAR_SCHEDULING,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
),
required_capabilities=(
CAPABILITY_POLL_SCHEDULING,
@@ -154,7 +160,8 @@ manifest = ModuleManifest(
ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, 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,

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import dataclasses
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
@@ -9,11 +8,10 @@ from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
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.core.people import search_visible_people
from govoplan_core.db.session import get_session
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
SchedulingAddressLookupCandidate,
SchedulingAddressLookupResponse,
SchedulingAvailabilityResponse,
SchedulingAvailabilityResponseRequest,
SchedulingCalendarActionResponse,
@@ -24,6 +22,9 @@ from govoplan_scheduling.backend.schemas import (
SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse,
SchedulingNotificationResponse,
SchedulingPeopleSearchCandidate,
SchedulingPeopleSearchGroup,
SchedulingPeopleSearchResponse,
SchedulingRequestCreateRequest,
SchedulingRequestListResponse,
SchedulingRequestResponse,
@@ -71,39 +72,6 @@ from govoplan_scheduling.backend.service import (
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:
@@ -285,21 +253,43 @@ def api_submit_public_scheduling_participation(
return validated
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
def api_lookup_scheduling_addresses(
@router.get("/people", response_model=SchedulingPeopleSearchResponse)
def api_search_scheduling_people(
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:
) -> SchedulingPeopleSearchResponse:
_require_scheduling_writer(principal)
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],
groups = search_visible_people(
get_registry(),
session,
principal,
query=query,
limit=limit,
)
return SchedulingPeopleSearchResponse(
groups=[
SchedulingPeopleSearchGroup(
key=group.key,
label=group.label,
candidates=[
SchedulingPeopleSearchCandidate(
selection_key=candidate.selection_key,
kind=candidate.kind,
reference_id=candidate.reference_id,
display_name=candidate.display_name,
email=candidate.email,
source_module=candidate.source_module,
source_label=candidate.source_label,
source_revision=candidate.source_revision,
description=candidate.description,
)
for candidate in group.candidates
],
)
for group in groups
]
)

View File

@@ -515,21 +515,25 @@ class SchedulingInvitationActionResponse(BaseModel):
notification: SchedulingNotificationResponse | None = None
class SchedulingAddressLookupCandidate(BaseModel):
contact_id: str
address_book_id: str
class SchedulingPeopleSearchCandidate(BaseModel):
"""Opaque, task-safe projection of a visible directory candidate."""
selection_key: str
kind: str
reference_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_module: str | None = None
source_label: str | None = None
source_revision: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
class SchedulingAddressLookupResponse(BaseModel):
available: bool = False
candidates: list[SchedulingAddressLookupCandidate] = Field(default_factory=list)
class SchedulingPeopleSearchGroup(BaseModel):
key: str
label: str
candidates: list[SchedulingPeopleSearchCandidate] = Field(default_factory=list)
class SchedulingPeopleSearchResponse(BaseModel):
groups: list[SchedulingPeopleSearchGroup] = Field(default_factory=list)