amochka 0.1.6__py3-none-any.whl → 0.1.7__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.7"
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
  # Создаём базовый логгер
@@ -301,7 +302,7 @@ class AmoCRMClient:
301
302
 
302
303
  @sleep_and_retry
303
304
  @limits(calls=RATE_LIMIT, period=1)
304
- def _make_request(self, method, endpoint, params=None, data=None):
305
+ def _make_request(self, method, endpoint, params=None, data=None, timeout=10):
305
306
  """
306
307
  Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
307
308
 
@@ -309,6 +310,7 @@ class AmoCRMClient:
309
310
  :param endpoint: Конечная точка API (начинается с /api/v4/).
310
311
  :param params: GET-параметры запроса.
311
312
  :param data: Данные, отправляемые в JSON-формате.
313
+ :param timeout: Тайм‑аут запроса в секундах (по умолчанию 10).
312
314
  :return: Ответ в формате JSON или None (если статус 204).
313
315
  :raises Exception: При получении кода ошибки, отличного от 200/204.
314
316
  """
@@ -318,7 +320,7 @@ class AmoCRMClient:
318
320
  "Content-Type": "application/json"
319
321
  }
320
322
  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)
323
+ response = requests.request(method, url, headers=headers, params=params, json=data, timeout=timeout)
322
324
  if response.status_code not in (200, 204):
323
325
  self.logger.error(f"Request error {response.status_code}: {response.text}")
324
326
  raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
@@ -326,6 +328,312 @@ class AmoCRMClient:
326
328
  return None
327
329
  return response.json()
328
330
 
331
+ def _to_timestamp(self, value: Optional[Union[int, float, str, datetime]]) -> Optional[int]:
332
+ """
333
+ Преобразует значение даты/времени в Unix timestamp.
334
+ Возвращает None, если значение не указано.
335
+ """
336
+ if value is None:
337
+ return None
338
+ if isinstance(value, datetime):
339
+ return int(value.timestamp())
340
+ if isinstance(value, (int, float)):
341
+ return int(value)
342
+ if isinstance(value, str):
343
+ try:
344
+ return int(datetime.fromisoformat(value).timestamp())
345
+ except ValueError as exc:
346
+ raise ValueError(f"Не удалось преобразовать '{value}' в timestamp") from exc
347
+ raise TypeError(f"Неподдерживаемый тип для timestamp: {type(value)}")
348
+
349
+ def _format_filter_values(self, values: Optional[Union[int, Sequence[Union[int, str]], str]]) -> Optional[Union[str, Sequence[Union[int, str]]]]:
350
+ """
351
+ Преобразует значение или последовательность значений для передачи в запрос.
352
+ """
353
+ if values is None:
354
+ return None
355
+ if isinstance(values, (list, tuple, set)):
356
+ return [str(v) for v in values]
357
+ return str(values)
358
+
359
+ def _extract_collection(self, response: dict, data_path: Sequence[str]) -> list:
360
+ """
361
+ Извлекает коллекцию элементов из ответа API по указанному пути ключей.
362
+ """
363
+ data = response or {}
364
+ for key in data_path:
365
+ if not isinstance(data, dict):
366
+ return []
367
+ data = data.get(key)
368
+ if data is None:
369
+ return []
370
+ if isinstance(data, list):
371
+ return data
372
+ return []
373
+
374
+ def _iterate_paginated(
375
+ self,
376
+ endpoint: str,
377
+ params: Optional[dict] = None,
378
+ data_path: Sequence[str] = ("_embedded",),
379
+ ) -> Iterator[dict]:
380
+ """
381
+ Возвращает генератор, проходящий по всем страницам ответа API и
382
+ yielding элементы коллекции.
383
+ """
384
+ query = dict(params or {})
385
+ query.setdefault("page", 1)
386
+ query.setdefault("limit", 250)
387
+
388
+ while True:
389
+ response = self._make_request("GET", endpoint, params=query)
390
+ if not response:
391
+ break
392
+ items = self._extract_collection(response, data_path)
393
+ if not items:
394
+ break
395
+ for item in items:
396
+ yield item
397
+
398
+ total_pages = response.get("_page_count")
399
+ if total_pages is not None:
400
+ has_next = query["page"] < total_pages
401
+ else:
402
+ links = response.get("_links") or {}
403
+ next_link = links.get("next") if isinstance(links, dict) else None
404
+ has_next = bool(next_link)
405
+ if not has_next:
406
+ break
407
+ query["page"] += 1
408
+
409
+ def iter_leads(
410
+ self,
411
+ updated_from: Optional[Union[int, float, str, datetime]] = None,
412
+ updated_to: Optional[Union[int, float, str, datetime]] = None,
413
+ pipeline_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
414
+ include_contacts: bool = False,
415
+ include: Optional[Union[str, Sequence[str]]] = None,
416
+ limit: int = 250,
417
+ extra_params: Optional[dict] = None,
418
+ ) -> Iterator[dict]:
419
+ """
420
+ Итератор сделок с фильтрацией по диапазону обновления и воронкам.
421
+ """
422
+ params = {"limit": limit, "page": 1}
423
+ start_ts = self._to_timestamp(updated_from)
424
+ end_ts = self._to_timestamp(updated_to)
425
+ if start_ts is not None:
426
+ params["filter[updated_at][from]"] = start_ts
427
+ if end_ts is not None:
428
+ params["filter[updated_at][to]"] = end_ts
429
+ pipeline_param = self._format_filter_values(pipeline_ids)
430
+ if pipeline_param:
431
+ params["filter[pipeline_id]"] = pipeline_param
432
+
433
+ include_parts: List[str] = []
434
+ if include_contacts:
435
+ include_parts.append("contacts")
436
+ if include:
437
+ if isinstance(include, str):
438
+ include_parts.append(include)
439
+ else:
440
+ include_parts.extend([str(item) for item in include])
441
+ if include_parts:
442
+ params["with"] = ",".join(sorted(set(include_parts)))
443
+ if extra_params:
444
+ params.update(extra_params)
445
+
446
+ yield from self._iterate_paginated(
447
+ "/api/v4/leads", params=params, data_path=("_embedded", "leads")
448
+ )
449
+
450
+ def fetch_leads(self, *args, **kwargs) -> List[dict]:
451
+ """
452
+ Возвращает список сделок. Обёртка над iter_leads.
453
+ """
454
+ return list(self.iter_leads(*args, **kwargs))
455
+
456
+ def iter_contacts(
457
+ self,
458
+ updated_from: Optional[Union[int, float, str, datetime]] = None,
459
+ updated_to: Optional[Union[int, float, str, datetime]] = None,
460
+ contact_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
461
+ limit: int = 250,
462
+ extra_params: Optional[dict] = None,
463
+ ) -> Iterator[dict]:
464
+ """
465
+ Итератор контактов с фильтрацией по диапазону обновления или списку ID.
466
+ """
467
+ params = {"limit": limit, "page": 1}
468
+ start_ts = self._to_timestamp(updated_from)
469
+ end_ts = self._to_timestamp(updated_to)
470
+ if start_ts is not None:
471
+ params["filter[updated_at][from]"] = start_ts
472
+ if end_ts is not None:
473
+ params["filter[updated_at][to]"] = end_ts
474
+ contact_param = self._format_filter_values(contact_ids)
475
+ if contact_param:
476
+ params["filter[id][]"] = contact_param
477
+ if extra_params:
478
+ params.update(extra_params)
479
+
480
+ yield from self._iterate_paginated(
481
+ "/api/v4/contacts", params=params, data_path=("_embedded", "contacts")
482
+ )
483
+
484
+ def fetch_contacts(self, *args, **kwargs) -> List[dict]:
485
+ """
486
+ Возвращает список контактов. Обёртка над iter_contacts.
487
+ """
488
+ return list(self.iter_contacts(*args, **kwargs))
489
+
490
+ def get_contact_by_id(self, contact_id: Union[int, str], include: Optional[Union[str, Sequence[str]]] = None) -> dict:
491
+ """
492
+ Получает данные контакта по его ID.
493
+ """
494
+ endpoint = f"/api/v4/contacts/{contact_id}"
495
+ params = {}
496
+ if include:
497
+ if isinstance(include, str):
498
+ params["with"] = include
499
+ else:
500
+ params["with"] = ",".join(str(item) for item in include)
501
+ data = self._make_request("GET", endpoint, params=params)
502
+ if not data or not isinstance(data, dict) or "id" not in data:
503
+ raise Exception(f"Contact {contact_id} not found or invalid response.")
504
+ return data
505
+
506
+ def iter_notes(
507
+ self,
508
+ entity: str = "lead",
509
+ updated_from: Optional[Union[int, float, str, datetime]] = None,
510
+ updated_to: Optional[Union[int, float, str, datetime]] = None,
511
+ note_type: Optional[Union[str, Sequence[str]]] = None,
512
+ entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
513
+ limit: int = 250,
514
+ extra_params: Optional[dict] = None,
515
+ ) -> Iterator[dict]:
516
+ """
517
+ Итератор примечаний для заданной сущности.
518
+ """
519
+ mapping = {
520
+ "lead": "leads",
521
+ "contact": "contacts",
522
+ "company": "companies",
523
+ "customer": "customers",
524
+ }
525
+ plural = mapping.get(entity.lower(), entity.lower() + "s")
526
+ endpoint = f"/api/v4/{plural}/notes"
527
+
528
+ params = {"limit": limit, "page": 1}
529
+ start_ts = self._to_timestamp(updated_from)
530
+ end_ts = self._to_timestamp(updated_to)
531
+ if start_ts is not None:
532
+ params["filter[updated_at][from]"] = start_ts
533
+ if end_ts is not None:
534
+ params["filter[updated_at][to]"] = end_ts
535
+ note_type_param = self._format_filter_values(note_type)
536
+ if note_type_param:
537
+ params["filter[note_type]"] = note_type_param
538
+ entity_param = self._format_filter_values(entity_ids)
539
+ if entity_param:
540
+ params["filter[entity_id]"] = entity_param
541
+ if extra_params:
542
+ params.update(extra_params)
543
+
544
+ yield from self._iterate_paginated(
545
+ endpoint, params=params, data_path=("_embedded", "notes")
546
+ )
547
+
548
+ def fetch_notes(self, *args, **kwargs) -> List[dict]:
549
+ """
550
+ Возвращает список примечаний. Обёртка над iter_notes.
551
+ """
552
+ return list(self.iter_notes(*args, **kwargs))
553
+
554
+ def iter_events(
555
+ self,
556
+ entity: Optional[str] = None,
557
+ entity_ids: Optional[Union[int, Sequence[Union[int, str]]]] = None,
558
+ event_type: Optional[Union[str, Sequence[str]]] = None,
559
+ created_from: Optional[Union[int, float, str, datetime]] = None,
560
+ created_to: Optional[Union[int, float, str, datetime]] = None,
561
+ limit: int = 250,
562
+ extra_params: Optional[dict] = None,
563
+ ) -> Iterator[dict]:
564
+ """
565
+ Итератор событий с фильтрацией по сущности, типам и диапазону дат.
566
+ """
567
+ params = {"limit": limit, "page": 1}
568
+ if entity:
569
+ params["filter[entity]"] = entity
570
+ entity_param = self._format_filter_values(entity_ids)
571
+ if entity_param:
572
+ params["filter[entity_id]"] = entity_param
573
+ event_type_param = self._format_filter_values(event_type)
574
+ if event_type_param:
575
+ params["filter[type]"] = event_type_param
576
+ start_ts = self._to_timestamp(created_from)
577
+ end_ts = self._to_timestamp(created_to)
578
+ if start_ts is not None:
579
+ params["filter[created_at][from]"] = start_ts
580
+ if end_ts is not None:
581
+ params["filter[created_at][to]"] = end_ts
582
+ if extra_params:
583
+ params.update(extra_params)
584
+
585
+ yield from self._iterate_paginated(
586
+ "/api/v4/events", params=params, data_path=("_embedded", "events")
587
+ )
588
+
589
+ def fetch_events(self, *args, **kwargs) -> List[dict]:
590
+ """
591
+ Возвращает список событий. Обёртка над iter_events.
592
+ """
593
+ return list(self.iter_events(*args, **kwargs))
594
+
595
+ def iter_users(
596
+ self,
597
+ limit: int = 250,
598
+ extra_params: Optional[dict] = None,
599
+ ) -> Iterator[dict]:
600
+ """
601
+ Итератор пользователей аккаунта.
602
+ """
603
+ params = {"limit": limit, "page": 1}
604
+ if extra_params:
605
+ params.update(extra_params)
606
+ yield from self._iterate_paginated(
607
+ "/api/v4/users", params=params, data_path=("_embedded", "users")
608
+ )
609
+
610
+ def fetch_users(self, *args, **kwargs) -> List[dict]:
611
+ """
612
+ Возвращает список пользователей. Обёртка над iter_users.
613
+ """
614
+ return list(self.iter_users(*args, **kwargs))
615
+
616
+ def iter_pipelines(
617
+ self,
618
+ limit: int = 250,
619
+ extra_params: Optional[dict] = None,
620
+ ) -> Iterator[dict]:
621
+ """
622
+ Итератор воронок со статусами.
623
+ """
624
+ params = {"limit": limit, "page": 1}
625
+ if extra_params:
626
+ params.update(extra_params)
627
+ yield from self._iterate_paginated(
628
+ "/api/v4/leads/pipelines", params=params, data_path=("_embedded", "pipelines")
629
+ )
630
+
631
+ def fetch_pipelines(self, *args, **kwargs) -> List[dict]:
632
+ """
633
+ Возвращает список воронок. Обёртка над iter_pipelines.
634
+ """
635
+ return list(self.iter_pipelines(*args, **kwargs))
636
+
329
637
  def get_deal_by_id(self, deal_id, skip_fields_mapping=False):
330
638
  """
331
639
  Получает данные сделки по её ID и возвращает объект Deal.
@@ -639,6 +947,40 @@ class AmoCRMClient:
639
947
  def get_contact_events(self, contact_id, **kwargs):
640
948
  return self.get_entity_events("contact", contact_id, **kwargs)
641
949
 
950
+ def fetch_updated_leads_raw(
951
+ self,
952
+ pipeline_id,
953
+ updated_from,
954
+ updated_to=None,
955
+ save_to_file=None,
956
+ limit=250,
957
+ include_contacts=False,
958
+ ):
959
+ """Возвращает сделки из указанной воронки, обновленные в заданный период.
960
+
961
+ :param pipeline_id: ID воронки.
962
+ :param updated_from: datetime, начиная с которого искать изменения.
963
+ :param updated_to: datetime окончания диапазона (опционально).
964
+ :param save_to_file: путь к файлу для сохранения результатов в формате JSON.
965
+ :param limit: количество элементов на страницу (максимум 250).
966
+ :param include_contacts: если True, в ответ будут включены данные контактов.
967
+ :return: список словарей со сделками.
968
+ """
969
+
970
+ all_leads = self.fetch_leads(
971
+ updated_from=updated_from,
972
+ updated_to=updated_to,
973
+ pipeline_ids=pipeline_id,
974
+ include_contacts=include_contacts,
975
+ limit=limit,
976
+ )
977
+ if save_to_file:
978
+ with open(save_to_file, "w", encoding="utf-8") as f:
979
+ json.dump(all_leads, f, ensure_ascii=False, indent=2)
980
+
981
+ self.logger.debug(f"Fetched {len(all_leads)} leads from pipeline {pipeline_id}")
982
+ return all_leads
983
+
642
984
  def get_event(self, event_id):
643
985
  """
644
986
  Получает подробную информацию по конкретному событию по его ID.
@@ -663,12 +1005,9 @@ class AmoCRMClient:
663
1005
  :return: Список словарей, где каждый словарь содержит данные воронки, а также, если присутствует, вложенные статусы.
664
1006
  :raises Exception: Если данные не получены или структура ответа неверна.
665
1007
  """
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']
1008
+ pipelines = self.fetch_pipelines()
1009
+ if pipelines:
670
1010
  self.logger.debug(f"Получено {len(pipelines)} воронок")
671
1011
  return pipelines
672
- else:
673
- self.logger.error("Не удалось получить воронки из amoCRM")
674
- raise Exception("Ошибка получения воронок из amoCRM")
1012
+ self.logger.error("Не удалось получить воронки из amoCRM")
1013
+ 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.7
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=RdQyNTzygG3l4X52op5afzvHjEjvYJB_yZz-jVd8R54,620
2
+ amochka/client.py,sha256=hRO7e0kGmvyw1RZR9hMXBQnwrH7n-7m-izCqjag_QS4,50047
3
+ amochka/etl.py,sha256=N8rXNFbtmlKfsYpgr7HDcP4enoj63XQPWuTDxGuMhw4,8901
4
+ amochka-0.1.7.dist-info/METADATA,sha256=RovMukJ-TfsjV4UHCb3_P098JkbyeHs_ETfZdwDlFi0,2218
5
+ amochka-0.1.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ amochka-0.1.7.dist-info/top_level.txt,sha256=y5qXFXJUECmDwO6hyupsuYcTpZKZyByeE9e-1sa2U24,8
7
+ amochka-0.1.7.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,,