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.
- amochka/__init__.py +28 -0
- amochka/client.py +1375 -0
- amochka/etl.py +302 -0
- amochka-0.3.1.dist-info/METADATA +205 -0
- amochka-0.3.1.dist-info/RECORD +14 -0
- amochka-0.3.1.dist-info/top_level.txt +2 -0
- etl/__init__.py +7 -0
- etl/config.py +236 -0
- etl/extractors.py +354 -0
- etl/loaders.py +813 -0
- etl/migrations/001_create_tables.sql +346 -0
- etl/run_etl.py +684 -0
- etl/transformers.py +470 -0
- amochka-0.1.9.dist-info/METADATA +0 -124
- amochka-0.1.9.dist-info/RECORD +0 -4
- amochka-0.1.9.dist-info/top_level.txt +0 -1
- {amochka-0.1.9.dist-info → amochka-0.3.1.dist-info}/WHEEL +0 -0
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
|
+
}
|
amochka-0.1.9.dist-info/METADATA
DELETED
|
@@ -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` демонстрирует полный процесс получения сделок и сохранения их в файл.
|
amochka-0.1.9.dist-info/RECORD
DELETED
|
@@ -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
|
-
|
|
File without changes
|