amochka 0.1.9__py3-none-any.whl → 0.3.1__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.
etl/transformers.py ADDED
@@ -0,0 +1,470 @@
1
+ """
2
+ Трансформация данных из amoCRM JSON в структуру mybi.
3
+
4
+ Преобразует payload из amochka в записи для таблиц PostgreSQL.
5
+ """
6
+
7
+ import json
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
11
+
12
+
13
+ def _timestamp_to_datetime(ts: Optional[int]) -> Optional[datetime]:
14
+ """Преобразует Unix timestamp в datetime с UTC."""
15
+ if ts is None:
16
+ return None
17
+ try:
18
+ return datetime.fromtimestamp(ts, tz=timezone.utc)
19
+ except (ValueError, OSError, OverflowError):
20
+ return None
21
+
22
+
23
+ def _extract_phone_email(custom_fields: List[Dict]) -> Tuple[Optional[str], Optional[str]]:
24
+ """
25
+ Извлекает основной телефон и email из custom_fields_values контакта.
26
+
27
+ В amoCRM телефон и email хранятся как multitext поля.
28
+ """
29
+ phone = None
30
+ email = None
31
+
32
+ for cf in custom_fields:
33
+ field_code = cf.get("field_code", "")
34
+ values = cf.get("values", [])
35
+
36
+ if field_code == "PHONE" and values:
37
+ # Собираем все телефоны через запятую
38
+ phones = [v.get("value") for v in values if v.get("value")]
39
+ if phones:
40
+ phone = ", ".join(phones)
41
+
42
+ elif field_code == "EMAIL" and values:
43
+ # Собираем все email через запятую
44
+ emails = [v.get("value") for v in values if v.get("value")]
45
+ if emails:
46
+ email = ", ".join(emails)
47
+
48
+ return phone, email
49
+
50
+
51
+ @dataclass
52
+ class TransformedLead:
53
+ """Результат трансформации одной сделки."""
54
+
55
+ lead: Dict[str, Any]
56
+ lead_facts: Dict[str, Any]
57
+ attributes: List[Dict[str, Any]]
58
+ tags: List[Dict[str, Any]]
59
+ contacts_links: List[Dict[str, Any]]
60
+
61
+
62
+ @dataclass
63
+ class TransformedContact:
64
+ """Результат трансформации одного контакта."""
65
+
66
+ contact: Dict[str, Any]
67
+ contact_facts: Dict[str, Any]
68
+ attributes: List[Dict[str, Any]]
69
+
70
+
71
+ @dataclass
72
+ class TransformedEvent:
73
+ """Результат трансформации одного события."""
74
+
75
+ event: Dict[str, Any]
76
+
77
+
78
+ class LeadTransformer:
79
+ """Трансформер для сделок (leads)."""
80
+
81
+ def __init__(self, account_id: int, pipelines_map: Optional[Dict[int, str]] = None, statuses_map: Optional[Dict[int, Dict]] = None):
82
+ """
83
+ Инициализирует трансформер.
84
+
85
+ Args:
86
+ account_id: ID аккаунта amoCRM
87
+ pipelines_map: {pipeline_id: name} для денормализации
88
+ statuses_map: {status_id: {"name": ..., "sort": ...}} для денормализации
89
+ """
90
+ self.account_id = account_id
91
+ self.pipelines_map = pipelines_map or {}
92
+ self.statuses_map = statuses_map or {}
93
+
94
+ def transform(self, lead: Dict[str, Any]) -> TransformedLead:
95
+ """
96
+ Преобразует сделку из amoCRM в структуру mybi.
97
+
98
+ Args:
99
+ lead: JSON сделки из amoCRM API
100
+
101
+ Returns:
102
+ TransformedLead с данными для всех таблиц
103
+ """
104
+ lead_id = lead.get("id")
105
+ pipeline_id = lead.get("pipeline_id")
106
+ status_id = lead.get("status_id")
107
+
108
+ # Основная запись сделки
109
+ lead_record = {
110
+ "account_id": self.account_id,
111
+ "lead_id": lead_id,
112
+ "name": lead.get("name"),
113
+ "pipeline": self.pipelines_map.get(pipeline_id),
114
+ "pipeline_id": pipeline_id,
115
+ "status": self.statuses_map.get(status_id, {}).get("name"),
116
+ "status_id": status_id,
117
+ "status_order": self.statuses_map.get(status_id, {}).get("sort"),
118
+ "request_id": None, # Заполняется из неразобранного
119
+ "loss_reason": None,
120
+ "loss_reason_id": lead.get("loss_reason_id"),
121
+ "is_deleted": lead.get("is_deleted", False),
122
+ }
123
+
124
+ # Извлекаем loss_reason из _embedded
125
+ embedded = lead.get("_embedded", {})
126
+ loss_reason = embedded.get("loss_reason")
127
+ if loss_reason:
128
+ lead_record["loss_reason"] = loss_reason.get("name")
129
+ if not lead_record["loss_reason_id"]:
130
+ lead_record["loss_reason_id"] = loss_reason.get("id")
131
+
132
+ # Факты по сделке
133
+ created_at = _timestamp_to_datetime(lead.get("created_at"))
134
+ updated_at = _timestamp_to_datetime(lead.get("updated_at"))
135
+ closed_at = _timestamp_to_datetime(lead.get("closed_at"))
136
+
137
+ # Основной контакт из _embedded.contacts
138
+ contacts = embedded.get("contacts", [])
139
+ main_contact_id = None
140
+ for contact in contacts:
141
+ if contact.get("is_main"):
142
+ main_contact_id = contact.get("id")
143
+ break
144
+ if not main_contact_id and contacts:
145
+ main_contact_id = contacts[0].get("id")
146
+
147
+ # Компания из _embedded.companies
148
+ companies = embedded.get("companies", [])
149
+ company_id = companies[0].get("id") if companies else None
150
+
151
+ lead_facts_record = {
152
+ "account_id": self.account_id,
153
+ "leads_id": None, # Будет заполнено после INSERT в leads
154
+ "contacts_id": main_contact_id,
155
+ "companies_id": company_id,
156
+ "users_id": lead.get("responsible_user_id"),
157
+ "created_id": None, # Будет заполнено через get_or_create_date_id()
158
+ "closed_id": None, # Будет заполнено через get_or_create_date_id()
159
+ "price": lead.get("price"),
160
+ "labor_cost": lead.get("labor_cost"),
161
+ "score": lead.get("score"),
162
+ "created_date": created_at,
163
+ "modified_date": updated_at,
164
+ "_created_at_raw": lead.get("created_at"), # Для вычисления created_id
165
+ "_closed_at_raw": lead.get("closed_at"), # Для вычисления closed_id
166
+ }
167
+
168
+ # Атрибуты (custom_fields_values)
169
+ attributes = []
170
+ custom_fields = lead.get("custom_fields_values") or []
171
+ for cf in custom_fields:
172
+ field_id = cf.get("field_id")
173
+ field_name = cf.get("field_name", "")
174
+ values = cf.get("values", [])
175
+
176
+ for val in values:
177
+ value = val.get("value")
178
+ if value is None:
179
+ # Для enum-полей берём enum_id или значение из enums
180
+ enum_id = val.get("enum_id")
181
+ if enum_id is not None:
182
+ value = str(enum_id)
183
+
184
+ attributes.append({
185
+ "account_id": self.account_id,
186
+ "leads_id": None, # Будет заполнено после INSERT
187
+ "attribute_id": str(field_id) if field_id else "",
188
+ "name": field_name,
189
+ "value": str(value) if value is not None else None,
190
+ })
191
+
192
+ # Теги из _embedded.tags
193
+ tags = []
194
+ for tag in embedded.get("tags", []):
195
+ tags.append({
196
+ "account_id": self.account_id,
197
+ "leads_id": None,
198
+ "tag_id": tag.get("id"),
199
+ "name": tag.get("name"),
200
+ })
201
+
202
+ # Связи с контактами
203
+ contacts_links = []
204
+ for i, contact in enumerate(contacts):
205
+ contacts_links.append({
206
+ "account_id": self.account_id,
207
+ "leads_id": None,
208
+ "contacts_id": contact.get("id"),
209
+ "main": contact.get("is_main", i == 0),
210
+ })
211
+
212
+ return TransformedLead(
213
+ lead=lead_record,
214
+ lead_facts=lead_facts_record,
215
+ attributes=attributes,
216
+ tags=tags,
217
+ contacts_links=contacts_links,
218
+ )
219
+
220
+
221
+ class ContactTransformer:
222
+ """Трансформер для контактов."""
223
+
224
+ def __init__(self, account_id: int):
225
+ self.account_id = account_id
226
+
227
+ def transform(self, contact: Dict[str, Any]) -> TransformedContact:
228
+ """Преобразует контакт из amoCRM в структуру mybi."""
229
+ contact_id = contact.get("id")
230
+ custom_fields = contact.get("custom_fields_values") or []
231
+
232
+ # Извлекаем телефон и email из custom_fields
233
+ phone, email = _extract_phone_email(custom_fields)
234
+
235
+ # Основная запись контакта
236
+ contact_record = {
237
+ "account_id": self.account_id,
238
+ "contact_id": contact_id,
239
+ "name": contact.get("name"),
240
+ "first_name": contact.get("first_name"),
241
+ "last_name": contact.get("last_name"),
242
+ "company": None, # Заполняется из _embedded.companies
243
+ "post": None, # Должность из custom_fields
244
+ "phone": phone,
245
+ "email": email,
246
+ "request_id": None,
247
+ "is_deleted": contact.get("is_deleted", False),
248
+ }
249
+
250
+ # Компания из _embedded
251
+ embedded = contact.get("_embedded", {})
252
+ companies = embedded.get("companies", [])
253
+ if companies:
254
+ contact_record["company"] = companies[0].get("name")
255
+
256
+ # Факты по контакту
257
+ created_at = _timestamp_to_datetime(contact.get("created_at"))
258
+ updated_at = _timestamp_to_datetime(contact.get("updated_at"))
259
+
260
+ contact_facts_record = {
261
+ "account_id": self.account_id,
262
+ "contacts_id": None, # Будет заполнено после INSERT
263
+ "companies_id": companies[0].get("id") if companies else None,
264
+ "users_id": contact.get("responsible_user_id"),
265
+ "registered_id": None, # Будет заполнено через get_or_create_date_id()
266
+ "created_date": created_at,
267
+ "modified_date": updated_at,
268
+ "_created_at_raw": contact.get("created_at"),
269
+ }
270
+
271
+ # Атрибуты
272
+ attributes = []
273
+ for cf in custom_fields:
274
+ field_id = cf.get("field_id")
275
+ field_name = cf.get("field_name", "")
276
+ field_code = cf.get("field_code", "")
277
+
278
+ # Пропускаем стандартные поля PHONE и EMAIL (они уже в основной записи)
279
+ if field_code in ("PHONE", "EMAIL"):
280
+ continue
281
+
282
+ values = cf.get("values", [])
283
+ for val in values:
284
+ value = val.get("value")
285
+ if value is None:
286
+ enum_id = val.get("enum_id")
287
+ if enum_id is not None:
288
+ value = str(enum_id)
289
+
290
+ attributes.append({
291
+ "account_id": self.account_id,
292
+ "contacts_id": None,
293
+ "attribute_id": str(field_id) if field_id else "",
294
+ "name": field_name,
295
+ "value": str(value) if value is not None else None,
296
+ })
297
+
298
+ return TransformedContact(
299
+ contact=contact_record,
300
+ contact_facts=contact_facts_record,
301
+ attributes=attributes,
302
+ )
303
+
304
+
305
+ class EventTransformer:
306
+ """Трансформер для событий."""
307
+
308
+ def __init__(self, account_id: int):
309
+ self.account_id = account_id
310
+
311
+ def transform(self, event: Dict[str, Any], entity_type: str = "lead") -> TransformedEvent:
312
+ """
313
+ Преобразует событие из amoCRM в структуру mybi.
314
+
315
+ Args:
316
+ event: JSON события из amoCRM API
317
+ entity_type: Тип сущности (lead, contact, company)
318
+ """
319
+ created_at = _timestamp_to_datetime(event.get("created_at"))
320
+
321
+ # value_before и value_after могут быть списками или объектами
322
+ value_before = event.get("value_before")
323
+ value_after = event.get("value_after")
324
+
325
+ # Сериализуем в JSON-строку для хранения
326
+ if value_before is not None:
327
+ value_before = json.dumps(value_before, ensure_ascii=False)
328
+ if value_after is not None:
329
+ value_after = json.dumps(value_after, ensure_ascii=False)
330
+
331
+ # Определяем leads_id из entity_id если это событие по сделке
332
+ leads_id = None
333
+ if entity_type == "lead":
334
+ leads_id = event.get("entity_id")
335
+
336
+ event_record = {
337
+ "account_id": self.account_id,
338
+ "leads_id": leads_id,
339
+ "event_id": event.get("id"),
340
+ "type": event.get("type"),
341
+ "created_by": event.get("created_by"),
342
+ "created_at": created_at,
343
+ "value_after": value_after,
344
+ "value_before": value_before,
345
+ }
346
+
347
+ return TransformedEvent(event=event_record)
348
+
349
+
350
+ class NoteTransformer:
351
+ """Трансформер для примечаний."""
352
+
353
+ def __init__(self, account_id: int):
354
+ self.account_id = account_id
355
+
356
+ def transform(self, note: Dict[str, Any], entity_type: str = "lead") -> Dict[str, Any]:
357
+ """Преобразует примечание из amoCRM в структуру mybi."""
358
+ created_at = _timestamp_to_datetime(note.get("created_at"))
359
+ updated_at = _timestamp_to_datetime(note.get("updated_at"))
360
+
361
+ # params может быть словарём с дополнительными данными
362
+ params = note.get("params")
363
+ if params is not None:
364
+ params = json.dumps(params, ensure_ascii=False)
365
+
366
+ # entity_id определяет к какой сущности привязано примечание
367
+ entity_id = note.get("entity_id")
368
+
369
+ return {
370
+ "account_id": self.account_id,
371
+ "leads_id": entity_id if entity_type == "lead" else None,
372
+ "creator_id": note.get("created_by"),
373
+ "responsible_id": note.get("responsible_user_id"),
374
+ "note_id": note.get("id"),
375
+ "note_type": note.get("note_type"),
376
+ "note_type_id": None, # В API v4 нет отдельного type_id
377
+ "created_at": created_at,
378
+ "updated_at": updated_at,
379
+ "text": note.get("params", {}).get("text") if isinstance(note.get("params"), dict) else None,
380
+ "params": params,
381
+ }
382
+
383
+
384
+ class PipelineTransformer:
385
+ """Трансформер для воронок и статусов."""
386
+
387
+ def __init__(self, account_id: int):
388
+ self.account_id = account_id
389
+
390
+ def transform_pipeline(self, pipeline: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
391
+ """
392
+ Преобразует воронку из amoCRM.
393
+
394
+ Returns:
395
+ Tuple[pipeline_record, list_of_status_records]
396
+ """
397
+ pipeline_id = pipeline.get("id")
398
+
399
+ pipeline_record = {
400
+ "account_id": self.account_id,
401
+ "pipeline_id": pipeline_id,
402
+ "name": pipeline.get("name"),
403
+ "sort": pipeline.get("sort"),
404
+ "is_main": pipeline.get("is_main", False),
405
+ "is_unsorted_on": pipeline.get("is_unsorted_on", False),
406
+ "is_archive": pipeline.get("is_archive", False),
407
+ }
408
+
409
+ # Статусы из _embedded.statuses
410
+ statuses = []
411
+ embedded = pipeline.get("_embedded", {})
412
+ for status in embedded.get("statuses", []):
413
+ statuses.append({
414
+ "account_id": self.account_id,
415
+ "pipeline_id": pipeline_id,
416
+ "status_id": status.get("id"),
417
+ "name": status.get("name"),
418
+ "color": status.get("color"),
419
+ "sort": status.get("sort"),
420
+ "is_editable": status.get("is_editable", True),
421
+ "type": status.get("type", 0),
422
+ })
423
+
424
+ return pipeline_record, statuses
425
+
426
+
427
+ class UserTransformer:
428
+ """Трансформер для пользователей."""
429
+
430
+ def __init__(self, account_id: int):
431
+ self.account_id = account_id
432
+
433
+ def transform(self, user: Dict[str, Any]) -> Dict[str, Any]:
434
+ """Преобразует пользователя из amoCRM в структуру mybi."""
435
+ # Группа из _embedded
436
+ embedded = user.get("_embedded", {})
437
+ groups = embedded.get("groups", [])
438
+ roles = embedded.get("roles", [])
439
+
440
+ group_name = None
441
+ group_id = None
442
+ if groups:
443
+ group_name = groups[0].get("name")
444
+ group_id = groups[0].get("id")
445
+
446
+ role_name = None
447
+ role_id = None
448
+ if roles:
449
+ role_name = roles[0].get("name")
450
+ role_id = roles[0].get("id")
451
+
452
+ rights = user.get("rights", {})
453
+
454
+ return {
455
+ "account_id": self.account_id,
456
+ "user_id": user.get("id"),
457
+ "login": user.get("email"), # В API v4 login = email
458
+ "name": user.get("name"),
459
+ "phone": user.get("phone"),
460
+ "email": user.get("email"),
461
+ "group_name": group_name,
462
+ "group_id": group_id,
463
+ "role_id": role_id,
464
+ "role_name": role_name,
465
+ "is_admin": rights.get("is_admin", False),
466
+ "is_active": rights.get("is_active", True),
467
+ "is_free": rights.get("is_free", False),
468
+ "mail_access": rights.get("mail_access", False),
469
+ "catalog_access": rights.get("catalog_access", False),
470
+ }
@@ -1,124 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: amochka
3
- Version: 0.1.9
4
- Summary: Python library for working with amoCRM API
5
- Author-email: Timur <timurdt@gmail.com>
6
- License: MIT
7
- Project-URL: Homepage, https://github.com/yourusername/amochka
8
- Project-URL: Documentation, https://github.com/yourusername/amochka
9
- Project-URL: Repository, https://github.com/yourusername/amochka
10
- Project-URL: Bug Tracker, https://github.com/yourusername/amochka/issues
11
- Keywords: amocrm,crm,api,client,automation
12
- Classifier: Development Status :: 4 - Beta
13
- Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: MIT License
15
- Classifier: Operating System :: OS Independent
16
- Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.6
18
- Classifier: Programming Language :: Python :: 3.7
19
- Classifier: Programming Language :: Python :: 3.8
20
- Classifier: Programming Language :: Python :: 3.9
21
- Classifier: Programming Language :: Python :: 3.10
22
- Classifier: Programming Language :: Python :: 3.11
23
- Classifier: Programming Language :: Python :: 3.12
24
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
- Classifier: Topic :: Internet :: WWW/HTTP
26
- Requires-Python: >=3.6
27
- Description-Content-Type: text/markdown
28
- Requires-Dist: requests>=2.25.0
29
- Requires-Dist: ratelimit>=2.2.0
30
-
31
- # amochka
32
-
33
- Официальная документация API amocrm - https://www.amocrm.ru/developers/content/crm_platform/api-reference
34
-
35
- **amochka** — библиотека для работы с API amoCRM на Python. Она поддерживает:
36
- - Получение данных сделок с вложенными сущностями (контакты, компании, теги, и т.д.)
37
- - Редактирование сделок, включая обновление стандартных и кастомных полей
38
- - Поддержку нескольких amoCRM-аккаунтов с персистентным кэшированием кастомных полей для каждого аккаунта отдельно
39
- - Ограничение запросов (7 запросов в секунду) с использованием декораторов из библиотеки `ratelimit`
40
-
41
- ### Основные функции
42
-
43
- - `get_deal_by_id(deal_id)` — получение детальной информации по сделке
44
- - `get_pipelines()` — список воронок и статусов
45
- - `fetch_updated_leads_raw(pipeline_id, updated_from, ...)` — выгрузка необработанных сделок за период
46
-
47
- ## Требования к окружению
48
-
49
- Python 3.8 или новее. Потребуются пакеты `requests` и `ratelimit`.
50
-
51
- ## Установка
52
-
53
- Установите зависимости командой:
54
-
55
- ```bash
56
- pip install requests ratelimit
57
- ```
58
-
59
- Затем скопируйте репозиторий или установите пакет из PyPI (после публикации):
60
-
61
- ```bash
62
- pip install amochka
63
- ```
64
-
65
- ## Кэширование кастомных полей
66
-
67
- Для уменьшения количества запросов к API кастомные поля кэшируются персистентно. Если параметр cache_file не указан, имя файла кэша генерируется автоматически на основе домена amoCRM-аккаунта. Вы можете обновлять кэш принудительно, передавая параметр force_update=True в метод get_custom_fields_mapping() или настроить время жизни кэша (по умолчанию — 24 часа).
68
-
69
- ## Выгрузка обновленных сделок
70
-
71
- Метод `fetch_updated_leads_raw()` позволяет получить все сделки из указанной воронки, которые были изменены в заданный промежуток времени. Результат можно сохранить в JSON-файл без какой‑либо обработки:
72
-
73
- ```python
74
- from datetime import datetime, timedelta
75
- from amochka import AmoCRMClient, CacheConfig
76
-
77
- client = AmoCRMClient(
78
- base_url="https://bneginskogo.amocrm.ru",
79
- token_file="/path/to/token.json",
80
- cache_config=CacheConfig.disabled(),
81
- disable_logging=True
82
- )
83
-
84
- three_hours_ago = datetime.utcnow() - timedelta(hours=3)
85
- client.fetch_updated_leads_raw(6241334, updated_from=three_hours_ago, save_to_file="leads.json")
86
- ```
87
-
88
- Пример получаемого JSON (укороченный):
89
-
90
- ```json
91
- [
92
- {
93
- "id": 26282337,
94
- "name": "Автосделка: Заявка от (Максим Брокер Дубай Бюро Негинского)",
95
- "custom_fields_values": [
96
- {
97
- "field_name": "roistat",
98
- "values": [{"value": "2026"}]
99
- }
100
- ],
101
- "_embedded": {
102
- "tags": [
103
- {"id": 179813, "name": "WZ (Федор 971568113315)"}
104
- ]
105
- }
106
- }
107
- ]
108
- ```
109
-
110
- Для подключения к реальному аккаунту сохраните JSON с OAuth‑токеном и укажите его путь в параметре `token_file` при создании клиента. Базовый URL можно взять из переменной окружения `AMO_BASE_URL`.
111
-
112
- ## Тесты
113
-
114
- Файл `tests/test_client.py` содержит небольшой набор автоматических тестов, написанных на [pytest](https://docs.pytest.org/). Они запускают методы клиента на подставном классе `DummyClient` и проверяют, что функции работают так, как ожидается. Запустить тесты можно командой:
115
-
116
- ```bash
117
- pytest -q
118
- ```
119
-
120
- Эти тесты помогают убедиться, что изменения в коде не ломают основную функциональность.
121
-
122
- ## Пример использования `fetch_updated_leads_raw`
123
-
124
- Кроме примера в разделе выше, код из `example_fetch.py` демонстрирует полный процесс получения сделок и сохранения их в файл.
@@ -1,4 +0,0 @@
1
- amochka-0.1.9.dist-info/METADATA,sha256=wNT6FZm6LKVPEhwjBtBWFTnPIZfx1IKTFYyNIy-AOgM,6271
2
- amochka-0.1.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
3
- amochka-0.1.9.dist-info/top_level.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
- amochka-0.1.9.dist-info/RECORD,,
@@ -1 +0,0 @@
1
-