amochka 0.1.3__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
@@ -19,35 +19,46 @@ RATE_LIMIT = 7 # Максимум 7 запросов в секунду
19
19
  class Deal(dict):
20
20
  """
21
21
  Объект сделки расширяет стандартный словарь данными из custom_fields_values.
22
- (Описание класса без изменений)
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 полей, а значения – модели полей.
23
31
  """
24
- def __init__(self, data, custom_fields_config=None):
32
+ def __init__(self, data, custom_fields_config=None, logger=None):
25
33
  super().__init__(data)
26
34
  self._custom = {}
27
35
  self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
36
+ self._logger = logger or logging.getLogger(__name__)
28
37
  custom = data.get("custom_fields_values") or []
29
- logger.debug(f"Processing custom_fields_values: {custom}")
38
+ self._logger.debug(f"Processing custom_fields_values: {custom}")
30
39
  for field in custom:
31
40
  if isinstance(field, dict):
32
41
  field_name = field.get("field_name")
33
42
  values = field.get("values")
34
43
  if field_name and values and isinstance(values, list) and len(values) > 0:
35
44
  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]}")
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}}}")
39
50
  field_id = field.get("field_id")
40
51
  if field_id is not None and values and isinstance(values, list) and len(values) > 0:
41
52
  stored_value = values[0].get("value")
42
53
  stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
43
54
  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}}}")
55
+ self._logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
45
56
  if custom_fields_config:
46
57
  for cid, field_obj in custom_fields_config.items():
47
58
  key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
48
59
  if key not in self._custom:
49
60
  self._custom[key] = None
50
- 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")
51
62
 
52
63
  def __getitem__(self, key):
53
64
  if key in super().keys():
@@ -69,11 +80,65 @@ class Deal(dict):
69
80
  except KeyError:
70
81
  return default
71
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
+
72
122
  def get_id(self, key, default=None):
73
123
  """
74
- Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля.
75
- (Описание метода без изменений)
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.
76
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
+
77
142
  stored = None
78
143
  if isinstance(key, str):
79
144
  lower_key = key.lower().strip()
@@ -100,48 +165,125 @@ class Deal(dict):
100
165
  for enum in enums:
101
166
  if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
102
167
  return enum.get("id", default)
103
- 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)
104
203
 
105
204
  class AmoCRMClient:
106
205
  """
107
206
  Клиент для работы с API amoCRM.
108
- (Описание класса без изменений, за исключением добавления параметра use_file_cache)
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.
109
218
  """
110
- def __init__(self, base_url, token_file=None, cache_file=None, log_level=logging.INFO, disable_logging=False, use_file_cache=True):
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
+ ):
111
227
  """
112
- Инициализирует клиента, задавая базовый URL, токен авторизации и файл кэша для кастомных полей.
228
+ Инициализирует клиента, задавая базовый URL, токен авторизации и настройки кэша для кастомных полей.
113
229
 
114
- :param use_file_cache: Если True, кэш будет сохраняться в файл; иначе — только в оперативной памяти.
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, логирование будет отключено.
115
235
  """
116
236
  self.base_url = base_url.rstrip('/')
117
237
  domain = self.base_url.split("//")[-1].split(".")[0]
118
238
  self.domain = domain
119
239
  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
-
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
+
127
250
  if disable_logging:
128
- logging.disable(logging.CRITICAL)
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()
129
258
  else:
130
- logger.setLevel(log_level)
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"
131
265
 
132
- logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
266
+ self.logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
267
+
268
+ self.token = self.load_token()
269
+ self._custom_fields_mapping = None
133
270
 
134
271
  def load_token(self):
135
- # Метод без изменений
272
+ """
273
+ Загружает токен авторизации из файла или строки, проверяет его срок действия.
274
+
275
+ :return: Действительный access_token.
276
+ :raises Exception: Если токен не найден или истёк.
277
+ """
136
278
  data = None
137
279
  if os.path.exists(self.token_file):
138
280
  with open(self.token_file, 'r') as f:
139
281
  data = json.load(f)
140
- logger.debug(f"Token loaded from file: {self.token_file}")
282
+ self.logger.debug(f"Token loaded from file: {self.token_file}")
141
283
  else:
142
284
  try:
143
285
  data = json.loads(self.token_file)
144
- logger.debug("Token parsed from provided string.")
286
+ self.logger.debug("Token parsed from provided string.")
145
287
  except Exception as e:
146
288
  raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
147
289
 
@@ -152,7 +294,7 @@ class AmoCRMClient:
152
294
  expires_at = float(expires_at_str)
153
295
 
154
296
  if expires_at and time.time() < expires_at:
155
- logger.debug("Token is valid.")
297
+ self.logger.debug("Token is valid.")
156
298
  return data.get('access_token')
157
299
  else:
158
300
  raise Exception("Токен найден, но он истёк. Обновите токен.")
@@ -160,77 +302,112 @@ class AmoCRMClient:
160
302
  @sleep_and_retry
161
303
  @limits(calls=RATE_LIMIT, period=1)
162
304
  def _make_request(self, method, endpoint, params=None, data=None):
163
- # Метод без изменений
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
+ """
164
315
  url = f"{self.base_url}{endpoint}"
165
316
  headers = {
166
317
  "Authorization": f"Bearer {self.token}",
167
318
  "Content-Type": "application/json"
168
319
  }
169
- 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}")
170
321
  response = requests.request(method, url, headers=headers, params=params, json=data)
171
322
  if response.status_code not in (200, 204):
172
- logger.error(f"Request error {response.status_code}: {response.text}")
323
+ self.logger.error(f"Request error {response.status_code}: {response.text}")
173
324
  raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
174
325
  if response.status_code == 204:
175
326
  return None
176
327
  return response.json()
177
328
 
178
- def get_deal_by_id(self, deal_id):
179
- # Метод без изменений
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
+ """
180
339
  endpoint = f"/api/v4/leads/{deal_id}"
181
340
  params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
182
341
  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)
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)
186
351
 
187
352
  def _save_custom_fields_cache(self, mapping):
188
353
  """
189
354
  Сохраняет кэш кастомных полей в файл, если используется файловый кэш.
190
- Если файловый кэш не используется, операция пропускается.
355
+ Если кэширование отключено или выбран кэш в памяти, операция пропускается.
191
356
  """
192
- if not self.use_file_cache:
193
- logger.debug("File caching disabled; cache stored in memory only.")
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.")
194
362
  return
195
363
  cache_data = {"last_updated": time.time(), "mapping": mapping}
196
- with open(self.cache_file, "w") as f:
364
+ with open(self.cache_config.file, "w") as f:
197
365
  json.dump(cache_data, f)
198
- 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}")
199
367
 
200
368
  def _load_custom_fields_cache(self):
201
369
  """
202
370
  Загружает кэш кастомных полей из файла, если используется файловый кэш.
203
- Если файловый кэш не используется, возвращает None.
371
+ Если кэширование отключено или выбран кэш в памяти, возвращает None.
204
372
  """
205
- if not self.use_file_cache:
206
- logger.debug("File caching disabled; no cache loaded from file.")
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.")
207
378
  return None
208
- if os.path.exists(self.cache_file):
209
- 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:
210
381
  try:
211
382
  cache_data = json.load(f)
212
- logger.debug("Custom fields cache loaded successfully.")
383
+ self.logger.debug("Custom fields cache loaded successfully.")
213
384
  return cache_data
214
385
  except Exception as e:
215
- logger.error(f"Error loading cache: {e}")
386
+ self.logger.error(f"Error loading cache: {e}")
216
387
  return None
217
388
  return None
218
389
 
219
- def get_custom_fields_mapping(self, force_update=False, cache_duration_hours=24):
390
+ def get_custom_fields_mapping(self, force_update=False):
220
391
  """
221
392
  Возвращает словарь отображения кастомных полей для сделок.
222
393
  Если данные кэшированы и не устарели, возвращает кэш; иначе выполняет запросы для получения данных.
223
394
  """
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:
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:
231
403
  self._custom_fields_mapping = cache_data.get("mapping")
232
- logger.debug("Using cached custom fields mapping.")
404
+ self.logger.debug("Using cached custom fields mapping.")
233
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
234
411
 
235
412
  mapping = {}
236
413
  page = 1
@@ -242,19 +419,23 @@ class AmoCRMClient:
242
419
  for field in response["_embedded"]["custom_fields"]:
243
420
  mapping[field["id"]] = field
244
421
  total_pages = response.get("_page_count", page)
245
- logger.debug(f"Fetched page {page} of {total_pages}")
422
+ self.logger.debug(f"Fetched page {page} of {total_pages}")
246
423
  page += 1
247
424
  else:
248
425
  break
249
426
 
250
- logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
427
+ self.logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
251
428
  self._custom_fields_mapping = mapping
252
- self._save_custom_fields_cache(mapping)
429
+ if self.cache_config.enabled:
430
+ self._save_custom_fields_cache(mapping)
253
431
  return mapping
254
432
 
255
433
  def find_custom_field_id(self, search_term):
256
434
  """
257
435
  Ищет кастомное поле по заданному названию (или части названия).
436
+
437
+ :param search_term: Строка для поиска по имени поля.
438
+ :return: Кортеж (field_id, field_obj) если найдено, иначе (None, None).
258
439
  """
259
440
  mapping = self.get_custom_fields_mapping()
260
441
  search_term_lower = search_term.lower().strip()
@@ -264,14 +445,26 @@ class AmoCRMClient:
264
445
  else:
265
446
  name = str(field_obj).lower().strip()
266
447
  if search_term_lower == name or search_term_lower in name:
267
- logger.debug(f"Found custom field '{name}' with id {key}")
448
+ self.logger.debug(f"Found custom field '{name}' with id {key}")
268
449
  return int(key), field_obj
269
- logger.debug(f"Custom field containing '{search_term}' not found.")
450
+ self.logger.debug(f"Custom field containing '{search_term}' not found.")
270
451
  return None, None
271
452
 
272
453
  def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
273
454
  """
274
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: Если одно из кастомных полей не найдено.
275
468
  """
276
469
  payload = {}
277
470
  standard_fields = {
@@ -282,7 +475,7 @@ class AmoCRMClient:
282
475
  for key, value in update_fields.items():
283
476
  if key in standard_fields:
284
477
  payload[key] = value
285
- logger.debug(f"Standard field {key} set to {value}")
478
+ self.logger.debug(f"Standard field {key} set to {value}")
286
479
  else:
287
480
  if isinstance(value, int):
288
481
  field_value_dict = {"enum_id": value}
@@ -291,12 +484,12 @@ class AmoCRMClient:
291
484
  try:
292
485
  field_id = int(key)
293
486
  custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
294
- 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}")
295
488
  except ValueError:
296
489
  field_id, field_obj = self.find_custom_field_id(key)
297
490
  if field_id is not None:
298
491
  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}")
492
+ self.logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
300
493
  else:
301
494
  raise Exception(f"Custom field '{key}' не найден.")
302
495
  if custom_fields:
@@ -305,8 +498,177 @@ class AmoCRMClient:
305
498
  payload["tags_to_add"] = tags_to_add
306
499
  if tags_to_delete:
307
500
  payload["tags_to_delete"] = tags_to_delete
308
- logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
501
+ self.logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
309
502
  endpoint = f"/api/v4/leads/{lead_id}"
310
503
  response = self._make_request("PATCH", endpoint, data=payload)
311
- logger.debug("Update response received.")
312
- 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.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
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=59iYwOQ90XTJvnzQCxRG8sgpbLRaZkFfYkZCN_Zl4Y8,15354
3
- amochka-0.1.3.dist-info/METADATA,sha256=68jkHlxGXCk6acxCM8OVg4jluTZKy3yUdPfFOqzGK0U,445
4
- amochka-0.1.3.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
5
- amochka-0.1.3.dist-info/top_level.txt,sha256=y5qXFXJUECmDwO6hyupsuYcTpZKZyByeE9e-1sa2U24,8
6
- amochka-0.1.3.dist-info/RECORD,,