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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
amochka/__init__.py CHANGED
@@ -2,4 +2,5 @@
2
2
  amochka: Библиотека для работы с API amoCRM.
3
3
  """
4
4
 
5
- from .client import AmoCRMClient
5
+ from .client import AmoCRMClient, CacheConfig
6
+ __all__ = ['AmoCRMClient', 'CacheConfig']
amochka/client.py CHANGED
@@ -29,12 +29,13 @@ class Deal(dict):
29
29
 
30
30
  Параметр custom_fields_config – словарь, где ключи – ID полей, а значения – модели полей.
31
31
  """
32
- def __init__(self, data, custom_fields_config=None):
32
+ def __init__(self, data, custom_fields_config=None, logger=None):
33
33
  super().__init__(data)
34
34
  self._custom = {}
35
35
  self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
36
+ self._logger = logger or logging.getLogger(__name__)
36
37
  custom = data.get("custom_fields_values") or []
37
- logger.debug(f"Processing custom_fields_values: {custom}")
38
+ self._logger.debug(f"Processing custom_fields_values: {custom}")
38
39
  for field in custom:
39
40
  if isinstance(field, dict):
40
41
  field_name = field.get("field_name")
@@ -45,19 +46,19 @@ class Deal(dict):
45
46
  stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
46
47
  # Сохраняем полную информацию (и для get() и для get_id())
47
48
  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}}}")
49
+ self._logger.debug(f"Set custom field '{key_name}' = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
49
50
  field_id = field.get("field_id")
50
51
  if field_id is not None and values and isinstance(values, list) and len(values) > 0:
51
52
  stored_value = values[0].get("value")
52
53
  stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
53
54
  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}}}")
55
+ self._logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
55
56
  if custom_fields_config:
56
57
  for cid, field_obj in custom_fields_config.items():
57
58
  key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
58
59
  if key not in self._custom:
59
60
  self._custom[key] = None
60
- logger.debug(f"Field '{key}' not found in deal data; set to None")
61
+ self._logger.debug(f"Field '{key}' not found in deal data; set to None")
61
62
 
62
63
  def __getitem__(self, key):
63
64
  if key in super().keys():
@@ -79,16 +80,65 @@ class Deal(dict):
79
80
  except KeyError:
80
81
  return default
81
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
+
82
122
  def get_id(self, key, default=None):
83
123
  """
84
- Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля.
124
+ Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля типа select.
125
+ Для полей других типов возвращает их значение, как метод get().
126
+
85
127
  Если значение enum_id отсутствует в данных, производится поиск в конфигурации кастомных полей,
86
128
  сравнение значения выполняется без учёта регистра и пробелов.
87
129
 
88
130
  :param key: Название поля (строка) или ID поля (integer).
89
131
  :param default: Значение по умолчанию, если enum_id не найден.
90
- :return: Идентификатор выбранного варианта (целое число) или default.
132
+ :return: Для полей типа select - идентификатор варианта (целое число).
133
+ Для других типов полей - значение поля.
134
+ Если поле не найдено - default.
91
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
+
92
142
  stored = None
93
143
  if isinstance(key, str):
94
144
  lower_key = key.lower().strip()
@@ -115,7 +165,41 @@ class Deal(dict):
115
165
  for enum in enums:
116
166
  if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
117
167
  return enum.get("id", default)
118
- return 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)
119
203
 
120
204
  class AmoCRMClient:
121
205
  """
@@ -136,50 +220,53 @@ class AmoCRMClient:
136
220
  self,
137
221
  base_url,
138
222
  token_file=None,
139
- cache_file=None,
223
+ cache_config=None,
140
224
  log_level=logging.INFO,
141
- disable_logging=False,
142
- cache_enabled=True,
143
- cache_storage='file', # 'file' или 'memory'
144
- cache_hours=24 # время жизни кэша в часах, или None для бесконечного кэша
225
+ disable_logging=False
145
226
  ):
146
227
  """
147
228
  Инициализирует клиента, задавая базовый URL, токен авторизации и настройки кэша для кастомных полей.
148
229
 
149
230
  :param base_url: Базовый URL API amoCRM.
150
231
  :param token_file: Файл, содержащий токен авторизации.
151
- :param cache_file: Файл для кэширования данных кастомных полей.
232
+ :param cache_config: Конфигурация кэширования (объект CacheConfig или None для значений по умолчанию)
152
233
  :param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
153
234
  :param disable_logging: Если True, логирование будет отключено.
154
- :param cache_enabled: Если False, кэширование отключается (остальные параметры игнорируются).
155
- :param cache_storage: 'file' для файлового кэша, 'memory' для кэша только в оперативной памяти.
156
- :param cache_hours: Время жизни кэша в часах. Если None – кэш считается бесконечным.
157
235
  """
158
236
  self.base_url = base_url.rstrip('/')
159
237
  domain = self.base_url.split("//")[-1].split(".")[0]
160
238
  self.domain = domain
161
239
  self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
162
240
 
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
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 # Отключаем передачу логов в родительский логгер
173
249
 
174
- self.token = self.load_token()
175
- self._custom_fields_mapping = None
176
-
177
250
  if disable_logging:
178
- logging.disable(logging.CRITICAL)
251
+ self.logger.setLevel(logging.CRITICAL + 1) # Выше, чем любой стандартный уровень
179
252
  else:
180
- logger.setLevel(log_level)
253
+ self.logger.setLevel(log_level)
181
254
 
182
- logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
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
183
270
 
184
271
  def load_token(self):
185
272
  """
@@ -192,11 +279,11 @@ class AmoCRMClient:
192
279
  if os.path.exists(self.token_file):
193
280
  with open(self.token_file, 'r') as f:
194
281
  data = json.load(f)
195
- logger.debug(f"Token loaded from file: {self.token_file}")
282
+ self.logger.debug(f"Token loaded from file: {self.token_file}")
196
283
  else:
197
284
  try:
198
285
  data = json.loads(self.token_file)
199
- logger.debug("Token parsed from provided string.")
286
+ self.logger.debug("Token parsed from provided string.")
200
287
  except Exception as e:
201
288
  raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
202
289
 
@@ -207,7 +294,7 @@ class AmoCRMClient:
207
294
  expires_at = float(expires_at_str)
208
295
 
209
296
  if expires_at and time.time() < expires_at:
210
- logger.debug("Token is valid.")
297
+ self.logger.debug("Token is valid.")
211
298
  return data.get('access_token')
212
299
  else:
213
300
  raise Exception("Токен найден, но он истёк. Обновите токен.")
@@ -230,64 +317,73 @@ class AmoCRMClient:
230
317
  "Authorization": f"Bearer {self.token}",
231
318
  "Content-Type": "application/json"
232
319
  }
233
- logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
320
+ self.logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
234
321
  response = requests.request(method, url, headers=headers, params=params, json=data)
235
322
  if response.status_code not in (200, 204):
236
- logger.error(f"Request error {response.status_code}: {response.text}")
323
+ self.logger.error(f"Request error {response.status_code}: {response.text}")
237
324
  raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
238
325
  if response.status_code == 204:
239
326
  return None
240
327
  return response.json()
241
328
 
242
- def get_deal_by_id(self, deal_id):
329
+ def get_deal_by_id(self, deal_id, skip_fields_mapping=False):
243
330
  """
244
331
  Получает данные сделки по её ID и возвращает объект Deal.
245
-
246
- :param deal_id: ID сделки.
247
- :return: Объект Deal, включающий данные стандартных и кастомных полей.
332
+ Если данные отсутствуют или имеют неверную структуру, выбрасывается исключение.
333
+
334
+ :param deal_id: ID сделки для получения
335
+ :param skip_fields_mapping: Если True, не загружает справочник кастомных полей
336
+ (используйте для работы только с ID полей)
337
+ :return: Объект Deal с данными сделки
248
338
  """
249
339
  endpoint = f"/api/v4/leads/{deal_id}"
250
340
  params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
251
341
  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)
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)
255
351
 
256
352
  def _save_custom_fields_cache(self, mapping):
257
353
  """
258
354
  Сохраняет кэш кастомных полей в файл, если используется файловый кэш.
259
355
  Если кэширование отключено или выбран кэш в памяти, операция пропускается.
260
356
  """
261
- if not self.cache_enabled:
262
- logger.debug("Caching disabled; cache not saved.")
357
+ if not self.cache_config.enabled:
358
+ self.logger.debug("Caching disabled; cache not saved.")
263
359
  return
264
- if self.cache_storage != 'file':
265
- logger.debug("Using memory caching; no file cache saved.")
360
+ if self.cache_config.storage != 'file':
361
+ self.logger.debug("Using memory caching; no file cache saved.")
266
362
  return
267
363
  cache_data = {"last_updated": time.time(), "mapping": mapping}
268
- with open(self.cache_file, "w") as f:
364
+ with open(self.cache_config.file, "w") as f:
269
365
  json.dump(cache_data, f)
270
- logger.debug(f"Custom fields cache saved to {self.cache_file}")
366
+ self.logger.debug(f"Custom fields cache saved to {self.cache_config.file}")
271
367
 
272
368
  def _load_custom_fields_cache(self):
273
369
  """
274
370
  Загружает кэш кастомных полей из файла, если используется файловый кэш.
275
371
  Если кэширование отключено или выбран кэш в памяти, возвращает None.
276
372
  """
277
- if not self.cache_enabled:
278
- logger.debug("Caching disabled; no cache loaded.")
373
+ if not self.cache_config.enabled:
374
+ self.logger.debug("Caching disabled; no cache loaded.")
279
375
  return None
280
- if self.cache_storage != 'file':
281
- logger.debug("Using memory caching; cache will be kept in memory only.")
376
+ if self.cache_config.storage != 'file':
377
+ self.logger.debug("Using memory caching; cache will be kept in memory only.")
282
378
  return None
283
- if os.path.exists(self.cache_file):
284
- with open(self.cache_file, "r") as f:
379
+ if os.path.exists(self.cache_config.file):
380
+ with open(self.cache_config.file, "r") as f:
285
381
  try:
286
382
  cache_data = json.load(f)
287
- logger.debug("Custom fields cache loaded successfully.")
383
+ self.logger.debug("Custom fields cache loaded successfully.")
288
384
  return cache_data
289
385
  except Exception as e:
290
- logger.error(f"Error loading cache: {e}")
386
+ self.logger.error(f"Error loading cache: {e}")
291
387
  return None
292
388
  return None
293
389
 
@@ -299,18 +395,18 @@ class AmoCRMClient:
299
395
  if not force_update and self._custom_fields_mapping is not None:
300
396
  return self._custom_fields_mapping
301
397
 
302
- cache_data = self._load_custom_fields_cache() if self.cache_enabled else None
398
+ cache_data = self._load_custom_fields_cache() if self.cache_config.enabled else None
303
399
  if cache_data:
304
400
  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:
401
+ if self.cache_config.lifetime_hours is not None:
402
+ if time.time() - last_updated < self.cache_config.lifetime_hours * 3600:
307
403
  self._custom_fields_mapping = cache_data.get("mapping")
308
- logger.debug("Using cached custom fields mapping.")
404
+ self.logger.debug("Using cached custom fields mapping.")
309
405
  return self._custom_fields_mapping
310
406
  else:
311
407
  # Бесконечный кэш – не проверяем срок
312
408
  self._custom_fields_mapping = cache_data.get("mapping")
313
- logger.debug("Using cached custom fields mapping (infinite cache).")
409
+ self.logger.debug("Using cached custom fields mapping (infinite cache).")
314
410
  return self._custom_fields_mapping
315
411
 
316
412
  mapping = {}
@@ -323,14 +419,14 @@ class AmoCRMClient:
323
419
  for field in response["_embedded"]["custom_fields"]:
324
420
  mapping[field["id"]] = field
325
421
  total_pages = response.get("_page_count", page)
326
- logger.debug(f"Fetched page {page} of {total_pages}")
422
+ self.logger.debug(f"Fetched page {page} of {total_pages}")
327
423
  page += 1
328
424
  else:
329
425
  break
330
426
 
331
- logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
427
+ self.logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
332
428
  self._custom_fields_mapping = mapping
333
- if self.cache_enabled:
429
+ if self.cache_config.enabled:
334
430
  self._save_custom_fields_cache(mapping)
335
431
  return mapping
336
432
 
@@ -349,9 +445,9 @@ class AmoCRMClient:
349
445
  else:
350
446
  name = str(field_obj).lower().strip()
351
447
  if search_term_lower == name or search_term_lower in name:
352
- logger.debug(f"Found custom field '{name}' with id {key}")
448
+ self.logger.debug(f"Found custom field '{name}' with id {key}")
353
449
  return int(key), field_obj
354
- logger.debug(f"Custom field containing '{search_term}' not found.")
450
+ self.logger.debug(f"Custom field containing '{search_term}' not found.")
355
451
  return None, None
356
452
 
357
453
  def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
@@ -379,7 +475,7 @@ class AmoCRMClient:
379
475
  for key, value in update_fields.items():
380
476
  if key in standard_fields:
381
477
  payload[key] = value
382
- logger.debug(f"Standard field {key} set to {value}")
478
+ self.logger.debug(f"Standard field {key} set to {value}")
383
479
  else:
384
480
  if isinstance(value, int):
385
481
  field_value_dict = {"enum_id": value}
@@ -388,12 +484,12 @@ class AmoCRMClient:
388
484
  try:
389
485
  field_id = int(key)
390
486
  custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
391
- logger.debug(f"Custom field by id {field_id} set to {value}")
487
+ self.logger.debug(f"Custom field by id {field_id} set to {value}")
392
488
  except ValueError:
393
489
  field_id, field_obj = self.find_custom_field_id(key)
394
490
  if field_id is not None:
395
491
  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}")
492
+ self.logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
397
493
  else:
398
494
  raise Exception(f"Custom field '{key}' не найден.")
399
495
  if custom_fields:
@@ -402,8 +498,177 @@ class AmoCRMClient:
402
498
  payload["tags_to_add"] = tags_to_add
403
499
  if tags_to_delete:
404
500
  payload["tags_to_delete"] = tags_to_delete
405
- logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
501
+ self.logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
406
502
  endpoint = f"/api/v4/leads/{lead_id}"
407
503
  response = self._make_request("PATCH", endpoint, data=payload)
408
- logger.debug("Update response received.")
409
- return response
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")
amochka/etl.py ADDED
@@ -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)} статусов.")
amochka/models.py ADDED
@@ -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,18 +1,19 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: amochka
3
- Version: 0.1.4
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
12
  Requires-Dist: requests
15
13
  Requires-Dist: ratelimit
16
-
17
- UNKNOWN
18
-
14
+ Dynamic: author
15
+ Dynamic: author-email
16
+ Dynamic: classifier
17
+ Dynamic: requires-dist
18
+ Dynamic: requires-python
19
+ Dynamic: summary
@@ -0,0 +1,8 @@
1
+ amochka/__init__.py,sha256=JZT3Q9jG3SfTS-vhlOCWYBrP8pFmaXFhNpJ1nOqrP5M,161
2
+ amochka/client.py,sha256=i0N6-9U7Pghh41yvKGlroVP2oDdIcdZyFSjiz9JKI6k,36284
3
+ amochka/etl.py,sha256=6pc3ymr72QH57hyeFaKziERXbD7v9GuQpQyBMxeO5MY,4142
4
+ amochka/models.py,sha256=clPPFuOf_Lk-mqTMxUix7JZ97ViD-wiKSZiC3l2slP4,1805
5
+ amochka-0.1.6.dist-info/METADATA,sha256=c1sNI6Qv0j7MCRN-oQpd3EIYUKtTShkfDqG1pFSCqo8,516
6
+ amochka-0.1.6.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
7
+ amochka-0.1.6.dist-info/top_level.txt,sha256=y5qXFXJUECmDwO6hyupsuYcTpZKZyByeE9e-1sa2U24,8
8
+ amochka-0.1.6.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.45.1)
2
+ Generator: setuptools (78.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,6 +0,0 @@
1
- amochka/__init__.py,sha256=92YIjNK3notY2wfG-l2jmGIIWjfE3ozpamc-e4LEILc,106
2
- amochka/client.py,sha256=1_CBE0p29_a3R4EDhN0fvS67pecQleMogO8WxyBdP74,22316
3
- amochka-0.1.4.dist-info/METADATA,sha256=g6TRutC1_0ypL2HXVtdr7IzXC70-avj9E8_1XVTSQMY,445
4
- amochka-0.1.4.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
5
- amochka-0.1.4.dist-info/top_level.txt,sha256=y5qXFXJUECmDwO6hyupsuYcTpZKZyByeE9e-1sa2U24,8
6
- amochka-0.1.4.dist-info/RECORD,,