309 lines
11 KiB
Python
309 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from .crm_mixin import CrmMixin
|
|
from .crm_shared import (
|
|
MAX_PAGE_SIZE,
|
|
Any,
|
|
Mapping,
|
|
NbCommandError,
|
|
_data,
|
|
_format_user_candidate,
|
|
_normalize_pagination,
|
|
_pick_user_matches,
|
|
_ref_from_mapping,
|
|
_relation_rows,
|
|
_unique_strings,
|
|
_unique_values,
|
|
build_and_filter,
|
|
build_eq_condition,
|
|
build_search_filter,
|
|
)
|
|
|
|
|
|
class CrmDirectoryMixin(CrmMixin):
|
|
def _attach_customer_names(
|
|
self,
|
|
rows: list[dict[str, Any]],
|
|
*,
|
|
id_key: str = "customer_id",
|
|
max_lookups: int = MAX_PAGE_SIZE,
|
|
) -> list[dict[str, Any]]:
|
|
"""Fill in customer names for id-keyed aggregation buckets.
|
|
|
|
Grouping runs on the aggregation endpoint and only ever yields ids, which
|
|
left callers reading rows identified by a bare number. Counts are never
|
|
touched -- this only adds a name.
|
|
"""
|
|
identifiers = _unique_values([row.get(id_key) for row in rows if row.get(id_key) is not None])
|
|
if not identifiers:
|
|
return rows
|
|
names: dict[Any, str] = {}
|
|
try:
|
|
lookup = self.client.list_records(
|
|
self.config.customer.resource,
|
|
filter={self.config.customer.id_field: {"$in": identifiers[:max_lookups]}},
|
|
fields=_unique_strings([self.config.customer.id_field, *(self.config.customer.name_fields or [])]),
|
|
page=1,
|
|
page_size=max_lookups,
|
|
)
|
|
except NbCommandError:
|
|
return rows
|
|
for customer in _data(lookup):
|
|
ref = _ref_from_mapping(customer, self._customer_spec())
|
|
if ref.get("id") is not None and ref.get("name"):
|
|
names[ref["id"]] = str(ref["name"])
|
|
for row in rows:
|
|
name = names.get(row.get(id_key))
|
|
if name:
|
|
row["customer_name"] = name
|
|
return rows
|
|
|
|
def resolve_owner(self, query: str) -> dict[str, Any]:
|
|
normalized = (query or "").strip()
|
|
if not normalized:
|
|
return {"query": "", "results": [], "count": 0}
|
|
rows = self._owner_matches(normalized)
|
|
results = [_format_user_candidate(row, self.config.user.id_field) for row in rows]
|
|
return {"query": normalized, "results": results, "count": len(results)}
|
|
|
|
def list_teams(
|
|
self,
|
|
query: str | None = None,
|
|
*,
|
|
include_members: bool = False,
|
|
formal_members_only: bool = True,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
) -> dict[str, Any]:
|
|
limit, offset = _normalize_pagination(limit, offset)
|
|
result = self._list_records_window(
|
|
self.config.team.resource,
|
|
limit=limit,
|
|
offset=offset,
|
|
filter=build_and_filter([build_search_filter(query, [self.config.team.name_field])]),
|
|
fields=self._team_fields(),
|
|
sort=[self.config.team.name_field],
|
|
)
|
|
result["results"] = [
|
|
self._compact_team(
|
|
row,
|
|
include_members=include_members,
|
|
formal_members_only=formal_members_only,
|
|
)
|
|
for row in result["results"]
|
|
]
|
|
return result
|
|
|
|
def resolve_team(self, query: str) -> dict[str, Any]:
|
|
normalized = (query or "").strip()
|
|
if not normalized:
|
|
return {"query": "", "results": [], "count": 0}
|
|
rows = self._team_matches(normalized)
|
|
results = [self._compact_team(row, include_members=False, formal_members_only=True) for row in rows]
|
|
return {"query": normalized, "results": results, "count": len(results)}
|
|
|
|
def get_team(
|
|
self,
|
|
team_id: Any,
|
|
*,
|
|
include_members: bool = True,
|
|
formal_members_only: bool = True,
|
|
) -> dict[str, Any]:
|
|
row = self._single(
|
|
self.client.get_record(
|
|
self.config.team.resource,
|
|
team_id,
|
|
fields=self._team_fields(),
|
|
)
|
|
)
|
|
if not isinstance(row, Mapping):
|
|
return {"error": {"code": "team_not_found", "message": f"No team matched id {team_id!r}"}}
|
|
return {
|
|
"team": self._compact_team(
|
|
row,
|
|
include_members=include_members,
|
|
formal_members_only=formal_members_only,
|
|
)
|
|
}
|
|
|
|
def _team_matches(self, team: str) -> list[dict[str, Any]]:
|
|
normalized = team.strip()
|
|
if not normalized:
|
|
return []
|
|
rows = self._data(
|
|
self.client.list_records(
|
|
self.config.team.resource,
|
|
filter=build_search_filter(normalized, [self.config.team.name_field]),
|
|
fields=self._team_fields(),
|
|
page=1,
|
|
page_size=50,
|
|
)
|
|
)
|
|
exact = [
|
|
row
|
|
for row in rows
|
|
if str(row.get(self.config.team.name_field) or "").strip().casefold() == normalized.casefold()
|
|
]
|
|
return exact or rows
|
|
|
|
def _resolve_team_member_ids_or_error(
|
|
self,
|
|
team: str | None,
|
|
) -> tuple[list[Any] | None, dict[str, Any] | None]:
|
|
normalized = (team or "").strip()
|
|
if not normalized:
|
|
return None, None
|
|
matches = self._team_matches(normalized)
|
|
candidates = [self._compact_team(row, include_members=False, formal_members_only=True) for row in matches[:10]]
|
|
if not matches:
|
|
return None, {
|
|
"code": "team_not_found",
|
|
"message": f"No team matched {normalized!r}",
|
|
"query": normalized,
|
|
"candidates": [],
|
|
}
|
|
if len(matches) > 1:
|
|
return None, {
|
|
"code": "team_ambiguous",
|
|
"message": f"Multiple teams matched {normalized!r}",
|
|
"query": normalized,
|
|
"candidates": candidates,
|
|
}
|
|
members = self._team_users(matches[0], self.config.team.member_field, formal_only=True)
|
|
member_ids = [
|
|
row.get(self.config.user.id_field) for row in members if row.get(self.config.user.id_field) is not None
|
|
]
|
|
if not member_ids:
|
|
return None, {
|
|
"code": "team_has_no_formal_members",
|
|
"message": f"Team {normalized!r} has no formal members",
|
|
"query": normalized,
|
|
"candidates": candidates,
|
|
}
|
|
return member_ids, None
|
|
|
|
def _team_users(
|
|
self,
|
|
team_row: Mapping[str, Any],
|
|
relation_field: str,
|
|
*,
|
|
formal_only: bool,
|
|
) -> list[dict[str, Any]]:
|
|
related = _relation_rows(team_row.get(relation_field))
|
|
user_ids = [
|
|
row.get(self.config.user.id_field) for row in related if row.get(self.config.user.id_field) is not None
|
|
]
|
|
if not user_ids:
|
|
return []
|
|
conditions: list[dict[str, Any] | None] = [
|
|
{self.config.user.id_field: {"$in": user_ids}},
|
|
]
|
|
if formal_only:
|
|
conditions.append(
|
|
build_eq_condition(
|
|
self.config.team.user_status_field,
|
|
self.config.team.formal_user_status_value,
|
|
)
|
|
)
|
|
fields = _unique_strings(
|
|
[
|
|
*(self.config.user.fields or []),
|
|
self.config.team.user_status_field,
|
|
]
|
|
)
|
|
users, _truncation = self._scan_records(
|
|
self.config.user.resource,
|
|
filter=build_and_filter(conditions),
|
|
fields=fields,
|
|
page_size=min(MAX_PAGE_SIZE, max(1, len(user_ids))),
|
|
max_records=len(user_ids),
|
|
)
|
|
return users
|
|
|
|
def _compact_team(
|
|
self,
|
|
row: Mapping[str, Any],
|
|
*,
|
|
include_members: bool,
|
|
formal_members_only: bool,
|
|
) -> dict[str, Any]:
|
|
managers = self._team_users(row, self.config.team.manager_field, formal_only=False)
|
|
raw_members = _relation_rows(row.get(self.config.team.member_field))
|
|
result: dict[str, Any] = {
|
|
"id": row.get(self.config.team.id_field),
|
|
"name": row.get(self.config.team.name_field),
|
|
"manager_names": [
|
|
candidate["label"]
|
|
for manager in managers
|
|
if (candidate := _format_user_candidate(manager, self.config.user.id_field)).get("label")
|
|
],
|
|
"member_count": len(raw_members),
|
|
}
|
|
if include_members:
|
|
members = self._team_users(
|
|
row,
|
|
self.config.team.member_field,
|
|
formal_only=formal_members_only,
|
|
)
|
|
result["members"] = [
|
|
{
|
|
**_format_user_candidate(member, self.config.user.id_field),
|
|
"status": member.get(self.config.team.user_status_field),
|
|
}
|
|
for member in members
|
|
]
|
|
result["returned_member_count"] = len(members)
|
|
result["formal_members_only"] = formal_members_only
|
|
return result
|
|
|
|
def _owner_matches(self, owner: str) -> list[dict[str, Any]]:
|
|
normalized = owner.strip()
|
|
if not normalized:
|
|
return []
|
|
rows = self._data(
|
|
self.client.list_records(
|
|
self.config.user.resource,
|
|
filter=build_search_filter(normalized, self.config.user.match_fields),
|
|
fields=self.config.user.fields,
|
|
page=1,
|
|
page_size=50,
|
|
)
|
|
)
|
|
return _pick_user_matches(rows, normalized, self.config.user.match_fields or [])
|
|
|
|
def _resolve_owner_id_or_error(self, owner: str | None) -> tuple[Any | None, dict[str, Any] | None]:
|
|
normalized = (owner or "").strip()
|
|
if not normalized:
|
|
return None, None
|
|
matches = self._owner_matches(normalized)
|
|
candidates = [_format_user_candidate(row, self.config.user.id_field) for row in matches[:10]]
|
|
if not matches:
|
|
return None, {
|
|
"code": "owner_not_found",
|
|
"message": f"No user matched owner {normalized!r}",
|
|
"query": normalized,
|
|
"candidates": [],
|
|
}
|
|
if len(matches) > 1:
|
|
return None, {
|
|
"code": "owner_ambiguous",
|
|
"message": f"Multiple users matched owner {normalized!r}",
|
|
"query": normalized,
|
|
"candidates": candidates,
|
|
}
|
|
user_id = matches[0].get(self.config.user.id_field)
|
|
if user_id is None or user_id == "":
|
|
return None, {
|
|
"code": "owner_invalid",
|
|
"message": f"Matched owner {normalized!r} has no {self.config.user.id_field!r}",
|
|
"query": normalized,
|
|
"candidates": candidates,
|
|
}
|
|
return user_id, None
|
|
|
|
def _resolve_owner_id(self, owner: str | None) -> Any | None:
|
|
owner_id, error = self._resolve_owner_id_or_error(owner)
|
|
if error:
|
|
raise ValueError(str(error["message"]))
|
|
return owner_id
|