amochka 0.1.3__tar.gz → 0.1.6__tar.gz

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.
@@ -1,16 +1,19 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: amochka
3
- Version: 0.1.3
3
+ Version: 0.1.6
4
4
  Summary: Библиотека для работы с API amoCRM
5
- Home-page: UNKNOWN
5
+ Home-page:
6
6
  Author: Timurka
7
7
  Author-email: timurdt@gmail.com
8
- License: UNKNOWN
9
- Platform: UNKNOWN
10
8
  Classifier: Programming Language :: Python :: 3
11
9
  Classifier: License :: OSI Approved :: MIT License
12
10
  Classifier: Operating System :: OS Independent
13
11
  Requires-Python: >=3.6
14
-
15
- UNKNOWN
16
-
12
+ Requires-Dist: requests
13
+ Requires-Dist: ratelimit
14
+ Dynamic: author
15
+ Dynamic: author-email
16
+ Dynamic: classifier
17
+ Dynamic: requires-dist
18
+ Dynamic: requires-python
19
+ Dynamic: summary
@@ -0,0 +1,6 @@
1
+ """
2
+ amochka: Библиотека для работы с API amoCRM.
3
+ """
4
+
5
+ from .client import AmoCRMClient, CacheConfig
6
+ __all__ = ['AmoCRMClient', 'CacheConfig']
@@ -0,0 +1,674 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import requests
5
+ import logging
6
+ from datetime import datetime
7
+ from ratelimit import limits, sleep_and_retry
8
+
9
+ # Создаём базовый логгер
10
+ logger = logging.getLogger(__name__)
11
+ if not logger.handlers:
12
+ ch = logging.StreamHandler()
13
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
14
+ ch.setFormatter(formatter)
15
+ logger.addHandler(ch)
16
+
17
+ RATE_LIMIT = 7 # Максимум 7 запросов в секунду
18
+
19
+ class Deal(dict):
20
+ """
21
+ Объект сделки расширяет стандартный словарь данными из custom_fields_values.
22
+
23
+ Обеспечивает два способа доступа к кастомным полям:
24
+ 1. get(key): при обращении по названию (строкой) или по ID поля (integer)
25
+ возвращает текстовое значение поля (например, «Дурина Юлия»).
26
+ 2. get_id(key): возвращает идентификатор выбранного варианта (enum_id) для полей типа select.
27
+ Если в данных enum_id отсутствует, производится поиск в переданной конфигурации полей,
28
+ сравнение выполняется без учёта регистра и лишних пробелов.
29
+
30
+ Параметр custom_fields_config – словарь, где ключи – ID полей, а значения – модели полей.
31
+ """
32
+ def __init__(self, data, custom_fields_config=None, logger=None):
33
+ super().__init__(data)
34
+ self._custom = {}
35
+ self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
36
+ self._logger = logger or logging.getLogger(__name__)
37
+ custom = data.get("custom_fields_values") or []
38
+ self._logger.debug(f"Processing custom_fields_values: {custom}")
39
+ for field in custom:
40
+ if isinstance(field, dict):
41
+ field_name = field.get("field_name")
42
+ values = field.get("values")
43
+ if field_name and values and isinstance(values, list) and len(values) > 0:
44
+ key_name = field_name.lower().strip()
45
+ stored_value = values[0].get("value")
46
+ stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
47
+ # Сохраняем полную информацию (и для get() и для get_id())
48
+ self._custom[key_name] = {"value": stored_value, "enum_id": stored_enum_id}
49
+ self._logger.debug(f"Set custom field '{key_name}' = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
50
+ field_id = field.get("field_id")
51
+ if field_id is not None and values and isinstance(values, list) and len(values) > 0:
52
+ stored_value = values[0].get("value")
53
+ stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
54
+ self._custom[int(field_id)] = {"value": stored_value, "enum_id": stored_enum_id}
55
+ self._logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
56
+ if custom_fields_config:
57
+ for cid, field_obj in custom_fields_config.items():
58
+ key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
59
+ if key not in self._custom:
60
+ self._custom[key] = None
61
+ self._logger.debug(f"Field '{key}' not found in deal data; set to None")
62
+
63
+ def __getitem__(self, key):
64
+ if key in super().keys():
65
+ return super().__getitem__(key)
66
+ if isinstance(key, str):
67
+ lower_key = key.lower().strip()
68
+ if lower_key in self._custom:
69
+ stored = self._custom[lower_key]
70
+ return stored.get("value") if isinstance(stored, dict) else stored
71
+ if isinstance(key, int):
72
+ if key in self._custom:
73
+ stored = self._custom[key]
74
+ return stored.get("value") if isinstance(stored, dict) else stored
75
+ raise KeyError(key)
76
+
77
+ def get(self, key, default=None):
78
+ try:
79
+ return self.__getitem__(key)
80
+ except KeyError:
81
+ return default
82
+
83
+ def get_field_type(self, key):
84
+ """
85
+ Определяет тип кастомного поля.
86
+
87
+ :param key: Название поля (строка) или ID поля (integer).
88
+ :return: Строка с типом поля ('text', 'select', 'numeric', 'checkbox', и т.д.)
89
+ или None, если поле не найдено или тип не определён.
90
+ """
91
+ field_def = None
92
+
93
+ # Получаем определение поля из конфигурации
94
+ if self._custom_config:
95
+ if isinstance(key, int):
96
+ field_def = self._custom_config.get(key)
97
+ else:
98
+ for fid, fdef in self._custom_config.items():
99
+ if isinstance(fdef, dict) and fdef.get("name", "").lower().strip() == key.lower().strip():
100
+ field_def = fdef
101
+ break
102
+
103
+ # Если нашли определение, возвращаем его тип
104
+ if field_def and isinstance(field_def, dict):
105
+ return field_def.get("type")
106
+
107
+ # Если конфигурации нет или поле не найдено, пробуем определить тип по данным
108
+ stored = None
109
+ if isinstance(key, str):
110
+ lower_key = key.lower().strip()
111
+ if lower_key in self._custom:
112
+ stored = self._custom[lower_key]
113
+ elif isinstance(key, int):
114
+ if key in self._custom:
115
+ stored = self._custom[key]
116
+
117
+ if isinstance(stored, dict) and "enum_id" in stored:
118
+ return "select"
119
+
120
+ return None
121
+
122
+ def get_id(self, key, default=None):
123
+ """
124
+ Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля типа select.
125
+ Для полей других типов возвращает их значение, как метод get().
126
+
127
+ Если значение enum_id отсутствует в данных, производится поиск в конфигурации кастомных полей,
128
+ сравнение значения выполняется без учёта регистра и пробелов.
129
+
130
+ :param key: Название поля (строка) или ID поля (integer).
131
+ :param default: Значение по умолчанию, если enum_id не найден.
132
+ :return: Для полей типа select - идентификатор варианта (целое число).
133
+ Для других типов полей - значение поля.
134
+ Если поле не найдено - default.
135
+ """
136
+ field_type = self.get_field_type(key)
137
+
138
+ # Если это не поле списка, возвращаем значение как get()
139
+ if field_type is not None and field_type != "select":
140
+ return self.get(key, default)
141
+
142
+ stored = None
143
+ if isinstance(key, str):
144
+ lower_key = key.lower().strip()
145
+ if lower_key in self._custom:
146
+ stored = self._custom[lower_key]
147
+ elif isinstance(key, int):
148
+ if key in self._custom:
149
+ stored = self._custom[key]
150
+ if isinstance(stored, dict):
151
+ enum_id = stored.get("enum_id")
152
+ if enum_id is not None:
153
+ return enum_id
154
+ if self._custom_config:
155
+ field_def = None
156
+ if isinstance(key, int):
157
+ field_def = self._custom_config.get(key)
158
+ else:
159
+ for fid, fdef in self._custom_config.items():
160
+ if fdef.get("name", "").lower().strip() == key.lower().strip():
161
+ field_def = fdef
162
+ break
163
+ if field_def:
164
+ enums = field_def.get("enums") or []
165
+ for enum in enums:
166
+ if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
167
+ return enum.get("id", default)
168
+
169
+ # Если это не поле типа select или не удалось найти enum_id,
170
+ # возвращаем значение поля
171
+ return self.get(key, default)
172
+
173
+ class CacheConfig:
174
+ """
175
+ Конфигурация кэширования для AmoCRMClient.
176
+
177
+ Параметры:
178
+ enabled (bool): Включено ли кэширование
179
+ storage (str): Тип хранилища ('file' или 'memory')
180
+ file (str): Путь к файлу кэша (используется только при storage='file')
181
+ lifetime_hours (int|None): Время жизни кэша в часах (None для бесконечного)
182
+ """
183
+ def __init__(self, enabled=True, storage='file', file=None, lifetime_hours=24):
184
+ self.enabled = enabled
185
+ self.storage = storage.lower()
186
+ self.file = file
187
+ self.lifetime_hours = lifetime_hours
188
+
189
+ @classmethod
190
+ def disabled(cls):
191
+ """Создает конфигурацию с отключенным кэшированием"""
192
+ return cls(enabled=False)
193
+
194
+ @classmethod
195
+ def memory_only(cls, lifetime_hours=24):
196
+ """Создает конфигурацию с кэшированием только в памяти"""
197
+ return cls(enabled=True, storage='memory', lifetime_hours=lifetime_hours)
198
+
199
+ @classmethod
200
+ def file_cache(cls, file=None, lifetime_hours=24):
201
+ """Создает конфигурацию с файловым кэшированием"""
202
+ return cls(enabled=True, storage='file', file=file, lifetime_hours=lifetime_hours)
203
+
204
+ class AmoCRMClient:
205
+ """
206
+ Клиент для работы с API amoCRM.
207
+
208
+ Основные функции:
209
+ - load_token: Загружает и проверяет токен авторизации.
210
+ - _make_request: Выполняет HTTP-запрос с учетом ограничения по скорости.
211
+ - get_deal_by_id: Получает данные сделки по ID и возвращает объект Deal.
212
+ - get_custom_fields_mapping: Загружает и кэширует список кастомных полей.
213
+ - find_custom_field_id: Ищет кастомное поле по его названию.
214
+ - update_lead: Обновляет сделку, включая стандартные и кастомные поля.
215
+
216
+ Дополнительно можно задать уровень логирования через параметр log_level,
217
+ либо полностью отключить логирование, установив disable_logging=True.
218
+ """
219
+ def __init__(
220
+ self,
221
+ base_url,
222
+ token_file=None,
223
+ cache_config=None,
224
+ log_level=logging.INFO,
225
+ disable_logging=False
226
+ ):
227
+ """
228
+ Инициализирует клиента, задавая базовый URL, токен авторизации и настройки кэша для кастомных полей.
229
+
230
+ :param base_url: Базовый URL API amoCRM.
231
+ :param token_file: Файл, содержащий токен авторизации.
232
+ :param cache_config: Конфигурация кэширования (объект CacheConfig или None для значений по умолчанию)
233
+ :param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
234
+ :param disable_logging: Если True, логирование будет отключено.
235
+ """
236
+ self.base_url = base_url.rstrip('/')
237
+ domain = self.base_url.split("//")[-1].split(".")[0]
238
+ self.domain = domain
239
+ self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
240
+
241
+ # Создаем логгер для конкретного экземпляра клиента
242
+ self.logger = logging.getLogger(f"{__name__}.{self.domain}")
243
+ if not self.logger.handlers:
244
+ handler = logging.StreamHandler()
245
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
246
+ handler.setFormatter(formatter)
247
+ self.logger.addHandler(handler)
248
+ self.logger.propagate = False # Отключаем передачу логов в родительский логгер
249
+
250
+ if disable_logging:
251
+ self.logger.setLevel(logging.CRITICAL + 1) # Выше, чем любой стандартный уровень
252
+ else:
253
+ self.logger.setLevel(log_level)
254
+
255
+ # Настройка кэширования
256
+ if cache_config is None:
257
+ self.cache_config = CacheConfig()
258
+ else:
259
+ self.cache_config = cache_config
260
+
261
+ # Установка файла кэша, если используется файловое хранилище
262
+ if self.cache_config.enabled and self.cache_config.storage == 'file':
263
+ if not self.cache_config.file:
264
+ self.cache_config.file = f"custom_fields_cache_{self.domain}.json"
265
+
266
+ self.logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
267
+
268
+ self.token = self.load_token()
269
+ self._custom_fields_mapping = None
270
+
271
+ def load_token(self):
272
+ """
273
+ Загружает токен авторизации из файла или строки, проверяет его срок действия.
274
+
275
+ :return: Действительный access_token.
276
+ :raises Exception: Если токен не найден или истёк.
277
+ """
278
+ data = None
279
+ if os.path.exists(self.token_file):
280
+ with open(self.token_file, 'r') as f:
281
+ data = json.load(f)
282
+ self.logger.debug(f"Token loaded from file: {self.token_file}")
283
+ else:
284
+ try:
285
+ data = json.loads(self.token_file)
286
+ self.logger.debug("Token parsed from provided string.")
287
+ except Exception as e:
288
+ raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
289
+
290
+ expires_at_str = data.get('expires_at')
291
+ try:
292
+ expires_at = datetime.fromisoformat(expires_at_str).timestamp()
293
+ except Exception:
294
+ expires_at = float(expires_at_str)
295
+
296
+ if expires_at and time.time() < expires_at:
297
+ self.logger.debug("Token is valid.")
298
+ return data.get('access_token')
299
+ else:
300
+ raise Exception("Токен найден, но он истёк. Обновите токен.")
301
+
302
+ @sleep_and_retry
303
+ @limits(calls=RATE_LIMIT, period=1)
304
+ def _make_request(self, method, endpoint, params=None, data=None):
305
+ """
306
+ Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
307
+
308
+ :param method: HTTP-метод (GET, PATCH, POST, DELETE и т.д.).
309
+ :param endpoint: Конечная точка API (начинается с /api/v4/).
310
+ :param params: GET-параметры запроса.
311
+ :param data: Данные, отправляемые в JSON-формате.
312
+ :return: Ответ в формате JSON или None (если статус 204).
313
+ :raises Exception: При получении кода ошибки, отличного от 200/204.
314
+ """
315
+ url = f"{self.base_url}{endpoint}"
316
+ headers = {
317
+ "Authorization": f"Bearer {self.token}",
318
+ "Content-Type": "application/json"
319
+ }
320
+ self.logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
321
+ response = requests.request(method, url, headers=headers, params=params, json=data)
322
+ if response.status_code not in (200, 204):
323
+ self.logger.error(f"Request error {response.status_code}: {response.text}")
324
+ raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
325
+ if response.status_code == 204:
326
+ return None
327
+ return response.json()
328
+
329
+ def get_deal_by_id(self, deal_id, skip_fields_mapping=False):
330
+ """
331
+ Получает данные сделки по её ID и возвращает объект Deal.
332
+ Если данные отсутствуют или имеют неверную структуру, выбрасывается исключение.
333
+
334
+ :param deal_id: ID сделки для получения
335
+ :param skip_fields_mapping: Если True, не загружает справочник кастомных полей
336
+ (используйте для работы только с ID полей)
337
+ :return: Объект Deal с данными сделки
338
+ """
339
+ endpoint = f"/api/v4/leads/{deal_id}"
340
+ params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
341
+ data = self._make_request("GET", endpoint, params=params)
342
+
343
+ # Проверяем, что получили данные и что они содержат ключ "id"
344
+ if not data or not isinstance(data, dict) or "id" not in data:
345
+ self.logger.error(f"Deal {deal_id} not found or invalid response: {data}")
346
+ raise Exception(f"Deal {deal_id} not found or invalid response.")
347
+
348
+ custom_config = None if skip_fields_mapping else self.get_custom_fields_mapping()
349
+ self.logger.debug(f"Deal {deal_id} data received (содержимое полей не выводится полностью).")
350
+ return Deal(data, custom_fields_config=custom_config, logger=self.logger)
351
+
352
+ def _save_custom_fields_cache(self, mapping):
353
+ """
354
+ Сохраняет кэш кастомных полей в файл, если используется файловый кэш.
355
+ Если кэширование отключено или выбран кэш в памяти, операция пропускается.
356
+ """
357
+ if not self.cache_config.enabled:
358
+ self.logger.debug("Caching disabled; cache not saved.")
359
+ return
360
+ if self.cache_config.storage != 'file':
361
+ self.logger.debug("Using memory caching; no file cache saved.")
362
+ return
363
+ cache_data = {"last_updated": time.time(), "mapping": mapping}
364
+ with open(self.cache_config.file, "w") as f:
365
+ json.dump(cache_data, f)
366
+ self.logger.debug(f"Custom fields cache saved to {self.cache_config.file}")
367
+
368
+ def _load_custom_fields_cache(self):
369
+ """
370
+ Загружает кэш кастомных полей из файла, если используется файловый кэш.
371
+ Если кэширование отключено или выбран кэш в памяти, возвращает None.
372
+ """
373
+ if not self.cache_config.enabled:
374
+ self.logger.debug("Caching disabled; no cache loaded.")
375
+ return None
376
+ if self.cache_config.storage != 'file':
377
+ self.logger.debug("Using memory caching; cache will be kept in memory only.")
378
+ return None
379
+ if os.path.exists(self.cache_config.file):
380
+ with open(self.cache_config.file, "r") as f:
381
+ try:
382
+ cache_data = json.load(f)
383
+ self.logger.debug("Custom fields cache loaded successfully.")
384
+ return cache_data
385
+ except Exception as e:
386
+ self.logger.error(f"Error loading cache: {e}")
387
+ return None
388
+ return None
389
+
390
+ def get_custom_fields_mapping(self, force_update=False):
391
+ """
392
+ Возвращает словарь отображения кастомных полей для сделок.
393
+ Если данные кэшированы и не устарели, возвращает кэш; иначе выполняет запросы для получения данных.
394
+ """
395
+ if not force_update and self._custom_fields_mapping is not None:
396
+ return self._custom_fields_mapping
397
+
398
+ cache_data = self._load_custom_fields_cache() if self.cache_config.enabled else None
399
+ if cache_data:
400
+ last_updated = cache_data.get("last_updated", 0)
401
+ if self.cache_config.lifetime_hours is not None:
402
+ if time.time() - last_updated < self.cache_config.lifetime_hours * 3600:
403
+ self._custom_fields_mapping = cache_data.get("mapping")
404
+ self.logger.debug("Using cached custom fields mapping.")
405
+ return self._custom_fields_mapping
406
+ else:
407
+ # Бесконечный кэш – не проверяем срок
408
+ self._custom_fields_mapping = cache_data.get("mapping")
409
+ self.logger.debug("Using cached custom fields mapping (infinite cache).")
410
+ return self._custom_fields_mapping
411
+
412
+ mapping = {}
413
+ page = 1
414
+ total_pages = 1 # Значение по умолчанию
415
+ while page <= total_pages:
416
+ endpoint = f"/api/v4/leads/custom_fields?limit=250&page={page}"
417
+ response = self._make_request("GET", endpoint)
418
+ if response and "_embedded" in response and "custom_fields" in response["_embedded"]:
419
+ for field in response["_embedded"]["custom_fields"]:
420
+ mapping[field["id"]] = field
421
+ total_pages = response.get("_page_count", page)
422
+ self.logger.debug(f"Fetched page {page} of {total_pages}")
423
+ page += 1
424
+ else:
425
+ break
426
+
427
+ self.logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
428
+ self._custom_fields_mapping = mapping
429
+ if self.cache_config.enabled:
430
+ self._save_custom_fields_cache(mapping)
431
+ return mapping
432
+
433
+ def find_custom_field_id(self, search_term):
434
+ """
435
+ Ищет кастомное поле по заданному названию (или части названия).
436
+
437
+ :param search_term: Строка для поиска по имени поля.
438
+ :return: Кортеж (field_id, field_obj) если найдено, иначе (None, None).
439
+ """
440
+ mapping = self.get_custom_fields_mapping()
441
+ search_term_lower = search_term.lower().strip()
442
+ for key, field_obj in mapping.items():
443
+ if isinstance(field_obj, dict):
444
+ name = field_obj.get("name", "").lower().strip()
445
+ else:
446
+ name = str(field_obj).lower().strip()
447
+ if search_term_lower == name or search_term_lower in name:
448
+ self.logger.debug(f"Found custom field '{name}' with id {key}")
449
+ return int(key), field_obj
450
+ self.logger.debug(f"Custom field containing '{search_term}' not found.")
451
+ return None, None
452
+
453
+ def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
454
+ """
455
+ Обновляет сделку, задавая новые значения для стандартных и кастомных полей.
456
+
457
+ Для кастомных полей:
458
+ - Если значение передается как целое число, оно интерпретируется как идентификатор варианта (enum_id)
459
+ для полей типа select.
460
+ - Если значение передается как строка, используется ключ "value".
461
+
462
+ :param lead_id: ID сделки, которую нужно обновить.
463
+ :param update_fields: Словарь с полями для обновления. Ключи могут быть стандартными или названием кастомного поля.
464
+ :param tags_to_add: Список тегов для добавления к сделке.
465
+ :param tags_to_delete: Список тегов для удаления из сделки.
466
+ :return: Ответ API в формате JSON.
467
+ :raises Exception: Если одно из кастомных полей не найдено.
468
+ """
469
+ payload = {}
470
+ standard_fields = {
471
+ "name", "price", "status_id", "pipeline_id", "created_by", "updated_by",
472
+ "closed_at", "created_at", "updated_at", "loss_reason_id", "responsible_user_id"
473
+ }
474
+ custom_fields = []
475
+ for key, value in update_fields.items():
476
+ if key in standard_fields:
477
+ payload[key] = value
478
+ self.logger.debug(f"Standard field {key} set to {value}")
479
+ else:
480
+ if isinstance(value, int):
481
+ field_value_dict = {"enum_id": value}
482
+ else:
483
+ field_value_dict = {"value": value}
484
+ try:
485
+ field_id = int(key)
486
+ custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
487
+ self.logger.debug(f"Custom field by id {field_id} set to {value}")
488
+ except ValueError:
489
+ field_id, field_obj = self.find_custom_field_id(key)
490
+ if field_id is not None:
491
+ custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
492
+ self.logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
493
+ else:
494
+ raise Exception(f"Custom field '{key}' не найден.")
495
+ if custom_fields:
496
+ payload["custom_fields_values"] = custom_fields
497
+ if tags_to_add:
498
+ payload["tags_to_add"] = tags_to_add
499
+ if tags_to_delete:
500
+ payload["tags_to_delete"] = tags_to_delete
501
+ self.logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
502
+ endpoint = f"/api/v4/leads/{lead_id}"
503
+ response = self._make_request("PATCH", endpoint, data=payload)
504
+ self.logger.debug("Update response received.")
505
+ return response
506
+
507
+ def get_entity_notes(self, entity, entity_id, get_all=False, note_type=None, extra_params=None):
508
+ """
509
+ Получает список примечаний для указанной сущности и её ID.
510
+
511
+ Используется эндпоинт:
512
+ GET /api/v4/{entity_plural}/{entity_id}/notes
513
+
514
+ :param entity: Тип сущности (например, 'lead', 'contact', 'company', 'customer' и т.д.).
515
+ Передаётся в единственном числе, для формирования конечной точки будет использована
516
+ таблица преобразования (например, 'lead' -> 'leads').
517
+ :param entity_id: ID сущности.
518
+ :param get_all: Если True, метод автоматически проходит по всем страницам пагинации.
519
+ :param note_type: Фильтр по типу примечания. Может быть строкой (например, 'common') или списком строк.
520
+ :param extra_params: Словарь дополнительных GET-параметров, если требуется.
521
+ :return: Список примечаний (каждый элемент – словарь с данными примечания).
522
+ """
523
+ # Преобразуем тип сущности в форму во множественном числе (для известных типов)
524
+ mapping = {
525
+ 'lead': 'leads',
526
+ 'contact': 'contacts',
527
+ 'company': 'companies',
528
+ 'customer': 'customers'
529
+ }
530
+ plural = mapping.get(entity.lower(), entity.lower() + "s")
531
+
532
+ endpoint = f"/api/v4/{plural}/{entity_id}/notes"
533
+ params = {
534
+ "page": 1,
535
+ "limit": 250
536
+ }
537
+ if note_type is not None:
538
+ params["filter[note_type]"] = note_type
539
+ if extra_params:
540
+ params.update(extra_params)
541
+
542
+ notes = []
543
+ while True:
544
+ response = self._make_request("GET", endpoint, params=params)
545
+ if response and "_embedded" in response and "notes" in response["_embedded"]:
546
+ notes.extend(response["_embedded"]["notes"])
547
+ if not get_all:
548
+ break
549
+ total_pages = response.get("_page_count", params["page"])
550
+ if params["page"] >= total_pages:
551
+ break
552
+ params["page"] += 1
553
+ self.logger.debug(f"Retrieved {len(notes)} notes for {entity} {entity_id}")
554
+ return notes
555
+
556
+ def get_entity_note(self, entity, entity_id, note_id):
557
+ """
558
+ Получает расширенную информацию по конкретному примечанию для указанной сущности.
559
+
560
+ Используется эндпоинт:
561
+ GET /api/v4/{entity_plural}/{entity_id}/notes/{note_id}
562
+
563
+ :param entity: Тип сущности (например, 'lead', 'contact', 'company', 'customer' и т.д.).
564
+ :param entity_id: ID сущности.
565
+ :param note_id: ID примечания.
566
+ :return: Словарь с полной информацией о примечании.
567
+ :raises Exception: При ошибке запроса.
568
+ """
569
+ mapping = {
570
+ 'lead': 'leads',
571
+ 'contact': 'contacts',
572
+ 'company': 'companies',
573
+ 'customer': 'customers'
574
+ }
575
+ plural = mapping.get(entity.lower(), entity.lower() + "s")
576
+ endpoint = f"/api/v4/{plural}/{entity_id}/notes/{note_id}"
577
+ self.logger.debug(f"Fetching note {note_id} for {entity} {entity_id}")
578
+ note_data = self._make_request("GET", endpoint)
579
+ self.logger.debug(f"Note {note_id} for {entity} {entity_id} fetched successfully.")
580
+ return note_data
581
+
582
+ # Удобные обёртки для сделок и контактов:
583
+ def get_deal_notes(self, deal_id, **kwargs):
584
+ return self.get_entity_notes("lead", deal_id, **kwargs)
585
+
586
+ def get_deal_note(self, deal_id, note_id):
587
+ return self.get_entity_note("lead", deal_id, note_id)
588
+
589
+ def get_contact_notes(self, contact_id, **kwargs):
590
+ return self.get_entity_notes("contact", contact_id, **kwargs)
591
+
592
+ def get_contact_note(self, contact_id, note_id):
593
+ return self.get_entity_note("contact", contact_id, note_id)
594
+
595
+ def get_entity_events(self, entity, entity_id=None, get_all=False, event_type=None, extra_params=None):
596
+ """
597
+ Получает список событий для указанной сущности.
598
+ Если entity_id не указан (None), возвращает события для всех сущностей данного типа.
599
+
600
+ :param entity: Тип сущности (например, 'lead', 'contact', 'company' и т.д.).
601
+ :param entity_id: ID сущности или None для получения событий по всем сущностям данного типа.
602
+ :param get_all: Если True, автоматически проходит по всем страницам пагинации.
603
+ :param event_type: Фильтр по типу события. Может быть строкой или списком строк.
604
+ :param extra_params: Словарь дополнительных GET-параметров.
605
+ :return: Список событий (каждый элемент – словарь с данными события).
606
+ """
607
+ params = {
608
+ 'page': 1,
609
+ 'limit': 100,
610
+ 'filter[entity]': entity,
611
+ }
612
+ # Добавляем фильтр по ID, если он указан
613
+ if entity_id is not None:
614
+ params['filter[entity_id]'] = entity_id
615
+ # Фильтр по типу события
616
+ if event_type is not None:
617
+ params['filter[type]'] = event_type
618
+ if extra_params:
619
+ params.update(extra_params)
620
+
621
+ events = []
622
+ while True:
623
+ response = self._make_request("GET", "/api/v4/events", params=params)
624
+ if response and "_embedded" in response and "events" in response["_embedded"]:
625
+ events.extend(response["_embedded"]["events"])
626
+ # Если не нужно получать все страницы, выходим
627
+ if not get_all:
628
+ break
629
+ total_pages = response.get("_page_count", params['page'])
630
+ if params['page'] >= total_pages:
631
+ break
632
+ params['page'] += 1
633
+ return events
634
+
635
+ # Удобные обёртки:
636
+ def get_deal_events(self, deal_id, **kwargs):
637
+ return self.get_entity_events("lead", deal_id, **kwargs)
638
+
639
+ def get_contact_events(self, contact_id, **kwargs):
640
+ return self.get_entity_events("contact", contact_id, **kwargs)
641
+
642
+ def get_event(self, event_id):
643
+ """
644
+ Получает подробную информацию по конкретному событию по его ID.
645
+
646
+ Используется эндпоинт:
647
+ GET /api/v4/events/{event_id}
648
+
649
+ :param event_id: ID события.
650
+ :return: Словарь с подробной информацией о событии.
651
+ :raises Exception: При ошибке запроса.
652
+ """
653
+ endpoint = f"/api/v4/events/{event_id}"
654
+ self.logger.debug(f"Fetching event with ID {event_id}")
655
+ event_data = self._make_request("GET", endpoint)
656
+ self.logger.debug(f"Event {event_id} details fetched successfully.")
657
+ return event_data
658
+
659
+ def get_pipelines(self):
660
+ """
661
+ Получает список всех воронок и их статусов из amoCRM.
662
+
663
+ :return: Список словарей, где каждый словарь содержит данные воронки, а также, если присутствует, вложенные статусы.
664
+ :raises Exception: Если данные не получены или структура ответа неверна.
665
+ """
666
+ endpoint = "/api/v4/leads/pipelines"
667
+ response = self._make_request("GET", endpoint)
668
+ if response and '_embedded' in response and 'pipelines' in response['_embedded']:
669
+ pipelines = response['_embedded']['pipelines']
670
+ self.logger.debug(f"Получено {len(pipelines)} воронок")
671
+ return pipelines
672
+ else:
673
+ self.logger.error("Не удалось получить воронки из amoCRM")
674
+ raise Exception("Ошибка получения воронок из amoCRM")
@@ -0,0 +1,91 @@
1
+ import logging
2
+ from sqlalchemy import select, or_
3
+ from sqlalchemy.dialects.postgresql import insert # Правильный импорт для PostgreSQL
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from amochka.models import Pipeline, Status
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ async def update_pipelines(session: AsyncSession, pipelines_data):
10
+ """
11
+ Обновляет таблицы воронок (Pipeline) и статусов (Status) в базе данных.
12
+
13
+ :param session: Асинхронная сессия SQLAlchemy.
14
+ :param pipelines_data: Список воронок, полученных из API amoCRM.
15
+ """
16
+ if not pipelines_data:
17
+ logger.warning("Получен пустой список воронок, обновление не выполнено")
18
+ return
19
+
20
+ account_id = 1111 # Пример: замените на актуальный идентификатор аккаунта
21
+ pipeline_values = []
22
+ all_statuses = []
23
+
24
+ # Подготавливаем данные для вставки в таблицу Pipeline и собираем статусы
25
+ for pipeline in pipelines_data:
26
+ pipeline_values.append({
27
+ 'account_id': account_id,
28
+ 'pipeline_id': pipeline['id'],
29
+ 'name': pipeline['name'],
30
+ 'sort': pipeline.get('sort'),
31
+ 'is_main': pipeline.get('is_main'),
32
+ 'is_archive': pipeline.get('is_archive'),
33
+ })
34
+ # Если воронка содержит статусы, обрабатываем их
35
+ if '_embedded' in pipeline and 'statuses' in pipeline['_embedded']:
36
+ for status in pipeline['_embedded']['statuses']:
37
+ all_statuses.append((pipeline['id'], status['id'], status))
38
+
39
+ # Массовая вставка/обновление данных в таблице Pipeline
40
+ stmt = insert(Pipeline).values(pipeline_values)
41
+ stmt = stmt.on_conflict_do_update(
42
+ index_elements=['pipeline_id'],
43
+ set_={
44
+ 'name': stmt.excluded.name,
45
+ 'sort': stmt.excluded.sort,
46
+ 'is_main': stmt.excluded.is_main,
47
+ 'is_archive': stmt.excluded.is_archive,
48
+ }
49
+ )
50
+ await session.execute(stmt)
51
+ logger.debug(f"Обновлено {len(pipeline_values)} воронок")
52
+
53
+ # Получаем сопоставление внутренних ID воронок по pipeline_id
54
+ result = await session.execute(select(Pipeline.id, Pipeline.pipeline_id))
55
+ pipeline_id_map = {row.pipeline_id: row.id for row in result}
56
+
57
+ # Подготавливаем данные для вставки в таблицу Status
58
+ status_values = []
59
+ for pipeline_id, status_id, status in all_statuses:
60
+ internal_pipeline_id = pipeline_id_map.get(pipeline_id)
61
+ if internal_pipeline_id is None:
62
+ logger.warning(f"Не найден внутренний ID для воронки {pipeline_id}, пропускаю статус {status_id}")
63
+ continue
64
+
65
+ status_values.append({
66
+ 'account_id': account_id,
67
+ 'pipeline_id': internal_pipeline_id,
68
+ 'status_id': status_id,
69
+ 'name': status.get('name', ''),
70
+ 'color': status.get('color', ''),
71
+ 'sort': status.get('sort'),
72
+ 'is_editable': status.get('is_editable'),
73
+ 'type': status.get('type'),
74
+ })
75
+
76
+ if status_values:
77
+ stmt = insert(Status).values(status_values)
78
+ stmt = stmt.on_conflict_do_update(
79
+ index_elements=['pipeline_id', 'status_id'],
80
+ set_={
81
+ 'name': stmt.excluded.name,
82
+ 'color': stmt.excluded.color,
83
+ 'sort': stmt.excluded.sort,
84
+ 'is_editable': stmt.excluded.is_editable,
85
+ 'type': stmt.excluded.type,
86
+ }
87
+ )
88
+ await session.execute(stmt)
89
+ logger.debug(f"Обновлено {len(status_values)} статусов")
90
+
91
+ logger.info(f"Обновлено {len(pipeline_values)} воронок и {len(status_values)} статусов.")
@@ -0,0 +1,50 @@
1
+ from sqlalchemy import Column, Integer, String, Boolean, BigInteger, ForeignKey, UniqueConstraint
2
+ from sqlalchemy.orm import declarative_base, relationship
3
+
4
+ # Базовый класс для всех моделей
5
+ Base = declarative_base()
6
+
7
+ class Pipeline(Base):
8
+ """
9
+ Модель для хранения воронок из amoCRM.
10
+ """
11
+ __tablename__ = 'a_pipelines'
12
+
13
+ id = Column(Integer, primary_key=True, autoincrement=True)
14
+ account_id = Column(Integer, nullable=False)
15
+ pipeline_id = Column(BigInteger, nullable=False, unique=True)
16
+ name = Column(String)
17
+ sort = Column(Integer)
18
+ is_main = Column(Boolean)
19
+ is_archive = Column(Boolean)
20
+
21
+ # Определяем связь с моделью статусов
22
+ statuses = relationship("Status", back_populates="pipeline")
23
+
24
+ __table_args__ = (
25
+ UniqueConstraint('pipeline_id', name='uq_pipeline_id'),
26
+ )
27
+
28
+ class Status(Base):
29
+ """
30
+ Модель для хранения статусов воронок.
31
+ """
32
+ __tablename__ = 'a_statuses'
33
+
34
+ id = Column(Integer, primary_key=True, autoincrement=True)
35
+ account_id = Column(Integer, nullable=False)
36
+ # Ссылка на внутренний id воронки (Pipeline.id)
37
+ pipeline_id = Column(Integer, ForeignKey('a_pipelines.id'), nullable=False)
38
+ status_id = Column(BigInteger, nullable=False)
39
+ name = Column(String)
40
+ color = Column(String)
41
+ sort = Column(Integer)
42
+ is_editable = Column(Boolean)
43
+ type = Column(Integer)
44
+
45
+ # Определяем обратную связь с моделью Pipeline
46
+ pipeline = relationship("Pipeline", back_populates="statuses")
47
+
48
+ __table_args__ = (
49
+ UniqueConstraint('pipeline_id', 'status_id', name='uq_pipeline_status_id'),
50
+ )
@@ -1,16 +1,19 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: amochka
3
- Version: 0.1.3
3
+ Version: 0.1.6
4
4
  Summary: Библиотека для работы с API amoCRM
5
- Home-page: UNKNOWN
5
+ Home-page:
6
6
  Author: Timurka
7
7
  Author-email: timurdt@gmail.com
8
- License: UNKNOWN
9
- Platform: UNKNOWN
10
8
  Classifier: Programming Language :: Python :: 3
11
9
  Classifier: License :: OSI Approved :: MIT License
12
10
  Classifier: Operating System :: OS Independent
13
11
  Requires-Python: >=3.6
14
-
15
- UNKNOWN
16
-
12
+ Requires-Dist: requests
13
+ Requires-Dist: ratelimit
14
+ Dynamic: author
15
+ Dynamic: author-email
16
+ Dynamic: classifier
17
+ Dynamic: requires-dist
18
+ Dynamic: requires-python
19
+ Dynamic: summary
@@ -2,6 +2,8 @@ README.md
2
2
  setup.py
3
3
  amochka/__init__.py
4
4
  amochka/client.py
5
+ amochka/etl.py
6
+ amochka/models.py
5
7
  amochka.egg-info/PKG-INFO
6
8
  amochka.egg-info/SOURCES.txt
7
9
  amochka.egg-info/dependency_links.txt
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='amochka',
5
- version='0.1.3',
5
+ version='0.1.6',
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  'requests',
@@ -1,5 +0,0 @@
1
- """
2
- amochka: Библиотека для работы с API amoCRM.
3
- """
4
-
5
- from .client import AmoCRMClient
@@ -1,312 +0,0 @@
1
- import os
2
- import time
3
- import json
4
- import requests
5
- import logging
6
- from datetime import datetime
7
- from ratelimit import limits, sleep_and_retry
8
-
9
- # Создаём базовый логгер
10
- logger = logging.getLogger(__name__)
11
- if not logger.handlers:
12
- ch = logging.StreamHandler()
13
- formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
14
- ch.setFormatter(formatter)
15
- logger.addHandler(ch)
16
-
17
- RATE_LIMIT = 7 # Максимум 7 запросов в секунду
18
-
19
- class Deal(dict):
20
- """
21
- Объект сделки расширяет стандартный словарь данными из custom_fields_values.
22
- (Описание класса без изменений)
23
- """
24
- def __init__(self, data, custom_fields_config=None):
25
- super().__init__(data)
26
- self._custom = {}
27
- self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
28
- custom = data.get("custom_fields_values") or []
29
- logger.debug(f"Processing custom_fields_values: {custom}")
30
- for field in custom:
31
- if isinstance(field, dict):
32
- field_name = field.get("field_name")
33
- values = field.get("values")
34
- if field_name and values and isinstance(values, list) and len(values) > 0:
35
- key_name = field_name.lower().strip()
36
- # Сохраняем текстовое значение для доступа по названию
37
- self._custom[key_name] = values[0].get("value")
38
- logger.debug(f"Set custom field '{key_name}' = {self._custom[key_name]}")
39
- field_id = field.get("field_id")
40
- if field_id is not None and values and isinstance(values, list) and len(values) > 0:
41
- stored_value = values[0].get("value")
42
- stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
43
- self._custom[int(field_id)] = {"value": stored_value, "enum_id": stored_enum_id}
44
- logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
45
- if custom_fields_config:
46
- for cid, field_obj in custom_fields_config.items():
47
- key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
48
- if key not in self._custom:
49
- self._custom[key] = None
50
- logger.debug(f"Field '{key}' not found in deal data; set to None")
51
-
52
- def __getitem__(self, key):
53
- if key in super().keys():
54
- return super().__getitem__(key)
55
- if isinstance(key, str):
56
- lower_key = key.lower().strip()
57
- if lower_key in self._custom:
58
- stored = self._custom[lower_key]
59
- return stored.get("value") if isinstance(stored, dict) else stored
60
- if isinstance(key, int):
61
- if key in self._custom:
62
- stored = self._custom[key]
63
- return stored.get("value") if isinstance(stored, dict) else stored
64
- raise KeyError(key)
65
-
66
- def get(self, key, default=None):
67
- try:
68
- return self.__getitem__(key)
69
- except KeyError:
70
- return default
71
-
72
- def get_id(self, key, default=None):
73
- """
74
- Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля.
75
- (Описание метода без изменений)
76
- """
77
- stored = None
78
- if isinstance(key, str):
79
- lower_key = key.lower().strip()
80
- if lower_key in self._custom:
81
- stored = self._custom[lower_key]
82
- elif isinstance(key, int):
83
- if key in self._custom:
84
- stored = self._custom[key]
85
- if isinstance(stored, dict):
86
- enum_id = stored.get("enum_id")
87
- if enum_id is not None:
88
- return enum_id
89
- if self._custom_config:
90
- field_def = None
91
- if isinstance(key, int):
92
- field_def = self._custom_config.get(key)
93
- else:
94
- for fid, fdef in self._custom_config.items():
95
- if fdef.get("name", "").lower().strip() == key.lower().strip():
96
- field_def = fdef
97
- break
98
- if field_def:
99
- enums = field_def.get("enums") or []
100
- for enum in enums:
101
- if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
102
- return enum.get("id", default)
103
- return default
104
-
105
- class AmoCRMClient:
106
- """
107
- Клиент для работы с API amoCRM.
108
- (Описание класса без изменений, за исключением добавления параметра use_file_cache)
109
- """
110
- def __init__(self, base_url, token_file=None, cache_file=None, log_level=logging.INFO, disable_logging=False, use_file_cache=True):
111
- """
112
- Инициализирует клиента, задавая базовый URL, токен авторизации и файл кэша для кастомных полей.
113
-
114
- :param use_file_cache: Если True, кэш будет сохраняться в файл; иначе — только в оперативной памяти.
115
- """
116
- self.base_url = base_url.rstrip('/')
117
- domain = self.base_url.split("//")[-1].split(".")[0]
118
- self.domain = domain
119
- self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
120
- if not cache_file:
121
- cache_file = f"custom_fields_cache_{self.domain}.json"
122
- self.cache_file = cache_file
123
- self.use_file_cache = use_file_cache
124
- self.token = self.load_token()
125
- self._custom_fields_mapping = None
126
-
127
- if disable_logging:
128
- logging.disable(logging.CRITICAL)
129
- else:
130
- logger.setLevel(log_level)
131
-
132
- logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
133
-
134
- def load_token(self):
135
- # Метод без изменений
136
- data = None
137
- if os.path.exists(self.token_file):
138
- with open(self.token_file, 'r') as f:
139
- data = json.load(f)
140
- logger.debug(f"Token loaded from file: {self.token_file}")
141
- else:
142
- try:
143
- data = json.loads(self.token_file)
144
- logger.debug("Token parsed from provided string.")
145
- except Exception as e:
146
- raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
147
-
148
- expires_at_str = data.get('expires_at')
149
- try:
150
- expires_at = datetime.fromisoformat(expires_at_str).timestamp()
151
- except Exception:
152
- expires_at = float(expires_at_str)
153
-
154
- if expires_at and time.time() < expires_at:
155
- logger.debug("Token is valid.")
156
- return data.get('access_token')
157
- else:
158
- raise Exception("Токен найден, но он истёк. Обновите токен.")
159
-
160
- @sleep_and_retry
161
- @limits(calls=RATE_LIMIT, period=1)
162
- def _make_request(self, method, endpoint, params=None, data=None):
163
- # Метод без изменений
164
- url = f"{self.base_url}{endpoint}"
165
- headers = {
166
- "Authorization": f"Bearer {self.token}",
167
- "Content-Type": "application/json"
168
- }
169
- logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
170
- response = requests.request(method, url, headers=headers, params=params, json=data)
171
- if response.status_code not in (200, 204):
172
- logger.error(f"Request error {response.status_code}: {response.text}")
173
- raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
174
- if response.status_code == 204:
175
- return None
176
- return response.json()
177
-
178
- def get_deal_by_id(self, deal_id):
179
- # Метод без изменений
180
- endpoint = f"/api/v4/leads/{deal_id}"
181
- params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
182
- data = self._make_request("GET", endpoint, params=params)
183
- custom_config = self.get_custom_fields_mapping()
184
- logger.debug(f"Deal {deal_id} data received (содержимое полей не выводится полностью).")
185
- return Deal(data, custom_fields_config=custom_config)
186
-
187
- def _save_custom_fields_cache(self, mapping):
188
- """
189
- Сохраняет кэш кастомных полей в файл, если используется файловый кэш.
190
- Если файловый кэш не используется, операция пропускается.
191
- """
192
- if not self.use_file_cache:
193
- logger.debug("File caching disabled; cache stored in memory only.")
194
- return
195
- cache_data = {"last_updated": time.time(), "mapping": mapping}
196
- with open(self.cache_file, "w") as f:
197
- json.dump(cache_data, f)
198
- logger.debug(f"Custom fields cache saved to {self.cache_file}")
199
-
200
- def _load_custom_fields_cache(self):
201
- """
202
- Загружает кэш кастомных полей из файла, если используется файловый кэш.
203
- Если файловый кэш не используется, возвращает None.
204
- """
205
- if not self.use_file_cache:
206
- logger.debug("File caching disabled; no cache loaded from file.")
207
- return None
208
- if os.path.exists(self.cache_file):
209
- with open(self.cache_file, "r") as f:
210
- try:
211
- cache_data = json.load(f)
212
- logger.debug("Custom fields cache loaded successfully.")
213
- return cache_data
214
- except Exception as e:
215
- logger.error(f"Error loading cache: {e}")
216
- return None
217
- return None
218
-
219
- def get_custom_fields_mapping(self, force_update=False, cache_duration_hours=24):
220
- """
221
- Возвращает словарь отображения кастомных полей для сделок.
222
- Если данные кэшированы и не устарели, возвращает кэш; иначе выполняет запросы для получения данных.
223
- """
224
- if not force_update:
225
- if self._custom_fields_mapping:
226
- return self._custom_fields_mapping
227
- cache_data = self._load_custom_fields_cache()
228
- if cache_data:
229
- last_updated = cache_data.get("last_updated", 0)
230
- if time.time() - last_updated < cache_duration_hours * 3600:
231
- self._custom_fields_mapping = cache_data.get("mapping")
232
- logger.debug("Using cached custom fields mapping.")
233
- return self._custom_fields_mapping
234
-
235
- mapping = {}
236
- page = 1
237
- total_pages = 1 # Значение по умолчанию
238
- while page <= total_pages:
239
- endpoint = f"/api/v4/leads/custom_fields?limit=250&page={page}"
240
- response = self._make_request("GET", endpoint)
241
- if response and "_embedded" in response and "custom_fields" in response["_embedded"]:
242
- for field in response["_embedded"]["custom_fields"]:
243
- mapping[field["id"]] = field
244
- total_pages = response.get("_page_count", page)
245
- logger.debug(f"Fetched page {page} of {total_pages}")
246
- page += 1
247
- else:
248
- break
249
-
250
- logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
251
- self._custom_fields_mapping = mapping
252
- self._save_custom_fields_cache(mapping)
253
- return mapping
254
-
255
- def find_custom_field_id(self, search_term):
256
- """
257
- Ищет кастомное поле по заданному названию (или части названия).
258
- """
259
- mapping = self.get_custom_fields_mapping()
260
- search_term_lower = search_term.lower().strip()
261
- for key, field_obj in mapping.items():
262
- if isinstance(field_obj, dict):
263
- name = field_obj.get("name", "").lower().strip()
264
- else:
265
- name = str(field_obj).lower().strip()
266
- if search_term_lower == name or search_term_lower in name:
267
- logger.debug(f"Found custom field '{name}' with id {key}")
268
- return int(key), field_obj
269
- logger.debug(f"Custom field containing '{search_term}' not found.")
270
- return None, None
271
-
272
- def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
273
- """
274
- Обновляет сделку, задавая новые значения для стандартных и кастомных полей.
275
- """
276
- payload = {}
277
- standard_fields = {
278
- "name", "price", "status_id", "pipeline_id", "created_by", "updated_by",
279
- "closed_at", "created_at", "updated_at", "loss_reason_id", "responsible_user_id"
280
- }
281
- custom_fields = []
282
- for key, value in update_fields.items():
283
- if key in standard_fields:
284
- payload[key] = value
285
- logger.debug(f"Standard field {key} set to {value}")
286
- else:
287
- if isinstance(value, int):
288
- field_value_dict = {"enum_id": value}
289
- else:
290
- field_value_dict = {"value": value}
291
- try:
292
- field_id = int(key)
293
- custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
294
- logger.debug(f"Custom field by id {field_id} set to {value}")
295
- except ValueError:
296
- field_id, field_obj = self.find_custom_field_id(key)
297
- if field_id is not None:
298
- custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
299
- logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
300
- else:
301
- raise Exception(f"Custom field '{key}' не найден.")
302
- if custom_fields:
303
- payload["custom_fields_values"] = custom_fields
304
- if tags_to_add:
305
- payload["tags_to_add"] = tags_to_add
306
- if tags_to_delete:
307
- payload["tags_to_delete"] = tags_to_delete
308
- logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
309
- endpoint = f"/api/v4/leads/{lead_id}"
310
- response = self._make_request("PATCH", endpoint, data=payload)
311
- logger.debug("Update response received.")
312
- return response
File without changes
File without changes