amochka 0.3.4__py3-none-any.whl → 0.4.0__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 +17 -1
- amochka/client.py +453 -146
- amochka/errors.py +49 -0
- {amochka-0.3.4.dist-info → amochka-0.4.0.dist-info}/METADATA +1 -1
- {amochka-0.3.4.dist-info → amochka-0.4.0.dist-info}/RECORD +8 -7
- etl/extractors.py +2 -2
- {amochka-0.3.4.dist-info → amochka-0.4.0.dist-info}/WHEEL +0 -0
- {amochka-0.3.4.dist-info → amochka-0.4.0.dist-info}/top_level.txt +0 -0
amochka/__init__.py
CHANGED
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
amochka: Библиотека для работы с API amoCRM.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
__version__ = "0.
|
|
5
|
+
__version__ = "0.4.0"
|
|
6
6
|
|
|
7
7
|
from .client import AmoCRMClient, CacheConfig
|
|
8
|
+
from .errors import (
|
|
9
|
+
AmoCRMError,
|
|
10
|
+
APIError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
8
16
|
from .etl import (
|
|
9
17
|
write_ndjson,
|
|
10
18
|
export_leads_to_ndjson,
|
|
@@ -18,6 +26,14 @@ from .etl import (
|
|
|
18
26
|
__all__ = [
|
|
19
27
|
"AmoCRMClient",
|
|
20
28
|
"CacheConfig",
|
|
29
|
+
# Exceptions
|
|
30
|
+
"AmoCRMError",
|
|
31
|
+
"APIError",
|
|
32
|
+
"AuthenticationError",
|
|
33
|
+
"NotFoundError",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"ValidationError",
|
|
36
|
+
# ETL functions
|
|
21
37
|
"write_ndjson",
|
|
22
38
|
"export_leads_to_ndjson",
|
|
23
39
|
"export_contacts_to_ndjson",
|
amochka/client.py
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import stat
|
|
2
3
|
import time
|
|
3
4
|
import json
|
|
4
5
|
import requests
|
|
5
6
|
import logging
|
|
6
7
|
from datetime import datetime
|
|
7
|
-
from typing import Iterator, List, Optional, Sequence, Union
|
|
8
|
+
from typing import Any, Callable, Iterator, List, Optional, Sequence, Union
|
|
8
9
|
# ratelimit больше не используется - rate limiting реализован вручную через self.rate_limit
|
|
9
10
|
|
|
11
|
+
from .errors import (
|
|
12
|
+
AmoCRMError,
|
|
13
|
+
APIError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
|
|
10
20
|
# Создаём базовый логгер
|
|
11
21
|
logger = logging.getLogger(__name__)
|
|
12
22
|
if not logger.handlers:
|
|
@@ -194,8 +204,18 @@ class CacheConfig:
|
|
|
194
204
|
def __init__(self, enabled=True, storage='file', base_dir=None, file=None, lifetime_hours='default'):
|
|
195
205
|
self.enabled = enabled
|
|
196
206
|
self.storage = storage.lower()
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
|
|
208
|
+
# Валидация и нормализация base_dir для защиты от path traversal
|
|
209
|
+
if base_dir is None:
|
|
210
|
+
self.base_dir = os.path.join(os.path.expanduser('~'), '.amocrm', 'cache')
|
|
211
|
+
else:
|
|
212
|
+
self.base_dir = self._validate_path(base_dir, "base_dir")
|
|
213
|
+
|
|
214
|
+
# Валидация file для обратной совместимости
|
|
215
|
+
if file is not None:
|
|
216
|
+
self.file = self._validate_path(file, "file")
|
|
217
|
+
else:
|
|
218
|
+
self.file = None
|
|
199
219
|
|
|
200
220
|
# Обработка lifetime_hours: может быть int, dict, None, или 'default'
|
|
201
221
|
if lifetime_hours == 'default':
|
|
@@ -218,6 +238,29 @@ class CacheConfig:
|
|
|
218
238
|
# Fallback на дефолтные значения
|
|
219
239
|
self.lifetime_hours = self.DEFAULT_LIFETIMES.copy()
|
|
220
240
|
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _validate_path(path: str, param_name: str) -> str:
|
|
243
|
+
"""
|
|
244
|
+
Валидирует путь для защиты от path traversal атак.
|
|
245
|
+
|
|
246
|
+
:param path: Путь для валидации
|
|
247
|
+
:param param_name: Название параметра (для сообщения об ошибке)
|
|
248
|
+
:return: Нормализованный абсолютный путь
|
|
249
|
+
:raises ValueError: Если путь содержит path traversal символы
|
|
250
|
+
"""
|
|
251
|
+
if not path or not isinstance(path, str):
|
|
252
|
+
raise ValueError(f"{param_name} must be a non-empty string")
|
|
253
|
+
|
|
254
|
+
# Разворачиваем ~ и нормализуем путь
|
|
255
|
+
normalized = os.path.normpath(os.path.expanduser(path))
|
|
256
|
+
|
|
257
|
+
# Проверяем на наличие ".." в нормализованном пути
|
|
258
|
+
# (это означает попытку выйти за пределы директории)
|
|
259
|
+
if ".." in normalized.split(os.sep):
|
|
260
|
+
raise ValueError(f"{param_name} contains path traversal sequences: {path}")
|
|
261
|
+
|
|
262
|
+
return normalized
|
|
263
|
+
|
|
221
264
|
def get_lifetime(self, data_type):
|
|
222
265
|
"""
|
|
223
266
|
Получает время жизни кэша для указанного типа данных.
|
|
@@ -288,7 +331,8 @@ class AmoCRMClient:
|
|
|
288
331
|
:param max_retries: Максимальное количество повторных попыток при ошибках (по умолчанию 3).
|
|
289
332
|
:param retry_delay: Базовая задержка между попытками в секундах (по умолчанию 1.0).
|
|
290
333
|
"""
|
|
291
|
-
|
|
334
|
+
# Валидация base_url для защиты от SSRF
|
|
335
|
+
self.base_url = self._validate_base_url(base_url)
|
|
292
336
|
self.rate_limit = rate_limit if rate_limit is not None else DEFAULT_RATE_LIMIT
|
|
293
337
|
self.max_retries = max_retries
|
|
294
338
|
self.retry_delay = retry_delay
|
|
@@ -339,16 +383,101 @@ class AmoCRMClient:
|
|
|
339
383
|
self._pipelines_cache = None
|
|
340
384
|
self._users_cache = None
|
|
341
385
|
|
|
386
|
+
# Memory cache TTL (время жизни кэша в памяти в секундах, по умолчанию 1 час)
|
|
387
|
+
self._memory_cache_ttl = 3600
|
|
388
|
+
self._cache_timestamps = {}
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def _mask_sensitive(value: str, show_chars: int = 4) -> str:
|
|
392
|
+
"""
|
|
393
|
+
Маскирует sensitive данные для безопасного логирования.
|
|
394
|
+
|
|
395
|
+
:param value: Строка для маскирования
|
|
396
|
+
:param show_chars: Количество символов для показа в начале и конце
|
|
397
|
+
:return: Замаскированная строка
|
|
398
|
+
"""
|
|
399
|
+
if not value or not isinstance(value, str):
|
|
400
|
+
return "***"
|
|
401
|
+
if len(value) <= show_chars * 2:
|
|
402
|
+
return "***"
|
|
403
|
+
return f"{value[:show_chars]}...{value[-show_chars:]}"
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def _validate_base_url(base_url: str) -> str:
|
|
407
|
+
"""
|
|
408
|
+
Валидирует base_url для защиты от SSRF атак.
|
|
409
|
+
|
|
410
|
+
:param base_url: URL для валидации
|
|
411
|
+
:return: Нормализованный URL (без trailing slash)
|
|
412
|
+
:raises ValueError: Если URL не соответствует разрешенным доменам amoCRM/Kommo
|
|
413
|
+
"""
|
|
414
|
+
if not base_url or not isinstance(base_url, str):
|
|
415
|
+
raise ValueError("base_url must be a non-empty string")
|
|
416
|
+
|
|
417
|
+
# Приводим к lowercase для проверки
|
|
418
|
+
url_lower = base_url.lower().rstrip('/')
|
|
419
|
+
|
|
420
|
+
# Проверяем протокол (только HTTPS)
|
|
421
|
+
if not url_lower.startswith('https://'):
|
|
422
|
+
raise ValueError("base_url must use HTTPS protocol for security")
|
|
423
|
+
|
|
424
|
+
# Извлекаем домен
|
|
425
|
+
try:
|
|
426
|
+
# Удаляем протокол и разбиваем на части
|
|
427
|
+
domain_part = url_lower.replace('https://', '')
|
|
428
|
+
domain = domain_part.split('/')[0] # Берем только домен без пути
|
|
429
|
+
|
|
430
|
+
# Разрешенные домены для amoCRM и Kommo
|
|
431
|
+
allowed_domains = [
|
|
432
|
+
'.amocrm.ru',
|
|
433
|
+
'.amocrm.com',
|
|
434
|
+
'.kommo.com',
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
# Проверяем, что домен заканчивается на один из разрешенных
|
|
438
|
+
is_valid = any(domain.endswith(suffix) for suffix in allowed_domains)
|
|
439
|
+
|
|
440
|
+
if not is_valid:
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f"base_url must be an official amoCRM or Kommo domain "
|
|
443
|
+
f"(*.amocrm.ru, *.amocrm.com, *.kommo.com), got: {domain}"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
except Exception as e:
|
|
447
|
+
if isinstance(e, ValueError):
|
|
448
|
+
raise
|
|
449
|
+
raise ValueError(f"Invalid base_url format: {base_url}") from e
|
|
450
|
+
|
|
451
|
+
return base_url.rstrip('/')
|
|
452
|
+
|
|
342
453
|
def load_token(self):
|
|
343
454
|
"""
|
|
344
|
-
Загружает токен авторизации из
|
|
455
|
+
Загружает токен авторизации из environment variables или файла, проверяет его срок действия.
|
|
345
456
|
При наличии refresh_token и учётных данных пробует обновить токен.
|
|
346
457
|
|
|
458
|
+
Приоритет загрузки токенов:
|
|
459
|
+
1. Environment variables (AMOCRM_ACCESS_TOKEN, AMOCRM_REFRESH_TOKEN, и т.д.)
|
|
460
|
+
2. Файл token_file или строка JSON
|
|
461
|
+
|
|
347
462
|
:return: Действительный access_token.
|
|
348
|
-
:raises
|
|
463
|
+
:raises AuthenticationError: Если токен не найден или истёк и нет возможности обновить.
|
|
349
464
|
"""
|
|
350
465
|
data = None
|
|
351
|
-
|
|
466
|
+
|
|
467
|
+
# 1. Проверяем environment variables (приоритет)
|
|
468
|
+
env_access_token = os.environ.get('AMOCRM_ACCESS_TOKEN')
|
|
469
|
+
if env_access_token:
|
|
470
|
+
self.logger.debug("Token loaded from environment variables.")
|
|
471
|
+
data = {
|
|
472
|
+
'access_token': env_access_token,
|
|
473
|
+
'refresh_token': os.environ.get('AMOCRM_REFRESH_TOKEN'),
|
|
474
|
+
'expires_at': os.environ.get('AMOCRM_EXPIRES_AT'),
|
|
475
|
+
'client_id': os.environ.get('AMOCRM_CLIENT_ID'),
|
|
476
|
+
'client_secret': os.environ.get('AMOCRM_CLIENT_SECRET'),
|
|
477
|
+
'redirect_uri': os.environ.get('AMOCRM_REDIRECT_URI'),
|
|
478
|
+
}
|
|
479
|
+
# 2. Загружаем из файла или строки
|
|
480
|
+
elif os.path.exists(self.token_file):
|
|
352
481
|
with open(self.token_file, 'r') as f:
|
|
353
482
|
data = json.load(f)
|
|
354
483
|
self.logger.debug(f"Token loaded from file: {self.token_file}")
|
|
@@ -357,7 +486,9 @@ class AmoCRMClient:
|
|
|
357
486
|
data = json.loads(self.token_file)
|
|
358
487
|
self.logger.debug("Token parsed from provided string.")
|
|
359
488
|
except Exception as e:
|
|
360
|
-
raise
|
|
489
|
+
raise AuthenticationError(
|
|
490
|
+
"Токен не найден: ни в environment variables, ни в файле, ни в переданной строке."
|
|
491
|
+
) from e
|
|
361
492
|
|
|
362
493
|
self.refresh_token = data.get('refresh_token', self.refresh_token)
|
|
363
494
|
self.client_id = data.get('client_id', self.client_id)
|
|
@@ -386,23 +517,25 @@ class AmoCRMClient:
|
|
|
386
517
|
self.logger.info("Access token истёк, пробую обновить через refresh_token…")
|
|
387
518
|
return self._refresh_access_token()
|
|
388
519
|
|
|
389
|
-
raise
|
|
520
|
+
raise AuthenticationError("Токен истёк или некорректен, и нет данных для refresh_token. Обновите токен.")
|
|
390
521
|
|
|
391
|
-
def
|
|
392
|
-
"""
|
|
522
|
+
def _clean_old_requests(self):
|
|
523
|
+
"""Удаляет запросы старше 1 секунды из истории запросов."""
|
|
393
524
|
now = time.time()
|
|
394
|
-
# Удаляем запросы старше 1 секунды
|
|
395
525
|
self._request_times = [t for t in self._request_times if now - t < 1.0]
|
|
396
526
|
|
|
527
|
+
def _wait_for_rate_limit(self):
|
|
528
|
+
"""Ожидает, если превышен лимит запросов в секунду."""
|
|
529
|
+
self._clean_old_requests()
|
|
530
|
+
|
|
397
531
|
if len(self._request_times) >= self.rate_limit:
|
|
398
532
|
# Ждём до освобождения слота
|
|
399
|
-
sleep_time = 1.0 - (
|
|
533
|
+
sleep_time = 1.0 - (time.time() - self._request_times[0])
|
|
400
534
|
if sleep_time > 0:
|
|
401
535
|
self.logger.debug(f"Rate limit: ожидание {sleep_time:.3f}с (лимит {self.rate_limit} req/s)")
|
|
402
536
|
time.sleep(sleep_time)
|
|
403
537
|
# Очищаем старые записи после ожидания
|
|
404
|
-
|
|
405
|
-
self._request_times = [t for t in self._request_times if now - t < 1.0]
|
|
538
|
+
self._clean_old_requests()
|
|
406
539
|
|
|
407
540
|
self._request_times.append(time.time())
|
|
408
541
|
|
|
@@ -416,9 +549,13 @@ class AmoCRMClient:
|
|
|
416
549
|
:param data: Данные, отправляемые в JSON-формате.
|
|
417
550
|
:param timeout: Тайм‑аут запроса в секундах (по умолчанию 10).
|
|
418
551
|
:return: Ответ в формате JSON или None (если статус 204).
|
|
419
|
-
:raises
|
|
552
|
+
:raises APIError: При получении кода ошибки, отличного от 200/204 после всех retry.
|
|
553
|
+
:raises RateLimitError: При превышении лимита запросов (429).
|
|
554
|
+
:raises AuthenticationError: При ошибках авторизации (401).
|
|
555
|
+
:raises NotFoundError: При отсутствии ресурса (404).
|
|
420
556
|
"""
|
|
421
557
|
url = f"{self.base_url}{endpoint}"
|
|
558
|
+
is_sensitive_endpoint = "oauth" in endpoint.lower() or "token" in endpoint.lower()
|
|
422
559
|
headers = {
|
|
423
560
|
"Authorization": f"Bearer {self.token}",
|
|
424
561
|
"Content-Type": "application/json"
|
|
@@ -454,36 +591,55 @@ class AmoCRMClient:
|
|
|
454
591
|
self.logger.warning(
|
|
455
592
|
f"Получен {response.status_code}, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})"
|
|
456
593
|
)
|
|
457
|
-
|
|
594
|
+
if response.status_code == 429:
|
|
595
|
+
message = "Rate limit exceeded"
|
|
596
|
+
if not is_sensitive_endpoint and response.text:
|
|
597
|
+
message = f"Rate limit exceeded: {response.text[:200]}"
|
|
598
|
+
last_exception = RateLimitError(
|
|
599
|
+
message,
|
|
600
|
+
retry_after=response.headers.get("Retry-After")
|
|
601
|
+
)
|
|
602
|
+
else:
|
|
603
|
+
if is_sensitive_endpoint:
|
|
604
|
+
last_exception = APIError(response.status_code, f"Request error at {endpoint}")
|
|
605
|
+
else:
|
|
606
|
+
last_exception = APIError(response.status_code, response.text)
|
|
458
607
|
if attempt < self.max_retries:
|
|
459
608
|
time.sleep(delay)
|
|
460
609
|
continue
|
|
461
610
|
raise last_exception
|
|
462
611
|
|
|
463
|
-
# Не-retryable
|
|
464
|
-
|
|
465
|
-
|
|
612
|
+
# Не-retryable ошибки (400, 403, 404 и т.д.)
|
|
613
|
+
if response.status_code == 404:
|
|
614
|
+
self.logger.error(f"Request error {response.status_code} at {endpoint}")
|
|
615
|
+
raise NotFoundError(f"Ресурс не найден: {endpoint}")
|
|
616
|
+
if is_sensitive_endpoint:
|
|
617
|
+
self.logger.error(f"Request error {response.status_code} at {endpoint}")
|
|
618
|
+
raise APIError(response.status_code, f"Request error at {endpoint}")
|
|
619
|
+
response_preview = response.text[:200] if response.text else ""
|
|
620
|
+
self.logger.error(f"Request error {response.status_code}: {response_preview}...")
|
|
621
|
+
raise APIError(response.status_code, response.text)
|
|
466
622
|
|
|
467
623
|
except requests.exceptions.Timeout as e:
|
|
468
|
-
last_exception = e
|
|
624
|
+
last_exception = APIError(0, f"Timeout: {e}")
|
|
469
625
|
delay = self._calculate_retry_delay(attempt)
|
|
470
626
|
self.logger.warning(f"Timeout, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})")
|
|
471
627
|
if attempt < self.max_retries:
|
|
472
628
|
time.sleep(delay)
|
|
473
629
|
continue
|
|
474
|
-
raise
|
|
630
|
+
raise APIError(0, f"Timeout после {self.max_retries + 1} попыток: {e}")
|
|
475
631
|
|
|
476
632
|
except requests.exceptions.ConnectionError as e:
|
|
477
|
-
last_exception = e
|
|
633
|
+
last_exception = APIError(0, f"Connection error: {e}")
|
|
478
634
|
delay = self._calculate_retry_delay(attempt)
|
|
479
635
|
self.logger.warning(f"Connection error, retry через {delay:.1f}с (попытка {attempt + 1}/{self.max_retries + 1})")
|
|
480
636
|
if attempt < self.max_retries:
|
|
481
637
|
time.sleep(delay)
|
|
482
638
|
continue
|
|
483
|
-
raise
|
|
639
|
+
raise APIError(0, f"Connection error после {self.max_retries + 1} попыток: {e}")
|
|
484
640
|
|
|
485
641
|
# Если дошли сюда — исчерпали все попытки
|
|
486
|
-
raise last_exception or
|
|
642
|
+
raise last_exception or APIError(0, "Неизвестная ошибка после всех retry")
|
|
487
643
|
|
|
488
644
|
def _calculate_retry_delay(self, attempt: int, response=None) -> float:
|
|
489
645
|
"""Вычисляет задержку перед retry с exponential backoff."""
|
|
@@ -502,7 +658,7 @@ class AmoCRMClient:
|
|
|
502
658
|
def _refresh_access_token(self):
|
|
503
659
|
"""Обновляет access_token по refresh_token и сохраняет его в token_file."""
|
|
504
660
|
if not all([self.refresh_token, self.client_id, self.client_secret, self.redirect_uri]):
|
|
505
|
-
raise
|
|
661
|
+
raise AuthenticationError("Нельзя обновить токен: отсутствует refresh_token или client_id/client_secret/redirect_uri")
|
|
506
662
|
|
|
507
663
|
payload = {
|
|
508
664
|
"client_id": self.client_id,
|
|
@@ -515,15 +671,16 @@ class AmoCRMClient:
|
|
|
515
671
|
self.logger.debug(f"Refreshing token via {token_url}")
|
|
516
672
|
resp = requests.post(token_url, json=payload, timeout=10)
|
|
517
673
|
if resp.status_code != 200:
|
|
518
|
-
|
|
519
|
-
|
|
674
|
+
# Не логируем response body, так как он может содержать токены
|
|
675
|
+
self.logger.error(f"Не удалось обновить токен: {resp.status_code}")
|
|
676
|
+
raise AuthenticationError(f"Не удалось обновить токен: {resp.status_code}")
|
|
520
677
|
|
|
521
678
|
data = resp.json() or {}
|
|
522
679
|
access_token = data.get("access_token")
|
|
523
680
|
refresh_token = data.get("refresh_token", self.refresh_token)
|
|
524
681
|
expires_in = data.get("expires_in")
|
|
525
682
|
if not access_token:
|
|
526
|
-
raise
|
|
683
|
+
raise AuthenticationError("Ответ на refresh не содержит access_token")
|
|
527
684
|
|
|
528
685
|
expires_at = None
|
|
529
686
|
if expires_in:
|
|
@@ -544,7 +701,9 @@ class AmoCRMClient:
|
|
|
544
701
|
"client_secret": self.client_secret,
|
|
545
702
|
"redirect_uri": self.redirect_uri,
|
|
546
703
|
}, f)
|
|
547
|
-
|
|
704
|
+
# Устанавливаем права доступа 600 (только владелец может читать/писать)
|
|
705
|
+
os.chmod(self.token_file, stat.S_IRUSR | stat.S_IWUSR)
|
|
706
|
+
self.logger.debug(f"Новый токен сохранён в {self.token_file} с правами 600")
|
|
548
707
|
except Exception as exc:
|
|
549
708
|
self.logger.error(f"Не удалось сохранить обновлённый токен: {exc}")
|
|
550
709
|
|
|
@@ -581,6 +740,7 @@ class AmoCRMClient:
|
|
|
581
740
|
|
|
582
741
|
:param data_type: Тип данных ('custom_fields', 'pipelines', 'users')
|
|
583
742
|
:return: Путь к файлу кэша
|
|
743
|
+
:raises ValueError: Если путь выходит за пределы base_dir (path traversal)
|
|
584
744
|
"""
|
|
585
745
|
# Для custom_fields используем старый путь, если указан (обратная совместимость)
|
|
586
746
|
if data_type == 'custom_fields' and self.cache_config.file:
|
|
@@ -591,8 +751,18 @@ class AmoCRMClient:
|
|
|
591
751
|
|
|
592
752
|
# Формируем имя файла: {account}_{data_type}.json
|
|
593
753
|
account_name = self._extract_account_name()
|
|
594
|
-
|
|
595
|
-
|
|
754
|
+
# Санитизация account_name: удаляем потенциально опасные символы
|
|
755
|
+
safe_account_name = account_name.replace("..", "").replace("/", "_").replace("\\", "_")
|
|
756
|
+
cache_filename = f"{safe_account_name}_{data_type}.json"
|
|
757
|
+
cache_path = os.path.join(self.cache_config.base_dir, cache_filename)
|
|
758
|
+
|
|
759
|
+
# Проверяем, что результирующий путь находится внутри base_dir
|
|
760
|
+
real_base = os.path.realpath(self.cache_config.base_dir)
|
|
761
|
+
real_cache = os.path.realpath(cache_path)
|
|
762
|
+
if not real_cache.startswith(real_base):
|
|
763
|
+
raise ValueError(f"Cache path traversal detected: {cache_path}")
|
|
764
|
+
|
|
765
|
+
return cache_path
|
|
596
766
|
|
|
597
767
|
def _is_cache_valid(self, data_type, last_updated):
|
|
598
768
|
"""
|
|
@@ -677,6 +847,67 @@ class AmoCRMClient:
|
|
|
677
847
|
self.logger.error(f"Error loading {data_type} cache: {e}")
|
|
678
848
|
return None
|
|
679
849
|
|
|
850
|
+
def _get_cached_resource(
|
|
851
|
+
self,
|
|
852
|
+
resource_type: str,
|
|
853
|
+
fetch_callable: Callable[[], Any],
|
|
854
|
+
force_update: bool = False
|
|
855
|
+
) -> Any:
|
|
856
|
+
"""
|
|
857
|
+
Универсальный метод для трехуровневого кэширования ресурсов.
|
|
858
|
+
|
|
859
|
+
Уровни кэширования:
|
|
860
|
+
1. Memory cache (с TTL) - самый быстрый
|
|
861
|
+
2. File cache (персистентный) - средний
|
|
862
|
+
3. API request - самый медленный
|
|
863
|
+
|
|
864
|
+
:param resource_type: Тип ресурса ('custom_fields', 'pipelines', 'users')
|
|
865
|
+
:param fetch_callable: Функция для загрузки данных из API
|
|
866
|
+
:param force_update: Если True, игнорирует кэш и загружает данные из API
|
|
867
|
+
:return: Кэшированные или свежие данные
|
|
868
|
+
"""
|
|
869
|
+
cache_attr = f"_{resource_type}_cache"
|
|
870
|
+
|
|
871
|
+
# 1. Проверяем memory cache
|
|
872
|
+
if not force_update:
|
|
873
|
+
cached_data = getattr(self, cache_attr, None)
|
|
874
|
+
if cached_data is not None:
|
|
875
|
+
# Проверяем TTL
|
|
876
|
+
cache_time = self._cache_timestamps.get(resource_type, 0)
|
|
877
|
+
if time.time() - cache_time < self._memory_cache_ttl:
|
|
878
|
+
self.logger.debug(f"Using memory-cached {resource_type}.")
|
|
879
|
+
return cached_data
|
|
880
|
+
else:
|
|
881
|
+
# Кэш устарел, очищаем
|
|
882
|
+
self.logger.debug(f"Memory cache for {resource_type} expired.")
|
|
883
|
+
setattr(self, cache_attr, None)
|
|
884
|
+
self._cache_timestamps.pop(resource_type, None)
|
|
885
|
+
|
|
886
|
+
# 2. Проверяем file cache
|
|
887
|
+
if not force_update and self.cache_config.enabled:
|
|
888
|
+
cached_data = self._load_cache(resource_type)
|
|
889
|
+
if cached_data is not None:
|
|
890
|
+
setattr(self, cache_attr, cached_data)
|
|
891
|
+
self._cache_timestamps[resource_type] = time.time()
|
|
892
|
+
self.logger.debug(f"{resource_type} loaded from file cache.")
|
|
893
|
+
return cached_data
|
|
894
|
+
|
|
895
|
+
# 3. Загружаем из API
|
|
896
|
+
self.logger.debug(f"Fetching {resource_type} from API...")
|
|
897
|
+
data = fetch_callable()
|
|
898
|
+
|
|
899
|
+
# Сохраняем в memory cache
|
|
900
|
+
setattr(self, cache_attr, data)
|
|
901
|
+
self._cache_timestamps[resource_type] = time.time()
|
|
902
|
+
|
|
903
|
+
# Сохраняем в file cache
|
|
904
|
+
if self.cache_config.enabled:
|
|
905
|
+
self._save_cache(resource_type, data)
|
|
906
|
+
|
|
907
|
+
data_count = len(data) if isinstance(data, (list, dict)) else "unknown"
|
|
908
|
+
self.logger.debug(f"Fetched {data_count} {resource_type} from API.")
|
|
909
|
+
return data
|
|
910
|
+
|
|
680
911
|
def _to_timestamp(self, value: Optional[Union[int, float, str, datetime]]) -> Optional[int]:
|
|
681
912
|
"""
|
|
682
913
|
Преобразует значение даты/времени в Unix timestamp.
|
|
@@ -725,16 +956,29 @@ class AmoCRMClient:
|
|
|
725
956
|
endpoint: str,
|
|
726
957
|
params: Optional[dict] = None,
|
|
727
958
|
data_path: Sequence[str] = ("_embedded",),
|
|
959
|
+
max_pages: Optional[int] = None,
|
|
728
960
|
) -> Iterator[dict]:
|
|
729
961
|
"""
|
|
730
962
|
Возвращает генератор, проходящий по всем страницам ответа API и
|
|
731
963
|
yielding элементы коллекции.
|
|
964
|
+
|
|
965
|
+
:param endpoint: API endpoint для запроса
|
|
966
|
+
:param params: Параметры запроса
|
|
967
|
+
:param data_path: Путь к данным в response
|
|
968
|
+
:param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
|
|
732
969
|
"""
|
|
733
970
|
query = dict(params or {})
|
|
734
971
|
query.setdefault("page", 1)
|
|
735
972
|
query.setdefault("limit", 250)
|
|
736
973
|
|
|
974
|
+
pages_fetched = 0
|
|
975
|
+
|
|
737
976
|
while True:
|
|
977
|
+
# Проверка на превышение max_pages
|
|
978
|
+
if max_pages is not None and pages_fetched >= max_pages:
|
|
979
|
+
self.logger.warning(f"Reached max_pages limit ({max_pages}), stopping pagination")
|
|
980
|
+
break
|
|
981
|
+
|
|
738
982
|
response = self._make_request("GET", endpoint, params=query)
|
|
739
983
|
if not response:
|
|
740
984
|
break
|
|
@@ -744,6 +988,8 @@ class AmoCRMClient:
|
|
|
744
988
|
for item in items:
|
|
745
989
|
yield item
|
|
746
990
|
|
|
991
|
+
pages_fetched += 1
|
|
992
|
+
|
|
747
993
|
total_pages = response.get("_page_count")
|
|
748
994
|
if total_pages is not None:
|
|
749
995
|
has_next = query["page"] < total_pages
|
|
@@ -764,10 +1010,36 @@ class AmoCRMClient:
|
|
|
764
1010
|
include: Optional[Union[str, Sequence[str]]] = None,
|
|
765
1011
|
limit: int = 250,
|
|
766
1012
|
extra_params: Optional[dict] = None,
|
|
1013
|
+
max_pages: Optional[int] = None,
|
|
767
1014
|
) -> Iterator[dict]:
|
|
768
1015
|
"""
|
|
769
1016
|
Итератор сделок с фильтрацией по диапазону обновления и воронкам.
|
|
770
|
-
|
|
1017
|
+
|
|
1018
|
+
:param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
|
|
1019
|
+
:raises ValidationError: Если параметры имеют некорректный тип или значение.
|
|
1020
|
+
"""
|
|
1021
|
+
# Валидация limit
|
|
1022
|
+
if not isinstance(limit, int):
|
|
1023
|
+
raise ValidationError(f"limit must be int, got {type(limit).__name__}")
|
|
1024
|
+
if not 1 <= limit <= 250:
|
|
1025
|
+
raise ValidationError(f"limit must be between 1 and 250, got {limit}")
|
|
1026
|
+
|
|
1027
|
+
# Валидация pipeline_ids
|
|
1028
|
+
if pipeline_ids is not None:
|
|
1029
|
+
if isinstance(pipeline_ids, int):
|
|
1030
|
+
if pipeline_ids <= 0:
|
|
1031
|
+
raise ValidationError(f"pipeline_id must be positive, got {pipeline_ids}")
|
|
1032
|
+
elif isinstance(pipeline_ids, (list, tuple)):
|
|
1033
|
+
if not pipeline_ids:
|
|
1034
|
+
raise ValidationError("pipeline_ids cannot be empty")
|
|
1035
|
+
for pid in pipeline_ids:
|
|
1036
|
+
if isinstance(pid, int) and pid <= 0:
|
|
1037
|
+
raise ValidationError(f"pipeline_id must be positive, got {pid}")
|
|
1038
|
+
if not isinstance(pid, (int, str)):
|
|
1039
|
+
raise ValidationError(f"pipeline_id must be int or str, got {type(pid).__name__}")
|
|
1040
|
+
elif not isinstance(pipeline_ids, str):
|
|
1041
|
+
raise ValidationError(f"pipeline_ids must be int, str or sequence, got {type(pipeline_ids).__name__}")
|
|
1042
|
+
|
|
771
1043
|
params = {"limit": limit, "page": 1}
|
|
772
1044
|
start_ts = self._to_timestamp(updated_from)
|
|
773
1045
|
end_ts = self._to_timestamp(updated_to)
|
|
@@ -793,7 +1065,7 @@ class AmoCRMClient:
|
|
|
793
1065
|
params.update(extra_params)
|
|
794
1066
|
|
|
795
1067
|
yield from self._iterate_paginated(
|
|
796
|
-
"/api/v4/leads", params=params, data_path=("_embedded", "leads")
|
|
1068
|
+
"/api/v4/leads", params=params, data_path=("_embedded", "leads"), max_pages=max_pages
|
|
797
1069
|
)
|
|
798
1070
|
|
|
799
1071
|
def fetch_leads(self, *args, **kwargs) -> List[dict]:
|
|
@@ -809,10 +1081,36 @@ class AmoCRMClient:
|
|
|
809
1081
|
contact_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
810
1082
|
limit: int = 250,
|
|
811
1083
|
extra_params: Optional[dict] = None,
|
|
1084
|
+
max_pages: Optional[int] = None,
|
|
812
1085
|
) -> Iterator[dict]:
|
|
813
1086
|
"""
|
|
814
1087
|
Итератор контактов с фильтрацией по диапазону обновления или списку ID.
|
|
815
|
-
|
|
1088
|
+
|
|
1089
|
+
:param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
|
|
1090
|
+
:raises ValidationError: Если параметры имеют некорректный тип или значение.
|
|
1091
|
+
"""
|
|
1092
|
+
# Валидация limit
|
|
1093
|
+
if not isinstance(limit, int):
|
|
1094
|
+
raise ValidationError(f"limit must be int, got {type(limit).__name__}")
|
|
1095
|
+
if not 1 <= limit <= 250:
|
|
1096
|
+
raise ValidationError(f"limit must be between 1 and 250, got {limit}")
|
|
1097
|
+
|
|
1098
|
+
# Валидация contact_ids
|
|
1099
|
+
if contact_ids is not None:
|
|
1100
|
+
if isinstance(contact_ids, int):
|
|
1101
|
+
if contact_ids <= 0:
|
|
1102
|
+
raise ValidationError(f"contact_id must be positive, got {contact_ids}")
|
|
1103
|
+
elif isinstance(contact_ids, (list, tuple)):
|
|
1104
|
+
if not contact_ids:
|
|
1105
|
+
raise ValidationError("contact_ids cannot be empty")
|
|
1106
|
+
for cid in contact_ids:
|
|
1107
|
+
if isinstance(cid, int) and cid <= 0:
|
|
1108
|
+
raise ValidationError(f"contact_id must be positive, got {cid}")
|
|
1109
|
+
if not isinstance(cid, (int, str)):
|
|
1110
|
+
raise ValidationError(f"contact_id must be int or str, got {type(cid).__name__}")
|
|
1111
|
+
elif not isinstance(contact_ids, str):
|
|
1112
|
+
raise ValidationError(f"contact_ids must be int, str or sequence, got {type(contact_ids).__name__}")
|
|
1113
|
+
|
|
816
1114
|
params = {"limit": limit, "page": 1}
|
|
817
1115
|
start_ts = self._to_timestamp(updated_from)
|
|
818
1116
|
end_ts = self._to_timestamp(updated_to)
|
|
@@ -827,7 +1125,7 @@ class AmoCRMClient:
|
|
|
827
1125
|
params.update(extra_params)
|
|
828
1126
|
|
|
829
1127
|
yield from self._iterate_paginated(
|
|
830
|
-
"/api/v4/contacts", params=params, data_path=("_embedded", "contacts")
|
|
1128
|
+
"/api/v4/contacts", params=params, data_path=("_embedded", "contacts"), max_pages=max_pages
|
|
831
1129
|
)
|
|
832
1130
|
|
|
833
1131
|
def fetch_contacts(self, *args, **kwargs) -> List[dict]:
|
|
@@ -839,6 +1137,8 @@ class AmoCRMClient:
|
|
|
839
1137
|
def get_contact_by_id(self, contact_id: Union[int, str], include: Optional[Union[str, Sequence[str]]] = None) -> dict:
|
|
840
1138
|
"""
|
|
841
1139
|
Получает данные контакта по его ID.
|
|
1140
|
+
|
|
1141
|
+
:raises NotFoundError: Если контакт не найден
|
|
842
1142
|
"""
|
|
843
1143
|
endpoint = f"/api/v4/contacts/{contact_id}"
|
|
844
1144
|
params = {}
|
|
@@ -849,7 +1149,7 @@ class AmoCRMClient:
|
|
|
849
1149
|
params["with"] = ",".join(str(item) for item in include)
|
|
850
1150
|
data = self._make_request("GET", endpoint, params=params)
|
|
851
1151
|
if not data or not isinstance(data, dict) or "id" not in data:
|
|
852
|
-
raise
|
|
1152
|
+
raise NotFoundError(f"Contact {contact_id} not found or invalid response.")
|
|
853
1153
|
return data
|
|
854
1154
|
|
|
855
1155
|
def iter_notes(
|
|
@@ -861,10 +1161,42 @@ class AmoCRMClient:
|
|
|
861
1161
|
entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
|
|
862
1162
|
limit: int = 250,
|
|
863
1163
|
extra_params: Optional[dict] = None,
|
|
1164
|
+
max_pages: Optional[int] = None,
|
|
864
1165
|
) -> Iterator[dict]:
|
|
865
1166
|
"""
|
|
866
1167
|
Итератор примечаний для заданной сущности.
|
|
867
|
-
|
|
1168
|
+
|
|
1169
|
+
:param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
|
|
1170
|
+
:raises ValidationError: Если параметры имеют некорректный тип или значение.
|
|
1171
|
+
"""
|
|
1172
|
+
# Валидация limit
|
|
1173
|
+
if not isinstance(limit, int):
|
|
1174
|
+
raise ValidationError(f"limit must be int, got {type(limit).__name__}")
|
|
1175
|
+
if not 1 <= limit <= 250:
|
|
1176
|
+
raise ValidationError(f"limit must be between 1 and 250, got {limit}")
|
|
1177
|
+
|
|
1178
|
+
# Валидация entity
|
|
1179
|
+
if not isinstance(entity, str):
|
|
1180
|
+
raise ValidationError(f"entity must be str, got {type(entity).__name__}")
|
|
1181
|
+
if not entity:
|
|
1182
|
+
raise ValidationError("entity cannot be empty")
|
|
1183
|
+
|
|
1184
|
+
# Валидация entity_ids
|
|
1185
|
+
if entity_ids is not None:
|
|
1186
|
+
if isinstance(entity_ids, int):
|
|
1187
|
+
if entity_ids <= 0:
|
|
1188
|
+
raise ValidationError(f"entity_id must be positive, got {entity_ids}")
|
|
1189
|
+
elif isinstance(entity_ids, (list, tuple)):
|
|
1190
|
+
if not entity_ids:
|
|
1191
|
+
raise ValidationError("entity_ids cannot be empty")
|
|
1192
|
+
for eid in entity_ids:
|
|
1193
|
+
if isinstance(eid, int) and eid <= 0:
|
|
1194
|
+
raise ValidationError(f"entity_id must be positive, got {eid}")
|
|
1195
|
+
if not isinstance(eid, (int, str)):
|
|
1196
|
+
raise ValidationError(f"entity_id must be int or str, got {type(eid).__name__}")
|
|
1197
|
+
elif not isinstance(entity_ids, str):
|
|
1198
|
+
raise ValidationError(f"entity_ids must be int, str or sequence, got {type(entity_ids).__name__}")
|
|
1199
|
+
|
|
868
1200
|
mapping = {
|
|
869
1201
|
"lead": "leads",
|
|
870
1202
|
"contact": "contacts",
|
|
@@ -891,7 +1223,7 @@ class AmoCRMClient:
|
|
|
891
1223
|
params.update(extra_params)
|
|
892
1224
|
|
|
893
1225
|
yield from self._iterate_paginated(
|
|
894
|
-
endpoint, params=params, data_path=("_embedded", "notes")
|
|
1226
|
+
endpoint, params=params, data_path=("_embedded", "notes"), max_pages=max_pages
|
|
895
1227
|
)
|
|
896
1228
|
|
|
897
1229
|
def fetch_notes(self, *args, **kwargs) -> List[dict]:
|
|
@@ -909,10 +1241,43 @@ class AmoCRMClient:
|
|
|
909
1241
|
created_to: Optional[Union[int, float, str, datetime]] = None,
|
|
910
1242
|
limit: int = 250,
|
|
911
1243
|
extra_params: Optional[dict] = None,
|
|
1244
|
+
max_pages: Optional[int] = None,
|
|
912
1245
|
) -> Iterator[dict]:
|
|
913
1246
|
"""
|
|
914
1247
|
Итератор событий с фильтрацией по сущности, типам и диапазону дат.
|
|
915
|
-
|
|
1248
|
+
|
|
1249
|
+
:param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
|
|
1250
|
+
:raises ValidationError: Если параметры имеют некорректный тип или значение.
|
|
1251
|
+
"""
|
|
1252
|
+
# Валидация limit
|
|
1253
|
+
if not isinstance(limit, int):
|
|
1254
|
+
raise ValidationError(f"limit must be int, got {type(limit).__name__}")
|
|
1255
|
+
if not 1 <= limit <= 250:
|
|
1256
|
+
raise ValidationError(f"limit must be between 1 and 250, got {limit}")
|
|
1257
|
+
|
|
1258
|
+
# Валидация entity (опционально, но должен быть строкой)
|
|
1259
|
+
if entity is not None:
|
|
1260
|
+
if not isinstance(entity, str):
|
|
1261
|
+
raise ValidationError(f"entity must be str, got {type(entity).__name__}")
|
|
1262
|
+
if not entity:
|
|
1263
|
+
raise ValidationError("entity cannot be empty string")
|
|
1264
|
+
|
|
1265
|
+
# Валидация entity_ids
|
|
1266
|
+
if entity_ids is not None:
|
|
1267
|
+
if isinstance(entity_ids, int):
|
|
1268
|
+
if entity_ids <= 0:
|
|
1269
|
+
raise ValidationError(f"entity_id must be positive, got {entity_ids}")
|
|
1270
|
+
elif isinstance(entity_ids, (list, tuple)):
|
|
1271
|
+
if not entity_ids:
|
|
1272
|
+
raise ValidationError("entity_ids cannot be empty")
|
|
1273
|
+
for eid in entity_ids:
|
|
1274
|
+
if isinstance(eid, int) and eid <= 0:
|
|
1275
|
+
raise ValidationError(f"entity_id must be positive, got {eid}")
|
|
1276
|
+
if not isinstance(eid, (int, str)):
|
|
1277
|
+
raise ValidationError(f"entity_id must be int or str, got {type(eid).__name__}")
|
|
1278
|
+
elif not isinstance(entity_ids, str):
|
|
1279
|
+
raise ValidationError(f"entity_ids must be int, str or sequence, got {type(entity_ids).__name__}")
|
|
1280
|
+
|
|
916
1281
|
params = {"limit": limit, "page": 1}
|
|
917
1282
|
if entity:
|
|
918
1283
|
params["filter[entity]"] = entity
|
|
@@ -932,7 +1297,7 @@ class AmoCRMClient:
|
|
|
932
1297
|
params.update(extra_params)
|
|
933
1298
|
|
|
934
1299
|
yield from self._iterate_paginated(
|
|
935
|
-
"/api/v4/events", params=params, data_path=("_embedded", "events")
|
|
1300
|
+
"/api/v4/events", params=params, data_path=("_embedded", "events"), max_pages=max_pages
|
|
936
1301
|
)
|
|
937
1302
|
|
|
938
1303
|
def fetch_events(self, *args, **kwargs) -> List[dict]:
|
|
@@ -945,15 +1310,18 @@ class AmoCRMClient:
|
|
|
945
1310
|
self,
|
|
946
1311
|
limit: int = 250,
|
|
947
1312
|
extra_params: Optional[dict] = None,
|
|
1313
|
+
max_pages: Optional[int] = None,
|
|
948
1314
|
) -> Iterator[dict]:
|
|
949
1315
|
"""
|
|
950
1316
|
Итератор пользователей аккаунта.
|
|
1317
|
+
|
|
1318
|
+
:param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
|
|
951
1319
|
"""
|
|
952
1320
|
params = {"limit": limit, "page": 1}
|
|
953
1321
|
if extra_params:
|
|
954
1322
|
params.update(extra_params)
|
|
955
1323
|
yield from self._iterate_paginated(
|
|
956
|
-
"/api/v4/users", params=params, data_path=("_embedded", "users")
|
|
1324
|
+
"/api/v4/users", params=params, data_path=("_embedded", "users"), max_pages=max_pages
|
|
957
1325
|
)
|
|
958
1326
|
|
|
959
1327
|
def fetch_users(self, *args, **kwargs) -> List[dict]:
|
|
@@ -967,53 +1335,31 @@ class AmoCRMClient:
|
|
|
967
1335
|
Возвращает список пользователей с кэшированием (по умолчанию 24 часа).
|
|
968
1336
|
|
|
969
1337
|
Использует трехуровневое кэширование:
|
|
970
|
-
1. Memory cache (самый быстрый)
|
|
1338
|
+
1. Memory cache (с TTL, самый быстрый)
|
|
971
1339
|
2. File cache (персистентный)
|
|
972
1340
|
3. API request (если кэш устарел или отсутствует)
|
|
973
1341
|
|
|
974
1342
|
:param force_update: Если True, игнорирует кэш и загружает данные из API
|
|
975
1343
|
:return: Список пользователей
|
|
976
1344
|
"""
|
|
977
|
-
|
|
978
|
-
if not force_update and self._users_cache is not None:
|
|
979
|
-
self.logger.debug("Using memory-cached users.")
|
|
980
|
-
return self._users_cache
|
|
981
|
-
|
|
982
|
-
# 2. Проверяем file cache
|
|
983
|
-
if not force_update and self.cache_config.enabled:
|
|
984
|
-
cached_data = self._load_cache('users')
|
|
985
|
-
if cached_data is not None:
|
|
986
|
-
self._users_cache = cached_data
|
|
987
|
-
self.logger.debug("Users loaded from file cache.")
|
|
988
|
-
return cached_data
|
|
989
|
-
|
|
990
|
-
# 3. Загружаем из API
|
|
991
|
-
self.logger.debug("Fetching users from API...")
|
|
992
|
-
users = self.fetch_users()
|
|
993
|
-
|
|
994
|
-
# Сохраняем в memory cache
|
|
995
|
-
self._users_cache = users
|
|
996
|
-
|
|
997
|
-
# Сохраняем в file cache
|
|
998
|
-
if self.cache_config.enabled:
|
|
999
|
-
self._save_cache('users', users)
|
|
1000
|
-
|
|
1001
|
-
self.logger.debug(f"Fetched {len(users)} users from API.")
|
|
1002
|
-
return users
|
|
1345
|
+
return self._get_cached_resource('users', self.fetch_users, force_update)
|
|
1003
1346
|
|
|
1004
1347
|
def iter_pipelines(
|
|
1005
1348
|
self,
|
|
1006
1349
|
limit: int = 250,
|
|
1007
1350
|
extra_params: Optional[dict] = None,
|
|
1351
|
+
max_pages: Optional[int] = None,
|
|
1008
1352
|
) -> Iterator[dict]:
|
|
1009
1353
|
"""
|
|
1010
1354
|
Итератор воронок со статусами.
|
|
1355
|
+
|
|
1356
|
+
:param max_pages: Максимальное количество страниц для итерации (None = без ограничений)
|
|
1011
1357
|
"""
|
|
1012
1358
|
params = {"limit": limit, "page": 1}
|
|
1013
1359
|
if extra_params:
|
|
1014
1360
|
params.update(extra_params)
|
|
1015
1361
|
yield from self._iterate_paginated(
|
|
1016
|
-
"/api/v4/leads/pipelines", params=params, data_path=("_embedded", "pipelines")
|
|
1362
|
+
"/api/v4/leads/pipelines", params=params, data_path=("_embedded", "pipelines"), max_pages=max_pages
|
|
1017
1363
|
)
|
|
1018
1364
|
|
|
1019
1365
|
def fetch_pipelines(self, *args, **kwargs) -> List[dict]:
|
|
@@ -1026,20 +1372,21 @@ class AmoCRMClient:
|
|
|
1026
1372
|
"""
|
|
1027
1373
|
Получает данные сделки по её ID и возвращает объект Deal.
|
|
1028
1374
|
Если данные отсутствуют или имеют неверную структуру, выбрасывается исключение.
|
|
1029
|
-
|
|
1375
|
+
|
|
1030
1376
|
:param deal_id: ID сделки для получения
|
|
1031
1377
|
:param skip_fields_mapping: Если True, не загружает справочник кастомных полей
|
|
1032
1378
|
(используйте для работы только с ID полей)
|
|
1033
1379
|
:return: Объект Deal с данными сделки
|
|
1380
|
+
:raises NotFoundError: Если сделка не найдена
|
|
1034
1381
|
"""
|
|
1035
1382
|
endpoint = f"/api/v4/leads/{deal_id}"
|
|
1036
1383
|
params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
|
|
1037
1384
|
data = self._make_request("GET", endpoint, params=params)
|
|
1038
|
-
|
|
1385
|
+
|
|
1039
1386
|
# Проверяем, что получили данные и что они содержат ключ "id"
|
|
1040
1387
|
if not data or not isinstance(data, dict) or "id" not in data:
|
|
1041
1388
|
self.logger.error(f"Deal {deal_id} not found or invalid response: {data}")
|
|
1042
|
-
raise
|
|
1389
|
+
raise NotFoundError(f"Deal {deal_id} not found or invalid response.")
|
|
1043
1390
|
|
|
1044
1391
|
custom_config = None if skip_fields_mapping else self.get_custom_fields_mapping()
|
|
1045
1392
|
self.logger.debug(f"Deal {deal_id} data received (содержимое полей не выводится полностью).")
|
|
@@ -1083,45 +1430,12 @@ class AmoCRMClient:
|
|
|
1083
1430
|
return None
|
|
1084
1431
|
return None
|
|
1085
1432
|
|
|
1086
|
-
def
|
|
1433
|
+
def _fetch_custom_fields_from_api(self):
|
|
1087
1434
|
"""
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
Использует трехуровневое кэширование:
|
|
1091
|
-
1. Memory cache (самый быстрый)
|
|
1092
|
-
2. File cache (персистентный)
|
|
1093
|
-
3. API request (если кэш устарел или отсутствует)
|
|
1435
|
+
Загружает кастомные поля из API (вспомогательный метод).
|
|
1094
1436
|
|
|
1095
|
-
:param force_update: Если True, игнорирует кэш и загружает данные из API
|
|
1096
1437
|
:return: Словарь с кастомными полями (ключ - field_id, значение - объект поля)
|
|
1097
1438
|
"""
|
|
1098
|
-
# 1. Проверяем memory cache
|
|
1099
|
-
if not force_update and self._custom_fields_mapping is not None:
|
|
1100
|
-
self.logger.debug("Using memory-cached custom fields mapping.")
|
|
1101
|
-
return self._custom_fields_mapping
|
|
1102
|
-
|
|
1103
|
-
# 2. Проверяем file cache (с поддержкой старого формата)
|
|
1104
|
-
if not force_update and self.cache_config.enabled:
|
|
1105
|
-
# Пробуем новый формат
|
|
1106
|
-
cached_data = self._load_cache('custom_fields')
|
|
1107
|
-
if cached_data is not None:
|
|
1108
|
-
self._custom_fields_mapping = cached_data
|
|
1109
|
-
self.logger.debug("Custom fields loaded from file cache (new format).")
|
|
1110
|
-
return cached_data
|
|
1111
|
-
|
|
1112
|
-
# Пробуем старый формат для обратной совместимости
|
|
1113
|
-
legacy_cache = self._load_custom_fields_cache()
|
|
1114
|
-
if legacy_cache:
|
|
1115
|
-
mapping = legacy_cache.get("mapping")
|
|
1116
|
-
if mapping:
|
|
1117
|
-
self._custom_fields_mapping = mapping
|
|
1118
|
-
self.logger.debug("Custom fields loaded from legacy cache format.")
|
|
1119
|
-
# Мигрируем в новый формат
|
|
1120
|
-
self._save_cache('custom_fields', mapping)
|
|
1121
|
-
return mapping
|
|
1122
|
-
|
|
1123
|
-
# 3. Загружаем из API
|
|
1124
|
-
self.logger.debug("Fetching custom fields from API...")
|
|
1125
1439
|
mapping = {}
|
|
1126
1440
|
page = 1
|
|
1127
1441
|
total_pages = 1
|
|
@@ -1136,16 +1450,34 @@ class AmoCRMClient:
|
|
|
1136
1450
|
page += 1
|
|
1137
1451
|
else:
|
|
1138
1452
|
break
|
|
1453
|
+
return mapping
|
|
1139
1454
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1455
|
+
def get_custom_fields_mapping(self, force_update=False):
|
|
1456
|
+
"""
|
|
1457
|
+
Возвращает словарь отображения кастомных полей для сделок с кэшированием (по умолчанию 24 часа).
|
|
1142
1458
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1459
|
+
Использует трехуровневое кэширование:
|
|
1460
|
+
1. Memory cache (с TTL, самый быстрый)
|
|
1461
|
+
2. File cache (персистентный)
|
|
1462
|
+
3. API request (если кэш устарел или отсутствует)
|
|
1146
1463
|
|
|
1147
|
-
|
|
1148
|
-
return
|
|
1464
|
+
:param force_update: Если True, игнорирует кэш и загружает данные из API
|
|
1465
|
+
:return: Словарь с кастомными полями (ключ - field_id, значение - объект поля)
|
|
1466
|
+
"""
|
|
1467
|
+
# Проверяем legacy cache для обратной совместимости
|
|
1468
|
+
if not force_update and self.cache_config.enabled and self._custom_fields_mapping is None:
|
|
1469
|
+
legacy_cache = self._load_custom_fields_cache()
|
|
1470
|
+
if legacy_cache:
|
|
1471
|
+
mapping = legacy_cache.get("mapping")
|
|
1472
|
+
if mapping:
|
|
1473
|
+
self._custom_fields_mapping = mapping
|
|
1474
|
+
self._cache_timestamps['custom_fields'] = time.time()
|
|
1475
|
+
self.logger.debug("Custom fields loaded from legacy cache format.")
|
|
1476
|
+
# Мигрируем в новый формат
|
|
1477
|
+
self._save_cache('custom_fields', mapping)
|
|
1478
|
+
return mapping
|
|
1479
|
+
|
|
1480
|
+
return self._get_cached_resource('custom_fields', self._fetch_custom_fields_from_api, force_update)
|
|
1149
1481
|
|
|
1150
1482
|
def find_custom_field_id(self, search_term):
|
|
1151
1483
|
"""
|
|
@@ -1181,7 +1513,7 @@ class AmoCRMClient:
|
|
|
1181
1513
|
:param tags_to_add: Список тегов для добавления к сделке.
|
|
1182
1514
|
:param tags_to_delete: Список тегов для удаления из сделки.
|
|
1183
1515
|
:return: Ответ API в формате JSON.
|
|
1184
|
-
:raises
|
|
1516
|
+
:raises ValidationError: Если одно из кастомных полей не найдено.
|
|
1185
1517
|
"""
|
|
1186
1518
|
payload = {}
|
|
1187
1519
|
standard_fields = {
|
|
@@ -1208,7 +1540,7 @@ class AmoCRMClient:
|
|
|
1208
1540
|
custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
|
|
1209
1541
|
self.logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
|
|
1210
1542
|
else:
|
|
1211
|
-
raise
|
|
1543
|
+
raise ValidationError(f"Custom field '{key}' не найден.")
|
|
1212
1544
|
if custom_fields:
|
|
1213
1545
|
payload["custom_fields_values"] = custom_fields
|
|
1214
1546
|
if tags_to_add:
|
|
@@ -1412,50 +1744,25 @@ class AmoCRMClient:
|
|
|
1412
1744
|
Получает список всех воронок и их статусов из amoCRM.
|
|
1413
1745
|
|
|
1414
1746
|
:return: Список словарей, где каждый словарь содержит данные воронки, а также, если присутствует, вложенные статусы.
|
|
1415
|
-
:raises
|
|
1747
|
+
:raises APIError: Если данные не получены или структура ответа неверна.
|
|
1416
1748
|
"""
|
|
1417
1749
|
pipelines = self.fetch_pipelines()
|
|
1418
1750
|
if pipelines:
|
|
1419
1751
|
self.logger.debug(f"Получено {len(pipelines)} воронок")
|
|
1420
1752
|
return pipelines
|
|
1421
1753
|
self.logger.error("Не удалось получить воронки из amoCRM")
|
|
1422
|
-
raise
|
|
1754
|
+
raise APIError(0, "Ошибка получения воронок из amoCRM")
|
|
1423
1755
|
|
|
1424
1756
|
def get_pipelines_cached(self, force_update=False):
|
|
1425
1757
|
"""
|
|
1426
1758
|
Возвращает список воронок с кэшированием (по умолчанию 7 дней).
|
|
1427
1759
|
|
|
1428
1760
|
Использует трехуровневое кэширование:
|
|
1429
|
-
1. Memory cache (самый быстрый)
|
|
1761
|
+
1. Memory cache (с TTL, самый быстрый)
|
|
1430
1762
|
2. File cache (персистентный)
|
|
1431
1763
|
3. API request (если кэш устарел или отсутствует)
|
|
1432
1764
|
|
|
1433
1765
|
:param force_update: Если True, игнорирует кэш и загружает данные из API
|
|
1434
1766
|
:return: Список воронок со статусами
|
|
1435
1767
|
"""
|
|
1436
|
-
|
|
1437
|
-
if not force_update and self._pipelines_cache is not None:
|
|
1438
|
-
self.logger.debug("Using memory-cached pipelines.")
|
|
1439
|
-
return self._pipelines_cache
|
|
1440
|
-
|
|
1441
|
-
# 2. Проверяем file cache
|
|
1442
|
-
if not force_update and self.cache_config.enabled:
|
|
1443
|
-
cached_data = self._load_cache('pipelines')
|
|
1444
|
-
if cached_data is not None:
|
|
1445
|
-
self._pipelines_cache = cached_data
|
|
1446
|
-
self.logger.debug("Pipelines loaded from file cache.")
|
|
1447
|
-
return cached_data
|
|
1448
|
-
|
|
1449
|
-
# 3. Загружаем из API
|
|
1450
|
-
self.logger.debug("Fetching pipelines from API...")
|
|
1451
|
-
pipelines = self.fetch_pipelines()
|
|
1452
|
-
|
|
1453
|
-
# Сохраняем в memory cache
|
|
1454
|
-
self._pipelines_cache = pipelines
|
|
1455
|
-
|
|
1456
|
-
# Сохраняем в file cache
|
|
1457
|
-
if self.cache_config.enabled:
|
|
1458
|
-
self._save_cache('pipelines', pipelines)
|
|
1459
|
-
|
|
1460
|
-
self.logger.debug(f"Fetched {len(pipelines)} pipelines from API.")
|
|
1461
|
-
return pipelines
|
|
1768
|
+
return self._get_cached_resource('pipelines', self.fetch_pipelines, force_update)
|
amochka/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Кастомные исключения для библиотеки amochka.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AmoCRMError(Exception):
|
|
7
|
+
"""Базовое исключение для всех ошибок amoCRM API."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthenticationError(AmoCRMError):
|
|
12
|
+
"""Исключение при ошибках авторизации и работы с токенами."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RateLimitError(AmoCRMError):
|
|
17
|
+
"""Исключение при превышении лимита запросов (429 Too Many Requests)."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message="Rate limit exceeded", retry_after=None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.retry_after = retry_after
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NotFoundError(AmoCRMError):
|
|
25
|
+
"""Исключение при отсутствии запрашиваемого ресурса (404 Not Found)."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class APIError(AmoCRMError):
|
|
30
|
+
"""Общее исключение для ошибок API."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, status_code, message):
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
super().__init__(f"API error {status_code}: {message}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ValidationError(AmoCRMError):
|
|
38
|
+
"""Исключение при некорректных входных данных."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"AmoCRMError",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"RateLimitError",
|
|
46
|
+
"NotFoundError",
|
|
47
|
+
"APIError",
|
|
48
|
+
"ValidationError",
|
|
49
|
+
]
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
amochka/__init__.py,sha256=
|
|
2
|
-
amochka/client.py,sha256=
|
|
1
|
+
amochka/__init__.py,sha256=aF51XIVOetHHu7xVAe--atPb6LrGzDgRo8uSbJW2icg,925
|
|
2
|
+
amochka/client.py,sha256=WFPlkBolKory7juyIYjqxFCw6QGN2Z7KSKlMd_KoLIY,87757
|
|
3
|
+
amochka/errors.py,sha256=Rg4U9srjboQwgU3wwhAed0p3vGcc0ZhuyKHIDhYFnp8,1371
|
|
3
4
|
amochka/etl.py,sha256=N8rXNFbtmlKfsYpgr7HDcP4enoj63XQPWuTDxGuMhw4,8901
|
|
4
5
|
etl/__init__.py,sha256=bp9fPqbKlOc7xzs27diHEvysy1FgBrwlpX6GnR6GL9U,255
|
|
5
6
|
etl/config.py,sha256=BvaGn5BSGMIfvUNNsnap04iy3BHyMOuRX81G7EiLUfE,9032
|
|
6
|
-
etl/extractors.py,sha256=
|
|
7
|
+
etl/extractors.py,sha256=PqjzlmUa8FLZa1z85mP6Y0-s_TH3REqW58632JzRKUc,13047
|
|
7
8
|
etl/loaders.py,sha256=x8PcDQoq2kjbd52H2VzKKz5vHzyD6DXSsb9X0foPX_U,31941
|
|
8
9
|
etl/run_etl.py,sha256=LxKQLE_yUPkg7kaFmmMxrdggz27puS1VdV9NTna9e4Q,26665
|
|
9
10
|
etl/transformers.py,sha256=OwYJ_9l3oqvy2Y3-umXjAGweOIqlfRI0iSiCFPrcQ8E,17867
|
|
10
11
|
etl/migrations/001_create_tables.sql,sha256=YrSaZjpofC1smjYx0bM4eHQumboruIBY3fwRDlJLLSo,15749
|
|
11
|
-
amochka-0.
|
|
12
|
-
amochka-0.
|
|
13
|
-
amochka-0.
|
|
14
|
-
amochka-0.
|
|
12
|
+
amochka-0.4.0.dist-info/METADATA,sha256=c9I25LoMxn4pI0pLFAIKw3qG992_9nl5YQmLSynK-LA,7530
|
|
13
|
+
amochka-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
amochka-0.4.0.dist-info/top_level.txt,sha256=grRX8aLFG-yYKPsAqCD6sUBmdLSQeOMHsc9Dl6S7Lzo,12
|
|
15
|
+
amochka-0.4.0.dist-info/RECORD,,
|
etl/extractors.py
CHANGED
|
@@ -255,8 +255,8 @@ class AmoCRMExtractor:
|
|
|
255
255
|
for note in self.client.iter_notes(
|
|
256
256
|
entity=entity_type,
|
|
257
257
|
note_type=note_type,
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
updated_from=updated_from,
|
|
259
|
+
updated_to=updated_to,
|
|
260
260
|
):
|
|
261
261
|
count += 1
|
|
262
262
|
if count % 100 == 0:
|
|
File without changes
|
|
File without changes
|