Files
nocobase-mcp/nocobase_crm_mcp/crm_directory.py
T

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