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