amochka 0.1.4__py3-none-any.whl → 0.1.7__py3-none-any.whl

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