From 04accaa20692280705a1ba4af71233d425515c1c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 03:02:14 +0200 Subject: [PATCH] feat(addresses): expose visible contact people search --- .../backend/capabilities.py | 63 +++++++++++++++++++ src/govoplan_addresses/backend/manifest.py | 3 + tests/test_addresses_service.py | 11 ++++ 3 files changed, 77 insertions(+) diff --git a/src/govoplan_addresses/backend/capabilities.py b/src/govoplan_addresses/backend/capabilities.py index f55759d..c6f552f 100644 --- a/src/govoplan_addresses/backend/capabilities.py +++ b/src/govoplan_addresses/backend/capabilities.py @@ -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() diff --git a/src/govoplan_addresses/backend/manifest.py b/src/govoplan_addresses/backend/manifest.py index 01c39f4..e00a19f 100644 --- a/src/govoplan_addresses/backend/manifest.py +++ b/src/govoplan_addresses/backend/manifest.py @@ -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"], diff --git a/tests/test_addresses_service.py b/tests/test_addresses_service.py index a72ebfc..27fdb15 100644 --- a/tests/test_addresses_service.py +++ b/tests/test_addresses_service.py @@ -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)