amochka 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: amochka
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Библиотека для работы с API amoCRM
5
5
  Home-page: UNKNOWN
6
6
  Author: Timurka
@@ -0,0 +1,5 @@
1
+ """
2
+ amochka: Библиотека для работы с API amoCRM.
3
+ """
4
+
5
+ from .client import AmoCRMClient
@@ -0,0 +1,312 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import requests
5
+ import logging
6
+ from datetime import datetime
7
+ from ratelimit import limits, sleep_and_retry
8
+
9
+ # Создаём базовый логгер
10
+ logger = logging.getLogger(__name__)
11
+ if not logger.handlers:
12
+ ch = logging.StreamHandler()
13
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
14
+ ch.setFormatter(formatter)
15
+ logger.addHandler(ch)
16
+
17
+ RATE_LIMIT = 7 # Максимум 7 запросов в секунду
18
+
19
+ class Deal(dict):
20
+ """
21
+ Объект сделки расширяет стандартный словарь данными из custom_fields_values.
22
+ (Описание класса без изменений)
23
+ """
24
+ def __init__(self, data, custom_fields_config=None):
25
+ super().__init__(data)
26
+ self._custom = {}
27
+ self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
28
+ custom = data.get("custom_fields_values") or []
29
+ logger.debug(f"Processing custom_fields_values: {custom}")
30
+ for field in custom:
31
+ if isinstance(field, dict):
32
+ field_name = field.get("field_name")
33
+ values = field.get("values")
34
+ if field_name and values and isinstance(values, list) and len(values) > 0:
35
+ key_name = field_name.lower().strip()
36
+ # Сохраняем текстовое значение для доступа по названию
37
+ self._custom[key_name] = values[0].get("value")
38
+ logger.debug(f"Set custom field '{key_name}' = {self._custom[key_name]}")
39
+ field_id = field.get("field_id")
40
+ if field_id is not None and values and isinstance(values, list) and len(values) > 0:
41
+ stored_value = values[0].get("value")
42
+ stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
43
+ self._custom[int(field_id)] = {"value": stored_value, "enum_id": stored_enum_id}
44
+ logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
45
+ if custom_fields_config:
46
+ for cid, field_obj in custom_fields_config.items():
47
+ key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
48
+ if key not in self._custom:
49
+ self._custom[key] = None
50
+ logger.debug(f"Field '{key}' not found in deal data; set to None")
51
+
52
+ def __getitem__(self, key):
53
+ if key in super().keys():
54
+ return super().__getitem__(key)
55
+ if isinstance(key, str):
56
+ lower_key = key.lower().strip()
57
+ if lower_key in self._custom:
58
+ stored = self._custom[lower_key]
59
+ return stored.get("value") if isinstance(stored, dict) else stored
60
+ if isinstance(key, int):
61
+ if key in self._custom:
62
+ stored = self._custom[key]
63
+ return stored.get("value") if isinstance(stored, dict) else stored
64
+ raise KeyError(key)
65
+
66
+ def get(self, key, default=None):
67
+ try:
68
+ return self.__getitem__(key)
69
+ except KeyError:
70
+ return default
71
+
72
+ def get_id(self, key, default=None):
73
+ """
74
+ Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля.
75
+ (Описание метода без изменений)
76
+ """
77
+ stored = None
78
+ if isinstance(key, str):
79
+ lower_key = key.lower().strip()
80
+ if lower_key in self._custom:
81
+ stored = self._custom[lower_key]
82
+ elif isinstance(key, int):
83
+ if key in self._custom:
84
+ stored = self._custom[key]
85
+ if isinstance(stored, dict):
86
+ enum_id = stored.get("enum_id")
87
+ if enum_id is not None:
88
+ return enum_id
89
+ if self._custom_config:
90
+ field_def = None
91
+ if isinstance(key, int):
92
+ field_def = self._custom_config.get(key)
93
+ else:
94
+ for fid, fdef in self._custom_config.items():
95
+ if fdef.get("name", "").lower().strip() == key.lower().strip():
96
+ field_def = fdef
97
+ break
98
+ if field_def:
99
+ enums = field_def.get("enums") or []
100
+ for enum in enums:
101
+ if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
102
+ return enum.get("id", default)
103
+ return default
104
+
105
+ class AmoCRMClient:
106
+ """
107
+ Клиент для работы с API amoCRM.
108
+ (Описание класса без изменений, за исключением добавления параметра use_file_cache)
109
+ """
110
+ def __init__(self, base_url, token_file=None, cache_file=None, log_level=logging.INFO, disable_logging=False, use_file_cache=True):
111
+ """
112
+ Инициализирует клиента, задавая базовый URL, токен авторизации и файл кэша для кастомных полей.
113
+
114
+ :param use_file_cache: Если True, кэш будет сохраняться в файл; иначе — только в оперативной памяти.
115
+ """
116
+ self.base_url = base_url.rstrip('/')
117
+ domain = self.base_url.split("//")[-1].split(".")[0]
118
+ self.domain = domain
119
+ self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
120
+ if not cache_file:
121
+ cache_file = f"custom_fields_cache_{self.domain}.json"
122
+ self.cache_file = cache_file
123
+ self.use_file_cache = use_file_cache
124
+ self.token = self.load_token()
125
+ self._custom_fields_mapping = None
126
+
127
+ if disable_logging:
128
+ logging.disable(logging.CRITICAL)
129
+ else:
130
+ logger.setLevel(log_level)
131
+
132
+ logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
133
+
134
+ def load_token(self):
135
+ # Метод без изменений
136
+ data = None
137
+ if os.path.exists(self.token_file):
138
+ with open(self.token_file, 'r') as f:
139
+ data = json.load(f)
140
+ logger.debug(f"Token loaded from file: {self.token_file}")
141
+ else:
142
+ try:
143
+ data = json.loads(self.token_file)
144
+ logger.debug("Token parsed from provided string.")
145
+ except Exception as e:
146
+ raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
147
+
148
+ expires_at_str = data.get('expires_at')
149
+ try:
150
+ expires_at = datetime.fromisoformat(expires_at_str).timestamp()
151
+ except Exception:
152
+ expires_at = float(expires_at_str)
153
+
154
+ if expires_at and time.time() < expires_at:
155
+ logger.debug("Token is valid.")
156
+ return data.get('access_token')
157
+ else:
158
+ raise Exception("Токен найден, но он истёк. Обновите токен.")
159
+
160
+ @sleep_and_retry
161
+ @limits(calls=RATE_LIMIT, period=1)
162
+ def _make_request(self, method, endpoint, params=None, data=None):
163
+ # Метод без изменений
164
+ url = f"{self.base_url}{endpoint}"
165
+ headers = {
166
+ "Authorization": f"Bearer {self.token}",
167
+ "Content-Type": "application/json"
168
+ }
169
+ logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
170
+ response = requests.request(method, url, headers=headers, params=params, json=data)
171
+ if response.status_code not in (200, 204):
172
+ logger.error(f"Request error {response.status_code}: {response.text}")
173
+ raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
174
+ if response.status_code == 204:
175
+ return None
176
+ return response.json()
177
+
178
+ def get_deal_by_id(self, deal_id):
179
+ # Метод без изменений
180
+ endpoint = f"/api/v4/leads/{deal_id}"
181
+ params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
182
+ data = self._make_request("GET", endpoint, params=params)
183
+ custom_config = self.get_custom_fields_mapping()
184
+ logger.debug(f"Deal {deal_id} data received (содержимое полей не выводится полностью).")
185
+ return Deal(data, custom_fields_config=custom_config)
186
+
187
+ def _save_custom_fields_cache(self, mapping):
188
+ """
189
+ Сохраняет кэш кастомных полей в файл, если используется файловый кэш.
190
+ Если файловый кэш не используется, операция пропускается.
191
+ """
192
+ if not self.use_file_cache:
193
+ logger.debug("File caching disabled; cache stored in memory only.")
194
+ return
195
+ cache_data = {"last_updated": time.time(), "mapping": mapping}
196
+ with open(self.cache_file, "w") as f:
197
+ json.dump(cache_data, f)
198
+ logger.debug(f"Custom fields cache saved to {self.cache_file}")
199
+
200
+ def _load_custom_fields_cache(self):
201
+ """
202
+ Загружает кэш кастомных полей из файла, если используется файловый кэш.
203
+ Если файловый кэш не используется, возвращает None.
204
+ """
205
+ if not self.use_file_cache:
206
+ logger.debug("File caching disabled; no cache loaded from file.")
207
+ return None
208
+ if os.path.exists(self.cache_file):
209
+ with open(self.cache_file, "r") as f:
210
+ try:
211
+ cache_data = json.load(f)
212
+ logger.debug("Custom fields cache loaded successfully.")
213
+ return cache_data
214
+ except Exception as e:
215
+ logger.error(f"Error loading cache: {e}")
216
+ return None
217
+ return None
218
+
219
+ def get_custom_fields_mapping(self, force_update=False, cache_duration_hours=24):
220
+ """
221
+ Возвращает словарь отображения кастомных полей для сделок.
222
+ Если данные кэшированы и не устарели, возвращает кэш; иначе выполняет запросы для получения данных.
223
+ """
224
+ if not force_update:
225
+ if self._custom_fields_mapping:
226
+ return self._custom_fields_mapping
227
+ cache_data = self._load_custom_fields_cache()
228
+ if cache_data:
229
+ last_updated = cache_data.get("last_updated", 0)
230
+ if time.time() - last_updated < cache_duration_hours * 3600:
231
+ self._custom_fields_mapping = cache_data.get("mapping")
232
+ logger.debug("Using cached custom fields mapping.")
233
+ return self._custom_fields_mapping
234
+
235
+ mapping = {}
236
+ page = 1
237
+ total_pages = 1 # Значение по умолчанию
238
+ while page <= total_pages:
239
+ endpoint = f"/api/v4/leads/custom_fields?limit=250&page={page}"
240
+ response = self._make_request("GET", endpoint)
241
+ if response and "_embedded" in response and "custom_fields" in response["_embedded"]:
242
+ for field in response["_embedded"]["custom_fields"]:
243
+ mapping[field["id"]] = field
244
+ total_pages = response.get("_page_count", page)
245
+ logger.debug(f"Fetched page {page} of {total_pages}")
246
+ page += 1
247
+ else:
248
+ break
249
+
250
+ logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
251
+ self._custom_fields_mapping = mapping
252
+ self._save_custom_fields_cache(mapping)
253
+ return mapping
254
+
255
+ def find_custom_field_id(self, search_term):
256
+ """
257
+ Ищет кастомное поле по заданному названию (или части названия).
258
+ """
259
+ mapping = self.get_custom_fields_mapping()
260
+ search_term_lower = search_term.lower().strip()
261
+ for key, field_obj in mapping.items():
262
+ if isinstance(field_obj, dict):
263
+ name = field_obj.get("name", "").lower().strip()
264
+ else:
265
+ name = str(field_obj).lower().strip()
266
+ if search_term_lower == name or search_term_lower in name:
267
+ logger.debug(f"Found custom field '{name}' with id {key}")
268
+ return int(key), field_obj
269
+ logger.debug(f"Custom field containing '{search_term}' not found.")
270
+ return None, None
271
+
272
+ def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
273
+ """
274
+ Обновляет сделку, задавая новые значения для стандартных и кастомных полей.
275
+ """
276
+ payload = {}
277
+ standard_fields = {
278
+ "name", "price", "status_id", "pipeline_id", "created_by", "updated_by",
279
+ "closed_at", "created_at", "updated_at", "loss_reason_id", "responsible_user_id"
280
+ }
281
+ custom_fields = []
282
+ for key, value in update_fields.items():
283
+ if key in standard_fields:
284
+ payload[key] = value
285
+ logger.debug(f"Standard field {key} set to {value}")
286
+ else:
287
+ if isinstance(value, int):
288
+ field_value_dict = {"enum_id": value}
289
+ else:
290
+ field_value_dict = {"value": value}
291
+ try:
292
+ field_id = int(key)
293
+ custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
294
+ logger.debug(f"Custom field by id {field_id} set to {value}")
295
+ except ValueError:
296
+ field_id, field_obj = self.find_custom_field_id(key)
297
+ if field_id is not None:
298
+ custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
299
+ logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
300
+ else:
301
+ raise Exception(f"Custom field '{key}' не найден.")
302
+ if custom_fields:
303
+ payload["custom_fields_values"] = custom_fields
304
+ if tags_to_add:
305
+ payload["tags_to_add"] = tags_to_add
306
+ if tags_to_delete:
307
+ payload["tags_to_delete"] = tags_to_delete
308
+ logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
309
+ endpoint = f"/api/v4/leads/{lead_id}"
310
+ response = self._make_request("PATCH", endpoint, data=payload)
311
+ logger.debug("Update response received.")
312
+ return response
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: amochka
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Библиотека для работы с API amoCRM
5
5
  Home-page: UNKNOWN
6
6
  Author: Timurka
@@ -1,5 +1,7 @@
1
1
  README.md
2
2
  setup.py
3
+ amochka/__init__.py
4
+ amochka/client.py
3
5
  amochka.egg-info/PKG-INFO
4
6
  amochka.egg-info/SOURCES.txt
5
7
  amochka.egg-info/dependency_links.txt
@@ -0,0 +1 @@
1
+ amochka
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='amochka',
5
- version='0.1.2',
5
+ version='0.1.3',
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  'requests',
@@ -1 +0,0 @@
1
-
File without changes
File without changes