103 lines
3.6 KiB
Python
103 lines
3.6 KiB
Python
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"]
|