Files
nocobase-mcp/nocobase_crm_mcp/crm_records.py
T

367 lines
14 KiB
Python

from __future__ import annotations
from .crm_mixin import CrmMixin
from .crm_shared import (
MAX_PAGE_SIZE,
MAX_SCAN_RECORDS,
Any,
Mapping,
_data,
_normalize_pagination,
_page_from_offset,
_paginated_rows,
_pagination_metadata,
_relation_refs,
_total,
build_and_filter,
build_date_range_condition,
build_eq_condition,
build_relation_id_condition,
build_relation_id_in_condition,
build_search_filter,
)
class CrmRecordsMixin(CrmMixin):
def list_customers(
self,
query: str | None = None,
status: str | None = None,
owner: str | None = None,
team: str | None = None,
source: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
if team_error:
return {"error": team_error}
filter_value = build_and_filter(
[
build_eq_condition(self.config.customer.status_field, status),
build_relation_id_condition(self.config.customer.owner_field, owner_id),
build_relation_id_in_condition(self.config.customer.owner_field, team_member_ids),
build_eq_condition(self.config.customer.source_field, source),
build_search_filter(query, self.config.customer.search_fields),
]
)
result = self._list_with_projection_fallback(
self.config.customer.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=(self.config.customer.fields or []) if include_raw else self._customer_compact_fields(),
sort=self.config.customer.sort,
)
result["results"] = self._compact_customers(result["results"], include_raw=include_raw)
result["timezone"] = self.config.timezone
return result
def get_customer(
self,
customer_id: Any,
include_contacts: bool = True,
include_recent_followups: bool = True,
followup_limit: int = 10,
include_raw: bool = False,
) -> dict[str, Any]:
fields = self.config.customer.fields if include_raw else self._customer_compact_fields(detail=True)
row = self._single(
self.client.get_record(
self.config.customer.resource,
customer_id,
fields=fields,
)
)
if not isinstance(row, Mapping):
return {
"error": {
"code": "customer_not_found",
"message": f"No customer matches id {customer_id!r}.",
"hint": "Use list_customers to find the customer id.",
"retryable": False,
}
}
result: dict[str, Any] = {
"customer": self._compact_customer(row, detail=True, include_raw=include_raw),
}
if include_contacts:
# Phone and email are deliberately withheld here: a customer overview
# does not need personal contact details. list_contacts still serves them.
contacts_result = self.list_contacts(
customer_id=customer_id,
limit=50,
offset=0,
include_details=False,
)
result["contacts"] = contacts_result["results"]
result["contacts_pagination"] = _pagination_metadata(contacts_result)
if include_recent_followups:
followups_result = self.list_followups(
customer_id=customer_id,
limit=followup_limit,
offset=0,
include_raw=include_raw,
)
result["recent_followups"] = followups_result["results"]
result["recent_followups_pagination"] = _pagination_metadata(followups_result)
result["timezone"] = self.config.timezone
return result
def list_projects(
self,
*,
customer_id: Any | None = None,
team: str | None = None,
stage: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
if team_error:
return {"error": team_error}
stage_value = stage
if stage:
if stage in self.config.project.progress_values:
stage_value = self.config.project.progress_values[stage]
elif stage not in self.config.project.progress_values.values():
return {
"error": {
"code": "invalid_project_stage",
"message": "stage must be one of the configured project progress labels or values",
}
}
filter_value = build_and_filter(
[
build_eq_condition(self.config.project.customer_field, customer_id),
build_relation_id_in_condition(
[self.config.project.customer_relation_field, self.config.customer.owner_field, "id"],
team_member_ids,
),
build_eq_condition(self.config.project.progress_field, stage_value),
]
)
result = self._list_with_projection_fallback(
self.config.project.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=self._project_fields(),
sort=["-updatedAt"],
)
result["results"] = [self._compact_project(row, include_raw=include_raw) for row in result["results"]]
result["timezone"] = self.config.timezone
return result
def get_project(
self,
project_id: Any,
*,
include_contacts: bool = True,
include_followups: bool = True,
include_updates: bool = True,
) -> dict[str, Any]:
row = self._single(
self.client.get_record(
self.config.project.resource,
project_id,
fields=self._project_fields(),
)
)
if not isinstance(row, Mapping):
return {"error": {"code": "project_not_found", "message": f"No project matched id {project_id!r}"}}
project = self._compact_project(row, include_raw=False)
result: dict[str, Any] = {"project": project, "timezone": self.config.timezone}
if include_contacts:
# Was returning raw contact rows, phone and email included.
result["contacts"] = _relation_refs(row, [self.config.project.contact_field], self._contact_spec())
if include_followups:
followups_result = self.list_followups(project_id=project_id, limit=100, offset=0)
result["followups"] = followups_result["results"]
result["followups_pagination"] = _pagination_metadata(followups_result)
if include_updates:
updates, truncation = self._scan_records(
self.config.project.update_resource,
filter=build_and_filter(
[build_relation_id_condition(self.config.project.update_project_field, project_id)]
),
fields=[
"id",
self.config.project.update_project_field,
self.config.project.update_date_field,
self.config.project.update_content_field,
self.config.project.update_attachment_field,
"createdBy",
"createdAt",
],
sort=[self.config.project.update_date_field, "createdAt"],
)
result["updates"] = [self._compact_project_update(update) for update in updates]
if truncation:
result["data_quality_warnings"] = ["project updates were truncated at the scan limit"]
result["truncation"] = {"updates": truncation}
return result
def list_contacts(
self,
customer_id: Any | None = None,
query: str | None = None,
limit: int = 20,
offset: int = 0,
include_details: bool = True,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
filter_value = build_and_filter(
[
build_relation_id_condition(self.config.contact.customer_field, customer_id),
build_search_filter(query, self.config.contact.search_fields),
]
)
result = self._list_with_projection_fallback(
self.config.contact.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=(
(self.config.contact.fields or [])
if include_raw
else self._contact_compact_fields(include_details=include_details)
),
sort=self.config.contact.sort,
)
result["results"] = self._compact_contacts(
result["results"], include_details=include_details, include_raw=include_raw
)
result["timezone"] = self.config.timezone
return result
def list_followups(
self,
customer_id: Any | None = None,
contact_id: Any | None = None,
owner: str | None = None,
team: str | None = None,
project_id: Any | None = None,
followup_type: str | None = None,
record_status: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 20,
offset: int = 0,
include_raw: bool = False,
) -> dict[str, Any]:
limit, offset = _normalize_pagination(limit, offset)
owner_id, owner_error = self._resolve_owner_id_or_error(owner)
if owner_error:
return {"error": owner_error}
team_member_ids, team_error = self._resolve_team_member_ids_or_error(team)
if team_error:
return {"error": team_error}
filter_value = build_and_filter(
[
build_relation_id_condition(self.config.followup.customer_field, customer_id),
build_relation_id_condition(self.config.followup.contact_field, contact_id),
build_relation_id_condition(self.config.followup.owner_field, owner_id),
build_relation_id_in_condition(self.config.followup.owner_field, team_member_ids),
build_relation_id_condition(self.config.followup.project_field, project_id),
build_eq_condition(self.config.followup.type_field, followup_type),
build_eq_condition(self.config.followup.status_field, record_status),
build_date_range_condition(self.config.followup.date_field, date_from, date_to),
]
)
result = self._list_with_projection_fallback(
self.config.followup.resource,
limit=limit,
offset=offset,
filter=filter_value,
fields=(self.config.followup.fields or []) if include_raw else self._followup_compact_fields(),
sort=self.config.followup.sort,
)
result["results"] = self._compact_followups(result["results"], include_raw=include_raw)
result["timezone"] = self.config.timezone
return result
def _list_records_window(
self,
resource: str,
*,
limit: int,
offset: int,
**kwargs: Any,
) -> dict[str, Any]:
page = _page_from_offset(limit, offset)
intra_page_offset = offset % limit
response = self.client.list_records(
resource,
**kwargs,
page=page,
page_size=limit,
)
rows = self._data(response)
selected = rows[intra_page_offset:]
if intra_page_offset and len(rows) == limit and len(selected) < limit:
next_response = self.client.list_records(
resource,
**kwargs,
page=page + 1,
page_size=limit,
)
selected.extend(self._data(next_response)[: limit - len(selected)])
selected = selected[:limit]
total = _total(response, offset + len(selected))
return _paginated_rows(selected, total, limit, offset)
def _scan_records(
self,
resource: str,
*,
page_size: int = MAX_PAGE_SIZE,
max_records: int = MAX_SCAN_RECORDS,
**kwargs: Any,
) -> tuple[list[dict[str, Any]], dict[str, int] | None]:
rows: list[dict[str, Any]] = []
page = 1
total: int | None = None
while len(rows) < max_records:
response = self.client.list_records(
resource,
**kwargs,
page=page,
page_size=page_size,
)
page_rows = self._data(response)
if total is None:
total = _total(response, len(page_rows))
rows.extend(page_rows)
if not page_rows or len(page_rows) < page_size or len(rows) >= total:
break
page += 1
selected = rows[:max_records]
if total is None or len(selected) >= total:
return selected, None
return selected, {
"total": total,
"returned": len(selected),
"limit": max_records,
"next_offset": len(selected),
}
@staticmethod
def _data(response: Mapping[str, Any]) -> list[dict[str, Any]]:
return _data(response)
@staticmethod
def _single(response: Mapping[str, Any]) -> Any:
data = response.get("data")
if isinstance(data, list):
return data[0] if data else None
return data