amochka 0.1.0__py3-none-any.whl → 0.1.2.1__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 ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ amochka: Библиотека для работы с API amoCRM.
3
+ """
4
+
5
+ from .client import AmoCRMClient
amochka/client.py ADDED
@@ -0,0 +1,378 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import requests
5
+ import logging
6
+ from datetime import datetime
7
+ from ratelimit import limits, sleep_and_retry
8
+
9
+ # Создаём базовый логгер
10
+ logger = logging.getLogger(__name__)
11
+ if not logger.handlers:
12
+ ch = logging.StreamHandler()
13
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
14
+ ch.setFormatter(formatter)
15
+ logger.addHandler(ch)
16
+
17
+ RATE_LIMIT = 7 # Максимум 7 запросов в секунду
18
+
19
+ class Deal(dict):
20
+ """
21
+ Объект сделки расширяет стандартный словарь данными из custom_fields_values.
22
+
23
+ Обеспечивает два способа доступа к кастомным полям:
24
+ 1. get(key): при обращении по названию (строкой) или по ID поля (integer)
25
+ возвращает текстовое значение поля (например, «Гурина Юлия»).
26
+ 2. get_id(key): возвращает идентификатор выбранного варианта (enum_id) для полей типа select.
27
+ Если в данных enum_id отсутствует, производится поиск в переданной конфигурации полей,
28
+ сравнение выполняется без учёта регистра и лишних пробелов.
29
+
30
+ Параметр custom_fields_config – словарь, где ключи – ID полей, а значения – модели полей.
31
+ """
32
+ def __init__(self, data, custom_fields_config=None):
33
+ super().__init__(data)
34
+ self._custom = {}
35
+ self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
36
+ custom = data.get("custom_fields_values") or []
37
+ logger.debug(f"Processing custom_fields_values: {custom}")
38
+ for field in custom:
39
+ if isinstance(field, dict):
40
+ field_name = field.get("field_name")
41
+ values = field.get("values")
42
+ if field_name and values and isinstance(values, list) and len(values) > 0:
43
+ key_name = field_name.lower().strip()
44
+ # Сохраняем текстовое значение для доступа по названию
45
+ self._custom[key_name] = values[0].get("value")
46
+ logger.debug(f"Set custom field '{key_name}' = {self._custom[key_name]}")
47
+ field_id = field.get("field_id")
48
+ if field_id is not None and values and isinstance(values, list) and len(values) > 0:
49
+ stored_value = values[0].get("value")
50
+ stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
51
+ self._custom[int(field_id)] = {"value": stored_value, "enum_id": stored_enum_id}
52
+ logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
53
+ if custom_fields_config:
54
+ for cid, field_obj in custom_fields_config.items():
55
+ key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
56
+ if key not in self._custom:
57
+ self._custom[key] = None
58
+ logger.debug(f"Field '{key}' not found in deal data; set to None")
59
+
60
+ def __getitem__(self, key):
61
+ if key in super().keys():
62
+ return super().__getitem__(key)
63
+ if isinstance(key, str):
64
+ lower_key = key.lower().strip()
65
+ if lower_key in self._custom:
66
+ stored = self._custom[lower_key]
67
+ return stored.get("value") if isinstance(stored, dict) else stored
68
+ if isinstance(key, int):
69
+ if key in self._custom:
70
+ stored = self._custom[key]
71
+ return stored.get("value") if isinstance(stored, dict) else stored
72
+ raise KeyError(key)
73
+
74
+ def get(self, key, default=None):
75
+ try:
76
+ return self.__getitem__(key)
77
+ except KeyError:
78
+ return default
79
+
80
+ def get_id(self, key, default=None):
81
+ """
82
+ Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля.
83
+ Если значение enum_id отсутствует в данных, производится поиск в конфигурации полей,
84
+ сравнение значения выполняется без учёта регистра и пробелов.
85
+
86
+ :param key: Название поля (строка) или ID поля (integer).
87
+ :param default: Значение по умолчанию, если enum_id не найден.
88
+ :return: Идентификатор выбранного варианта (целое число) или default.
89
+ """
90
+ stored = None
91
+ if isinstance(key, str):
92
+ lower_key = key.lower().strip()
93
+ if lower_key in self._custom:
94
+ stored = self._custom[lower_key]
95
+ elif isinstance(key, int):
96
+ if key in self._custom:
97
+ stored = self._custom[key]
98
+ if isinstance(stored, dict):
99
+ enum_id = stored.get("enum_id")
100
+ if enum_id is not None:
101
+ return enum_id
102
+ # Если enum_id отсутствует, пробуем найти его в конфигурации кастомных полей
103
+ if self._custom_config:
104
+ field_def = None
105
+ if isinstance(key, int):
106
+ field_def = self._custom_config.get(key)
107
+ else:
108
+ for fid, fdef in self._custom_config.items():
109
+ if fdef.get("name", "").lower().strip() == key.lower().strip():
110
+ field_def = fdef
111
+ break
112
+ if field_def:
113
+ enums = field_def.get("enums") or []
114
+ for enum in enums:
115
+ # Сравниваем текстовое значение без учёта регистра и лишних пробелов
116
+ if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
117
+ return enum.get("id", default)
118
+ return default
119
+
120
+ class AmoCRMClient:
121
+ """
122
+ Клиент для работы с API amoCRM.
123
+
124
+ Основные функции:
125
+ - load_token: Загружает и проверяет токен авторизации.
126
+ - _make_request: Выполняет HTTP-запрос с учетом ограничения по скорости.
127
+ - get_deal_by_id: Получает данные сделки по ID и возвращает объект Deal.
128
+ - get_custom_fields_mapping: Загружает и кэширует список кастомных полей.
129
+ - find_custom_field_id: Ищет кастомное поле по его названию.
130
+ - update_lead: Обновляет сделку, включая стандартные и кастомные поля.
131
+
132
+ Дополнительно можно задать уровень логирования через параметр log_level,
133
+ либо полностью отключить логирование, установив disable_logging=True.
134
+ """
135
+ def __init__(self, base_url, token_file=None, cache_file=None, log_level=logging.INFO, disable_logging=False):
136
+ """
137
+ Инициализирует клиента, задавая базовый URL, токен авторизации и файл кэша для кастомных полей.
138
+
139
+ :param base_url: Базовый URL API amoCRM.
140
+ :param token_file: Файл, содержащий токен авторизации.
141
+ :param cache_file: Файл для кэширования данных кастомных полей.
142
+ :param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
143
+ :param disable_logging: Если True, логирование будет отключено.
144
+ """
145
+ self.base_url = base_url.rstrip('/')
146
+ domain = self.base_url.split("//")[-1].split(".")[0]
147
+ self.domain = domain
148
+ self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
149
+ if not cache_file:
150
+ cache_file = f"custom_fields_cache_{self.domain}.json"
151
+ self.cache_file = cache_file
152
+ self.token = self.load_token()
153
+ self._custom_fields_mapping = None
154
+
155
+ if disable_logging:
156
+ logging.disable(logging.CRITICAL)
157
+ else:
158
+ logger.setLevel(log_level)
159
+
160
+ logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
161
+
162
+ def load_token(self):
163
+ """
164
+ Загружает токен авторизации из файла или строки, проверяет его срок действия.
165
+
166
+ :return: Действительный access_token.
167
+ :raises Exception: Если токен не найден или истёк.
168
+ """
169
+ data = None
170
+ if os.path.exists(self.token_file):
171
+ with open(self.token_file, 'r') as f:
172
+ data = json.load(f)
173
+ logger.debug(f"Token loaded from file: {self.token_file}")
174
+ else:
175
+ try:
176
+ data = json.loads(self.token_file)
177
+ logger.debug("Token parsed from provided string.")
178
+ except Exception as e:
179
+ raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
180
+
181
+ expires_at_str = data.get('expires_at')
182
+ try:
183
+ expires_at = datetime.fromisoformat(expires_at_str).timestamp()
184
+ except Exception:
185
+ expires_at = float(expires_at_str)
186
+
187
+ if expires_at and time.time() < expires_at:
188
+ logger.debug("Token is valid.")
189
+ return data.get('access_token')
190
+ else:
191
+ raise Exception("Токен найден, но он истёк. Обновите токен.")
192
+
193
+ @sleep_and_retry
194
+ @limits(calls=RATE_LIMIT, period=1)
195
+ def _make_request(self, method, endpoint, params=None, data=None):
196
+ """
197
+ Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
198
+
199
+ :param method: HTTP-метод (GET, PATCH, POST, DELETE и т.д.).
200
+ :param endpoint: Конечная точка API (начинается с /api/v4/).
201
+ :param params: GET-параметры запроса.
202
+ :param data: Данные, отправляемые в JSON-формате.
203
+ :return: Ответ в формате JSON или None (если статус 204).
204
+ :raises Exception: При получении кода ошибки, отличного от 200/204.
205
+ """
206
+ url = f"{self.base_url}{endpoint}"
207
+ headers = {
208
+ "Authorization": f"Bearer {self.token}",
209
+ "Content-Type": "application/json"
210
+ }
211
+ logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
212
+ response = requests.request(method, url, headers=headers, params=params, json=data)
213
+ if response.status_code not in (200, 204):
214
+ logger.error(f"Request error {response.status_code}: {response.text}")
215
+ raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
216
+ if response.status_code == 204:
217
+ return None
218
+ return response.json()
219
+
220
+ def get_deal_by_id(self, deal_id):
221
+ """
222
+ Получает данные сделки по её ID и возвращает объект Deal.
223
+
224
+ :param deal_id: ID сделки.
225
+ :return: Объект Deal, включающий данные стандартных и кастомных полей.
226
+ """
227
+ endpoint = f"/api/v4/leads/{deal_id}"
228
+ params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
229
+ data = self._make_request("GET", endpoint, params=params)
230
+ custom_config = self.get_custom_fields_mapping()
231
+ logger.debug(f"Deal {deal_id} data received (содержимое полей не выводится полностью).")
232
+ return Deal(data, custom_fields_config=custom_config)
233
+
234
+ def _save_custom_fields_cache(self, mapping):
235
+ """
236
+ Сохраняет кэшированное отображение кастомных полей в файл.
237
+
238
+ :param mapping: Словарь с отображением кастомных полей.
239
+ """
240
+ cache_data = {"last_updated": time.time(), "mapping": mapping}
241
+ with open(self.cache_file, "w") as f:
242
+ json.dump(cache_data, f)
243
+ logger.debug(f"Custom fields cache saved to {self.cache_file}")
244
+
245
+ def _load_custom_fields_cache(self):
246
+ """
247
+ Загружает кэш кастомных полей из файла.
248
+
249
+ :return: Кэшированные данные или None, если не удалось загрузить.
250
+ """
251
+ if os.path.exists(self.cache_file):
252
+ with open(self.cache_file, "r") as f:
253
+ try:
254
+ cache_data = json.load(f)
255
+ logger.debug("Custom fields cache loaded successfully.")
256
+ return cache_data
257
+ except Exception as e:
258
+ logger.error(f"Error loading cache: {e}")
259
+ return None
260
+ return None
261
+
262
+ def get_custom_fields_mapping(self, force_update=False, cache_duration_hours=24):
263
+ """
264
+ Возвращает словарь отображения кастомных полей для сделок.
265
+ Если данные кэшированы и не устарели, возвращает кэш; иначе выполняет серию запросов,
266
+ чтобы получить все страницы (с лимитом 250 полей на запрос).
267
+
268
+ :param force_update: Принудительное обновление кэша.
269
+ :param cache_duration_hours: Время жизни кэша в часах.
270
+ :return: Словарь mapping, где ключи – ID полей, а значения – их модели.
271
+ """
272
+ if not force_update:
273
+ if self._custom_fields_mapping:
274
+ return self._custom_fields_mapping
275
+ cache_data = self._load_custom_fields_cache()
276
+ if cache_data:
277
+ last_updated = cache_data.get("last_updated", 0)
278
+ if time.time() - last_updated < cache_duration_hours * 3600:
279
+ self._custom_fields_mapping = cache_data.get("mapping")
280
+ logger.debug("Using cached custom fields mapping.")
281
+ return self._custom_fields_mapping
282
+
283
+ mapping = {}
284
+ page = 1
285
+ total_pages = 1 # Значение по умолчанию
286
+ while page <= total_pages:
287
+ endpoint = f"/api/v4/leads/custom_fields?limit=250&page={page}"
288
+ response = self._make_request("GET", endpoint)
289
+ if response and "_embedded" in response and "custom_fields" in response["_embedded"]:
290
+ for field in response["_embedded"]["custom_fields"]:
291
+ mapping[field["id"]] = field
292
+ total_pages = response.get("_page_count", page)
293
+ logger.debug(f"Fetched page {page} of {total_pages}")
294
+ page += 1
295
+ else:
296
+ break
297
+
298
+ logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
299
+ self._custom_fields_mapping = mapping
300
+ self._save_custom_fields_cache(mapping)
301
+ return mapping
302
+
303
+ def find_custom_field_id(self, search_term):
304
+ """
305
+ Ищет кастомное поле по заданному названию (или части названия).
306
+
307
+ :param search_term: Строка для поиска по имени поля.
308
+ :return: Кортеж (field_id, field_obj) если найдено, иначе (None, None).
309
+ """
310
+ mapping = self.get_custom_fields_mapping()
311
+ search_term_lower = search_term.lower().strip()
312
+ for key, field_obj in mapping.items():
313
+ if isinstance(field_obj, dict):
314
+ name = field_obj.get("name", "").lower().strip()
315
+ else:
316
+ name = str(field_obj).lower().strip()
317
+ if search_term_lower == name or search_term_lower in name:
318
+ logger.debug(f"Found custom field '{name}' with id {key}")
319
+ return int(key), field_obj
320
+ logger.debug(f"Custom field containing '{search_term}' not found.")
321
+ return None, None
322
+
323
+ def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
324
+ """
325
+ Обновляет сделку, задавая новые значения для стандартных и кастомных полей.
326
+
327
+ Для кастомных полей:
328
+ - Если значение передается как целое число, оно интерпретируется как идентификатор варианта (enum_id)
329
+ для полей типа select.
330
+ - Если значение передается как строка, используется ключ "value".
331
+
332
+ :param lead_id: ID сделки, которую нужно обновить.
333
+ :param update_fields: Словарь с полями для обновления. Ключи могут быть стандартными или названием кастомного поля.
334
+ :param tags_to_add: Список тегов для добавления к сделке.
335
+ :param tags_to_delete: Список тегов для удаления из сделки.
336
+ :return: Ответ API в формате JSON.
337
+ :raises Exception: Если одно из кастомных полей не найдено.
338
+ """
339
+ payload = {}
340
+ standard_fields = {
341
+ "name", "price", "status_id", "pipeline_id", "created_by", "updated_by",
342
+ "closed_at", "created_at", "updated_at", "loss_reason_id", "responsible_user_id"
343
+ }
344
+ custom_fields = []
345
+ for key, value in update_fields.items():
346
+ if key in standard_fields:
347
+ payload[key] = value
348
+ logger.debug(f"Standard field {key} set to {value}")
349
+ else:
350
+ # Если значение integer, интерпретируем как enum_id для полей типа select,
351
+ # иначе как текстовое значение.
352
+ if isinstance(value, int):
353
+ field_value_dict = {"enum_id": value}
354
+ else:
355
+ field_value_dict = {"value": value}
356
+ try:
357
+ # Если ключ уже число, считаем его field_id
358
+ field_id = int(key)
359
+ custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
360
+ logger.debug(f"Custom field by id {field_id} set to {value}")
361
+ except ValueError:
362
+ field_id, field_obj = self.find_custom_field_id(key)
363
+ if field_id is not None:
364
+ custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
365
+ logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
366
+ else:
367
+ raise Exception(f"Custom field '{key}' не найден.")
368
+ if custom_fields:
369
+ payload["custom_fields_values"] = custom_fields
370
+ if tags_to_add:
371
+ payload["tags_to_add"] = tags_to_add
372
+ if tags_to_delete:
373
+ payload["tags_to_delete"] = tags_to_delete
374
+ logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
375
+ endpoint = f"/api/v4/leads/{lead_id}"
376
+ response = self._make_request("PATCH", endpoint, data=payload)
377
+ logger.debug("Update response received.")
378
+ return response
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: amochka
3
- Version: 0.1.0
3
+ Version: 0.1.2.1
4
4
  Summary: Библиотека для работы с API amoCRM
5
5
  Home-page: UNKNOWN
6
6
  Author: Timurka
@@ -0,0 +1,6 @@
1
+ amochka/__init__.py,sha256=92YIjNK3notY2wfG-l2jmGIIWjfE3ozpamc-e4LEILc,106
2
+ amochka/client.py,sha256=ltU7_LwaxVPqAeLFwY-jnMDgDAQ6C1vafyKzZ0BtGjc,20816
3
+ amochka-0.1.2.1.dist-info/METADATA,sha256=Bu4bqvfMsAKcIr9-emUpAG2_3B3iqZkwRTXSStVcjZY,447
4
+ amochka-0.1.2.1.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
5
+ amochka-0.1.2.1.dist-info/top_level.txt,sha256=y5qXFXJUECmDwO6hyupsuYcTpZKZyByeE9e-1sa2U24,8
6
+ amochka-0.1.2.1.dist-info/RECORD,,
@@ -0,0 +1 @@
1
+ amochka
@@ -1,4 +0,0 @@
1
- amochka-0.1.0.dist-info/METADATA,sha256=AMjMd1He3gib75bFKOicICBOMu1iHMsXRnHFc1V7biE,445
2
- amochka-0.1.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
3
- amochka-0.1.0.dist-info/top_level.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
- amochka-0.1.0.dist-info/RECORD,,
@@ -1 +0,0 @@
1
-