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 +23 -1
- amochka/client.py +348 -9
- amochka/etl.py +296 -85
- amochka-0.1.7.dist-info/METADATA +40 -0
- amochka-0.1.7.dist-info/RECORD +7 -0
- {amochka-0.1.6.dist-info → amochka-0.1.7.dist-info}/WHEEL +1 -1
- amochka/models.py +0 -50
- amochka-0.1.6.dist-info/METADATA +0 -19
- amochka-0.1.6.dist-info/RECORD +0 -8
- {amochka-0.1.6.dist-info → amochka-0.1.7.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
667
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
raise Exception("Ошибка получения воронок из amoCRM")
|
|
1012
|
+
self.logger.error("Не удалось получить воронки из amoCRM")
|
|
1013
|
+
raise Exception("Ошибка получения воронок из amoCRM")
|
amochka/etl.py
CHANGED
|
@@ -1,91 +1,302 @@
|
|
|
1
|
-
import
|
|
2
|
-
from
|
|
3
|
-
from
|
|
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
|
-
|
|
5
|
+
from .client import AmoCRMClient
|
|
8
6
|
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
:param pipelines_data: Список воронок, полученных из API amoCRM.
|
|
35
|
+
Записывает переданные записи в формат NDJSON.
|
|
36
|
+
|
|
37
|
+
Возвращает количество записанных строк.
|
|
15
38
|
"""
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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,,
|
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
|
-
)
|
amochka-0.1.6.dist-info/METADATA
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: amochka
|
|
3
|
-
Version: 0.1.6
|
|
4
|
-
Summary: Библиотека для работы с API amoCRM
|
|
5
|
-
Home-page:
|
|
6
|
-
Author: Timurka
|
|
7
|
-
Author-email: timurdt@gmail.com
|
|
8
|
-
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
-
Classifier: Operating System :: OS Independent
|
|
11
|
-
Requires-Python: >=3.6
|
|
12
|
-
Requires-Dist: requests
|
|
13
|
-
Requires-Dist: ratelimit
|
|
14
|
-
Dynamic: author
|
|
15
|
-
Dynamic: author-email
|
|
16
|
-
Dynamic: classifier
|
|
17
|
-
Dynamic: requires-dist
|
|
18
|
-
Dynamic: requires-python
|
|
19
|
-
Dynamic: summary
|
amochka-0.1.6.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|