amochka 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
amochka/__init__.py CHANGED
@@ -2,5 +2,27 @@
2
2
  amochka: Библиотека для работы с API amoCRM.
3
3
  """
4
4
 
5
+ __version__ = "0.1.8"
6
+
5
7
  from .client import AmoCRMClient, CacheConfig
6
- __all__ = ['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
+ ]
amochka/client.py CHANGED
@@ -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 = self.load_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
- try:
292
- expires_at = datetime.fromisoformat(expires_at_str).timestamp()
293
- except Exception:
294
- expires_at = float(expires_at_str)
295
-
296
- if expires_at and time.time() < expires_at:
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
- return data.get('access_token')
299
- else:
300
- raise Exception("Токен найден, но он истёк. Обновите токен.")
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
- endpoint = "/api/v4/leads/pipelines"
667
- response = self._make_request("GET", endpoint)
668
- if response and '_embedded' in response and 'pipelines' in response['_embedded']:
669
- pipelines = response['_embedded']['pipelines']
1096
+ pipelines = self.fetch_pipelines()
1097
+ if pipelines:
670
1098
  self.logger.debug(f"Получено {len(pipelines)} воронок")
671
1099
  return pipelines
672
- else:
673
- self.logger.error("Не удалось получить воронки из amoCRM")
674
- raise Exception("Ошибка получения воронок из amoCRM")
1100
+ self.logger.error("Не удалось получить воронки из amoCRM")
1101
+ raise Exception("Ошибка получения воронок из amoCRM")
amochka/etl.py CHANGED
@@ -1,91 +1,302 @@
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
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Callable, Iterable, List, Optional, Sequence, Set, Union
6
4
 
7
- logger = logging.getLogger(__name__)
5
+ from .client import AmoCRMClient
8
6
 
9
- async def update_pipelines(session: AsyncSession, pipelines_data):
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:
10
34
  """
11
- Обновляет таблицы воронок (Pipeline) и статусов (Status) в базе данных.
12
-
13
- :param session: Асинхронная сессия SQLAlchemy.
14
- :param pipelines_data: Список воронок, полученных из API amoCRM.
35
+ Записывает переданные записи в формат NDJSON.
36
+
37
+ Возвращает количество записанных строк.
15
38
  """
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,
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,
86
50
  }
87
- )
88
- await session.execute(stmt)
89
- logger.debug(f"Обновлено {len(status_values)} статусов")
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
+
90
293
 
91
- logger.info(f"Обновлено {len(pipeline_values)} воронок и {len(status_values)} статусов.")
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,7 @@
1
+ amochka/__init__.py,sha256=LTjIPz4-miZ7A90vIUnbBKIpc_CyEPqRgnoqzEZngtw,620
2
+ amochka/client.py,sha256=zs72v79nplCiRvI-ccVauctCQRILYrWfvbol8G8w2L0,54392
3
+ amochka/etl.py,sha256=N8rXNFbtmlKfsYpgr7HDcP4enoj63XQPWuTDxGuMhw4,8901
4
+ amochka-0.1.8.dist-info/METADATA,sha256=BQXYM8C4pmHBUvl-siS3luV-YKMOuFvuNg05t9Hh7Iw,2218
5
+ amochka-0.1.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ amochka-0.1.8.dist-info/top_level.txt,sha256=y5qXFXJUECmDwO6hyupsuYcTpZKZyByeE9e-1sa2U24,8
7
+ amochka-0.1.8.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
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,8 +0,0 @@
1
- amochka/__init__.py,sha256=JZT3Q9jG3SfTS-vhlOCWYBrP8pFmaXFhNpJ1nOqrP5M,161
2
- amochka/client.py,sha256=i0N6-9U7Pghh41yvKGlroVP2oDdIcdZyFSjiz9JKI6k,36284
3
- amochka/etl.py,sha256=6pc3ymr72QH57hyeFaKziERXbD7v9GuQpQyBMxeO5MY,4142
4
- amochka/models.py,sha256=clPPFuOf_Lk-mqTMxUix7JZ97ViD-wiKSZiC3l2slP4,1805
5
- amochka-0.1.6.dist-info/METADATA,sha256=c1sNI6Qv0j7MCRN-oQpd3EIYUKtTShkfDqG1pFSCqo8,516
6
- amochka-0.1.6.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
7
- amochka-0.1.6.dist-info/top_level.txt,sha256=y5qXFXJUECmDwO6hyupsuYcTpZKZyByeE9e-1sa2U24,8
8
- amochka-0.1.6.dist-info/RECORD,,