feat(addresses): expose visible contact people search

This commit is contained in:
2026-07-22 03:02:14 +02:00
parent 75c9ece709
commit 04accaa206
3 changed files with 77 additions and 0 deletions

View File

@@ -8,6 +8,11 @@ from sqlalchemy import func
from govoplan_core.auth import ApiPrincipal
from govoplan_core.db.base import utcnow
from govoplan_core.core.people import (
PeopleSearchGroup,
PersonSearchCandidate,
person_selection_key,
)
from govoplan_addresses.backend.db.models import AddressBook, AddressList, AddressListEntry, Contact, ContactEmail, ContactPhone
from govoplan_addresses.backend.schemas import ContactCreateRequest
from govoplan_addresses.backend.service import (
@@ -137,6 +142,60 @@ class AddressesLookupCapability:
return tuple(candidates)
class AddressesPeopleSearchProvider:
"""Adapt visible address-book contacts to the shared people-search contract."""
def __init__(self, lookup: AddressesLookupCapability | None = None) -> None:
self._lookup = lookup or AddressesLookupCapability()
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> tuple[PeopleSearchGroup, ...]:
if not hasattr(principal, "account_id") or not hasattr(principal, "tenant_id") or not hasattr(principal, "group_ids"):
raise AddressBookError("Address contact search requires an authenticated tenant principal.")
candidates = self._lookup.lookup(
session,
principal, # type: ignore[arg-type] - validated principal contract
query=query,
limit=limit,
)
return (
PeopleSearchGroup(
key="contacts",
label="Contacts",
candidates=tuple(
PersonSearchCandidate(
selection_key=person_selection_key("contact", item.contact_id, email=item.email),
kind="contact",
reference_id=item.contact_id,
display_name=item.display_name,
email=item.email,
source_module="addresses",
source_label="Contacts",
source_ref=item.source_ref or f"addresses:contact:{item.contact_id}",
source_revision=item.source_revision,
description=" · ".join(part for part in (item.organization, item.role_title) if part) or None,
provenance=dict(item.provenance),
metadata={
"address_book_id": item.address_book_id,
"email_label": item.email_label,
"organization": item.organization,
"role_title": item.role_title,
"tags": tuple(item.tags),
"source_kind": item.source_kind,
},
)
for item in candidates
),
),
)
class AddressesContactWriterCapability:
def list_write_targets(
self,
@@ -301,6 +360,10 @@ def lookup_capability(_context: Any) -> AddressesLookupCapability:
return AddressesLookupCapability()
def people_search_capability(_context: Any) -> AddressesPeopleSearchProvider:
return AddressesPeopleSearchProvider()
def recipient_source_capability(_context: Any) -> AddressesRecipientSourceCapability:
return AddressesRecipientSourceCapability()

View File

@@ -13,6 +13,7 @@ from govoplan_addresses.backend.capabilities import (
from govoplan_addresses.backend.db import models as addresses_models # noqa: F401 - populate address ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
@@ -153,6 +154,7 @@ manifest = ModuleManifest(
optional_dependencies=("campaigns", "mail", "forms", "reporting", "portal", "postbox"),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_LOOKUP, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_RECIPIENT_SOURCE, version="0.1.8"),
ModuleInterfaceProvider(name=CAPABILITY_ADDRESSES_CONTACT_WRITER, version="0.1.8"),
),
@@ -177,6 +179,7 @@ manifest = ModuleManifest(
),
capability_factories={
CAPABILITY_ADDRESSES_LOOKUP: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["lookup_capability"]).lookup_capability(context),
CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda context: __import__("govoplan_addresses.backend.capabilities", fromlist=["people_search_capability"]).people_search_capability(context),
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE: lambda context: __import__(
"govoplan_addresses.backend.capabilities",
fromlist=["recipient_source_capability"],

View File

@@ -7,6 +7,7 @@ from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider
from govoplan_core.db.base import Base
from govoplan_core.db.base import utcnow
from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult
@@ -16,6 +17,7 @@ from govoplan_addresses.backend.capabilities import (
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE,
AddressesContactWriterCapability,
AddressesLookupCapability,
AddressesPeopleSearchProvider,
AddressesRecipientSourceCapability,
AddressWriterError,
)
@@ -345,8 +347,17 @@ END:VCARD
self.assertEqual(len(lookup), 1)
self.assertEqual(lookup[0].email, "ada@example.local")
self.assertEqual(lookup[0].contact_id, contact.id)
people_provider = AddressesPeopleSearchProvider()
self.assertIsInstance(people_provider, PeopleSearchProvider)
people = people_provider.search_people(self.session, self.principal, query="ada")
self.assertEqual(people[0].key, "contacts")
self.assertEqual(people[0].candidates[0].reference_id, contact.id)
self.assertEqual(people[0].candidates[0].source_ref, f"addresses:contact:{contact.id}")
self.assertEqual(people[0].candidates[0].metadata["address_book_id"], book.id)
warm_lookup = AddressesLookupCapability().lookup(self.session, self.principal, query="", limit=10)
self.assertEqual([item.email for item in warm_lookup], ["ada@example.local"])
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, manifest.capability_factories)
self.assertIn(CAPABILITY_ADDRESSES_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
recipient_sources = AddressesRecipientSourceCapability().list_sources(self.session, self.principal)
self.assertEqual(len(recipient_sources), 1)