from __future__ import annotations from sqlalchemy import func, or_ from sqlalchemy.orm import Session from govoplan_access.backend.db.models import Account, User from govoplan_core.core.people import ( PeopleSearchError, PeopleSearchGroup, PersonSearchCandidate, person_selection_key, ) def _principal_tenant_id(principal: object) -> str: try: tenant_id = getattr(principal, "tenant_id") except (AttributeError, RuntimeError) as exc: raise PeopleSearchError("People search requires an active tenant context.") from exc normalized = str(tenant_id or "").strip() if not normalized: raise PeopleSearchError("People search requires an active tenant context.") return normalized def _like_pattern(query: str) -> str: escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") return f"%{escaped}%" class AccessPeopleSearchProvider: """Search active accounts through their active tenant membership. The active principal's tenant is the only accepted visibility boundary; global accounts and memberships of other tenants are never candidates. Feature routers authorize the surrounding task before invoking this capability. """ def search_people( self, session: object, principal: object, *, query: str, limit: int = 25, ) -> tuple[PeopleSearchGroup, ...]: if not isinstance(session, Session): raise PeopleSearchError("People search requires a database session.") tenant_id = _principal_tenant_id(principal) normalized_limit = max(1, min(int(limit), 100)) account_query = ( session.query(User, Account) .join(Account, Account.id == User.account_id) .filter( User.tenant_id == tenant_id, User.is_active.is_(True), Account.is_active.is_(True), ) ) normalized_query = str(query or "").strip() if normalized_query: pattern = _like_pattern(normalized_query) account_query = account_query.filter( or_( func.lower(User.display_name).like(pattern, escape="\\"), func.lower(User.email).like(pattern, escape="\\"), func.lower(Account.display_name).like(pattern, escape="\\"), func.lower(Account.email).like(pattern, escape="\\"), ) ) rows = ( account_query .order_by( func.coalesce(User.display_name, Account.display_name, User.email, Account.email).asc(), Account.id.asc(), ) .limit(normalized_limit) .all() ) candidates = tuple( PersonSearchCandidate( selection_key=person_selection_key("account", account.id), kind="account", reference_id=account.id, display_name=user.display_name or account.display_name or user.email or account.email, email=user.email or account.email, source_module="access", source_label="Accounts", source_ref=f"access:account:{account.id}", ) for user, account in rows ) return (PeopleSearchGroup(key="accounts", label="Accounts", candidates=candidates),) def people_search_capability(_context: object) -> AccessPeopleSearchProvider: return AccessPeopleSearchProvider() __all__ = ["AccessPeopleSearchProvider", "people_search_capability"]