amochka 0.1.6__tar.gz → 0.1.8__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.
- amochka-0.1.8/MANIFEST.in +12 -0
- amochka-0.1.8/PKG-INFO +40 -0
- amochka-0.1.8/amochka/amochka/__init__.py +28 -0
- {amochka-0.1.6 → amochka-0.1.8/amochka}/amochka/client.py +448 -21
- amochka-0.1.8/amochka/amochka/etl.py +302 -0
- amochka-0.1.8/amochka/amochka.egg-info/PKG-INFO +40 -0
- amochka-0.1.8/amochka/amochka.egg-info/SOURCES.txt +11 -0
- {amochka-0.1.6 → amochka-0.1.8}/setup.py +9 -3
- amochka-0.1.6/PKG-INFO +0 -19
- amochka-0.1.6/amochka/__init__.py +0 -6
- amochka-0.1.6/amochka/etl.py +0 -91
- amochka-0.1.6/amochka/models.py +0 -50
- amochka-0.1.6/amochka.egg-info/PKG-INFO +0 -19
- amochka-0.1.6/amochka.egg-info/SOURCES.txt +0 -11
- {amochka-0.1.6 → amochka-0.1.8}/README.md +0 -0
- {amochka-0.1.6 → amochka-0.1.8/amochka}/amochka.egg-info/dependency_links.txt +0 -0
- {amochka-0.1.6 → amochka-0.1.8/amochka}/amochka.egg-info/requires.txt +0 -0
- {amochka-0.1.6 → amochka-0.1.8/amochka}/amochka.egg-info/top_level.txt +0 -0
- {amochka-0.1.6 → amochka-0.1.8}/setup.cfg +0 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Включаем только исходники пакета
|
|
2
|
+
recursive-include amochka/amochka *.py
|
|
3
|
+
|
|
4
|
+
include README.md
|
|
5
|
+
|
|
6
|
+
# Исключаем dev-артефакты
|
|
7
|
+
recursive-exclude amochka/tests *
|
|
8
|
+
prune amochka/venv
|
|
9
|
+
recursive-exclude amochka *.json
|
|
10
|
+
exclude amochka/leads.json
|
|
11
|
+
exclude amochka/.git*
|
|
12
|
+
exclude amochka/__pycache__/
|
amochka-0.1.8/PKG-INFO
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amochka
|
|
3
|
+
Version: 0.1.8
|
|
4
|
+
Summary: Библиотека для работы с API amoCRM
|
|
5
|
+
Home-page:
|
|
6
|
+
Author: Timurka
|
|
7
|
+
Author-email: timurdt@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.6
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: requests
|
|
14
|
+
Requires-Dist: ratelimit
|
|
15
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: requires-dist
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
|
23
|
+
|
|
24
|
+
# amochka
|
|
25
|
+
|
|
26
|
+
**amochka** — библиотека для работы с API amoCRM на Python. Она поддерживает:
|
|
27
|
+
- Получение данных сделок с вложенными сущностями (контакты, компании, теги, и т.д.)
|
|
28
|
+
- Редактирование сделок, включая обновление стандартных и кастомных полей
|
|
29
|
+
- Поддержку нескольких amoCRM-аккаунтов с персистентным кэшированием кастомных полей для каждого аккаунта отдельно
|
|
30
|
+
- Ограничение запросов (7 запросов в секунду) с использованием декораторов из библиотеки `ratelimit`
|
|
31
|
+
|
|
32
|
+
## Установка
|
|
33
|
+
|
|
34
|
+
Установить библиотеку можно из PyPI (после публикации):
|
|
35
|
+
|
|
36
|
+
pip install amochka
|
|
37
|
+
|
|
38
|
+
## Кэширование кастомных полей
|
|
39
|
+
|
|
40
|
+
Для уменьшения количества запросов к API кастомные поля кэшируются персистентно. Если параметр cache_file не указан, имя файла кэша генерируется автоматически на основе домена amoCRM-аккаунта. Вы можете обновлять кэш принудительно, передавая параметр force_update=True в метод get_custom_fields_mapping() или настроить время жизни кэша (по умолчанию — 24 часа).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
amochka: Библиотека для работы с API amoCRM.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.8"
|
|
6
|
+
|
|
7
|
+
from .client import AmoCRMClient, CacheConfig
|
|
8
|
+
from .etl import (
|
|
9
|
+
write_ndjson,
|
|
10
|
+
export_leads_to_ndjson,
|
|
11
|
+
export_contacts_to_ndjson,
|
|
12
|
+
export_notes_to_ndjson,
|
|
13
|
+
export_events_to_ndjson,
|
|
14
|
+
export_users_to_ndjson,
|
|
15
|
+
export_pipelines_to_ndjson,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AmoCRMClient",
|
|
20
|
+
"CacheConfig",
|
|
21
|
+
"write_ndjson",
|
|
22
|
+
"export_leads_to_ndjson",
|
|
23
|
+
"export_contacts_to_ndjson",
|
|
24
|
+
"export_notes_to_ndjson",
|
|
25
|
+
"export_events_to_ndjson",
|
|
26
|
+
"export_users_to_ndjson",
|
|
27
|
+
"export_pipelines_to_ndjson",
|
|
28
|
+
]
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import requests
|
|
5
5
|
import logging
|
|
6
6
|
from datetime import datetime
|
|
7
|
+
from typing import Iterator, List, Optional, Sequence, Union
|
|
7
8
|
from ratelimit import limits, sleep_and_retry
|
|
8
9
|
|
|
9
10
|
# Создаём базовый логгер
|
|
@@ -222,7 +223,11 @@ class AmoCRMClient:
|
|
|
222
223
|
token_file=None,
|
|
223
224
|
cache_config=None,
|
|
224
225
|
log_level=logging.INFO,
|
|
225
|
-
disable_logging=False
|
|
226
|
+
disable_logging=False,
|
|
227
|
+
*,
|
|
228
|
+
client_id: Optional[str] = None,
|
|
229
|
+
client_secret: Optional[str] = None,
|
|
230
|
+
redirect_uri: Optional[str] = None,
|
|
226
231
|
):
|
|
227
232
|
"""
|
|
228
233
|
Инициализирует клиента, задавая базовый URL, токен авторизации и настройки кэша для кастомных полей.
|
|
@@ -237,6 +242,11 @@ class AmoCRMClient:
|
|
|
237
242
|
domain = self.base_url.split("//")[-1].split(".")[0]
|
|
238
243
|
self.domain = domain
|
|
239
244
|
self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
|
|
245
|
+
|
|
246
|
+
# OAuth2 credentials (используются для авто‑refresh токена)
|
|
247
|
+
self.client_id = client_id
|
|
248
|
+
self.client_secret = client_secret
|
|
249
|
+
self.redirect_uri = redirect_uri
|
|
240
250
|
|
|
241
251
|
# Создаем логгер для конкретного экземпляра клиента
|
|
242
252
|
self.logger = logging.getLogger(f"{__name__}.{self.domain}")
|
|
@@ -265,15 +275,19 @@ class AmoCRMClient:
|
|
|
265
275
|
|
|
266
276
|
self.logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
|
|
267
277
|
|
|
268
|
-
self.token =
|
|
278
|
+
self.token = None
|
|
279
|
+
self.refresh_token = None
|
|
280
|
+
self.expires_at = None
|
|
281
|
+
self.load_token()
|
|
269
282
|
self._custom_fields_mapping = None
|
|
270
283
|
|
|
271
284
|
def load_token(self):
|
|
272
285
|
"""
|
|
273
286
|
Загружает токен авторизации из файла или строки, проверяет его срок действия.
|
|
287
|
+
При наличии refresh_token и учётных данных пробует обновить токен.
|
|
274
288
|
|
|
275
289
|
:return: Действительный access_token.
|
|
276
|
-
:raises Exception: Если токен не найден или
|
|
290
|
+
:raises Exception: Если токен не найден или истёк и нет возможности обновить.
|
|
277
291
|
"""
|
|
278
292
|
data = None
|
|
279
293
|
if os.path.exists(self.token_file):
|
|
@@ -287,21 +301,38 @@ class AmoCRMClient:
|
|
|
287
301
|
except Exception as e:
|
|
288
302
|
raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
|
|
289
303
|
|
|
304
|
+
self.refresh_token = data.get('refresh_token', self.refresh_token)
|
|
305
|
+
self.client_id = data.get('client_id', self.client_id)
|
|
306
|
+
self.client_secret = data.get('client_secret', self.client_secret)
|
|
307
|
+
self.redirect_uri = data.get('redirect_uri', self.redirect_uri)
|
|
308
|
+
|
|
290
309
|
expires_at_str = data.get('expires_at')
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
310
|
+
expires_at = None
|
|
311
|
+
if expires_at_str:
|
|
312
|
+
try:
|
|
313
|
+
expires_at = datetime.fromisoformat(expires_at_str).timestamp()
|
|
314
|
+
except Exception:
|
|
315
|
+
try:
|
|
316
|
+
expires_at = float(expires_at_str)
|
|
317
|
+
except Exception:
|
|
318
|
+
expires_at = None
|
|
319
|
+
self.expires_at = expires_at
|
|
320
|
+
|
|
321
|
+
access_token = data.get('access_token')
|
|
322
|
+
if access_token and expires_at and time.time() < expires_at:
|
|
297
323
|
self.logger.debug("Token is valid.")
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
324
|
+
self.token = access_token
|
|
325
|
+
return access_token
|
|
326
|
+
|
|
327
|
+
if self.refresh_token and self.client_id and self.client_secret and self.redirect_uri:
|
|
328
|
+
self.logger.info("Access token истёк, пробую обновить через refresh_token…")
|
|
329
|
+
return self._refresh_access_token()
|
|
330
|
+
|
|
331
|
+
raise Exception("Токен истёк или некорректен, и нет данных для refresh_token. Обновите токен.")
|
|
301
332
|
|
|
302
333
|
@sleep_and_retry
|
|
303
334
|
@limits(calls=RATE_LIMIT, period=1)
|
|
304
|
-
def _make_request(self, method, endpoint, params=None, data=None):
|
|
335
|
+
def _make_request(self, method, endpoint, params=None, data=None, timeout=10):
|
|
305
336
|
"""
|
|
306
337
|
Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
|
|
307
338
|
|
|
@@ -309,6 +340,7 @@ class AmoCRMClient:
|
|
|
309
340
|
:param endpoint: Конечная точка API (начинается с /api/v4/).
|
|
310
341
|
:param params: GET-параметры запроса.
|
|
311
342
|
:param data: Данные, отправляемые в JSON-формате.
|
|
343
|
+
:param timeout: Тайм‑аут запроса в секундах (по умолчанию 10).
|
|
312
344
|
:return: Ответ в формате JSON или None (если статус 204).
|
|
313
345
|
:raises Exception: При получении кода ошибки, отличного от 200/204.
|
|
314
346
|
"""
|
|
@@ -318,7 +350,14 @@ class AmoCRMClient:
|
|
|
318
350
|
"Content-Type": "application/json"
|
|
319
351
|
}
|
|
320
352
|
self.logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
|
|
321
|
-
response = requests.request(method, url, headers=headers, params=params, json=data)
|
|
353
|
+
response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
|
|
354
|
+
|
|
355
|
+
if response.status_code == 401 and self.refresh_token:
|
|
356
|
+
self.logger.info("Получен 401, пробую обновить токен и повторить запрос…")
|
|
357
|
+
self._refresh_access_token()
|
|
358
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
359
|
+
response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
|
|
360
|
+
|
|
322
361
|
if response.status_code not in (200, 204):
|
|
323
362
|
self.logger.error(f"Request error {response.status_code}: {response.text}")
|
|
324
363
|
raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
|
|
@@ -326,6 +365,363 @@ class AmoCRMClient:
|
|
|
326
365
|
return None
|
|
327
366
|
return response.json()
|
|
328
367
|
|
|
368
|
+
def _refresh_access_token(self):
|
|
369
|
+
"""Обновляет access_token по refresh_token и сохраняет его в token_file."""
|
|
370
|
+
if not all([self.refresh_token, self.client_id, self.client_secret, self.redirect_uri]):
|
|
371
|
+
raise Exception("Нельзя обновить токен: отсутствует refresh_token или client_id/client_secret/redirect_uri")
|
|
372
|
+
|
|
373
|
+
payload = {
|
|
374
|
+
"client_id": self.client_id,
|
|
375
|
+
"client_secret": self.client_secret,
|
|
376
|
+
"grant_type": "refresh_token",
|
|
377
|
+
"refresh_token": self.refresh_token,
|
|
378
|
+
"redirect_uri": self.redirect_uri,
|
|
379
|
+
}
|
|
380
|
+
token_url = f"{self.base_url}/oauth2/access_token"
|
|
381
|
+
self.logger.debug(f"Refreshing token via {token_url}")
|
|
382
|
+
resp = requests.post(token_url, json=payload, timeout=10)
|
|
383
|
+
if resp.status_code != 200:
|
|
384
|
+
self.logger.error(f"Не удалось обновить токен: {resp.status_code} {resp.text}")
|
|
385
|
+
raise Exception(f"Не удалось обновить токен: {resp.status_code}")
|
|
386
|
+
|
|
387
|
+
data = resp.json() or {}
|
|
388
|
+
access_token = data.get("access_token")
|
|
389
|
+
refresh_token = data.get("refresh_token", self.refresh_token)
|
|
390
|
+
expires_in = data.get("expires_in")
|
|
391
|
+
if not access_token:
|
|
392
|
+
raise Exception("Ответ на refresh не содержит access_token")
|
|
393
|
+
|
|
394
|
+
expires_at = None
|
|
395
|
+
if expires_in:
|
|
396
|
+
expires_at = time.time() + int(expires_in)
|
|
397
|
+
|
|
398
|
+
self.token = access_token
|
|
399
|
+
self.refresh_token = refresh_token
|
|
400
|
+
self.expires_at = expires_at
|
|
401
|
+
|
|
402
|
+
if self.token_file:
|
|
403
|
+
try:
|
|
404
|
+
with open(self.token_file, "w") as f:
|
|
405
|
+
json.dump({
|
|
406
|
+
"access_token": access_token,
|
|
407
|
+
"refresh_token": refresh_token,
|
|
408
|
+
"expires_at": datetime.fromtimestamp(expires_at).isoformat() if expires_at else None,
|
|
409
|
+
"client_id": self.client_id,
|
|
410
|
+
"client_secret": self.client_secret,
|
|
411
|
+
"redirect_uri": self.redirect_uri,
|
|
412
|
+
}, f)
|
|
413
|
+
self.logger.debug(f"Новый токен сохранён в {self.token_file}")
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
self.logger.error(f"Не удалось сохранить обновлённый токен: {exc}")
|
|
416
|
+
|
|
417
|
+
return access_token
|
|
418
|
+
|
|
419
|
+
def _to_timestamp(self, value: Optional[Union[int, float, str, datetime]]) -> Optional[int]:
|
|
420
|
+
"""
|
|
421
|
+
Преобразует значение даты/времени в Unix timestamp.
|
|
422
|
+
Возвращает None, если значение не указано.
|
|
423
|
+
"""
|
|
424
|
+
if value is None:
|
|
425
|
+
return None
|
|
426
|
+
if isinstance(value, datetime):
|
|
427
|
+
return int(value.timestamp())
|
|
428
|
+
if isinstance(value, (int, float)):
|
|
429
|
+
return int(value)
|
|
430
|
+
if isinstance(value, str):
|
|
431
|
+
try:
|
|
432
|
+
return int(datetime.fromisoformat(value).timestamp())
|
|
433
|
+
except ValueError as exc:
|
|
434
|
+
raise ValueError(f"Не удалось преобразовать '{value}' в timestamp") from exc
|
|
435
|
+
raise TypeError(f"Неподдерживаемый тип для timestamp: {type(value)}")
|
|
436
|
+
|
|
437
|
+
def _format_filter_values(self, values: Optional[Union[int, Sequence[Union[int, str]], str]]) -> Optional[Union[str, Sequence[Union[int, str]]]]:
|
|
438
|
+
"""
|
|
439
|
+
Преобразует значение или последовательность значений для передачи в запрос.
|
|
440
|
+
"""
|
|
441
|
+
if values is None:
|
|
442
|
+
return None
|
|
443
|
+
if isinstance(values, (list, tuple, set)):
|
|
444
|
+
return [str(v) for v in values]
|
|
445
|
+
return str(values)
|
|
446
|
+
|
|
447
|
+
def _extract_collection(self, response: dict, data_path: Sequence[str]) -> list:
|
|
448
|
+
"""
|
|
449
|
+
Извлекает коллекцию элементов из ответа API по указанному пути ключей.
|
|
450
|
+
"""
|
|
451
|
+
data = response or {}
|
|
452
|
+
for key in data_path:
|
|
453
|
+
if not isinstance(data, dict):
|
|
454
|
+
return []
|
|
455
|
+
data = data.get(key)
|
|
456
|
+
if data is None:
|
|
457
|
+
return []
|
|
458
|
+
if isinstance(data, list):
|
|
459
|
+
return data
|
|
460
|
+
return []
|
|
461
|
+
|
|
462
|
+
def _iterate_paginated(
|
|
463
|
+
self,
|
|
464
|
+
endpoint: str,
|
|
465
|
+
params: Optional[dict] = None,
|
|
466
|
+
data_path: Sequence[str] = ("_embedded",),
|
|
467
|
+
) -> Iterator[dict]:
|
|
468
|
+
"""
|
|
469
|
+
Возвращает генератор, проходящий по всем страницам ответа API и
|
|
470
|
+
yielding элементы коллекции.
|
|
471
|
+
"""
|
|
472
|
+
query = dict(params or {})
|
|
473
|
+
query.setdefault("page", 1)
|
|
474
|
+
query.setdefault("limit", 250)
|
|
475
|
+
|
|
476
|
+
while True:
|
|
477
|
+
response = self._make_request("GET", endpoint, params=query)
|
|
478
|
+
if not response:
|
|
479
|
+
break
|
|
480
|
+
items = self._extract_collection(response, data_path)
|
|
481
|
+
if not items:
|
|
482
|
+
break
|
|
483
|
+
for item in items:
|
|
484
|
+
yield item
|
|
485
|
+
|
|
486
|
+
total_pages = response.get("_page_count")
|
|
487
|
+
if total_pages is not None:
|
|
488
|
+
has_next = query["page"] < total_pages
|
|
489
|
+
else:
|
|
490
|
+
links = response.get("_links") or {}
|
|
491
|
+
next_link = links.get("next") if isinstance(links, dict) else None
|
|
492
|
+
has_next = bool(next_link)
|
|
493
|
+
if not has_next:
|
|
494
|
+
break
|
|
495
|
+
query["page"] += 1
|
|
496
|
+
|
|
497
|
+
def iter_leads(
|
|
498
|
+
self,
|
|
499
|
+
updated_from: Optional[Union[int, float, str, datetime]] = None,
|
|
500
|
+
updated_to: Optional[Union[int, float, str, datetime]] = None,
|
|
501
|
+
pipeline_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
502
|
+
include_contacts: bool = False,
|
|
503
|
+
include: Optional[Union[str, Sequence[str]]] = None,
|
|
504
|
+
limit: int = 250,
|
|
505
|
+
extra_params: Optional[dict] = None,
|
|
506
|
+
) -> Iterator[dict]:
|
|
507
|
+
"""
|
|
508
|
+
Итератор сделок с фильтрацией по диапазону обновления и воронкам.
|
|
509
|
+
"""
|
|
510
|
+
params = {"limit": limit, "page": 1}
|
|
511
|
+
start_ts = self._to_timestamp(updated_from)
|
|
512
|
+
end_ts = self._to_timestamp(updated_to)
|
|
513
|
+
if start_ts is not None:
|
|
514
|
+
params["filter[updated_at][from]"] = start_ts
|
|
515
|
+
if end_ts is not None:
|
|
516
|
+
params["filter[updated_at][to]"] = end_ts
|
|
517
|
+
pipeline_param = self._format_filter_values(pipeline_ids)
|
|
518
|
+
if pipeline_param:
|
|
519
|
+
params["filter[pipeline_id]"] = pipeline_param
|
|
520
|
+
|
|
521
|
+
include_parts: List[str] = []
|
|
522
|
+
if include_contacts:
|
|
523
|
+
include_parts.append("contacts")
|
|
524
|
+
if include:
|
|
525
|
+
if isinstance(include, str):
|
|
526
|
+
include_parts.append(include)
|
|
527
|
+
else:
|
|
528
|
+
include_parts.extend([str(item) for item in include])
|
|
529
|
+
if include_parts:
|
|
530
|
+
params["with"] = ",".join(sorted(set(include_parts)))
|
|
531
|
+
if extra_params:
|
|
532
|
+
params.update(extra_params)
|
|
533
|
+
|
|
534
|
+
yield from self._iterate_paginated(
|
|
535
|
+
"/api/v4/leads", params=params, data_path=("_embedded", "leads")
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def fetch_leads(self, *args, **kwargs) -> List[dict]:
|
|
539
|
+
"""
|
|
540
|
+
Возвращает список сделок. Обёртка над iter_leads.
|
|
541
|
+
"""
|
|
542
|
+
return list(self.iter_leads(*args, **kwargs))
|
|
543
|
+
|
|
544
|
+
def iter_contacts(
|
|
545
|
+
self,
|
|
546
|
+
updated_from: Optional[Union[int, float, str, datetime]] = None,
|
|
547
|
+
updated_to: Optional[Union[int, float, str, datetime]] = None,
|
|
548
|
+
contact_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
549
|
+
limit: int = 250,
|
|
550
|
+
extra_params: Optional[dict] = None,
|
|
551
|
+
) -> Iterator[dict]:
|
|
552
|
+
"""
|
|
553
|
+
Итератор контактов с фильтрацией по диапазону обновления или списку ID.
|
|
554
|
+
"""
|
|
555
|
+
params = {"limit": limit, "page": 1}
|
|
556
|
+
start_ts = self._to_timestamp(updated_from)
|
|
557
|
+
end_ts = self._to_timestamp(updated_to)
|
|
558
|
+
if start_ts is not None:
|
|
559
|
+
params["filter[updated_at][from]"] = start_ts
|
|
560
|
+
if end_ts is not None:
|
|
561
|
+
params["filter[updated_at][to]"] = end_ts
|
|
562
|
+
contact_param = self._format_filter_values(contact_ids)
|
|
563
|
+
if contact_param:
|
|
564
|
+
params["filter[id][]"] = contact_param
|
|
565
|
+
if extra_params:
|
|
566
|
+
params.update(extra_params)
|
|
567
|
+
|
|
568
|
+
yield from self._iterate_paginated(
|
|
569
|
+
"/api/v4/contacts", params=params, data_path=("_embedded", "contacts")
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def fetch_contacts(self, *args, **kwargs) -> List[dict]:
|
|
573
|
+
"""
|
|
574
|
+
Возвращает список контактов. Обёртка над iter_contacts.
|
|
575
|
+
"""
|
|
576
|
+
return list(self.iter_contacts(*args, **kwargs))
|
|
577
|
+
|
|
578
|
+
def get_contact_by_id(self, contact_id: Union[int, str], include: Optional[Union[str, Sequence[str]]] = None) -> dict:
|
|
579
|
+
"""
|
|
580
|
+
Получает данные контакта по его ID.
|
|
581
|
+
"""
|
|
582
|
+
endpoint = f"/api/v4/contacts/{contact_id}"
|
|
583
|
+
params = {}
|
|
584
|
+
if include:
|
|
585
|
+
if isinstance(include, str):
|
|
586
|
+
params["with"] = include
|
|
587
|
+
else:
|
|
588
|
+
params["with"] = ",".join(str(item) for item in include)
|
|
589
|
+
data = self._make_request("GET", endpoint, params=params)
|
|
590
|
+
if not data or not isinstance(data, dict) or "id" not in data:
|
|
591
|
+
raise Exception(f"Contact {contact_id} not found or invalid response.")
|
|
592
|
+
return data
|
|
593
|
+
|
|
594
|
+
def iter_notes(
|
|
595
|
+
self,
|
|
596
|
+
entity: str = "lead",
|
|
597
|
+
updated_from: Optional[Union[int, float, str, datetime]] = None,
|
|
598
|
+
updated_to: Optional[Union[int, float, str, datetime]] = None,
|
|
599
|
+
note_type: Optional[Union[str, Sequence[str]]] = None,
|
|
600
|
+
entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
601
|
+
limit: int = 250,
|
|
602
|
+
extra_params: Optional[dict] = None,
|
|
603
|
+
) -> Iterator[dict]:
|
|
604
|
+
"""
|
|
605
|
+
Итератор примечаний для заданной сущности.
|
|
606
|
+
"""
|
|
607
|
+
mapping = {
|
|
608
|
+
"lead": "leads",
|
|
609
|
+
"contact": "contacts",
|
|
610
|
+
"company": "companies",
|
|
611
|
+
"customer": "customers",
|
|
612
|
+
}
|
|
613
|
+
plural = mapping.get(entity.lower(), entity.lower() + "s")
|
|
614
|
+
endpoint = f"/api/v4/{plural}/notes"
|
|
615
|
+
|
|
616
|
+
params = {"limit": limit, "page": 1}
|
|
617
|
+
start_ts = self._to_timestamp(updated_from)
|
|
618
|
+
end_ts = self._to_timestamp(updated_to)
|
|
619
|
+
if start_ts is not None:
|
|
620
|
+
params["filter[updated_at][from]"] = start_ts
|
|
621
|
+
if end_ts is not None:
|
|
622
|
+
params["filter[updated_at][to]"] = end_ts
|
|
623
|
+
note_type_param = self._format_filter_values(note_type)
|
|
624
|
+
if note_type_param:
|
|
625
|
+
params["filter[note_type]"] = note_type_param
|
|
626
|
+
entity_param = self._format_filter_values(entity_ids)
|
|
627
|
+
if entity_param:
|
|
628
|
+
params["filter[entity_id]"] = entity_param
|
|
629
|
+
if extra_params:
|
|
630
|
+
params.update(extra_params)
|
|
631
|
+
|
|
632
|
+
yield from self._iterate_paginated(
|
|
633
|
+
endpoint, params=params, data_path=("_embedded", "notes")
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def fetch_notes(self, *args, **kwargs) -> List[dict]:
|
|
637
|
+
"""
|
|
638
|
+
Возвращает список примечаний. Обёртка над iter_notes.
|
|
639
|
+
"""
|
|
640
|
+
return list(self.iter_notes(*args, **kwargs))
|
|
641
|
+
|
|
642
|
+
def iter_events(
|
|
643
|
+
self,
|
|
644
|
+
entity: Optional[str] = None,
|
|
645
|
+
entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
646
|
+
event_type: Optional[Union[str, Sequence[str]]] = None,
|
|
647
|
+
created_from: Optional[Union[int, float, str, datetime]] = None,
|
|
648
|
+
created_to: Optional[Union[int, float, str, datetime]] = None,
|
|
649
|
+
limit: int = 250,
|
|
650
|
+
extra_params: Optional[dict] = None,
|
|
651
|
+
) -> Iterator[dict]:
|
|
652
|
+
"""
|
|
653
|
+
Итератор событий с фильтрацией по сущности, типам и диапазону дат.
|
|
654
|
+
"""
|
|
655
|
+
params = {"limit": limit, "page": 1}
|
|
656
|
+
if entity:
|
|
657
|
+
params["filter[entity]"] = entity
|
|
658
|
+
entity_param = self._format_filter_values(entity_ids)
|
|
659
|
+
if entity_param:
|
|
660
|
+
params["filter[entity_id]"] = entity_param
|
|
661
|
+
event_type_param = self._format_filter_values(event_type)
|
|
662
|
+
if event_type_param:
|
|
663
|
+
params["filter[type]"] = event_type_param
|
|
664
|
+
start_ts = self._to_timestamp(created_from)
|
|
665
|
+
end_ts = self._to_timestamp(created_to)
|
|
666
|
+
if start_ts is not None:
|
|
667
|
+
params["filter[created_at][from]"] = start_ts
|
|
668
|
+
if end_ts is not None:
|
|
669
|
+
params["filter[created_at][to]"] = end_ts
|
|
670
|
+
if extra_params:
|
|
671
|
+
params.update(extra_params)
|
|
672
|
+
|
|
673
|
+
yield from self._iterate_paginated(
|
|
674
|
+
"/api/v4/events", params=params, data_path=("_embedded", "events")
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
def fetch_events(self, *args, **kwargs) -> List[dict]:
|
|
678
|
+
"""
|
|
679
|
+
Возвращает список событий. Обёртка над iter_events.
|
|
680
|
+
"""
|
|
681
|
+
return list(self.iter_events(*args, **kwargs))
|
|
682
|
+
|
|
683
|
+
def iter_users(
|
|
684
|
+
self,
|
|
685
|
+
limit: int = 250,
|
|
686
|
+
extra_params: Optional[dict] = None,
|
|
687
|
+
) -> Iterator[dict]:
|
|
688
|
+
"""
|
|
689
|
+
Итератор пользователей аккаунта.
|
|
690
|
+
"""
|
|
691
|
+
params = {"limit": limit, "page": 1}
|
|
692
|
+
if extra_params:
|
|
693
|
+
params.update(extra_params)
|
|
694
|
+
yield from self._iterate_paginated(
|
|
695
|
+
"/api/v4/users", params=params, data_path=("_embedded", "users")
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
def fetch_users(self, *args, **kwargs) -> List[dict]:
|
|
699
|
+
"""
|
|
700
|
+
Возвращает список пользователей. Обёртка над iter_users.
|
|
701
|
+
"""
|
|
702
|
+
return list(self.iter_users(*args, **kwargs))
|
|
703
|
+
|
|
704
|
+
def iter_pipelines(
|
|
705
|
+
self,
|
|
706
|
+
limit: int = 250,
|
|
707
|
+
extra_params: Optional[dict] = None,
|
|
708
|
+
) -> Iterator[dict]:
|
|
709
|
+
"""
|
|
710
|
+
Итератор воронок со статусами.
|
|
711
|
+
"""
|
|
712
|
+
params = {"limit": limit, "page": 1}
|
|
713
|
+
if extra_params:
|
|
714
|
+
params.update(extra_params)
|
|
715
|
+
yield from self._iterate_paginated(
|
|
716
|
+
"/api/v4/leads/pipelines", params=params, data_path=("_embedded", "pipelines")
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
def fetch_pipelines(self, *args, **kwargs) -> List[dict]:
|
|
720
|
+
"""
|
|
721
|
+
Возвращает список воронок. Обёртка над iter_pipelines.
|
|
722
|
+
"""
|
|
723
|
+
return list(self.iter_pipelines(*args, **kwargs))
|
|
724
|
+
|
|
329
725
|
def get_deal_by_id(self, deal_id, skip_fields_mapping=False):
|
|
330
726
|
"""
|
|
331
727
|
Получает данные сделки по её ID и возвращает объект Deal.
|
|
@@ -639,6 +1035,40 @@ class AmoCRMClient:
|
|
|
639
1035
|
def get_contact_events(self, contact_id, **kwargs):
|
|
640
1036
|
return self.get_entity_events("contact", contact_id, **kwargs)
|
|
641
1037
|
|
|
1038
|
+
def fetch_updated_leads_raw(
|
|
1039
|
+
self,
|
|
1040
|
+
pipeline_id,
|
|
1041
|
+
updated_from,
|
|
1042
|
+
updated_to=None,
|
|
1043
|
+
save_to_file=None,
|
|
1044
|
+
limit=250,
|
|
1045
|
+
include_contacts=False,
|
|
1046
|
+
):
|
|
1047
|
+
"""Возвращает сделки из указанной воронки, обновленные в заданный период.
|
|
1048
|
+
|
|
1049
|
+
:param pipeline_id: ID воронки.
|
|
1050
|
+
:param updated_from: datetime, начиная с которого искать изменения.
|
|
1051
|
+
:param updated_to: datetime окончания диапазона (опционально).
|
|
1052
|
+
:param save_to_file: путь к файлу для сохранения результатов в формате JSON.
|
|
1053
|
+
:param limit: количество элементов на страницу (максимум 250).
|
|
1054
|
+
:param include_contacts: если True, в ответ будут включены данные контактов.
|
|
1055
|
+
:return: список словарей со сделками.
|
|
1056
|
+
"""
|
|
1057
|
+
|
|
1058
|
+
all_leads = self.fetch_leads(
|
|
1059
|
+
updated_from=updated_from,
|
|
1060
|
+
updated_to=updated_to,
|
|
1061
|
+
pipeline_ids=pipeline_id,
|
|
1062
|
+
include_contacts=include_contacts,
|
|
1063
|
+
limit=limit,
|
|
1064
|
+
)
|
|
1065
|
+
if save_to_file:
|
|
1066
|
+
with open(save_to_file, "w", encoding="utf-8") as f:
|
|
1067
|
+
json.dump(all_leads, f, ensure_ascii=False, indent=2)
|
|
1068
|
+
|
|
1069
|
+
self.logger.debug(f"Fetched {len(all_leads)} leads from pipeline {pipeline_id}")
|
|
1070
|
+
return all_leads
|
|
1071
|
+
|
|
642
1072
|
def get_event(self, event_id):
|
|
643
1073
|
"""
|
|
644
1074
|
Получает подробную информацию по конкретному событию по его ID.
|
|
@@ -663,12 +1093,9 @@ class AmoCRMClient:
|
|
|
663
1093
|
:return: Список словарей, где каждый словарь содержит данные воронки, а также, если присутствует, вложенные статусы.
|
|
664
1094
|
:raises Exception: Если данные не получены или структура ответа неверна.
|
|
665
1095
|
"""
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if response and '_embedded' in response and 'pipelines' in response['_embedded']:
|
|
669
|
-
pipelines = response['_embedded']['pipelines']
|
|
1096
|
+
pipelines = self.fetch_pipelines()
|
|
1097
|
+
if pipelines:
|
|
670
1098
|
self.logger.debug(f"Получено {len(pipelines)} воронок")
|
|
671
1099
|
return pipelines
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
raise Exception("Ошибка получения воронок из amoCRM")
|
|
1100
|
+
self.logger.error("Не удалось получить воронки из amoCRM")
|
|
1101
|
+
raise Exception("Ошибка получения воронок из amoCRM")
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Callable, Iterable, List, Optional, Sequence, Set, Union
|
|
4
|
+
|
|
5
|
+
from .client import AmoCRMClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _ensure_path(path: Union[str, Path]) -> Path:
|
|
9
|
+
output_path = Path(path)
|
|
10
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
return output_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolve_timestamp(record: dict, timestamp_fields: Sequence[str]) -> Optional[Union[int, float, str]]:
|
|
15
|
+
for field in timestamp_fields:
|
|
16
|
+
if not field:
|
|
17
|
+
continue
|
|
18
|
+
value = record.get(field)
|
|
19
|
+
if value is not None:
|
|
20
|
+
return value
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_ndjson(
|
|
25
|
+
records: Iterable[dict],
|
|
26
|
+
output_path: Union[str, Path],
|
|
27
|
+
*,
|
|
28
|
+
entity: str,
|
|
29
|
+
account_id: Optional[Union[int, str]] = None,
|
|
30
|
+
timestamp_fields: Sequence[str] = ("updated_at", "created_at"),
|
|
31
|
+
transform: Optional[Callable[[dict], dict]] = None,
|
|
32
|
+
on_record: Optional[Callable[[dict], None]] = None,
|
|
33
|
+
) -> int:
|
|
34
|
+
"""
|
|
35
|
+
Записывает переданные записи в формат NDJSON.
|
|
36
|
+
|
|
37
|
+
Возвращает количество записанных строк.
|
|
38
|
+
"""
|
|
39
|
+
path = _ensure_path(output_path)
|
|
40
|
+
count = 0
|
|
41
|
+
with path.open("w", encoding="utf-8") as handler:
|
|
42
|
+
for original in records:
|
|
43
|
+
payload = transform(original) if transform else original
|
|
44
|
+
timestamp = _resolve_timestamp(original, timestamp_fields)
|
|
45
|
+
line = {
|
|
46
|
+
"entity": entity,
|
|
47
|
+
"account_id": account_id,
|
|
48
|
+
"updated_at": timestamp,
|
|
49
|
+
"payload": payload,
|
|
50
|
+
}
|
|
51
|
+
handler.write(json.dumps(line, ensure_ascii=False))
|
|
52
|
+
handler.write("\n")
|
|
53
|
+
count += 1
|
|
54
|
+
if on_record:
|
|
55
|
+
on_record(original)
|
|
56
|
+
return count
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def export_leads_to_ndjson(
|
|
60
|
+
client: AmoCRMClient,
|
|
61
|
+
output_path: Union[str, Path],
|
|
62
|
+
account_id: Union[int, str],
|
|
63
|
+
*,
|
|
64
|
+
start=None,
|
|
65
|
+
end=None,
|
|
66
|
+
pipeline_ids=None,
|
|
67
|
+
include_contacts: bool = True,
|
|
68
|
+
include=None,
|
|
69
|
+
limit: int = 250,
|
|
70
|
+
extra_params: Optional[dict] = None,
|
|
71
|
+
on_record: Optional[Callable[[dict], None]] = None,
|
|
72
|
+
) -> int:
|
|
73
|
+
"""
|
|
74
|
+
Выгружает сделки и записывает их в NDJSON.
|
|
75
|
+
"""
|
|
76
|
+
records = client.iter_leads(
|
|
77
|
+
updated_from=start,
|
|
78
|
+
updated_to=end,
|
|
79
|
+
pipeline_ids=pipeline_ids,
|
|
80
|
+
include_contacts=include_contacts,
|
|
81
|
+
include=include,
|
|
82
|
+
limit=limit,
|
|
83
|
+
extra_params=extra_params,
|
|
84
|
+
)
|
|
85
|
+
return write_ndjson(
|
|
86
|
+
records,
|
|
87
|
+
output_path,
|
|
88
|
+
entity="lead",
|
|
89
|
+
account_id=account_id,
|
|
90
|
+
timestamp_fields=("updated_at", "created_at"),
|
|
91
|
+
on_record=on_record,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def export_contacts_to_ndjson(
|
|
96
|
+
client: AmoCRMClient,
|
|
97
|
+
output_path: Union[str, Path],
|
|
98
|
+
account_id: Union[int, str],
|
|
99
|
+
*,
|
|
100
|
+
start=None,
|
|
101
|
+
end=None,
|
|
102
|
+
contact_ids=None,
|
|
103
|
+
limit: int = 250,
|
|
104
|
+
extra_params: Optional[dict] = None,
|
|
105
|
+
on_record: Optional[Callable[[dict], None]] = None,
|
|
106
|
+
) -> int:
|
|
107
|
+
"""
|
|
108
|
+
Выгружает контакты и записывает их в NDJSON.
|
|
109
|
+
"""
|
|
110
|
+
contact_id_list: Optional[List[int]] = None
|
|
111
|
+
if contact_ids is not None:
|
|
112
|
+
if isinstance(contact_ids, (list, tuple, set)):
|
|
113
|
+
contact_id_list = [int(cid) for cid in contact_ids if cid is not None]
|
|
114
|
+
else:
|
|
115
|
+
contact_id_list = [int(contact_ids)]
|
|
116
|
+
|
|
117
|
+
def _iter_contacts():
|
|
118
|
+
seen: Set[int] = set()
|
|
119
|
+
if contact_id_list:
|
|
120
|
+
params = dict(extra_params or {})
|
|
121
|
+
params["filter[id][]"] = [str(cid) for cid in contact_id_list]
|
|
122
|
+
params["page"] = 1
|
|
123
|
+
params["limit"] = limit
|
|
124
|
+
while True:
|
|
125
|
+
response = client._make_request("GET", "/api/v4/contacts", params=params)
|
|
126
|
+
embedded = (response or {}).get("_embedded", {})
|
|
127
|
+
contacts = embedded.get("contacts") or []
|
|
128
|
+
if not contacts:
|
|
129
|
+
break
|
|
130
|
+
for contact in contacts:
|
|
131
|
+
cid = contact.get("id")
|
|
132
|
+
if cid is not None:
|
|
133
|
+
seen.add(int(cid))
|
|
134
|
+
yield contact
|
|
135
|
+
total_pages = response.get("_page_count", params["page"])
|
|
136
|
+
if params["page"] >= total_pages:
|
|
137
|
+
break
|
|
138
|
+
params["page"] += 1
|
|
139
|
+
else:
|
|
140
|
+
for contact in client.iter_contacts(
|
|
141
|
+
updated_from=start,
|
|
142
|
+
updated_to=end,
|
|
143
|
+
contact_ids=None,
|
|
144
|
+
limit=limit,
|
|
145
|
+
extra_params=extra_params,
|
|
146
|
+
):
|
|
147
|
+
cid = contact.get("id")
|
|
148
|
+
if cid is not None:
|
|
149
|
+
seen.add(int(cid))
|
|
150
|
+
yield contact
|
|
151
|
+
|
|
152
|
+
if contact_id_list:
|
|
153
|
+
missing = [cid for cid in contact_id_list if cid not in seen]
|
|
154
|
+
for cid in missing:
|
|
155
|
+
try:
|
|
156
|
+
contact = client.get_contact_by_id(cid)
|
|
157
|
+
except Exception:
|
|
158
|
+
continue
|
|
159
|
+
retrieved_id = contact.get("id")
|
|
160
|
+
if retrieved_id is not None and int(retrieved_id) not in seen:
|
|
161
|
+
seen.add(int(retrieved_id))
|
|
162
|
+
yield contact
|
|
163
|
+
|
|
164
|
+
return write_ndjson(
|
|
165
|
+
_iter_contacts(),
|
|
166
|
+
output_path,
|
|
167
|
+
entity="contact",
|
|
168
|
+
account_id=account_id,
|
|
169
|
+
timestamp_fields=("updated_at", "created_at"),
|
|
170
|
+
on_record=on_record,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def export_notes_to_ndjson(
|
|
175
|
+
client: AmoCRMClient,
|
|
176
|
+
output_path: Union[str, Path],
|
|
177
|
+
account_id: Union[int, str],
|
|
178
|
+
*,
|
|
179
|
+
entity: str = "lead",
|
|
180
|
+
start=None,
|
|
181
|
+
end=None,
|
|
182
|
+
note_type=None,
|
|
183
|
+
entity_ids=None,
|
|
184
|
+
limit: int = 250,
|
|
185
|
+
extra_params: Optional[dict] = None,
|
|
186
|
+
on_record: Optional[Callable[[dict], None]] = None,
|
|
187
|
+
) -> int:
|
|
188
|
+
"""
|
|
189
|
+
Выгружает примечания и записывает их в NDJSON.
|
|
190
|
+
"""
|
|
191
|
+
records = client.iter_notes(
|
|
192
|
+
entity=entity,
|
|
193
|
+
updated_from=start,
|
|
194
|
+
updated_to=end,
|
|
195
|
+
note_type=note_type,
|
|
196
|
+
entity_ids=entity_ids,
|
|
197
|
+
limit=limit,
|
|
198
|
+
extra_params=extra_params,
|
|
199
|
+
)
|
|
200
|
+
entity_name = f"{entity}_note" if entity else "note"
|
|
201
|
+
return write_ndjson(
|
|
202
|
+
records,
|
|
203
|
+
output_path,
|
|
204
|
+
entity=entity_name,
|
|
205
|
+
account_id=account_id,
|
|
206
|
+
timestamp_fields=("updated_at", "created_at"),
|
|
207
|
+
on_record=on_record,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def export_events_to_ndjson(
|
|
212
|
+
client: AmoCRMClient,
|
|
213
|
+
output_path: Union[str, Path],
|
|
214
|
+
account_id: Union[int, str],
|
|
215
|
+
*,
|
|
216
|
+
entity: Optional[str] = "lead",
|
|
217
|
+
start=None,
|
|
218
|
+
end=None,
|
|
219
|
+
event_type=None,
|
|
220
|
+
entity_ids=None,
|
|
221
|
+
limit: int = 250,
|
|
222
|
+
extra_params: Optional[dict] = None,
|
|
223
|
+
on_record: Optional[Callable[[dict], None]] = None,
|
|
224
|
+
) -> int:
|
|
225
|
+
"""
|
|
226
|
+
Выгружает события и записывает их в NDJSON.
|
|
227
|
+
"""
|
|
228
|
+
records = client.iter_events(
|
|
229
|
+
entity=entity,
|
|
230
|
+
entity_ids=entity_ids,
|
|
231
|
+
event_type=event_type,
|
|
232
|
+
created_from=start,
|
|
233
|
+
created_to=end,
|
|
234
|
+
limit=limit,
|
|
235
|
+
extra_params=extra_params,
|
|
236
|
+
)
|
|
237
|
+
entity_name = f"{entity}_event" if entity else "event"
|
|
238
|
+
return write_ndjson(
|
|
239
|
+
records,
|
|
240
|
+
output_path,
|
|
241
|
+
entity=entity_name,
|
|
242
|
+
account_id=account_id,
|
|
243
|
+
timestamp_fields=("created_at", "updated_at"),
|
|
244
|
+
on_record=on_record,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def export_users_to_ndjson(
|
|
249
|
+
client: AmoCRMClient,
|
|
250
|
+
output_path: Union[str, Path],
|
|
251
|
+
account_id: Union[int, str],
|
|
252
|
+
*,
|
|
253
|
+
limit: int = 250,
|
|
254
|
+
extra_params: Optional[dict] = None,
|
|
255
|
+
on_record: Optional[Callable[[dict], None]] = None,
|
|
256
|
+
) -> int:
|
|
257
|
+
"""
|
|
258
|
+
Выгружает пользователей и записывает их в NDJSON.
|
|
259
|
+
"""
|
|
260
|
+
records = client.iter_users(limit=limit, extra_params=extra_params)
|
|
261
|
+
return write_ndjson(
|
|
262
|
+
records,
|
|
263
|
+
output_path,
|
|
264
|
+
entity="user",
|
|
265
|
+
account_id=account_id,
|
|
266
|
+
timestamp_fields=("updated_at", "created_at"),
|
|
267
|
+
on_record=on_record,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def export_pipelines_to_ndjson(
|
|
272
|
+
client: AmoCRMClient,
|
|
273
|
+
output_path: Union[str, Path],
|
|
274
|
+
account_id: Union[int, str],
|
|
275
|
+
*,
|
|
276
|
+
limit: int = 250,
|
|
277
|
+
extra_params: Optional[dict] = None,
|
|
278
|
+
on_record: Optional[Callable[[dict], None]] = None,
|
|
279
|
+
) -> int:
|
|
280
|
+
"""
|
|
281
|
+
Выгружает воронки и записывает их в NDJSON.
|
|
282
|
+
"""
|
|
283
|
+
records = client.iter_pipelines(limit=limit, extra_params=extra_params)
|
|
284
|
+
return write_ndjson(
|
|
285
|
+
records,
|
|
286
|
+
output_path,
|
|
287
|
+
entity="pipeline",
|
|
288
|
+
account_id=account_id,
|
|
289
|
+
timestamp_fields=("updated_at", "created_at"),
|
|
290
|
+
on_record=on_record,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
__all__ = [
|
|
295
|
+
"write_ndjson",
|
|
296
|
+
"export_leads_to_ndjson",
|
|
297
|
+
"export_contacts_to_ndjson",
|
|
298
|
+
"export_notes_to_ndjson",
|
|
299
|
+
"export_events_to_ndjson",
|
|
300
|
+
"export_users_to_ndjson",
|
|
301
|
+
"export_pipelines_to_ndjson",
|
|
302
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amochka
|
|
3
|
+
Version: 0.1.8
|
|
4
|
+
Summary: Библиотека для работы с API amoCRM
|
|
5
|
+
Home-page:
|
|
6
|
+
Author: Timurka
|
|
7
|
+
Author-email: timurdt@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.6
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: requests
|
|
14
|
+
Requires-Dist: ratelimit
|
|
15
|
+
Dynamic: author
|
|
16
|
+
Dynamic: author-email
|
|
17
|
+
Dynamic: classifier
|
|
18
|
+
Dynamic: description
|
|
19
|
+
Dynamic: description-content-type
|
|
20
|
+
Dynamic: requires-dist
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
|
23
|
+
|
|
24
|
+
# amochka
|
|
25
|
+
|
|
26
|
+
**amochka** — библиотека для работы с API amoCRM на Python. Она поддерживает:
|
|
27
|
+
- Получение данных сделок с вложенными сущностями (контакты, компании, теги, и т.д.)
|
|
28
|
+
- Редактирование сделок, включая обновление стандартных и кастомных полей
|
|
29
|
+
- Поддержку нескольких amoCRM-аккаунтов с персистентным кэшированием кастомных полей для каждого аккаунта отдельно
|
|
30
|
+
- Ограничение запросов (7 запросов в секунду) с использованием декораторов из библиотеки `ratelimit`
|
|
31
|
+
|
|
32
|
+
## Установка
|
|
33
|
+
|
|
34
|
+
Установить библиотеку можно из PyPI (после публикации):
|
|
35
|
+
|
|
36
|
+
pip install amochka
|
|
37
|
+
|
|
38
|
+
## Кэширование кастомных полей
|
|
39
|
+
|
|
40
|
+
Для уменьшения количества запросов к API кастомные поля кэшируются персистентно. Если параметр cache_file не указан, имя файла кэша генерируется автоматически на основе домена amoCRM-аккаунта. Вы можете обновлять кэш принудительно, передавая параметр force_update=True в метод get_custom_fields_mapping() или настроить время жизни кэша (по умолчанию — 24 часа).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
setup.py
|
|
4
|
+
amochka/amochka/__init__.py
|
|
5
|
+
amochka/amochka/client.py
|
|
6
|
+
amochka/amochka/etl.py
|
|
7
|
+
amochka/amochka.egg-info/PKG-INFO
|
|
8
|
+
amochka/amochka.egg-info/SOURCES.txt
|
|
9
|
+
amochka/amochka.egg-info/dependency_links.txt
|
|
10
|
+
amochka/amochka.egg-info/requires.txt
|
|
11
|
+
amochka/amochka.egg-info/top_level.txt
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
from pathlib import Path
|
|
1
2
|
from setuptools import setup, find_packages
|
|
2
3
|
|
|
4
|
+
README = (Path(__file__).parent / "README.md").read_text(encoding="utf-8")
|
|
5
|
+
|
|
3
6
|
setup(
|
|
4
7
|
name='amochka',
|
|
5
|
-
version='0.1.
|
|
6
|
-
|
|
8
|
+
version='0.1.8',
|
|
9
|
+
package_dir={"": "amochka"},
|
|
10
|
+
packages=find_packages(where="amochka"),
|
|
7
11
|
install_requires=[
|
|
8
12
|
'requests',
|
|
9
13
|
'ratelimit'
|
|
@@ -11,6 +15,8 @@ setup(
|
|
|
11
15
|
author='Timurka',
|
|
12
16
|
author_email='timurdt@gmail.com',
|
|
13
17
|
description='Библиотека для работы с API amoCRM',
|
|
18
|
+
long_description=README,
|
|
19
|
+
long_description_content_type='text/markdown',
|
|
14
20
|
url='', # Укажите ваш URL репозитория
|
|
15
21
|
classifiers=[
|
|
16
22
|
'Programming Language :: Python :: 3',
|
|
@@ -18,4 +24,4 @@ setup(
|
|
|
18
24
|
'Operating System :: OS Independent',
|
|
19
25
|
],
|
|
20
26
|
python_requires='>=3.6',
|
|
21
|
-
)
|
|
27
|
+
)
|
amochka-0.1.6/PKG-INFO
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: amochka
|
|
3
|
-
Version: 0.1.6
|
|
4
|
-
Summary: Библиотека для работы с API amoCRM
|
|
5
|
-
Home-page:
|
|
6
|
-
Author: Timurka
|
|
7
|
-
Author-email: timurdt@gmail.com
|
|
8
|
-
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
-
Classifier: Operating System :: OS Independent
|
|
11
|
-
Requires-Python: >=3.6
|
|
12
|
-
Requires-Dist: requests
|
|
13
|
-
Requires-Dist: ratelimit
|
|
14
|
-
Dynamic: author
|
|
15
|
-
Dynamic: author-email
|
|
16
|
-
Dynamic: classifier
|
|
17
|
-
Dynamic: requires-dist
|
|
18
|
-
Dynamic: requires-python
|
|
19
|
-
Dynamic: summary
|
amochka-0.1.6/amochka/etl.py
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
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-0.1.6/amochka/models.py
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
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,19 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: amochka
|
|
3
|
-
Version: 0.1.6
|
|
4
|
-
Summary: Библиотека для работы с API amoCRM
|
|
5
|
-
Home-page:
|
|
6
|
-
Author: Timurka
|
|
7
|
-
Author-email: timurdt@gmail.com
|
|
8
|
-
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
-
Classifier: Operating System :: OS Independent
|
|
11
|
-
Requires-Python: >=3.6
|
|
12
|
-
Requires-Dist: requests
|
|
13
|
-
Requires-Dist: ratelimit
|
|
14
|
-
Dynamic: author
|
|
15
|
-
Dynamic: author-email
|
|
16
|
-
Dynamic: classifier
|
|
17
|
-
Dynamic: requires-dist
|
|
18
|
-
Dynamic: requires-python
|
|
19
|
-
Dynamic: summary
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
README.md
|
|
2
|
-
setup.py
|
|
3
|
-
amochka/__init__.py
|
|
4
|
-
amochka/client.py
|
|
5
|
-
amochka/etl.py
|
|
6
|
-
amochka/models.py
|
|
7
|
-
amochka.egg-info/PKG-INFO
|
|
8
|
-
amochka.egg-info/SOURCES.txt
|
|
9
|
-
amochka.egg-info/dependency_links.txt
|
|
10
|
-
amochka.egg-info/requires.txt
|
|
11
|
-
amochka.egg-info/top_level.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|