feat(access): expose tenant-bounded people search
This commit is contained in:
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||
from govoplan_core.core.access import (
|
||||
@@ -33,12 +34,13 @@ from govoplan_core.core.modules import (
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
|
||||
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
|
||||
@@ -597,11 +599,20 @@ def _route_factory(context: ModuleContext):
|
||||
return router
|
||||
|
||||
|
||||
def _people_search(context: ModuleContext) -> object:
|
||||
from govoplan_access.backend.people_search import people_search_capability
|
||||
|
||||
return people_search_capability(context)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="0.1.10",
|
||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
||||
),
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
@@ -653,6 +664,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search,
|
||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
},
|
||||
documentation=ACCESS_DOCUMENTATION,
|
||||
|
||||
102
src/govoplan_access/backend/people_search.py
Normal file
102
src/govoplan_access/backend/people_search.py
Normal file
@@ -0,0 +1,102 @@
|
||||
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"]
|
||||
Reference in New Issue
Block a user