amochka 0.1.9__py3-none-any.whl → 0.3.0__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/client.py ADDED
@@ -0,0 +1,1375 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import requests
5
+ import logging
6
+ from datetime import datetime
7
+ from typing import Iterator, List, Optional, Sequence, Union
8
+ from ratelimit import limits, sleep_and_retry
9
+
10
+ # Создаём базовый логгер
11
+ logger = logging.getLogger(__name__)
12
+ if not logger.handlers:
13
+ ch = logging.StreamHandler()
14
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
15
+ ch.setFormatter(formatter)
16
+ logger.addHandler(ch)
17
+
18
+ RATE_LIMIT = 7 # Максимум 7 запросов в секунду
19
+
20
+ class Deal(dict):
21
+ """
22
+ Объект сделки расширяет стандартный словарь данными из custom_fields_values.
23
+
24
+ Обеспечивает два способа доступа к кастомным полям:
25
+ 1. get(key): при обращении по названию (строкой) или по ID поля (integer)
26
+ возвращает текстовое значение поля (например, «Дурина Юлия»).
27
+ 2. get_id(key): возвращает идентификатор выбранного варианта (enum_id) для полей типа select.
28
+ Если в данных enum_id отсутствует, производится поиск в переданной конфигурации полей,
29
+ сравнение выполняется без учёта регистра и лишних пробелов.
30
+
31
+ Параметр custom_fields_config – словарь, где ключи – ID полей, а значения – модели полей.
32
+ """
33
+ def __init__(self, data, custom_fields_config=None, logger=None):
34
+ super().__init__(data)
35
+ self._custom = {}
36
+ self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
37
+ self._logger = logger or logging.getLogger(__name__)
38
+ custom = data.get("custom_fields_values") or []
39
+ self._logger.debug(f"Processing custom_fields_values: {custom}")
40
+ for field in custom:
41
+ if isinstance(field, dict):
42
+ field_name = field.get("field_name")
43
+ values = field.get("values")
44
+ if field_name and values and isinstance(values, list) and len(values) > 0:
45
+ key_name = field_name.lower().strip()
46
+ stored_value = values[0].get("value")
47
+ stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
48
+ # Сохраняем полную информацию (и для get() и для get_id())
49
+ self._custom[key_name] = {"value": stored_value, "enum_id": stored_enum_id}
50
+ self._logger.debug(f"Set custom field '{key_name}' = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
51
+ field_id = field.get("field_id")
52
+ if field_id is not None and values and isinstance(values, list) and len(values) > 0:
53
+ stored_value = values[0].get("value")
54
+ stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
55
+ self._custom[int(field_id)] = {"value": stored_value, "enum_id": stored_enum_id}
56
+ self._logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
57
+ if custom_fields_config:
58
+ for cid, field_obj in custom_fields_config.items():
59
+ key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
60
+ if key not in self._custom:
61
+ self._custom[key] = None
62
+ self._logger.debug(f"Field '{key}' not found in deal data; set to None")
63
+
64
+ def __getitem__(self, key):
65
+ if key in super().keys():
66
+ return super().__getitem__(key)
67
+ if isinstance(key, str):
68
+ lower_key = key.lower().strip()
69
+ if lower_key in self._custom:
70
+ stored = self._custom[lower_key]
71
+ return stored.get("value") if isinstance(stored, dict) else stored
72
+ if isinstance(key, int):
73
+ if key in self._custom:
74
+ stored = self._custom[key]
75
+ return stored.get("value") if isinstance(stored, dict) else stored
76
+ raise KeyError(key)
77
+
78
+ def get(self, key, default=None):
79
+ try:
80
+ return self.__getitem__(key)
81
+ except KeyError:
82
+ return default
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
+
123
+ def get_id(self, key, default=None):
124
+ """
125
+ Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля типа select.
126
+ Для полей других типов возвращает их значение, как метод get().
127
+
128
+ Если значение enum_id отсутствует в данных, производится поиск в конфигурации кастомных полей,
129
+ сравнение значения выполняется без учёта регистра и пробелов.
130
+
131
+ :param key: Название поля (строка) или ID поля (integer).
132
+ :param default: Значение по умолчанию, если enum_id не найден.
133
+ :return: Для полей типа select - идентификатор варианта (целое число).
134
+ Для других типов полей - значение поля.
135
+ Если поле не найдено - default.
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
+
143
+ stored = None
144
+ if isinstance(key, str):
145
+ lower_key = key.lower().strip()
146
+ if lower_key in self._custom:
147
+ stored = self._custom[lower_key]
148
+ elif isinstance(key, int):
149
+ if key in self._custom:
150
+ stored = self._custom[key]
151
+ if isinstance(stored, dict):
152
+ enum_id = stored.get("enum_id")
153
+ if enum_id is not None:
154
+ return enum_id
155
+ if self._custom_config:
156
+ field_def = None
157
+ if isinstance(key, int):
158
+ field_def = self._custom_config.get(key)
159
+ else:
160
+ for fid, fdef in self._custom_config.items():
161
+ if fdef.get("name", "").lower().strip() == key.lower().strip():
162
+ field_def = fdef
163
+ break
164
+ if field_def:
165
+ enums = field_def.get("enums") or []
166
+ for enum in enums:
167
+ if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
168
+ return enum.get("id", default)
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
+ base_dir (str): Базовая директория для кэша (по умолчанию ~/.amocrm/cache/)
182
+ file (str): Путь к файлу кэша (устаревший, для обратной совместимости)
183
+ lifetime_hours (int|dict|None): Время жизни кэша в часах
184
+ - int: одинаковое время для всех типов данных
185
+ - dict: разное время для каждого типа (например, {'pipelines': 168, 'users': 24})
186
+ - None: бесконечный кэш
187
+ """
188
+ DEFAULT_LIFETIMES = {
189
+ 'custom_fields': 24,
190
+ 'pipelines': 168, # 7 дней
191
+ 'users': 24,
192
+ }
193
+
194
+ def __init__(self, enabled=True, storage='file', base_dir=None, file=None, lifetime_hours='default'):
195
+ self.enabled = enabled
196
+ self.storage = storage.lower()
197
+ self.base_dir = base_dir or os.path.join(os.path.expanduser('~'), '.amocrm', 'cache')
198
+ self.file = file # Для обратной совместимости с custom fields
199
+
200
+ # Обработка lifetime_hours: может быть int, dict, None, или 'default'
201
+ if lifetime_hours == 'default':
202
+ # Используем дефолтные значения
203
+ self.lifetime_hours = self.DEFAULT_LIFETIMES.copy()
204
+ elif isinstance(lifetime_hours, dict):
205
+ # Объединяем дефолтные значения с пользовательскими
206
+ self.lifetime_hours = {**self.DEFAULT_LIFETIMES, **lifetime_hours}
207
+ elif lifetime_hours is None:
208
+ # Бесконечный кэш для всех типов
209
+ self.lifetime_hours = None
210
+ elif isinstance(lifetime_hours, (int, float)):
211
+ # Одинаковое время для всех типов
212
+ self.lifetime_hours = {
213
+ 'custom_fields': lifetime_hours,
214
+ 'pipelines': lifetime_hours,
215
+ 'users': lifetime_hours,
216
+ }
217
+ else:
218
+ # Fallback на дефолтные значения
219
+ self.lifetime_hours = self.DEFAULT_LIFETIMES.copy()
220
+
221
+ def get_lifetime(self, data_type):
222
+ """
223
+ Получает время жизни кэша для указанного типа данных.
224
+
225
+ :param data_type: Тип данных ('custom_fields', 'pipelines', 'users')
226
+ :return: Время жизни в часах или None для бесконечного кэша
227
+ """
228
+ if self.lifetime_hours is None:
229
+ return None
230
+ if isinstance(self.lifetime_hours, dict):
231
+ return self.lifetime_hours.get(data_type, 24)
232
+ return self.lifetime_hours
233
+
234
+ @classmethod
235
+ def disabled(cls):
236
+ """Создает конфигурацию с отключенным кэшированием"""
237
+ return cls(enabled=False)
238
+
239
+ @classmethod
240
+ def memory_only(cls, lifetime_hours=24):
241
+ """Создает конфигурацию с кэшированием только в памяти"""
242
+ return cls(enabled=True, storage='memory', lifetime_hours=lifetime_hours)
243
+
244
+ @classmethod
245
+ def file_cache(cls, file=None, base_dir=None, lifetime_hours='default'):
246
+ """Создает конфигурацию с файловым кэшированием"""
247
+ return cls(enabled=True, storage='file', base_dir=base_dir, file=file, lifetime_hours=lifetime_hours)
248
+
249
+ class AmoCRMClient:
250
+ """
251
+ Клиент для работы с API amoCRM.
252
+
253
+ Основные функции:
254
+ - load_token: Загружает и проверяет токен авторизации.
255
+ - _make_request: Выполняет HTTP-запрос с учетом ограничения по скорости.
256
+ - get_deal_by_id: Получает данные сделки по ID и возвращает объект Deal.
257
+ - get_custom_fields_mapping: Загружает и кэширует список кастомных полей.
258
+ - find_custom_field_id: Ищет кастомное поле по его названию.
259
+ - update_lead: Обновляет сделку, включая стандартные и кастомные поля.
260
+
261
+ Дополнительно можно задать уровень логирования через параметр log_level,
262
+ либо полностью отключить логирование, установив disable_logging=True.
263
+ """
264
+ def __init__(
265
+ self,
266
+ base_url,
267
+ token_file=None,
268
+ cache_config=None,
269
+ log_level=logging.INFO,
270
+ disable_logging=False,
271
+ *,
272
+ client_id: Optional[str] = None,
273
+ client_secret: Optional[str] = None,
274
+ redirect_uri: Optional[str] = None,
275
+ ):
276
+ """
277
+ Инициализирует клиента, задавая базовый URL, токен авторизации и настройки кэша для кастомных полей.
278
+
279
+ :param base_url: Базовый URL API amoCRM.
280
+ :param token_file: Файл, содержащий токен авторизации.
281
+ :param cache_config: Конфигурация кэширования (объект CacheConfig или None для значений по умолчанию)
282
+ :param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
283
+ :param disable_logging: Если True, логирование будет отключено.
284
+ """
285
+ self.base_url = base_url.rstrip('/')
286
+ domain = self.base_url.split("//")[-1].split(".")[0]
287
+ self.domain = domain
288
+ self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
289
+
290
+ # OAuth2 credentials (используются для авто‑refresh токена)
291
+ self.client_id = client_id
292
+ self.client_secret = client_secret
293
+ self.redirect_uri = redirect_uri
294
+
295
+ # Создаем логгер для конкретного экземпляра клиента
296
+ self.logger = logging.getLogger(f"{__name__}.{self.domain}")
297
+ if not self.logger.handlers:
298
+ handler = logging.StreamHandler()
299
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
300
+ handler.setFormatter(formatter)
301
+ self.logger.addHandler(handler)
302
+ self.logger.propagate = False # Отключаем передачу логов в родительский логгер
303
+
304
+ if disable_logging:
305
+ self.logger.setLevel(logging.CRITICAL + 1) # Выше, чем любой стандартный уровень
306
+ else:
307
+ self.logger.setLevel(log_level)
308
+
309
+ # Настройка кэширования
310
+ if cache_config is None:
311
+ self.cache_config = CacheConfig()
312
+ else:
313
+ self.cache_config = cache_config
314
+
315
+ # Установка файла кэша, если используется файловое хранилище
316
+ if self.cache_config.enabled and self.cache_config.storage == 'file':
317
+ if not self.cache_config.file:
318
+ self.cache_config.file = f"custom_fields_cache_{self.domain}.json"
319
+
320
+ self.logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
321
+
322
+ self.token = None
323
+ self.refresh_token = None
324
+ self.expires_at = None
325
+ self.load_token()
326
+
327
+ # Memory caches для разных типов данных
328
+ self._custom_fields_mapping = None
329
+ self._pipelines_cache = None
330
+ self._users_cache = None
331
+
332
+ def load_token(self):
333
+ """
334
+ Загружает токен авторизации из файла или строки, проверяет его срок действия.
335
+ При наличии refresh_token и учётных данных пробует обновить токен.
336
+
337
+ :return: Действительный access_token.
338
+ :raises Exception: Если токен не найден или истёк и нет возможности обновить.
339
+ """
340
+ data = None
341
+ if os.path.exists(self.token_file):
342
+ with open(self.token_file, 'r') as f:
343
+ data = json.load(f)
344
+ self.logger.debug(f"Token loaded from file: {self.token_file}")
345
+ else:
346
+ try:
347
+ data = json.loads(self.token_file)
348
+ self.logger.debug("Token parsed from provided string.")
349
+ except Exception as e:
350
+ raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
351
+
352
+ self.refresh_token = data.get('refresh_token', self.refresh_token)
353
+ self.client_id = data.get('client_id', self.client_id)
354
+ self.client_secret = data.get('client_secret', self.client_secret)
355
+ self.redirect_uri = data.get('redirect_uri', self.redirect_uri)
356
+
357
+ expires_at_str = data.get('expires_at')
358
+ expires_at = None
359
+ if expires_at_str:
360
+ try:
361
+ expires_at = datetime.fromisoformat(expires_at_str).timestamp()
362
+ except Exception:
363
+ try:
364
+ expires_at = float(expires_at_str)
365
+ except Exception:
366
+ expires_at = None
367
+ self.expires_at = expires_at
368
+
369
+ access_token = data.get('access_token')
370
+ if access_token and expires_at and time.time() < expires_at:
371
+ self.logger.debug("Token is valid.")
372
+ self.token = access_token
373
+ return access_token
374
+
375
+ if self.refresh_token and self.client_id and self.client_secret and self.redirect_uri:
376
+ self.logger.info("Access token истёк, пробую обновить через refresh_token…")
377
+ return self._refresh_access_token()
378
+
379
+ raise Exception("Токен истёк или некорректен, и нет данных для refresh_token. Обновите токен.")
380
+
381
+ @sleep_and_retry
382
+ @limits(calls=RATE_LIMIT, period=1)
383
+ def _make_request(self, method, endpoint, params=None, data=None, timeout=10):
384
+ """
385
+ Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
386
+
387
+ :param method: HTTP-метод (GET, PATCH, POST, DELETE и т.д.).
388
+ :param endpoint: Конечная точка API (начинается с /api/v4/).
389
+ :param params: GET-параметры запроса.
390
+ :param data: Данные, отправляемые в JSON-формате.
391
+ :param timeout: Тайм‑аут запроса в секундах (по умолчанию 10).
392
+ :return: Ответ в формате JSON или None (если статус 204).
393
+ :raises Exception: При получении кода ошибки, отличного от 200/204.
394
+ """
395
+ url = f"{self.base_url}{endpoint}"
396
+ headers = {
397
+ "Authorization": f"Bearer {self.token}",
398
+ "Content-Type": "application/json"
399
+ }
400
+ self.logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
401
+ response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
402
+
403
+ if response.status_code == 401 and self.refresh_token:
404
+ self.logger.info("Получен 401, пробую обновить токен и повторить запрос…")
405
+ self._refresh_access_token()
406
+ headers["Authorization"] = f"Bearer {self.token}"
407
+ response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
408
+
409
+ if response.status_code not in (200, 204):
410
+ self.logger.error(f"Request error {response.status_code}: {response.text}")
411
+ raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
412
+ if response.status_code == 204:
413
+ return None
414
+ return response.json()
415
+
416
+ def _refresh_access_token(self):
417
+ """Обновляет access_token по refresh_token и сохраняет его в token_file."""
418
+ if not all([self.refresh_token, self.client_id, self.client_secret, self.redirect_uri]):
419
+ raise Exception("Нельзя обновить токен: отсутствует refresh_token или client_id/client_secret/redirect_uri")
420
+
421
+ payload = {
422
+ "client_id": self.client_id,
423
+ "client_secret": self.client_secret,
424
+ "grant_type": "refresh_token",
425
+ "refresh_token": self.refresh_token,
426
+ "redirect_uri": self.redirect_uri,
427
+ }
428
+ token_url = f"{self.base_url}/oauth2/access_token"
429
+ self.logger.debug(f"Refreshing token via {token_url}")
430
+ resp = requests.post(token_url, json=payload, timeout=10)
431
+ if resp.status_code != 200:
432
+ self.logger.error(f"Не удалось обновить токен: {resp.status_code} {resp.text}")
433
+ raise Exception(f"Не удалось обновить токен: {resp.status_code}")
434
+
435
+ data = resp.json() or {}
436
+ access_token = data.get("access_token")
437
+ refresh_token = data.get("refresh_token", self.refresh_token)
438
+ expires_in = data.get("expires_in")
439
+ if not access_token:
440
+ raise Exception("Ответ на refresh не содержит access_token")
441
+
442
+ expires_at = None
443
+ if expires_in:
444
+ expires_at = time.time() + int(expires_in)
445
+
446
+ self.token = access_token
447
+ self.refresh_token = refresh_token
448
+ self.expires_at = expires_at
449
+
450
+ if self.token_file:
451
+ try:
452
+ with open(self.token_file, "w") as f:
453
+ json.dump({
454
+ "access_token": access_token,
455
+ "refresh_token": refresh_token,
456
+ "expires_at": datetime.fromtimestamp(expires_at).isoformat() if expires_at else None,
457
+ "client_id": self.client_id,
458
+ "client_secret": self.client_secret,
459
+ "redirect_uri": self.redirect_uri,
460
+ }, f)
461
+ self.logger.debug(f"Новый токен сохранён в {self.token_file}")
462
+ except Exception as exc:
463
+ self.logger.error(f"Не удалось сохранить обновлённый токен: {exc}")
464
+
465
+ return access_token
466
+
467
+ def _extract_account_name(self):
468
+ """
469
+ Извлекает имя аккаунта из пути к файлу токена.
470
+
471
+ Примеры:
472
+ ~/.amocrm/accounts/bneginskogo.json -> default
473
+ ~/.amocrm/accounts/bneginskogo_eng.json -> eng
474
+ ~/.amocrm/accounts/bneginskogo_thai.json -> thai
475
+
476
+ :return: Имя аккаунта или 'default'
477
+ """
478
+ if not self.token_file:
479
+ return 'default'
480
+
481
+ # Получаем имя файла без расширения
482
+ filename = os.path.splitext(os.path.basename(self.token_file))[0]
483
+
484
+ # Проверяем паттерн: base_name или base_name_account
485
+ parts = filename.split('_')
486
+ if len(parts) > 1:
487
+ # Последняя часть - это имя аккаунта (eng, thai и т.д.)
488
+ return parts[-1]
489
+
490
+ return 'default'
491
+
492
+ def _get_cache_file_path(self, data_type):
493
+ """
494
+ Получает путь к файлу кэша для указанного типа данных.
495
+
496
+ :param data_type: Тип данных ('custom_fields', 'pipelines', 'users')
497
+ :return: Путь к файлу кэша
498
+ """
499
+ # Для custom_fields используем старый путь, если указан (обратная совместимость)
500
+ if data_type == 'custom_fields' and self.cache_config.file:
501
+ return self.cache_config.file
502
+
503
+ # Создаем директорию кэша, если не существует
504
+ os.makedirs(self.cache_config.base_dir, exist_ok=True)
505
+
506
+ # Формируем имя файла: {account}_{data_type}.json
507
+ account_name = self._extract_account_name()
508
+ cache_filename = f"{account_name}_{data_type}.json"
509
+ return os.path.join(self.cache_config.base_dir, cache_filename)
510
+
511
+ def _is_cache_valid(self, data_type, last_updated):
512
+ """
513
+ Проверяет, валиден ли кэш на основе времени последнего обновления.
514
+
515
+ :param data_type: Тип данных ('custom_fields', 'pipelines', 'users')
516
+ :param last_updated: Время последнего обновления (timestamp)
517
+ :return: True если кэш валиден, False если устарел
518
+ """
519
+ lifetime = self.cache_config.get_lifetime(data_type)
520
+
521
+ if lifetime is None:
522
+ # Бесконечный кэш
523
+ return True
524
+
525
+ # Проверяем срок жизни
526
+ return time.time() - last_updated < lifetime * 3600
527
+
528
+ def _save_cache(self, data_type, data):
529
+ """
530
+ Сохраняет данные в кэш.
531
+
532
+ :param data_type: Тип данных ('custom_fields', 'pipelines', 'users')
533
+ :param data: Данные для сохранения
534
+ """
535
+ if not self.cache_config.enabled:
536
+ self.logger.debug(f"Caching disabled; {data_type} cache not saved.")
537
+ return
538
+
539
+ if self.cache_config.storage != 'file':
540
+ self.logger.debug(f"Using memory caching; {data_type} cache not saved to file.")
541
+ return
542
+
543
+ cache_file = self._get_cache_file_path(data_type)
544
+ cache_data = {
545
+ "last_updated": time.time(),
546
+ "data": data
547
+ }
548
+
549
+ try:
550
+ with open(cache_file, "w", encoding="utf-8") as f:
551
+ json.dump(cache_data, f, ensure_ascii=False, indent=2)
552
+ self.logger.debug(f"{data_type} cache saved to {cache_file}")
553
+ except Exception as e:
554
+ self.logger.error(f"Failed to save {data_type} cache: {e}")
555
+
556
+ def _load_cache(self, data_type):
557
+ """
558
+ Загружает данные из кэша.
559
+
560
+ :param data_type: Тип данных ('custom_fields', 'pipelines', 'users')
561
+ :return: Кэшированные данные или None
562
+ """
563
+ if not self.cache_config.enabled:
564
+ self.logger.debug(f"Caching disabled; no {data_type} cache loaded.")
565
+ return None
566
+
567
+ if self.cache_config.storage != 'file':
568
+ self.logger.debug(f"Using memory caching; {data_type} cache kept in memory only.")
569
+ return None
570
+
571
+ cache_file = self._get_cache_file_path(data_type)
572
+
573
+ if not os.path.exists(cache_file):
574
+ self.logger.debug(f"{data_type} cache file not found: {cache_file}")
575
+ return None
576
+
577
+ try:
578
+ with open(cache_file, "r", encoding="utf-8") as f:
579
+ cache_data = json.load(f)
580
+
581
+ last_updated = cache_data.get("last_updated", 0)
582
+
583
+ if not self._is_cache_valid(data_type, last_updated):
584
+ self.logger.debug(f"{data_type} cache expired.")
585
+ return None
586
+
587
+ self.logger.debug(f"{data_type} cache loaded from {cache_file}")
588
+ return cache_data.get("data")
589
+
590
+ except Exception as e:
591
+ self.logger.error(f"Error loading {data_type} cache: {e}")
592
+ return None
593
+
594
+ def _to_timestamp(self, value: Optional[Union[int, float, str, datetime]]) -> Optional[int]:
595
+ """
596
+ Преобразует значение даты/времени в Unix timestamp.
597
+ Возвращает None, если значение не указано.
598
+ """
599
+ if value is None:
600
+ return None
601
+ if isinstance(value, datetime):
602
+ return int(value.timestamp())
603
+ if isinstance(value, (int, float)):
604
+ return int(value)
605
+ if isinstance(value, str):
606
+ try:
607
+ return int(datetime.fromisoformat(value).timestamp())
608
+ except ValueError as exc:
609
+ raise ValueError(f"Не удалось преобразовать '{value}' в timestamp") from exc
610
+ raise TypeError(f"Неподдерживаемый тип для timestamp: {type(value)}")
611
+
612
+ def _format_filter_values(self, values: Optional[Union[int, Sequence[Union[int, str]], str]]) -> Optional[Union[str, Sequence[Union[int, str]]]]:
613
+ """
614
+ Преобразует значение или последовательность значений для передачи в запрос.
615
+ """
616
+ if values is None:
617
+ return None
618
+ if isinstance(values, (list, tuple, set)):
619
+ return [str(v) for v in values]
620
+ return str(values)
621
+
622
+ def _extract_collection(self, response: dict, data_path: Sequence[str]) -> list:
623
+ """
624
+ Извлекает коллекцию элементов из ответа API по указанному пути ключей.
625
+ """
626
+ data = response or {}
627
+ for key in data_path:
628
+ if not isinstance(data, dict):
629
+ return []
630
+ data = data.get(key)
631
+ if data is None:
632
+ return []
633
+ if isinstance(data, list):
634
+ return data
635
+ return []
636
+
637
+ def _iterate_paginated(
638
+ self,
639
+ endpoint: str,
640
+ params: Optional[dict] = None,
641
+ data_path: Sequence[str] = ("_embedded",),
642
+ ) -> Iterator[dict]:
643
+ """
644
+ Возвращает генератор, проходящий по всем страницам ответа API и
645
+ yielding элементы коллекции.
646
+ """
647
+ query = dict(params or {})
648
+ query.setdefault("page", 1)
649
+ query.setdefault("limit", 250)
650
+
651
+ while True:
652
+ response = self._make_request("GET", endpoint, params=query)
653
+ if not response:
654
+ break
655
+ items = self._extract_collection(response, data_path)
656
+ if not items:
657
+ break
658
+ for item in items:
659
+ yield item
660
+
661
+ total_pages = response.get("_page_count")
662
+ if total_pages is not None:
663
+ has_next = query["page"] < total_pages
664
+ else:
665
+ links = response.get("_links") or {}
666
+ next_link = links.get("next") if isinstance(links, dict) else None
667
+ has_next = bool(next_link)
668
+ if not has_next:
669
+ break
670
+ query["page"] += 1
671
+
672
+ def iter_leads(
673
+ self,
674
+ updated_from: Optional[Union[int, float, str, datetime]] = None,
675
+ updated_to: Optional[Union[int, float, str, datetime]] = None,
676
+ pipeline_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
677
+ include_contacts: bool = False,
678
+ include: Optional[Union[str, Sequence[str]]] = None,
679
+ limit: int = 250,
680
+ extra_params: Optional[dict] = None,
681
+ ) -> Iterator[dict]:
682
+ """
683
+ Итератор сделок с фильтрацией по диапазону обновления и воронкам.
684
+ """
685
+ params = {"limit": limit, "page": 1}
686
+ start_ts = self._to_timestamp(updated_from)
687
+ end_ts = self._to_timestamp(updated_to)
688
+ if start_ts is not None:
689
+ params["filter[updated_at][from]"] = start_ts
690
+ if end_ts is not None:
691
+ params["filter[updated_at][to]"] = end_ts
692
+ pipeline_param = self._format_filter_values(pipeline_ids)
693
+ if pipeline_param:
694
+ params["filter[pipeline_id]"] = pipeline_param
695
+
696
+ include_parts: List[str] = []
697
+ if include_contacts:
698
+ include_parts.append("contacts")
699
+ if include:
700
+ if isinstance(include, str):
701
+ include_parts.append(include)
702
+ else:
703
+ include_parts.extend([str(item) for item in include])
704
+ if include_parts:
705
+ params["with"] = ",".join(sorted(set(include_parts)))
706
+ if extra_params:
707
+ params.update(extra_params)
708
+
709
+ yield from self._iterate_paginated(
710
+ "/api/v4/leads", params=params, data_path=("_embedded", "leads")
711
+ )
712
+
713
+ def fetch_leads(self, *args, **kwargs) -> List[dict]:
714
+ """
715
+ Возвращает список сделок. Обёртка над iter_leads.
716
+ """
717
+ return list(self.iter_leads(*args, **kwargs))
718
+
719
+ def iter_contacts(
720
+ self,
721
+ updated_from: Optional[Union[int, float, str, datetime]] = None,
722
+ updated_to: Optional[Union[int, float, str, datetime]] = None,
723
+ contact_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
724
+ limit: int = 250,
725
+ extra_params: Optional[dict] = None,
726
+ ) -> Iterator[dict]:
727
+ """
728
+ Итератор контактов с фильтрацией по диапазону обновления или списку ID.
729
+ """
730
+ params = {"limit": limit, "page": 1}
731
+ start_ts = self._to_timestamp(updated_from)
732
+ end_ts = self._to_timestamp(updated_to)
733
+ if start_ts is not None:
734
+ params["filter[updated_at][from]"] = start_ts
735
+ if end_ts is not None:
736
+ params["filter[updated_at][to]"] = end_ts
737
+ contact_param = self._format_filter_values(contact_ids)
738
+ if contact_param:
739
+ params["filter[id][]"] = contact_param
740
+ if extra_params:
741
+ params.update(extra_params)
742
+
743
+ yield from self._iterate_paginated(
744
+ "/api/v4/contacts", params=params, data_path=("_embedded", "contacts")
745
+ )
746
+
747
+ def fetch_contacts(self, *args, **kwargs) -> List[dict]:
748
+ """
749
+ Возвращает список контактов. Обёртка над iter_contacts.
750
+ """
751
+ return list(self.iter_contacts(*args, **kwargs))
752
+
753
+ def get_contact_by_id(self, contact_id: Union[int, str], include: Optional[Union[str, Sequence[str]]] = None) -> dict:
754
+ """
755
+ Получает данные контакта по его ID.
756
+ """
757
+ endpoint = f"/api/v4/contacts/{contact_id}"
758
+ params = {}
759
+ if include:
760
+ if isinstance(include, str):
761
+ params["with"] = include
762
+ else:
763
+ params["with"] = ",".join(str(item) for item in include)
764
+ data = self._make_request("GET", endpoint, params=params)
765
+ if not data or not isinstance(data, dict) or "id" not in data:
766
+ raise Exception(f"Contact {contact_id} not found or invalid response.")
767
+ return data
768
+
769
+ def iter_notes(
770
+ self,
771
+ entity: str = "lead",
772
+ updated_from: Optional[Union[int, float, str, datetime]] = None,
773
+ updated_to: Optional[Union[int, float, str, datetime]] = None,
774
+ note_type: Optional[Union[str, Sequence[str]]] = None,
775
+ entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
776
+ limit: int = 250,
777
+ extra_params: Optional[dict] = None,
778
+ ) -> Iterator[dict]:
779
+ """
780
+ Итератор примечаний для заданной сущности.
781
+ """
782
+ mapping = {
783
+ "lead": "leads",
784
+ "contact": "contacts",
785
+ "company": "companies",
786
+ "customer": "customers",
787
+ }
788
+ plural = mapping.get(entity.lower(), entity.lower() + "s")
789
+ endpoint = f"/api/v4/{plural}/notes"
790
+
791
+ params = {"limit": limit, "page": 1}
792
+ start_ts = self._to_timestamp(updated_from)
793
+ end_ts = self._to_timestamp(updated_to)
794
+ if start_ts is not None:
795
+ params["filter[updated_at][from]"] = start_ts
796
+ if end_ts is not None:
797
+ params["filter[updated_at][to]"] = end_ts
798
+ note_type_param = self._format_filter_values(note_type)
799
+ if note_type_param:
800
+ params["filter[note_type]"] = note_type_param
801
+ entity_param = self._format_filter_values(entity_ids)
802
+ if entity_param:
803
+ params["filter[entity_id]"] = entity_param
804
+ if extra_params:
805
+ params.update(extra_params)
806
+
807
+ yield from self._iterate_paginated(
808
+ endpoint, params=params, data_path=("_embedded", "notes")
809
+ )
810
+
811
+ def fetch_notes(self, *args, **kwargs) -> List[dict]:
812
+ """
813
+ Возвращает список примечаний. Обёртка над iter_notes.
814
+ """
815
+ return list(self.iter_notes(*args, **kwargs))
816
+
817
+ def iter_events(
818
+ self,
819
+ entity: Optional[str] = None,
820
+ entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
821
+ event_type: Optional[Union[str, Sequence[str]]] = None,
822
+ created_from: Optional[Union[int, float, str, datetime]] = None,
823
+ created_to: Optional[Union[int, float, str, datetime]] = None,
824
+ limit: int = 250,
825
+ extra_params: Optional[dict] = None,
826
+ ) -> Iterator[dict]:
827
+ """
828
+ Итератор событий с фильтрацией по сущности, типам и диапазону дат.
829
+ """
830
+ params = {"limit": limit, "page": 1}
831
+ if entity:
832
+ params["filter[entity]"] = entity
833
+ entity_param = self._format_filter_values(entity_ids)
834
+ if entity_param:
835
+ params["filter[entity_id]"] = entity_param
836
+ event_type_param = self._format_filter_values(event_type)
837
+ if event_type_param:
838
+ params["filter[type]"] = event_type_param
839
+ start_ts = self._to_timestamp(created_from)
840
+ end_ts = self._to_timestamp(created_to)
841
+ if start_ts is not None:
842
+ params["filter[created_at][from]"] = start_ts
843
+ if end_ts is not None:
844
+ params["filter[created_at][to]"] = end_ts
845
+ if extra_params:
846
+ params.update(extra_params)
847
+
848
+ yield from self._iterate_paginated(
849
+ "/api/v4/events", params=params, data_path=("_embedded", "events")
850
+ )
851
+
852
+ def fetch_events(self, *args, **kwargs) -> List[dict]:
853
+ """
854
+ Возвращает список событий. Обёртка над iter_events.
855
+ """
856
+ return list(self.iter_events(*args, **kwargs))
857
+
858
+ def iter_users(
859
+ self,
860
+ limit: int = 250,
861
+ extra_params: Optional[dict] = None,
862
+ ) -> Iterator[dict]:
863
+ """
864
+ Итератор пользователей аккаунта.
865
+ """
866
+ params = {"limit": limit, "page": 1}
867
+ if extra_params:
868
+ params.update(extra_params)
869
+ yield from self._iterate_paginated(
870
+ "/api/v4/users", params=params, data_path=("_embedded", "users")
871
+ )
872
+
873
+ def fetch_users(self, *args, **kwargs) -> List[dict]:
874
+ """
875
+ Возвращает список пользователей. Обёртка над iter_users.
876
+ """
877
+ return list(self.iter_users(*args, **kwargs))
878
+
879
+ def get_users_cached(self, force_update=False):
880
+ """
881
+ Возвращает список пользователей с кэшированием (по умолчанию 24 часа).
882
+
883
+ Использует трехуровневое кэширование:
884
+ 1. Memory cache (самый быстрый)
885
+ 2. File cache (персистентный)
886
+ 3. API request (если кэш устарел или отсутствует)
887
+
888
+ :param force_update: Если True, игнорирует кэш и загружает данные из API
889
+ :return: Список пользователей
890
+ """
891
+ # 1. Проверяем memory cache
892
+ if not force_update and self._users_cache is not None:
893
+ self.logger.debug("Using memory-cached users.")
894
+ return self._users_cache
895
+
896
+ # 2. Проверяем file cache
897
+ if not force_update and self.cache_config.enabled:
898
+ cached_data = self._load_cache('users')
899
+ if cached_data is not None:
900
+ self._users_cache = cached_data
901
+ self.logger.debug("Users loaded from file cache.")
902
+ return cached_data
903
+
904
+ # 3. Загружаем из API
905
+ self.logger.debug("Fetching users from API...")
906
+ users = self.fetch_users()
907
+
908
+ # Сохраняем в memory cache
909
+ self._users_cache = users
910
+
911
+ # Сохраняем в file cache
912
+ if self.cache_config.enabled:
913
+ self._save_cache('users', users)
914
+
915
+ self.logger.debug(f"Fetched {len(users)} users from API.")
916
+ return users
917
+
918
+ def iter_pipelines(
919
+ self,
920
+ limit: int = 250,
921
+ extra_params: Optional[dict] = None,
922
+ ) -> Iterator[dict]:
923
+ """
924
+ Итератор воронок со статусами.
925
+ """
926
+ params = {"limit": limit, "page": 1}
927
+ if extra_params:
928
+ params.update(extra_params)
929
+ yield from self._iterate_paginated(
930
+ "/api/v4/leads/pipelines", params=params, data_path=("_embedded", "pipelines")
931
+ )
932
+
933
+ def fetch_pipelines(self, *args, **kwargs) -> List[dict]:
934
+ """
935
+ Возвращает список воронок. Обёртка над iter_pipelines.
936
+ """
937
+ return list(self.iter_pipelines(*args, **kwargs))
938
+
939
+ def get_deal_by_id(self, deal_id, skip_fields_mapping=False):
940
+ """
941
+ Получает данные сделки по её ID и возвращает объект Deal.
942
+ Если данные отсутствуют или имеют неверную структуру, выбрасывается исключение.
943
+
944
+ :param deal_id: ID сделки для получения
945
+ :param skip_fields_mapping: Если True, не загружает справочник кастомных полей
946
+ (используйте для работы только с ID полей)
947
+ :return: Объект Deal с данными сделки
948
+ """
949
+ endpoint = f"/api/v4/leads/{deal_id}"
950
+ params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
951
+ data = self._make_request("GET", endpoint, params=params)
952
+
953
+ # Проверяем, что получили данные и что они содержат ключ "id"
954
+ if not data or not isinstance(data, dict) or "id" not in data:
955
+ self.logger.error(f"Deal {deal_id} not found or invalid response: {data}")
956
+ raise Exception(f"Deal {deal_id} not found or invalid response.")
957
+
958
+ custom_config = None if skip_fields_mapping else self.get_custom_fields_mapping()
959
+ self.logger.debug(f"Deal {deal_id} data received (содержимое полей не выводится полностью).")
960
+ return Deal(data, custom_fields_config=custom_config, logger=self.logger)
961
+
962
+ def _save_custom_fields_cache(self, mapping):
963
+ """
964
+ Сохраняет кэш кастомных полей в файл, если используется файловый кэш.
965
+ Если кэширование отключено или выбран кэш в памяти, операция пропускается.
966
+ """
967
+ if not self.cache_config.enabled:
968
+ self.logger.debug("Caching disabled; cache not saved.")
969
+ return
970
+ if self.cache_config.storage != 'file':
971
+ self.logger.debug("Using memory caching; no file cache saved.")
972
+ return
973
+ cache_data = {"last_updated": time.time(), "mapping": mapping}
974
+ with open(self.cache_config.file, "w") as f:
975
+ json.dump(cache_data, f)
976
+ self.logger.debug(f"Custom fields cache saved to {self.cache_config.file}")
977
+
978
+ def _load_custom_fields_cache(self):
979
+ """
980
+ Загружает кэш кастомных полей из файла, если используется файловый кэш.
981
+ Если кэширование отключено или выбран кэш в памяти, возвращает None.
982
+ """
983
+ if not self.cache_config.enabled:
984
+ self.logger.debug("Caching disabled; no cache loaded.")
985
+ return None
986
+ if self.cache_config.storage != 'file':
987
+ self.logger.debug("Using memory caching; cache will be kept in memory only.")
988
+ return None
989
+ if os.path.exists(self.cache_config.file):
990
+ with open(self.cache_config.file, "r") as f:
991
+ try:
992
+ cache_data = json.load(f)
993
+ self.logger.debug("Custom fields cache loaded successfully.")
994
+ return cache_data
995
+ except Exception as e:
996
+ self.logger.error(f"Error loading cache: {e}")
997
+ return None
998
+ return None
999
+
1000
+ def get_custom_fields_mapping(self, force_update=False):
1001
+ """
1002
+ Возвращает словарь отображения кастомных полей для сделок с кэшированием (по умолчанию 24 часа).
1003
+
1004
+ Использует трехуровневое кэширование:
1005
+ 1. Memory cache (самый быстрый)
1006
+ 2. File cache (персистентный)
1007
+ 3. API request (если кэш устарел или отсутствует)
1008
+
1009
+ :param force_update: Если True, игнорирует кэш и загружает данные из API
1010
+ :return: Словарь с кастомными полями (ключ - field_id, значение - объект поля)
1011
+ """
1012
+ # 1. Проверяем memory cache
1013
+ if not force_update and self._custom_fields_mapping is not None:
1014
+ self.logger.debug("Using memory-cached custom fields mapping.")
1015
+ return self._custom_fields_mapping
1016
+
1017
+ # 2. Проверяем file cache (с поддержкой старого формата)
1018
+ if not force_update and self.cache_config.enabled:
1019
+ # Пробуем новый формат
1020
+ cached_data = self._load_cache('custom_fields')
1021
+ if cached_data is not None:
1022
+ self._custom_fields_mapping = cached_data
1023
+ self.logger.debug("Custom fields loaded from file cache (new format).")
1024
+ return cached_data
1025
+
1026
+ # Пробуем старый формат для обратной совместимости
1027
+ legacy_cache = self._load_custom_fields_cache()
1028
+ if legacy_cache:
1029
+ mapping = legacy_cache.get("mapping")
1030
+ if mapping:
1031
+ self._custom_fields_mapping = mapping
1032
+ self.logger.debug("Custom fields loaded from legacy cache format.")
1033
+ # Мигрируем в новый формат
1034
+ self._save_cache('custom_fields', mapping)
1035
+ return mapping
1036
+
1037
+ # 3. Загружаем из API
1038
+ self.logger.debug("Fetching custom fields from API...")
1039
+ mapping = {}
1040
+ page = 1
1041
+ total_pages = 1
1042
+ while page <= total_pages:
1043
+ endpoint = f"/api/v4/leads/custom_fields?limit=250&page={page}"
1044
+ response = self._make_request("GET", endpoint)
1045
+ if response and "_embedded" in response and "custom_fields" in response["_embedded"]:
1046
+ for field in response["_embedded"]["custom_fields"]:
1047
+ mapping[field["id"]] = field
1048
+ total_pages = response.get("_page_count", page)
1049
+ self.logger.debug(f"Fetched page {page} of {total_pages}")
1050
+ page += 1
1051
+ else:
1052
+ break
1053
+
1054
+ # Сохраняем в memory cache
1055
+ self._custom_fields_mapping = mapping
1056
+
1057
+ # Сохраняем в file cache (новый формат)
1058
+ if self.cache_config.enabled:
1059
+ self._save_cache('custom_fields', mapping)
1060
+
1061
+ self.logger.debug(f"Fetched {len(mapping)} custom fields from API.")
1062
+ return mapping
1063
+
1064
+ def find_custom_field_id(self, search_term):
1065
+ """
1066
+ Ищет кастомное поле по заданному названию (или части названия).
1067
+
1068
+ :param search_term: Строка для поиска по имени поля.
1069
+ :return: Кортеж (field_id, field_obj) если найдено, иначе (None, None).
1070
+ """
1071
+ mapping = self.get_custom_fields_mapping()
1072
+ search_term_lower = search_term.lower().strip()
1073
+ for key, field_obj in mapping.items():
1074
+ if isinstance(field_obj, dict):
1075
+ name = field_obj.get("name", "").lower().strip()
1076
+ else:
1077
+ name = str(field_obj).lower().strip()
1078
+ if search_term_lower == name or search_term_lower in name:
1079
+ self.logger.debug(f"Found custom field '{name}' with id {key}")
1080
+ return int(key), field_obj
1081
+ self.logger.debug(f"Custom field containing '{search_term}' not found.")
1082
+ return None, None
1083
+
1084
+ def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
1085
+ """
1086
+ Обновляет сделку, задавая новые значения для стандартных и кастомных полей.
1087
+
1088
+ Для кастомных полей:
1089
+ - Если значение передается как целое число, оно интерпретируется как идентификатор варианта (enum_id)
1090
+ для полей типа select.
1091
+ - Если значение передается как строка, используется ключ "value".
1092
+
1093
+ :param lead_id: ID сделки, которую нужно обновить.
1094
+ :param update_fields: Словарь с полями для обновления. Ключи могут быть стандартными или названием кастомного поля.
1095
+ :param tags_to_add: Список тегов для добавления к сделке.
1096
+ :param tags_to_delete: Список тегов для удаления из сделки.
1097
+ :return: Ответ API в формате JSON.
1098
+ :raises Exception: Если одно из кастомных полей не найдено.
1099
+ """
1100
+ payload = {}
1101
+ standard_fields = {
1102
+ "name", "price", "status_id", "pipeline_id", "created_by", "updated_by",
1103
+ "closed_at", "created_at", "updated_at", "loss_reason_id", "responsible_user_id"
1104
+ }
1105
+ custom_fields = []
1106
+ for key, value in update_fields.items():
1107
+ if key in standard_fields:
1108
+ payload[key] = value
1109
+ self.logger.debug(f"Standard field {key} set to {value}")
1110
+ else:
1111
+ if isinstance(value, int):
1112
+ field_value_dict = {"enum_id": value}
1113
+ else:
1114
+ field_value_dict = {"value": value}
1115
+ try:
1116
+ field_id = int(key)
1117
+ custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
1118
+ self.logger.debug(f"Custom field by id {field_id} set to {value}")
1119
+ except ValueError:
1120
+ field_id, field_obj = self.find_custom_field_id(key)
1121
+ if field_id is not None:
1122
+ custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
1123
+ self.logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
1124
+ else:
1125
+ raise Exception(f"Custom field '{key}' не найден.")
1126
+ if custom_fields:
1127
+ payload["custom_fields_values"] = custom_fields
1128
+ if tags_to_add:
1129
+ payload["tags_to_add"] = tags_to_add
1130
+ if tags_to_delete:
1131
+ payload["tags_to_delete"] = tags_to_delete
1132
+ self.logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
1133
+ endpoint = f"/api/v4/leads/{lead_id}"
1134
+ response = self._make_request("PATCH", endpoint, data=payload)
1135
+ self.logger.debug("Update response received.")
1136
+ return response
1137
+
1138
+ def get_entity_notes(self, entity, entity_id, get_all=False, note_type=None, extra_params=None):
1139
+ """
1140
+ Получает список примечаний для указанной сущности и её ID.
1141
+
1142
+ Используется эндпоинт:
1143
+ GET /api/v4/{entity_plural}/{entity_id}/notes
1144
+
1145
+ :param entity: Тип сущности (например, 'lead', 'contact', 'company', 'customer' и т.д.).
1146
+ Передаётся в единственном числе, для формирования конечной точки будет использована
1147
+ таблица преобразования (например, 'lead' -> 'leads').
1148
+ :param entity_id: ID сущности.
1149
+ :param get_all: Если True, метод автоматически проходит по всем страницам пагинации.
1150
+ :param note_type: Фильтр по типу примечания. Может быть строкой (например, 'common') или списком строк.
1151
+ :param extra_params: Словарь дополнительных GET-параметров, если требуется.
1152
+ :return: Список примечаний (каждый элемент – словарь с данными примечания).
1153
+ """
1154
+ # Преобразуем тип сущности в форму во множественном числе (для известных типов)
1155
+ mapping = {
1156
+ 'lead': 'leads',
1157
+ 'contact': 'contacts',
1158
+ 'company': 'companies',
1159
+ 'customer': 'customers'
1160
+ }
1161
+ plural = mapping.get(entity.lower(), entity.lower() + "s")
1162
+
1163
+ endpoint = f"/api/v4/{plural}/{entity_id}/notes"
1164
+ params = {
1165
+ "page": 1,
1166
+ "limit": 250
1167
+ }
1168
+ if note_type is not None:
1169
+ params["filter[note_type]"] = note_type
1170
+ if extra_params:
1171
+ params.update(extra_params)
1172
+
1173
+ notes = []
1174
+ while True:
1175
+ response = self._make_request("GET", endpoint, params=params)
1176
+ if response and "_embedded" in response and "notes" in response["_embedded"]:
1177
+ notes.extend(response["_embedded"]["notes"])
1178
+ if not get_all:
1179
+ break
1180
+ total_pages = response.get("_page_count", params["page"])
1181
+ if params["page"] >= total_pages:
1182
+ break
1183
+ params["page"] += 1
1184
+ self.logger.debug(f"Retrieved {len(notes)} notes for {entity} {entity_id}")
1185
+ return notes
1186
+
1187
+ def get_entity_note(self, entity, entity_id, note_id):
1188
+ """
1189
+ Получает расширенную информацию по конкретному примечанию для указанной сущности.
1190
+
1191
+ Используется эндпоинт:
1192
+ GET /api/v4/{entity_plural}/{entity_id}/notes/{note_id}
1193
+
1194
+ :param entity: Тип сущности (например, 'lead', 'contact', 'company', 'customer' и т.д.).
1195
+ :param entity_id: ID сущности.
1196
+ :param note_id: ID примечания.
1197
+ :return: Словарь с полной информацией о примечании.
1198
+ :raises Exception: При ошибке запроса.
1199
+ """
1200
+ mapping = {
1201
+ 'lead': 'leads',
1202
+ 'contact': 'contacts',
1203
+ 'company': 'companies',
1204
+ 'customer': 'customers'
1205
+ }
1206
+ plural = mapping.get(entity.lower(), entity.lower() + "s")
1207
+ endpoint = f"/api/v4/{plural}/{entity_id}/notes/{note_id}"
1208
+ self.logger.debug(f"Fetching note {note_id} for {entity} {entity_id}")
1209
+ note_data = self._make_request("GET", endpoint)
1210
+ self.logger.debug(f"Note {note_id} for {entity} {entity_id} fetched successfully.")
1211
+ return note_data
1212
+
1213
+ # Удобные обёртки для сделок и контактов:
1214
+ def get_deal_notes(self, deal_id, **kwargs):
1215
+ return self.get_entity_notes("lead", deal_id, **kwargs)
1216
+
1217
+ def get_deal_note(self, deal_id, note_id):
1218
+ return self.get_entity_note("lead", deal_id, note_id)
1219
+
1220
+ def get_contact_notes(self, contact_id, **kwargs):
1221
+ return self.get_entity_notes("contact", contact_id, **kwargs)
1222
+
1223
+ def get_contact_note(self, contact_id, note_id):
1224
+ return self.get_entity_note("contact", contact_id, note_id)
1225
+
1226
+ def get_entity_events(self, entity, entity_id=None, get_all=False, event_type=None, extra_params=None):
1227
+ """
1228
+ Получает список событий для указанной сущности.
1229
+ Если entity_id не указан (None), возвращает события для всех сущностей данного типа.
1230
+
1231
+ :param entity: Тип сущности (например, 'lead', 'contact', 'company' и т.д.).
1232
+ :param entity_id: ID сущности или None для получения событий по всем сущностям данного типа.
1233
+ :param get_all: Если True, автоматически проходит по всем страницам пагинации.
1234
+ :param event_type: Фильтр по типу события. Может быть строкой или списком строк.
1235
+ :param extra_params: Словарь дополнительных GET-параметров.
1236
+ :return: Список событий (каждый элемент – словарь с данными события).
1237
+ """
1238
+ params = {
1239
+ 'page': 1,
1240
+ 'limit': 100,
1241
+ 'filter[entity]': entity,
1242
+ }
1243
+ # Добавляем фильтр по ID, если он указан
1244
+ if entity_id is not None:
1245
+ params['filter[entity_id]'] = entity_id
1246
+ # Фильтр по типу события
1247
+ if event_type is not None:
1248
+ params['filter[type]'] = event_type
1249
+ if extra_params:
1250
+ params.update(extra_params)
1251
+
1252
+ events = []
1253
+ while True:
1254
+ response = self._make_request("GET", "/api/v4/events", params=params)
1255
+ if response and "_embedded" in response and "events" in response["_embedded"]:
1256
+ events.extend(response["_embedded"]["events"])
1257
+ # Если не нужно получать все страницы, выходим
1258
+ if not get_all:
1259
+ break
1260
+ total_pages = response.get("_page_count", params['page'])
1261
+ if params['page'] >= total_pages:
1262
+ break
1263
+ params['page'] += 1
1264
+ return events
1265
+
1266
+ # Удобные обёртки:
1267
+ def get_deal_events(self, deal_id, **kwargs):
1268
+ return self.get_entity_events("lead", deal_id, **kwargs)
1269
+
1270
+ def get_contact_events(self, contact_id, **kwargs):
1271
+ return self.get_entity_events("contact", contact_id, **kwargs)
1272
+
1273
+ def fetch_updated_leads_raw(
1274
+ self,
1275
+ pipeline_id,
1276
+ updated_from,
1277
+ updated_to=None,
1278
+ save_to_file=None,
1279
+ limit=250,
1280
+ include_contacts=False,
1281
+ ):
1282
+ """Возвращает сделки из указанной воронки, обновленные в заданный период.
1283
+
1284
+ :param pipeline_id: ID воронки.
1285
+ :param updated_from: datetime, начиная с которого искать изменения.
1286
+ :param updated_to: datetime окончания диапазона (опционально).
1287
+ :param save_to_file: путь к файлу для сохранения результатов в формате JSON.
1288
+ :param limit: количество элементов на страницу (максимум 250).
1289
+ :param include_contacts: если True, в ответ будут включены данные контактов.
1290
+ :return: список словарей со сделками.
1291
+ """
1292
+
1293
+ all_leads = self.fetch_leads(
1294
+ updated_from=updated_from,
1295
+ updated_to=updated_to,
1296
+ pipeline_ids=pipeline_id,
1297
+ include_contacts=include_contacts,
1298
+ limit=limit,
1299
+ )
1300
+ if save_to_file:
1301
+ with open(save_to_file, "w", encoding="utf-8") as f:
1302
+ json.dump(all_leads, f, ensure_ascii=False, indent=2)
1303
+
1304
+ self.logger.debug(f"Fetched {len(all_leads)} leads from pipeline {pipeline_id}")
1305
+ return all_leads
1306
+
1307
+ def get_event(self, event_id):
1308
+ """
1309
+ Получает подробную информацию по конкретному событию по его ID.
1310
+
1311
+ Используется эндпоинт:
1312
+ GET /api/v4/events/{event_id}
1313
+
1314
+ :param event_id: ID события.
1315
+ :return: Словарь с подробной информацией о событии.
1316
+ :raises Exception: При ошибке запроса.
1317
+ """
1318
+ endpoint = f"/api/v4/events/{event_id}"
1319
+ self.logger.debug(f"Fetching event with ID {event_id}")
1320
+ event_data = self._make_request("GET", endpoint)
1321
+ self.logger.debug(f"Event {event_id} details fetched successfully.")
1322
+ return event_data
1323
+
1324
+ def get_pipelines(self):
1325
+ """
1326
+ Получает список всех воронок и их статусов из amoCRM.
1327
+
1328
+ :return: Список словарей, где каждый словарь содержит данные воронки, а также, если присутствует, вложенные статусы.
1329
+ :raises Exception: Если данные не получены или структура ответа неверна.
1330
+ """
1331
+ pipelines = self.fetch_pipelines()
1332
+ if pipelines:
1333
+ self.logger.debug(f"Получено {len(pipelines)} воронок")
1334
+ return pipelines
1335
+ self.logger.error("Не удалось получить воронки из amoCRM")
1336
+ raise Exception("Ошибка получения воронок из amoCRM")
1337
+
1338
+ def get_pipelines_cached(self, force_update=False):
1339
+ """
1340
+ Возвращает список воронок с кэшированием (по умолчанию 7 дней).
1341
+
1342
+ Использует трехуровневое кэширование:
1343
+ 1. Memory cache (самый быстрый)
1344
+ 2. File cache (персистентный)
1345
+ 3. API request (если кэш устарел или отсутствует)
1346
+
1347
+ :param force_update: Если True, игнорирует кэш и загружает данные из API
1348
+ :return: Список воронок со статусами
1349
+ """
1350
+ # 1. Проверяем memory cache
1351
+ if not force_update and self._pipelines_cache is not None:
1352
+ self.logger.debug("Using memory-cached pipelines.")
1353
+ return self._pipelines_cache
1354
+
1355
+ # 2. Проверяем file cache
1356
+ if not force_update and self.cache_config.enabled:
1357
+ cached_data = self._load_cache('pipelines')
1358
+ if cached_data is not None:
1359
+ self._pipelines_cache = cached_data
1360
+ self.logger.debug("Pipelines loaded from file cache.")
1361
+ return cached_data
1362
+
1363
+ # 3. Загружаем из API
1364
+ self.logger.debug("Fetching pipelines from API...")
1365
+ pipelines = self.fetch_pipelines()
1366
+
1367
+ # Сохраняем в memory cache
1368
+ self._pipelines_cache = pipelines
1369
+
1370
+ # Сохраняем в file cache
1371
+ if self.cache_config.enabled:
1372
+ self._save_cache('pipelines', pipelines)
1373
+
1374
+ self.logger.debug(f"Fetched {len(pipelines)} pipelines from API.")
1375
+ return pipelines