amochka 0.1.4__py3-none-any.whl → 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- amochka/__init__.py +24 -1
- amochka/client.py +681 -77
- amochka/etl.py +302 -0
- amochka-0.1.7.dist-info/METADATA +40 -0
- amochka-0.1.7.dist-info/RECORD +7 -0
- {amochka-0.1.4.dist-info → amochka-0.1.7.dist-info}/WHEEL +1 -1
- amochka-0.1.4.dist-info/METADATA +0 -18
- amochka-0.1.4.dist-info/RECORD +0 -6
- {amochka-0.1.4.dist-info → amochka-0.1.7.dist-info}/top_level.txt +0 -0
amochka/client.py
CHANGED
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import requests
|
|
5
5
|
import logging
|
|
6
6
|
from datetime import datetime
|
|
7
|
+
from typing import Iterator, List, Optional, Sequence, Union
|
|
7
8
|
from ratelimit import limits, sleep_and_retry
|
|
8
9
|
|
|
9
10
|
# Создаём базовый логгер
|
|
@@ -29,12 +30,13 @@ class Deal(dict):
|
|
|
29
30
|
|
|
30
31
|
Параметр custom_fields_config – словарь, где ключи – ID полей, а значения – модели полей.
|
|
31
32
|
"""
|
|
32
|
-
def __init__(self, data, custom_fields_config=None):
|
|
33
|
+
def __init__(self, data, custom_fields_config=None, logger=None):
|
|
33
34
|
super().__init__(data)
|
|
34
35
|
self._custom = {}
|
|
35
36
|
self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
|
|
37
|
+
self._logger = logger or logging.getLogger(__name__)
|
|
36
38
|
custom = data.get("custom_fields_values") or []
|
|
37
|
-
|
|
39
|
+
self._logger.debug(f"Processing custom_fields_values: {custom}")
|
|
38
40
|
for field in custom:
|
|
39
41
|
if isinstance(field, dict):
|
|
40
42
|
field_name = field.get("field_name")
|
|
@@ -45,19 +47,19 @@ class Deal(dict):
|
|
|
45
47
|
stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
|
|
46
48
|
# Сохраняем полную информацию (и для get() и для get_id())
|
|
47
49
|
self._custom[key_name] = {"value": stored_value, "enum_id": stored_enum_id}
|
|
48
|
-
|
|
50
|
+
self._logger.debug(f"Set custom field '{key_name}' = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
|
|
49
51
|
field_id = field.get("field_id")
|
|
50
52
|
if field_id is not None and values and isinstance(values, list) and len(values) > 0:
|
|
51
53
|
stored_value = values[0].get("value")
|
|
52
54
|
stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
|
|
53
55
|
self._custom[int(field_id)] = {"value": stored_value, "enum_id": stored_enum_id}
|
|
54
|
-
|
|
56
|
+
self._logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
|
|
55
57
|
if custom_fields_config:
|
|
56
58
|
for cid, field_obj in custom_fields_config.items():
|
|
57
59
|
key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
|
|
58
60
|
if key not in self._custom:
|
|
59
61
|
self._custom[key] = None
|
|
60
|
-
|
|
62
|
+
self._logger.debug(f"Field '{key}' not found in deal data; set to None")
|
|
61
63
|
|
|
62
64
|
def __getitem__(self, key):
|
|
63
65
|
if key in super().keys():
|
|
@@ -79,16 +81,65 @@ class Deal(dict):
|
|
|
79
81
|
except KeyError:
|
|
80
82
|
return default
|
|
81
83
|
|
|
84
|
+
def get_field_type(self, key):
|
|
85
|
+
"""
|
|
86
|
+
Определяет тип кастомного поля.
|
|
87
|
+
|
|
88
|
+
:param key: Название поля (строка) или ID поля (integer).
|
|
89
|
+
:return: Строка с типом поля ('text', 'select', 'numeric', 'checkbox', и т.д.)
|
|
90
|
+
или None, если поле не найдено или тип не определён.
|
|
91
|
+
"""
|
|
92
|
+
field_def = None
|
|
93
|
+
|
|
94
|
+
# Получаем определение поля из конфигурации
|
|
95
|
+
if self._custom_config:
|
|
96
|
+
if isinstance(key, int):
|
|
97
|
+
field_def = self._custom_config.get(key)
|
|
98
|
+
else:
|
|
99
|
+
for fid, fdef in self._custom_config.items():
|
|
100
|
+
if isinstance(fdef, dict) and fdef.get("name", "").lower().strip() == key.lower().strip():
|
|
101
|
+
field_def = fdef
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
# Если нашли определение, возвращаем его тип
|
|
105
|
+
if field_def and isinstance(field_def, dict):
|
|
106
|
+
return field_def.get("type")
|
|
107
|
+
|
|
108
|
+
# Если конфигурации нет или поле не найдено, пробуем определить тип по данным
|
|
109
|
+
stored = None
|
|
110
|
+
if isinstance(key, str):
|
|
111
|
+
lower_key = key.lower().strip()
|
|
112
|
+
if lower_key in self._custom:
|
|
113
|
+
stored = self._custom[lower_key]
|
|
114
|
+
elif isinstance(key, int):
|
|
115
|
+
if key in self._custom:
|
|
116
|
+
stored = self._custom[key]
|
|
117
|
+
|
|
118
|
+
if isinstance(stored, dict) and "enum_id" in stored:
|
|
119
|
+
return "select"
|
|
120
|
+
|
|
121
|
+
return None
|
|
122
|
+
|
|
82
123
|
def get_id(self, key, default=None):
|
|
83
124
|
"""
|
|
84
|
-
Возвращает идентификатор выбранного варианта (enum_id) для кастомного
|
|
125
|
+
Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля типа select.
|
|
126
|
+
Для полей других типов возвращает их значение, как метод get().
|
|
127
|
+
|
|
85
128
|
Если значение enum_id отсутствует в данных, производится поиск в конфигурации кастомных полей,
|
|
86
129
|
сравнение значения выполняется без учёта регистра и пробелов.
|
|
87
130
|
|
|
88
131
|
:param key: Название поля (строка) или ID поля (integer).
|
|
89
132
|
:param default: Значение по умолчанию, если enum_id не найден.
|
|
90
|
-
:return:
|
|
133
|
+
:return: Для полей типа select - идентификатор варианта (целое число).
|
|
134
|
+
Для других типов полей - значение поля.
|
|
135
|
+
Если поле не найдено - default.
|
|
91
136
|
"""
|
|
137
|
+
field_type = self.get_field_type(key)
|
|
138
|
+
|
|
139
|
+
# Если это не поле списка, возвращаем значение как get()
|
|
140
|
+
if field_type is not None and field_type != "select":
|
|
141
|
+
return self.get(key, default)
|
|
142
|
+
|
|
92
143
|
stored = None
|
|
93
144
|
if isinstance(key, str):
|
|
94
145
|
lower_key = key.lower().strip()
|
|
@@ -115,7 +166,41 @@ class Deal(dict):
|
|
|
115
166
|
for enum in enums:
|
|
116
167
|
if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
|
|
117
168
|
return enum.get("id", default)
|
|
118
|
-
|
|
169
|
+
|
|
170
|
+
# Если это не поле типа select или не удалось найти enum_id,
|
|
171
|
+
# возвращаем значение поля
|
|
172
|
+
return self.get(key, default)
|
|
173
|
+
|
|
174
|
+
class CacheConfig:
|
|
175
|
+
"""
|
|
176
|
+
Конфигурация кэширования для AmoCRMClient.
|
|
177
|
+
|
|
178
|
+
Параметры:
|
|
179
|
+
enabled (bool): Включено ли кэширование
|
|
180
|
+
storage (str): Тип хранилища ('file' или 'memory')
|
|
181
|
+
file (str): Путь к файлу кэша (используется только при storage='file')
|
|
182
|
+
lifetime_hours (int|None): Время жизни кэша в часах (None для бесконечного)
|
|
183
|
+
"""
|
|
184
|
+
def __init__(self, enabled=True, storage='file', file=None, lifetime_hours=24):
|
|
185
|
+
self.enabled = enabled
|
|
186
|
+
self.storage = storage.lower()
|
|
187
|
+
self.file = file
|
|
188
|
+
self.lifetime_hours = lifetime_hours
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def disabled(cls):
|
|
192
|
+
"""Создает конфигурацию с отключенным кэшированием"""
|
|
193
|
+
return cls(enabled=False)
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def memory_only(cls, lifetime_hours=24):
|
|
197
|
+
"""Создает конфигурацию с кэшированием только в памяти"""
|
|
198
|
+
return cls(enabled=True, storage='memory', lifetime_hours=lifetime_hours)
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def file_cache(cls, file=None, lifetime_hours=24):
|
|
202
|
+
"""Создает конфигурацию с файловым кэшированием"""
|
|
203
|
+
return cls(enabled=True, storage='file', file=file, lifetime_hours=lifetime_hours)
|
|
119
204
|
|
|
120
205
|
class AmoCRMClient:
|
|
121
206
|
"""
|
|
@@ -136,50 +221,53 @@ class AmoCRMClient:
|
|
|
136
221
|
self,
|
|
137
222
|
base_url,
|
|
138
223
|
token_file=None,
|
|
139
|
-
|
|
224
|
+
cache_config=None,
|
|
140
225
|
log_level=logging.INFO,
|
|
141
|
-
disable_logging=False
|
|
142
|
-
cache_enabled=True,
|
|
143
|
-
cache_storage='file', # 'file' или 'memory'
|
|
144
|
-
cache_hours=24 # время жизни кэша в часах, или None для бесконечного кэша
|
|
226
|
+
disable_logging=False
|
|
145
227
|
):
|
|
146
228
|
"""
|
|
147
229
|
Инициализирует клиента, задавая базовый URL, токен авторизации и настройки кэша для кастомных полей.
|
|
148
230
|
|
|
149
231
|
:param base_url: Базовый URL API amoCRM.
|
|
150
232
|
:param token_file: Файл, содержащий токен авторизации.
|
|
151
|
-
:param
|
|
233
|
+
:param cache_config: Конфигурация кэширования (объект CacheConfig или None для значений по умолчанию)
|
|
152
234
|
:param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
|
|
153
235
|
:param disable_logging: Если True, логирование будет отключено.
|
|
154
|
-
:param cache_enabled: Если False, кэширование отключается (остальные параметры игнорируются).
|
|
155
|
-
:param cache_storage: 'file' для файлового кэша, 'memory' для кэша только в оперативной памяти.
|
|
156
|
-
:param cache_hours: Время жизни кэша в часах. Если None – кэш считается бесконечным.
|
|
157
236
|
"""
|
|
158
237
|
self.base_url = base_url.rstrip('/')
|
|
159
238
|
domain = self.base_url.split("//")[-1].split(".")[0]
|
|
160
239
|
self.domain = domain
|
|
161
240
|
self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
|
|
162
241
|
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
self.
|
|
170
|
-
|
|
171
|
-
self.cache_storage = cache_storage.lower() # 'file' или 'memory'
|
|
172
|
-
self.cache_hours = cache_hours
|
|
242
|
+
# Создаем логгер для конкретного экземпляра клиента
|
|
243
|
+
self.logger = logging.getLogger(f"{__name__}.{self.domain}")
|
|
244
|
+
if not self.logger.handlers:
|
|
245
|
+
handler = logging.StreamHandler()
|
|
246
|
+
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
|
247
|
+
handler.setFormatter(formatter)
|
|
248
|
+
self.logger.addHandler(handler)
|
|
249
|
+
self.logger.propagate = False # Отключаем передачу логов в родительский логгер
|
|
173
250
|
|
|
174
|
-
self.token = self.load_token()
|
|
175
|
-
self._custom_fields_mapping = None
|
|
176
|
-
|
|
177
251
|
if disable_logging:
|
|
178
|
-
|
|
252
|
+
self.logger.setLevel(logging.CRITICAL + 1) # Выше, чем любой стандартный уровень
|
|
179
253
|
else:
|
|
180
|
-
logger.setLevel(log_level)
|
|
254
|
+
self.logger.setLevel(log_level)
|
|
181
255
|
|
|
182
|
-
|
|
256
|
+
# Настройка кэширования
|
|
257
|
+
if cache_config is None:
|
|
258
|
+
self.cache_config = CacheConfig()
|
|
259
|
+
else:
|
|
260
|
+
self.cache_config = cache_config
|
|
261
|
+
|
|
262
|
+
# Установка файла кэша, если используется файловое хранилище
|
|
263
|
+
if self.cache_config.enabled and self.cache_config.storage == 'file':
|
|
264
|
+
if not self.cache_config.file:
|
|
265
|
+
self.cache_config.file = f"custom_fields_cache_{self.domain}.json"
|
|
266
|
+
|
|
267
|
+
self.logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
|
|
268
|
+
|
|
269
|
+
self.token = self.load_token()
|
|
270
|
+
self._custom_fields_mapping = None
|
|
183
271
|
|
|
184
272
|
def load_token(self):
|
|
185
273
|
"""
|
|
@@ -192,11 +280,11 @@ class AmoCRMClient:
|
|
|
192
280
|
if os.path.exists(self.token_file):
|
|
193
281
|
with open(self.token_file, 'r') as f:
|
|
194
282
|
data = json.load(f)
|
|
195
|
-
logger.debug(f"Token loaded from file: {self.token_file}")
|
|
283
|
+
self.logger.debug(f"Token loaded from file: {self.token_file}")
|
|
196
284
|
else:
|
|
197
285
|
try:
|
|
198
286
|
data = json.loads(self.token_file)
|
|
199
|
-
logger.debug("Token parsed from provided string.")
|
|
287
|
+
self.logger.debug("Token parsed from provided string.")
|
|
200
288
|
except Exception as e:
|
|
201
289
|
raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
|
|
202
290
|
|
|
@@ -207,14 +295,14 @@ class AmoCRMClient:
|
|
|
207
295
|
expires_at = float(expires_at_str)
|
|
208
296
|
|
|
209
297
|
if expires_at and time.time() < expires_at:
|
|
210
|
-
logger.debug("Token is valid.")
|
|
298
|
+
self.logger.debug("Token is valid.")
|
|
211
299
|
return data.get('access_token')
|
|
212
300
|
else:
|
|
213
301
|
raise Exception("Токен найден, но он истёк. Обновите токен.")
|
|
214
302
|
|
|
215
303
|
@sleep_and_retry
|
|
216
304
|
@limits(calls=RATE_LIMIT, period=1)
|
|
217
|
-
def _make_request(self, method, endpoint, params=None, data=None):
|
|
305
|
+
def _make_request(self, method, endpoint, params=None, data=None, timeout=10):
|
|
218
306
|
"""
|
|
219
307
|
Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
|
|
220
308
|
|
|
@@ -222,6 +310,7 @@ class AmoCRMClient:
|
|
|
222
310
|
:param endpoint: Конечная точка API (начинается с /api/v4/).
|
|
223
311
|
:param params: GET-параметры запроса.
|
|
224
312
|
:param data: Данные, отправляемые в JSON-формате.
|
|
313
|
+
:param timeout: Тайм‑аут запроса в секундах (по умолчанию 10).
|
|
225
314
|
:return: Ответ в формате JSON или None (если статус 204).
|
|
226
315
|
:raises Exception: При получении кода ошибки, отличного от 200/204.
|
|
227
316
|
"""
|
|
@@ -230,64 +319,379 @@ class AmoCRMClient:
|
|
|
230
319
|
"Authorization": f"Bearer {self.token}",
|
|
231
320
|
"Content-Type": "application/json"
|
|
232
321
|
}
|
|
233
|
-
logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
|
|
234
|
-
response = requests.request(method, url, headers=headers, params=params, json=data)
|
|
322
|
+
self.logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
|
|
323
|
+
response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
|
|
235
324
|
if response.status_code not in (200, 204):
|
|
236
|
-
logger.error(f"Request error {response.status_code}: {response.text}")
|
|
325
|
+
self.logger.error(f"Request error {response.status_code}: {response.text}")
|
|
237
326
|
raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
|
|
238
327
|
if response.status_code == 204:
|
|
239
328
|
return None
|
|
240
329
|
return response.json()
|
|
241
330
|
|
|
242
|
-
def
|
|
331
|
+
def _to_timestamp(self, value: Optional[Union[int, float, str, datetime]]) -> Optional[int]:
|
|
243
332
|
"""
|
|
244
|
-
|
|
333
|
+
Преобразует значение даты/времени в Unix timestamp.
|
|
334
|
+
Возвращает None, если значение не указано.
|
|
335
|
+
"""
|
|
336
|
+
if value is None:
|
|
337
|
+
return None
|
|
338
|
+
if isinstance(value, datetime):
|
|
339
|
+
return int(value.timestamp())
|
|
340
|
+
if isinstance(value, (int, float)):
|
|
341
|
+
return int(value)
|
|
342
|
+
if isinstance(value, str):
|
|
343
|
+
try:
|
|
344
|
+
return int(datetime.fromisoformat(value).timestamp())
|
|
345
|
+
except ValueError as exc:
|
|
346
|
+
raise ValueError(f"Не удалось преобразовать '{value}' в timestamp") from exc
|
|
347
|
+
raise TypeError(f"Неподдерживаемый тип для timestamp: {type(value)}")
|
|
245
348
|
|
|
246
|
-
|
|
247
|
-
|
|
349
|
+
def _format_filter_values(self, values: Optional[Union[int, Sequence[Union[int, str]], str]]) -> Optional[Union[str, Sequence[Union[int, str]]]]:
|
|
350
|
+
"""
|
|
351
|
+
Преобразует значение или последовательность значений для передачи в запрос.
|
|
352
|
+
"""
|
|
353
|
+
if values is None:
|
|
354
|
+
return None
|
|
355
|
+
if isinstance(values, (list, tuple, set)):
|
|
356
|
+
return [str(v) for v in values]
|
|
357
|
+
return str(values)
|
|
358
|
+
|
|
359
|
+
def _extract_collection(self, response: dict, data_path: Sequence[str]) -> list:
|
|
360
|
+
"""
|
|
361
|
+
Извлекает коллекцию элементов из ответа API по указанному пути ключей.
|
|
362
|
+
"""
|
|
363
|
+
data = response or {}
|
|
364
|
+
for key in data_path:
|
|
365
|
+
if not isinstance(data, dict):
|
|
366
|
+
return []
|
|
367
|
+
data = data.get(key)
|
|
368
|
+
if data is None:
|
|
369
|
+
return []
|
|
370
|
+
if isinstance(data, list):
|
|
371
|
+
return data
|
|
372
|
+
return []
|
|
373
|
+
|
|
374
|
+
def _iterate_paginated(
|
|
375
|
+
self,
|
|
376
|
+
endpoint: str,
|
|
377
|
+
params: Optional[dict] = None,
|
|
378
|
+
data_path: Sequence[str] = ("_embedded",),
|
|
379
|
+
) -> Iterator[dict]:
|
|
380
|
+
"""
|
|
381
|
+
Возвращает генератор, проходящий по всем страницам ответа API и
|
|
382
|
+
yielding элементы коллекции.
|
|
383
|
+
"""
|
|
384
|
+
query = dict(params or {})
|
|
385
|
+
query.setdefault("page", 1)
|
|
386
|
+
query.setdefault("limit", 250)
|
|
387
|
+
|
|
388
|
+
while True:
|
|
389
|
+
response = self._make_request("GET", endpoint, params=query)
|
|
390
|
+
if not response:
|
|
391
|
+
break
|
|
392
|
+
items = self._extract_collection(response, data_path)
|
|
393
|
+
if not items:
|
|
394
|
+
break
|
|
395
|
+
for item in items:
|
|
396
|
+
yield item
|
|
397
|
+
|
|
398
|
+
total_pages = response.get("_page_count")
|
|
399
|
+
if total_pages is not None:
|
|
400
|
+
has_next = query["page"] < total_pages
|
|
401
|
+
else:
|
|
402
|
+
links = response.get("_links") or {}
|
|
403
|
+
next_link = links.get("next") if isinstance(links, dict) else None
|
|
404
|
+
has_next = bool(next_link)
|
|
405
|
+
if not has_next:
|
|
406
|
+
break
|
|
407
|
+
query["page"] += 1
|
|
408
|
+
|
|
409
|
+
def iter_leads(
|
|
410
|
+
self,
|
|
411
|
+
updated_from: Optional[Union[int, float, str, datetime]] = None,
|
|
412
|
+
updated_to: Optional[Union[int, float, str, datetime]] = None,
|
|
413
|
+
pipeline_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
414
|
+
include_contacts: bool = False,
|
|
415
|
+
include: Optional[Union[str, Sequence[str]]] = None,
|
|
416
|
+
limit: int = 250,
|
|
417
|
+
extra_params: Optional[dict] = None,
|
|
418
|
+
) -> Iterator[dict]:
|
|
419
|
+
"""
|
|
420
|
+
Итератор сделок с фильтрацией по диапазону обновления и воронкам.
|
|
421
|
+
"""
|
|
422
|
+
params = {"limit": limit, "page": 1}
|
|
423
|
+
start_ts = self._to_timestamp(updated_from)
|
|
424
|
+
end_ts = self._to_timestamp(updated_to)
|
|
425
|
+
if start_ts is not None:
|
|
426
|
+
params["filter[updated_at][from]"] = start_ts
|
|
427
|
+
if end_ts is not None:
|
|
428
|
+
params["filter[updated_at][to]"] = end_ts
|
|
429
|
+
pipeline_param = self._format_filter_values(pipeline_ids)
|
|
430
|
+
if pipeline_param:
|
|
431
|
+
params["filter[pipeline_id]"] = pipeline_param
|
|
432
|
+
|
|
433
|
+
include_parts: List[str] = []
|
|
434
|
+
if include_contacts:
|
|
435
|
+
include_parts.append("contacts")
|
|
436
|
+
if include:
|
|
437
|
+
if isinstance(include, str):
|
|
438
|
+
include_parts.append(include)
|
|
439
|
+
else:
|
|
440
|
+
include_parts.extend([str(item) for item in include])
|
|
441
|
+
if include_parts:
|
|
442
|
+
params["with"] = ",".join(sorted(set(include_parts)))
|
|
443
|
+
if extra_params:
|
|
444
|
+
params.update(extra_params)
|
|
445
|
+
|
|
446
|
+
yield from self._iterate_paginated(
|
|
447
|
+
"/api/v4/leads", params=params, data_path=("_embedded", "leads")
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def fetch_leads(self, *args, **kwargs) -> List[dict]:
|
|
451
|
+
"""
|
|
452
|
+
Возвращает список сделок. Обёртка над iter_leads.
|
|
453
|
+
"""
|
|
454
|
+
return list(self.iter_leads(*args, **kwargs))
|
|
455
|
+
|
|
456
|
+
def iter_contacts(
|
|
457
|
+
self,
|
|
458
|
+
updated_from: Optional[Union[int, float, str, datetime]] = None,
|
|
459
|
+
updated_to: Optional[Union[int, float, str, datetime]] = None,
|
|
460
|
+
contact_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
461
|
+
limit: int = 250,
|
|
462
|
+
extra_params: Optional[dict] = None,
|
|
463
|
+
) -> Iterator[dict]:
|
|
464
|
+
"""
|
|
465
|
+
Итератор контактов с фильтрацией по диапазону обновления или списку ID.
|
|
466
|
+
"""
|
|
467
|
+
params = {"limit": limit, "page": 1}
|
|
468
|
+
start_ts = self._to_timestamp(updated_from)
|
|
469
|
+
end_ts = self._to_timestamp(updated_to)
|
|
470
|
+
if start_ts is not None:
|
|
471
|
+
params["filter[updated_at][from]"] = start_ts
|
|
472
|
+
if end_ts is not None:
|
|
473
|
+
params["filter[updated_at][to]"] = end_ts
|
|
474
|
+
contact_param = self._format_filter_values(contact_ids)
|
|
475
|
+
if contact_param:
|
|
476
|
+
params["filter[id][]"] = contact_param
|
|
477
|
+
if extra_params:
|
|
478
|
+
params.update(extra_params)
|
|
479
|
+
|
|
480
|
+
yield from self._iterate_paginated(
|
|
481
|
+
"/api/v4/contacts", params=params, data_path=("_embedded", "contacts")
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def fetch_contacts(self, *args, **kwargs) -> List[dict]:
|
|
485
|
+
"""
|
|
486
|
+
Возвращает список контактов. Обёртка над iter_contacts.
|
|
487
|
+
"""
|
|
488
|
+
return list(self.iter_contacts(*args, **kwargs))
|
|
489
|
+
|
|
490
|
+
def get_contact_by_id(self, contact_id: Union[int, str], include: Optional[Union[str, Sequence[str]]] = None) -> dict:
|
|
491
|
+
"""
|
|
492
|
+
Получает данные контакта по его ID.
|
|
493
|
+
"""
|
|
494
|
+
endpoint = f"/api/v4/contacts/{contact_id}"
|
|
495
|
+
params = {}
|
|
496
|
+
if include:
|
|
497
|
+
if isinstance(include, str):
|
|
498
|
+
params["with"] = include
|
|
499
|
+
else:
|
|
500
|
+
params["with"] = ",".join(str(item) for item in include)
|
|
501
|
+
data = self._make_request("GET", endpoint, params=params)
|
|
502
|
+
if not data or not isinstance(data, dict) or "id" not in data:
|
|
503
|
+
raise Exception(f"Contact {contact_id} not found or invalid response.")
|
|
504
|
+
return data
|
|
505
|
+
|
|
506
|
+
def iter_notes(
|
|
507
|
+
self,
|
|
508
|
+
entity: str = "lead",
|
|
509
|
+
updated_from: Optional[Union[int, float, str, datetime]] = None,
|
|
510
|
+
updated_to: Optional[Union[int, float, str, datetime]] = None,
|
|
511
|
+
note_type: Optional[Union[str, Sequence[str]]] = None,
|
|
512
|
+
entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
513
|
+
limit: int = 250,
|
|
514
|
+
extra_params: Optional[dict] = None,
|
|
515
|
+
) -> Iterator[dict]:
|
|
516
|
+
"""
|
|
517
|
+
Итератор примечаний для заданной сущности.
|
|
518
|
+
"""
|
|
519
|
+
mapping = {
|
|
520
|
+
"lead": "leads",
|
|
521
|
+
"contact": "contacts",
|
|
522
|
+
"company": "companies",
|
|
523
|
+
"customer": "customers",
|
|
524
|
+
}
|
|
525
|
+
plural = mapping.get(entity.lower(), entity.lower() + "s")
|
|
526
|
+
endpoint = f"/api/v4/{plural}/notes"
|
|
527
|
+
|
|
528
|
+
params = {"limit": limit, "page": 1}
|
|
529
|
+
start_ts = self._to_timestamp(updated_from)
|
|
530
|
+
end_ts = self._to_timestamp(updated_to)
|
|
531
|
+
if start_ts is not None:
|
|
532
|
+
params["filter[updated_at][from]"] = start_ts
|
|
533
|
+
if end_ts is not None:
|
|
534
|
+
params["filter[updated_at][to]"] = end_ts
|
|
535
|
+
note_type_param = self._format_filter_values(note_type)
|
|
536
|
+
if note_type_param:
|
|
537
|
+
params["filter[note_type]"] = note_type_param
|
|
538
|
+
entity_param = self._format_filter_values(entity_ids)
|
|
539
|
+
if entity_param:
|
|
540
|
+
params["filter[entity_id]"] = entity_param
|
|
541
|
+
if extra_params:
|
|
542
|
+
params.update(extra_params)
|
|
543
|
+
|
|
544
|
+
yield from self._iterate_paginated(
|
|
545
|
+
endpoint, params=params, data_path=("_embedded", "notes")
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
def fetch_notes(self, *args, **kwargs) -> List[dict]:
|
|
549
|
+
"""
|
|
550
|
+
Возвращает список примечаний. Обёртка над iter_notes.
|
|
551
|
+
"""
|
|
552
|
+
return list(self.iter_notes(*args, **kwargs))
|
|
553
|
+
|
|
554
|
+
def iter_events(
|
|
555
|
+
self,
|
|
556
|
+
entity: Optional[str] = None,
|
|
557
|
+
entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
558
|
+
event_type: Optional[Union[str, Sequence[str]]] = None,
|
|
559
|
+
created_from: Optional[Union[int, float, str, datetime]] = None,
|
|
560
|
+
created_to: Optional[Union[int, float, str, datetime]] = None,
|
|
561
|
+
limit: int = 250,
|
|
562
|
+
extra_params: Optional[dict] = None,
|
|
563
|
+
) -> Iterator[dict]:
|
|
564
|
+
"""
|
|
565
|
+
Итератор событий с фильтрацией по сущности, типам и диапазону дат.
|
|
566
|
+
"""
|
|
567
|
+
params = {"limit": limit, "page": 1}
|
|
568
|
+
if entity:
|
|
569
|
+
params["filter[entity]"] = entity
|
|
570
|
+
entity_param = self._format_filter_values(entity_ids)
|
|
571
|
+
if entity_param:
|
|
572
|
+
params["filter[entity_id]"] = entity_param
|
|
573
|
+
event_type_param = self._format_filter_values(event_type)
|
|
574
|
+
if event_type_param:
|
|
575
|
+
params["filter[type]"] = event_type_param
|
|
576
|
+
start_ts = self._to_timestamp(created_from)
|
|
577
|
+
end_ts = self._to_timestamp(created_to)
|
|
578
|
+
if start_ts is not None:
|
|
579
|
+
params["filter[created_at][from]"] = start_ts
|
|
580
|
+
if end_ts is not None:
|
|
581
|
+
params["filter[created_at][to]"] = end_ts
|
|
582
|
+
if extra_params:
|
|
583
|
+
params.update(extra_params)
|
|
584
|
+
|
|
585
|
+
yield from self._iterate_paginated(
|
|
586
|
+
"/api/v4/events", params=params, data_path=("_embedded", "events")
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
def fetch_events(self, *args, **kwargs) -> List[dict]:
|
|
590
|
+
"""
|
|
591
|
+
Возвращает список событий. Обёртка над iter_events.
|
|
592
|
+
"""
|
|
593
|
+
return list(self.iter_events(*args, **kwargs))
|
|
594
|
+
|
|
595
|
+
def iter_users(
|
|
596
|
+
self,
|
|
597
|
+
limit: int = 250,
|
|
598
|
+
extra_params: Optional[dict] = None,
|
|
599
|
+
) -> Iterator[dict]:
|
|
600
|
+
"""
|
|
601
|
+
Итератор пользователей аккаунта.
|
|
602
|
+
"""
|
|
603
|
+
params = {"limit": limit, "page": 1}
|
|
604
|
+
if extra_params:
|
|
605
|
+
params.update(extra_params)
|
|
606
|
+
yield from self._iterate_paginated(
|
|
607
|
+
"/api/v4/users", params=params, data_path=("_embedded", "users")
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
def fetch_users(self, *args, **kwargs) -> List[dict]:
|
|
611
|
+
"""
|
|
612
|
+
Возвращает список пользователей. Обёртка над iter_users.
|
|
613
|
+
"""
|
|
614
|
+
return list(self.iter_users(*args, **kwargs))
|
|
615
|
+
|
|
616
|
+
def iter_pipelines(
|
|
617
|
+
self,
|
|
618
|
+
limit: int = 250,
|
|
619
|
+
extra_params: Optional[dict] = None,
|
|
620
|
+
) -> Iterator[dict]:
|
|
621
|
+
"""
|
|
622
|
+
Итератор воронок со статусами.
|
|
623
|
+
"""
|
|
624
|
+
params = {"limit": limit, "page": 1}
|
|
625
|
+
if extra_params:
|
|
626
|
+
params.update(extra_params)
|
|
627
|
+
yield from self._iterate_paginated(
|
|
628
|
+
"/api/v4/leads/pipelines", params=params, data_path=("_embedded", "pipelines")
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
def fetch_pipelines(self, *args, **kwargs) -> List[dict]:
|
|
632
|
+
"""
|
|
633
|
+
Возвращает список воронок. Обёртка над iter_pipelines.
|
|
634
|
+
"""
|
|
635
|
+
return list(self.iter_pipelines(*args, **kwargs))
|
|
636
|
+
|
|
637
|
+
def get_deal_by_id(self, deal_id, skip_fields_mapping=False):
|
|
638
|
+
"""
|
|
639
|
+
Получает данные сделки по её ID и возвращает объект Deal.
|
|
640
|
+
Если данные отсутствуют или имеют неверную структуру, выбрасывается исключение.
|
|
641
|
+
|
|
642
|
+
:param deal_id: ID сделки для получения
|
|
643
|
+
:param skip_fields_mapping: Если True, не загружает справочник кастомных полей
|
|
644
|
+
(используйте для работы только с ID полей)
|
|
645
|
+
:return: Объект Deal с данными сделки
|
|
248
646
|
"""
|
|
249
647
|
endpoint = f"/api/v4/leads/{deal_id}"
|
|
250
648
|
params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
|
|
251
649
|
data = self._make_request("GET", endpoint, params=params)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
650
|
+
|
|
651
|
+
# Проверяем, что получили данные и что они содержат ключ "id"
|
|
652
|
+
if not data or not isinstance(data, dict) or "id" not in data:
|
|
653
|
+
self.logger.error(f"Deal {deal_id} not found or invalid response: {data}")
|
|
654
|
+
raise Exception(f"Deal {deal_id} not found or invalid response.")
|
|
655
|
+
|
|
656
|
+
custom_config = None if skip_fields_mapping else self.get_custom_fields_mapping()
|
|
657
|
+
self.logger.debug(f"Deal {deal_id} data received (содержимое полей не выводится полностью).")
|
|
658
|
+
return Deal(data, custom_fields_config=custom_config, logger=self.logger)
|
|
255
659
|
|
|
256
660
|
def _save_custom_fields_cache(self, mapping):
|
|
257
661
|
"""
|
|
258
662
|
Сохраняет кэш кастомных полей в файл, если используется файловый кэш.
|
|
259
663
|
Если кэширование отключено или выбран кэш в памяти, операция пропускается.
|
|
260
664
|
"""
|
|
261
|
-
if not self.
|
|
262
|
-
logger.debug("Caching disabled; cache not saved.")
|
|
665
|
+
if not self.cache_config.enabled:
|
|
666
|
+
self.logger.debug("Caching disabled; cache not saved.")
|
|
263
667
|
return
|
|
264
|
-
if self.
|
|
265
|
-
logger.debug("Using memory caching; no file cache saved.")
|
|
668
|
+
if self.cache_config.storage != 'file':
|
|
669
|
+
self.logger.debug("Using memory caching; no file cache saved.")
|
|
266
670
|
return
|
|
267
671
|
cache_data = {"last_updated": time.time(), "mapping": mapping}
|
|
268
|
-
with open(self.
|
|
672
|
+
with open(self.cache_config.file, "w") as f:
|
|
269
673
|
json.dump(cache_data, f)
|
|
270
|
-
logger.debug(f"Custom fields cache saved to {self.
|
|
674
|
+
self.logger.debug(f"Custom fields cache saved to {self.cache_config.file}")
|
|
271
675
|
|
|
272
676
|
def _load_custom_fields_cache(self):
|
|
273
677
|
"""
|
|
274
678
|
Загружает кэш кастомных полей из файла, если используется файловый кэш.
|
|
275
679
|
Если кэширование отключено или выбран кэш в памяти, возвращает None.
|
|
276
680
|
"""
|
|
277
|
-
if not self.
|
|
278
|
-
logger.debug("Caching disabled; no cache loaded.")
|
|
681
|
+
if not self.cache_config.enabled:
|
|
682
|
+
self.logger.debug("Caching disabled; no cache loaded.")
|
|
279
683
|
return None
|
|
280
|
-
if self.
|
|
281
|
-
logger.debug("Using memory caching; cache will be kept in memory only.")
|
|
684
|
+
if self.cache_config.storage != 'file':
|
|
685
|
+
self.logger.debug("Using memory caching; cache will be kept in memory only.")
|
|
282
686
|
return None
|
|
283
|
-
if os.path.exists(self.
|
|
284
|
-
with open(self.
|
|
687
|
+
if os.path.exists(self.cache_config.file):
|
|
688
|
+
with open(self.cache_config.file, "r") as f:
|
|
285
689
|
try:
|
|
286
690
|
cache_data = json.load(f)
|
|
287
|
-
logger.debug("Custom fields cache loaded successfully.")
|
|
691
|
+
self.logger.debug("Custom fields cache loaded successfully.")
|
|
288
692
|
return cache_data
|
|
289
693
|
except Exception as e:
|
|
290
|
-
logger.error(f"Error loading cache: {e}")
|
|
694
|
+
self.logger.error(f"Error loading cache: {e}")
|
|
291
695
|
return None
|
|
292
696
|
return None
|
|
293
697
|
|
|
@@ -299,18 +703,18 @@ class AmoCRMClient:
|
|
|
299
703
|
if not force_update and self._custom_fields_mapping is not None:
|
|
300
704
|
return self._custom_fields_mapping
|
|
301
705
|
|
|
302
|
-
cache_data = self._load_custom_fields_cache() if self.
|
|
706
|
+
cache_data = self._load_custom_fields_cache() if self.cache_config.enabled else None
|
|
303
707
|
if cache_data:
|
|
304
708
|
last_updated = cache_data.get("last_updated", 0)
|
|
305
|
-
if self.
|
|
306
|
-
if time.time() - last_updated < self.
|
|
709
|
+
if self.cache_config.lifetime_hours is not None:
|
|
710
|
+
if time.time() - last_updated < self.cache_config.lifetime_hours * 3600:
|
|
307
711
|
self._custom_fields_mapping = cache_data.get("mapping")
|
|
308
|
-
logger.debug("Using cached custom fields mapping.")
|
|
712
|
+
self.logger.debug("Using cached custom fields mapping.")
|
|
309
713
|
return self._custom_fields_mapping
|
|
310
714
|
else:
|
|
311
715
|
# Бесконечный кэш – не проверяем срок
|
|
312
716
|
self._custom_fields_mapping = cache_data.get("mapping")
|
|
313
|
-
logger.debug("Using cached custom fields mapping (infinite cache).")
|
|
717
|
+
self.logger.debug("Using cached custom fields mapping (infinite cache).")
|
|
314
718
|
return self._custom_fields_mapping
|
|
315
719
|
|
|
316
720
|
mapping = {}
|
|
@@ -323,14 +727,14 @@ class AmoCRMClient:
|
|
|
323
727
|
for field in response["_embedded"]["custom_fields"]:
|
|
324
728
|
mapping[field["id"]] = field
|
|
325
729
|
total_pages = response.get("_page_count", page)
|
|
326
|
-
logger.debug(f"Fetched page {page} of {total_pages}")
|
|
730
|
+
self.logger.debug(f"Fetched page {page} of {total_pages}")
|
|
327
731
|
page += 1
|
|
328
732
|
else:
|
|
329
733
|
break
|
|
330
734
|
|
|
331
|
-
logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
|
|
735
|
+
self.logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
|
|
332
736
|
self._custom_fields_mapping = mapping
|
|
333
|
-
if self.
|
|
737
|
+
if self.cache_config.enabled:
|
|
334
738
|
self._save_custom_fields_cache(mapping)
|
|
335
739
|
return mapping
|
|
336
740
|
|
|
@@ -349,9 +753,9 @@ class AmoCRMClient:
|
|
|
349
753
|
else:
|
|
350
754
|
name = str(field_obj).lower().strip()
|
|
351
755
|
if search_term_lower == name or search_term_lower in name:
|
|
352
|
-
logger.debug(f"Found custom field '{name}' with id {key}")
|
|
756
|
+
self.logger.debug(f"Found custom field '{name}' with id {key}")
|
|
353
757
|
return int(key), field_obj
|
|
354
|
-
logger.debug(f"Custom field containing '{search_term}' not found.")
|
|
758
|
+
self.logger.debug(f"Custom field containing '{search_term}' not found.")
|
|
355
759
|
return None, None
|
|
356
760
|
|
|
357
761
|
def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
|
|
@@ -379,7 +783,7 @@ class AmoCRMClient:
|
|
|
379
783
|
for key, value in update_fields.items():
|
|
380
784
|
if key in standard_fields:
|
|
381
785
|
payload[key] = value
|
|
382
|
-
logger.debug(f"Standard field {key} set to {value}")
|
|
786
|
+
self.logger.debug(f"Standard field {key} set to {value}")
|
|
383
787
|
else:
|
|
384
788
|
if isinstance(value, int):
|
|
385
789
|
field_value_dict = {"enum_id": value}
|
|
@@ -388,12 +792,12 @@ class AmoCRMClient:
|
|
|
388
792
|
try:
|
|
389
793
|
field_id = int(key)
|
|
390
794
|
custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
|
|
391
|
-
logger.debug(f"Custom field by id {field_id} set to {value}")
|
|
795
|
+
self.logger.debug(f"Custom field by id {field_id} set to {value}")
|
|
392
796
|
except ValueError:
|
|
393
797
|
field_id, field_obj = self.find_custom_field_id(key)
|
|
394
798
|
if field_id is not None:
|
|
395
799
|
custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
|
|
396
|
-
logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
|
|
800
|
+
self.logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
|
|
397
801
|
else:
|
|
398
802
|
raise Exception(f"Custom field '{key}' не найден.")
|
|
399
803
|
if custom_fields:
|
|
@@ -402,8 +806,208 @@ class AmoCRMClient:
|
|
|
402
806
|
payload["tags_to_add"] = tags_to_add
|
|
403
807
|
if tags_to_delete:
|
|
404
808
|
payload["tags_to_delete"] = tags_to_delete
|
|
405
|
-
logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
|
|
809
|
+
self.logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
|
|
406
810
|
endpoint = f"/api/v4/leads/{lead_id}"
|
|
407
811
|
response = self._make_request("PATCH", endpoint, data=payload)
|
|
408
|
-
logger.debug("Update response received.")
|
|
409
|
-
return response
|
|
812
|
+
self.logger.debug("Update response received.")
|
|
813
|
+
return response
|
|
814
|
+
|
|
815
|
+
def get_entity_notes(self, entity, entity_id, get_all=False, note_type=None, extra_params=None):
|
|
816
|
+
"""
|
|
817
|
+
Получает список примечаний для указанной сущности и её ID.
|
|
818
|
+
|
|
819
|
+
Используется эндпоинт:
|
|
820
|
+
GET /api/v4/{entity_plural}/{entity_id}/notes
|
|
821
|
+
|
|
822
|
+
:param entity: Тип сущности (например, 'lead', 'contact', 'company', 'customer' и т.д.).
|
|
823
|
+
Передаётся в единственном числе, для формирования конечной точки будет использована
|
|
824
|
+
таблица преобразования (например, 'lead' -> 'leads').
|
|
825
|
+
:param entity_id: ID сущности.
|
|
826
|
+
:param get_all: Если True, метод автоматически проходит по всем страницам пагинации.
|
|
827
|
+
:param note_type: Фильтр по типу примечания. Может быть строкой (например, 'common') или списком строк.
|
|
828
|
+
:param extra_params: Словарь дополнительных GET-параметров, если требуется.
|
|
829
|
+
:return: Список примечаний (каждый элемент – словарь с данными примечания).
|
|
830
|
+
"""
|
|
831
|
+
# Преобразуем тип сущности в форму во множественном числе (для известных типов)
|
|
832
|
+
mapping = {
|
|
833
|
+
'lead': 'leads',
|
|
834
|
+
'contact': 'contacts',
|
|
835
|
+
'company': 'companies',
|
|
836
|
+
'customer': 'customers'
|
|
837
|
+
}
|
|
838
|
+
plural = mapping.get(entity.lower(), entity.lower() + "s")
|
|
839
|
+
|
|
840
|
+
endpoint = f"/api/v4/{plural}/{entity_id}/notes"
|
|
841
|
+
params = {
|
|
842
|
+
"page": 1,
|
|
843
|
+
"limit": 250
|
|
844
|
+
}
|
|
845
|
+
if note_type is not None:
|
|
846
|
+
params["filter[note_type]"] = note_type
|
|
847
|
+
if extra_params:
|
|
848
|
+
params.update(extra_params)
|
|
849
|
+
|
|
850
|
+
notes = []
|
|
851
|
+
while True:
|
|
852
|
+
response = self._make_request("GET", endpoint, params=params)
|
|
853
|
+
if response and "_embedded" in response and "notes" in response["_embedded"]:
|
|
854
|
+
notes.extend(response["_embedded"]["notes"])
|
|
855
|
+
if not get_all:
|
|
856
|
+
break
|
|
857
|
+
total_pages = response.get("_page_count", params["page"])
|
|
858
|
+
if params["page"] >= total_pages:
|
|
859
|
+
break
|
|
860
|
+
params["page"] += 1
|
|
861
|
+
self.logger.debug(f"Retrieved {len(notes)} notes for {entity} {entity_id}")
|
|
862
|
+
return notes
|
|
863
|
+
|
|
864
|
+
def get_entity_note(self, entity, entity_id, note_id):
|
|
865
|
+
"""
|
|
866
|
+
Получает расширенную информацию по конкретному примечанию для указанной сущности.
|
|
867
|
+
|
|
868
|
+
Используется эндпоинт:
|
|
869
|
+
GET /api/v4/{entity_plural}/{entity_id}/notes/{note_id}
|
|
870
|
+
|
|
871
|
+
:param entity: Тип сущности (например, 'lead', 'contact', 'company', 'customer' и т.д.).
|
|
872
|
+
:param entity_id: ID сущности.
|
|
873
|
+
:param note_id: ID примечания.
|
|
874
|
+
:return: Словарь с полной информацией о примечании.
|
|
875
|
+
:raises Exception: При ошибке запроса.
|
|
876
|
+
"""
|
|
877
|
+
mapping = {
|
|
878
|
+
'lead': 'leads',
|
|
879
|
+
'contact': 'contacts',
|
|
880
|
+
'company': 'companies',
|
|
881
|
+
'customer': 'customers'
|
|
882
|
+
}
|
|
883
|
+
plural = mapping.get(entity.lower(), entity.lower() + "s")
|
|
884
|
+
endpoint = f"/api/v4/{plural}/{entity_id}/notes/{note_id}"
|
|
885
|
+
self.logger.debug(f"Fetching note {note_id} for {entity} {entity_id}")
|
|
886
|
+
note_data = self._make_request("GET", endpoint)
|
|
887
|
+
self.logger.debug(f"Note {note_id} for {entity} {entity_id} fetched successfully.")
|
|
888
|
+
return note_data
|
|
889
|
+
|
|
890
|
+
# Удобные обёртки для сделок и контактов:
|
|
891
|
+
def get_deal_notes(self, deal_id, **kwargs):
|
|
892
|
+
return self.get_entity_notes("lead", deal_id, **kwargs)
|
|
893
|
+
|
|
894
|
+
def get_deal_note(self, deal_id, note_id):
|
|
895
|
+
return self.get_entity_note("lead", deal_id, note_id)
|
|
896
|
+
|
|
897
|
+
def get_contact_notes(self, contact_id, **kwargs):
|
|
898
|
+
return self.get_entity_notes("contact", contact_id, **kwargs)
|
|
899
|
+
|
|
900
|
+
def get_contact_note(self, contact_id, note_id):
|
|
901
|
+
return self.get_entity_note("contact", contact_id, note_id)
|
|
902
|
+
|
|
903
|
+
def get_entity_events(self, entity, entity_id=None, get_all=False, event_type=None, extra_params=None):
|
|
904
|
+
"""
|
|
905
|
+
Получает список событий для указанной сущности.
|
|
906
|
+
Если entity_id не указан (None), возвращает события для всех сущностей данного типа.
|
|
907
|
+
|
|
908
|
+
:param entity: Тип сущности (например, 'lead', 'contact', 'company' и т.д.).
|
|
909
|
+
:param entity_id: ID сущности или None для получения событий по всем сущностям данного типа.
|
|
910
|
+
:param get_all: Если True, автоматически проходит по всем страницам пагинации.
|
|
911
|
+
:param event_type: Фильтр по типу события. Может быть строкой или списком строк.
|
|
912
|
+
:param extra_params: Словарь дополнительных GET-параметров.
|
|
913
|
+
:return: Список событий (каждый элемент – словарь с данными события).
|
|
914
|
+
"""
|
|
915
|
+
params = {
|
|
916
|
+
'page': 1,
|
|
917
|
+
'limit': 100,
|
|
918
|
+
'filter[entity]': entity,
|
|
919
|
+
}
|
|
920
|
+
# Добавляем фильтр по ID, если он указан
|
|
921
|
+
if entity_id is not None:
|
|
922
|
+
params['filter[entity_id]'] = entity_id
|
|
923
|
+
# Фильтр по типу события
|
|
924
|
+
if event_type is not None:
|
|
925
|
+
params['filter[type]'] = event_type
|
|
926
|
+
if extra_params:
|
|
927
|
+
params.update(extra_params)
|
|
928
|
+
|
|
929
|
+
events = []
|
|
930
|
+
while True:
|
|
931
|
+
response = self._make_request("GET", "/api/v4/events", params=params)
|
|
932
|
+
if response and "_embedded" in response and "events" in response["_embedded"]:
|
|
933
|
+
events.extend(response["_embedded"]["events"])
|
|
934
|
+
# Если не нужно получать все страницы, выходим
|
|
935
|
+
if not get_all:
|
|
936
|
+
break
|
|
937
|
+
total_pages = response.get("_page_count", params['page'])
|
|
938
|
+
if params['page'] >= total_pages:
|
|
939
|
+
break
|
|
940
|
+
params['page'] += 1
|
|
941
|
+
return events
|
|
942
|
+
|
|
943
|
+
# Удобные обёртки:
|
|
944
|
+
def get_deal_events(self, deal_id, **kwargs):
|
|
945
|
+
return self.get_entity_events("lead", deal_id, **kwargs)
|
|
946
|
+
|
|
947
|
+
def get_contact_events(self, contact_id, **kwargs):
|
|
948
|
+
return self.get_entity_events("contact", contact_id, **kwargs)
|
|
949
|
+
|
|
950
|
+
def fetch_updated_leads_raw(
|
|
951
|
+
self,
|
|
952
|
+
pipeline_id,
|
|
953
|
+
updated_from,
|
|
954
|
+
updated_to=None,
|
|
955
|
+
save_to_file=None,
|
|
956
|
+
limit=250,
|
|
957
|
+
include_contacts=False,
|
|
958
|
+
):
|
|
959
|
+
"""Возвращает сделки из указанной воронки, обновленные в заданный период.
|
|
960
|
+
|
|
961
|
+
:param pipeline_id: ID воронки.
|
|
962
|
+
:param updated_from: datetime, начиная с которого искать изменения.
|
|
963
|
+
:param updated_to: datetime окончания диапазона (опционально).
|
|
964
|
+
:param save_to_file: путь к файлу для сохранения результатов в формате JSON.
|
|
965
|
+
:param limit: количество элементов на страницу (максимум 250).
|
|
966
|
+
:param include_contacts: если True, в ответ будут включены данные контактов.
|
|
967
|
+
:return: список словарей со сделками.
|
|
968
|
+
"""
|
|
969
|
+
|
|
970
|
+
all_leads = self.fetch_leads(
|
|
971
|
+
updated_from=updated_from,
|
|
972
|
+
updated_to=updated_to,
|
|
973
|
+
pipeline_ids=pipeline_id,
|
|
974
|
+
include_contacts=include_contacts,
|
|
975
|
+
limit=limit,
|
|
976
|
+
)
|
|
977
|
+
if save_to_file:
|
|
978
|
+
with open(save_to_file, "w", encoding="utf-8") as f:
|
|
979
|
+
json.dump(all_leads, f, ensure_ascii=False, indent=2)
|
|
980
|
+
|
|
981
|
+
self.logger.debug(f"Fetched {len(all_leads)} leads from pipeline {pipeline_id}")
|
|
982
|
+
return all_leads
|
|
983
|
+
|
|
984
|
+
def get_event(self, event_id):
|
|
985
|
+
"""
|
|
986
|
+
Получает подробную информацию по конкретному событию по его ID.
|
|
987
|
+
|
|
988
|
+
Используется эндпоинт:
|
|
989
|
+
GET /api/v4/events/{event_id}
|
|
990
|
+
|
|
991
|
+
:param event_id: ID события.
|
|
992
|
+
:return: Словарь с подробной информацией о событии.
|
|
993
|
+
:raises Exception: При ошибке запроса.
|
|
994
|
+
"""
|
|
995
|
+
endpoint = f"/api/v4/events/{event_id}"
|
|
996
|
+
self.logger.debug(f"Fetching event with ID {event_id}")
|
|
997
|
+
event_data = self._make_request("GET", endpoint)
|
|
998
|
+
self.logger.debug(f"Event {event_id} details fetched successfully.")
|
|
999
|
+
return event_data
|
|
1000
|
+
|
|
1001
|
+
def get_pipelines(self):
|
|
1002
|
+
"""
|
|
1003
|
+
Получает список всех воронок и их статусов из amoCRM.
|
|
1004
|
+
|
|
1005
|
+
:return: Список словарей, где каждый словарь содержит данные воронки, а также, если присутствует, вложенные статусы.
|
|
1006
|
+
:raises Exception: Если данные не получены или структура ответа неверна.
|
|
1007
|
+
"""
|
|
1008
|
+
pipelines = self.fetch_pipelines()
|
|
1009
|
+
if pipelines:
|
|
1010
|
+
self.logger.debug(f"Получено {len(pipelines)} воронок")
|
|
1011
|
+
return pipelines
|
|
1012
|
+
self.logger.error("Не удалось получить воронки из amoCRM")
|
|
1013
|
+
raise Exception("Ошибка получения воронок из amoCRM")
|