amochka 0.1.3__py3-none-any.whl → 0.1.6__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 +2 -1
- amochka/client.py +431 -69
- amochka/etl.py +91 -0
- amochka/models.py +50 -0
- {amochka-0.1.3.dist-info → amochka-0.1.6.dist-info}/METADATA +9 -8
- amochka-0.1.6.dist-info/RECORD +8 -0
- {amochka-0.1.3.dist-info → amochka-0.1.6.dist-info}/WHEEL +1 -1
- amochka-0.1.3.dist-info/RECORD +0 -6
- {amochka-0.1.3.dist-info → amochka-0.1.6.dist-info}/top_level.txt +0 -0
amochka/__init__.py
CHANGED
amochka/client.py
CHANGED
|
@@ -19,35 +19,46 @@ RATE_LIMIT = 7 # Максимум 7 запросов в секунду
|
|
|
19
19
|
class Deal(dict):
|
|
20
20
|
"""
|
|
21
21
|
Объект сделки расширяет стандартный словарь данными из custom_fields_values.
|
|
22
|
-
|
|
22
|
+
|
|
23
|
+
Обеспечивает два способа доступа к кастомным полям:
|
|
24
|
+
1. get(key): при обращении по названию (строкой) или по ID поля (integer)
|
|
25
|
+
возвращает текстовое значение поля (например, «Дурина Юлия»).
|
|
26
|
+
2. get_id(key): возвращает идентификатор выбранного варианта (enum_id) для полей типа select.
|
|
27
|
+
Если в данных enum_id отсутствует, производится поиск в переданной конфигурации полей,
|
|
28
|
+
сравнение выполняется без учёта регистра и лишних пробелов.
|
|
29
|
+
|
|
30
|
+
Параметр custom_fields_config – словарь, где ключи – ID полей, а значения – модели полей.
|
|
23
31
|
"""
|
|
24
|
-
def __init__(self, data, custom_fields_config=None):
|
|
32
|
+
def __init__(self, data, custom_fields_config=None, logger=None):
|
|
25
33
|
super().__init__(data)
|
|
26
34
|
self._custom = {}
|
|
27
35
|
self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
|
|
36
|
+
self._logger = logger or logging.getLogger(__name__)
|
|
28
37
|
custom = data.get("custom_fields_values") or []
|
|
29
|
-
|
|
38
|
+
self._logger.debug(f"Processing custom_fields_values: {custom}")
|
|
30
39
|
for field in custom:
|
|
31
40
|
if isinstance(field, dict):
|
|
32
41
|
field_name = field.get("field_name")
|
|
33
42
|
values = field.get("values")
|
|
34
43
|
if field_name and values and isinstance(values, list) and len(values) > 0:
|
|
35
44
|
key_name = field_name.lower().strip()
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
stored_value = values[0].get("value")
|
|
46
|
+
stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
|
|
47
|
+
# Сохраняем полную информацию (и для get() и для get_id())
|
|
48
|
+
self._custom[key_name] = {"value": stored_value, "enum_id": stored_enum_id}
|
|
49
|
+
self._logger.debug(f"Set custom field '{key_name}' = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
|
|
39
50
|
field_id = field.get("field_id")
|
|
40
51
|
if field_id is not None and values and isinstance(values, list) and len(values) > 0:
|
|
41
52
|
stored_value = values[0].get("value")
|
|
42
53
|
stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
|
|
43
54
|
self._custom[int(field_id)] = {"value": stored_value, "enum_id": stored_enum_id}
|
|
44
|
-
|
|
55
|
+
self._logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
|
|
45
56
|
if custom_fields_config:
|
|
46
57
|
for cid, field_obj in custom_fields_config.items():
|
|
47
58
|
key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
|
|
48
59
|
if key not in self._custom:
|
|
49
60
|
self._custom[key] = None
|
|
50
|
-
|
|
61
|
+
self._logger.debug(f"Field '{key}' not found in deal data; set to None")
|
|
51
62
|
|
|
52
63
|
def __getitem__(self, key):
|
|
53
64
|
if key in super().keys():
|
|
@@ -69,11 +80,65 @@ class Deal(dict):
|
|
|
69
80
|
except KeyError:
|
|
70
81
|
return default
|
|
71
82
|
|
|
83
|
+
def get_field_type(self, key):
|
|
84
|
+
"""
|
|
85
|
+
Определяет тип кастомного поля.
|
|
86
|
+
|
|
87
|
+
:param key: Название поля (строка) или ID поля (integer).
|
|
88
|
+
:return: Строка с типом поля ('text', 'select', 'numeric', 'checkbox', и т.д.)
|
|
89
|
+
или None, если поле не найдено или тип не определён.
|
|
90
|
+
"""
|
|
91
|
+
field_def = None
|
|
92
|
+
|
|
93
|
+
# Получаем определение поля из конфигурации
|
|
94
|
+
if self._custom_config:
|
|
95
|
+
if isinstance(key, int):
|
|
96
|
+
field_def = self._custom_config.get(key)
|
|
97
|
+
else:
|
|
98
|
+
for fid, fdef in self._custom_config.items():
|
|
99
|
+
if isinstance(fdef, dict) and fdef.get("name", "").lower().strip() == key.lower().strip():
|
|
100
|
+
field_def = fdef
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
# Если нашли определение, возвращаем его тип
|
|
104
|
+
if field_def and isinstance(field_def, dict):
|
|
105
|
+
return field_def.get("type")
|
|
106
|
+
|
|
107
|
+
# Если конфигурации нет или поле не найдено, пробуем определить тип по данным
|
|
108
|
+
stored = None
|
|
109
|
+
if isinstance(key, str):
|
|
110
|
+
lower_key = key.lower().strip()
|
|
111
|
+
if lower_key in self._custom:
|
|
112
|
+
stored = self._custom[lower_key]
|
|
113
|
+
elif isinstance(key, int):
|
|
114
|
+
if key in self._custom:
|
|
115
|
+
stored = self._custom[key]
|
|
116
|
+
|
|
117
|
+
if isinstance(stored, dict) and "enum_id" in stored:
|
|
118
|
+
return "select"
|
|
119
|
+
|
|
120
|
+
return None
|
|
121
|
+
|
|
72
122
|
def get_id(self, key, default=None):
|
|
73
123
|
"""
|
|
74
|
-
Возвращает идентификатор выбранного варианта (enum_id) для кастомного
|
|
75
|
-
|
|
124
|
+
Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля типа select.
|
|
125
|
+
Для полей других типов возвращает их значение, как метод get().
|
|
126
|
+
|
|
127
|
+
Если значение enum_id отсутствует в данных, производится поиск в конфигурации кастомных полей,
|
|
128
|
+
сравнение значения выполняется без учёта регистра и пробелов.
|
|
129
|
+
|
|
130
|
+
:param key: Название поля (строка) или ID поля (integer).
|
|
131
|
+
:param default: Значение по умолчанию, если enum_id не найден.
|
|
132
|
+
:return: Для полей типа select - идентификатор варианта (целое число).
|
|
133
|
+
Для других типов полей - значение поля.
|
|
134
|
+
Если поле не найдено - default.
|
|
76
135
|
"""
|
|
136
|
+
field_type = self.get_field_type(key)
|
|
137
|
+
|
|
138
|
+
# Если это не поле списка, возвращаем значение как get()
|
|
139
|
+
if field_type is not None and field_type != "select":
|
|
140
|
+
return self.get(key, default)
|
|
141
|
+
|
|
77
142
|
stored = None
|
|
78
143
|
if isinstance(key, str):
|
|
79
144
|
lower_key = key.lower().strip()
|
|
@@ -100,48 +165,125 @@ class Deal(dict):
|
|
|
100
165
|
for enum in enums:
|
|
101
166
|
if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
|
|
102
167
|
return enum.get("id", default)
|
|
103
|
-
|
|
168
|
+
|
|
169
|
+
# Если это не поле типа select или не удалось найти enum_id,
|
|
170
|
+
# возвращаем значение поля
|
|
171
|
+
return self.get(key, default)
|
|
172
|
+
|
|
173
|
+
class CacheConfig:
|
|
174
|
+
"""
|
|
175
|
+
Конфигурация кэширования для AmoCRMClient.
|
|
176
|
+
|
|
177
|
+
Параметры:
|
|
178
|
+
enabled (bool): Включено ли кэширование
|
|
179
|
+
storage (str): Тип хранилища ('file' или 'memory')
|
|
180
|
+
file (str): Путь к файлу кэша (используется только при storage='file')
|
|
181
|
+
lifetime_hours (int|None): Время жизни кэша в часах (None для бесконечного)
|
|
182
|
+
"""
|
|
183
|
+
def __init__(self, enabled=True, storage='file', file=None, lifetime_hours=24):
|
|
184
|
+
self.enabled = enabled
|
|
185
|
+
self.storage = storage.lower()
|
|
186
|
+
self.file = file
|
|
187
|
+
self.lifetime_hours = lifetime_hours
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def disabled(cls):
|
|
191
|
+
"""Создает конфигурацию с отключенным кэшированием"""
|
|
192
|
+
return cls(enabled=False)
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def memory_only(cls, lifetime_hours=24):
|
|
196
|
+
"""Создает конфигурацию с кэшированием только в памяти"""
|
|
197
|
+
return cls(enabled=True, storage='memory', lifetime_hours=lifetime_hours)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def file_cache(cls, file=None, lifetime_hours=24):
|
|
201
|
+
"""Создает конфигурацию с файловым кэшированием"""
|
|
202
|
+
return cls(enabled=True, storage='file', file=file, lifetime_hours=lifetime_hours)
|
|
104
203
|
|
|
105
204
|
class AmoCRMClient:
|
|
106
205
|
"""
|
|
107
206
|
Клиент для работы с API amoCRM.
|
|
108
|
-
|
|
207
|
+
|
|
208
|
+
Основные функции:
|
|
209
|
+
- load_token: Загружает и проверяет токен авторизации.
|
|
210
|
+
- _make_request: Выполняет HTTP-запрос с учетом ограничения по скорости.
|
|
211
|
+
- get_deal_by_id: Получает данные сделки по ID и возвращает объект Deal.
|
|
212
|
+
- get_custom_fields_mapping: Загружает и кэширует список кастомных полей.
|
|
213
|
+
- find_custom_field_id: Ищет кастомное поле по его названию.
|
|
214
|
+
- update_lead: Обновляет сделку, включая стандартные и кастомные поля.
|
|
215
|
+
|
|
216
|
+
Дополнительно можно задать уровень логирования через параметр log_level,
|
|
217
|
+
либо полностью отключить логирование, установив disable_logging=True.
|
|
109
218
|
"""
|
|
110
|
-
def __init__(
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
base_url,
|
|
222
|
+
token_file=None,
|
|
223
|
+
cache_config=None,
|
|
224
|
+
log_level=logging.INFO,
|
|
225
|
+
disable_logging=False
|
|
226
|
+
):
|
|
111
227
|
"""
|
|
112
|
-
Инициализирует клиента, задавая базовый URL, токен авторизации и
|
|
228
|
+
Инициализирует клиента, задавая базовый URL, токен авторизации и настройки кэша для кастомных полей.
|
|
113
229
|
|
|
114
|
-
:param
|
|
230
|
+
:param base_url: Базовый URL API amoCRM.
|
|
231
|
+
:param token_file: Файл, содержащий токен авторизации.
|
|
232
|
+
:param cache_config: Конфигурация кэширования (объект CacheConfig или None для значений по умолчанию)
|
|
233
|
+
:param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
|
|
234
|
+
:param disable_logging: Если True, логирование будет отключено.
|
|
115
235
|
"""
|
|
116
236
|
self.base_url = base_url.rstrip('/')
|
|
117
237
|
domain = self.base_url.split("//")[-1].split(".")[0]
|
|
118
238
|
self.domain = domain
|
|
119
239
|
self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
self.
|
|
123
|
-
self.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
240
|
+
|
|
241
|
+
# Создаем логгер для конкретного экземпляра клиента
|
|
242
|
+
self.logger = logging.getLogger(f"{__name__}.{self.domain}")
|
|
243
|
+
if not self.logger.handlers:
|
|
244
|
+
handler = logging.StreamHandler()
|
|
245
|
+
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
|
246
|
+
handler.setFormatter(formatter)
|
|
247
|
+
self.logger.addHandler(handler)
|
|
248
|
+
self.logger.propagate = False # Отключаем передачу логов в родительский логгер
|
|
249
|
+
|
|
127
250
|
if disable_logging:
|
|
128
|
-
|
|
251
|
+
self.logger.setLevel(logging.CRITICAL + 1) # Выше, чем любой стандартный уровень
|
|
252
|
+
else:
|
|
253
|
+
self.logger.setLevel(log_level)
|
|
254
|
+
|
|
255
|
+
# Настройка кэширования
|
|
256
|
+
if cache_config is None:
|
|
257
|
+
self.cache_config = CacheConfig()
|
|
129
258
|
else:
|
|
130
|
-
|
|
259
|
+
self.cache_config = cache_config
|
|
260
|
+
|
|
261
|
+
# Установка файла кэша, если используется файловое хранилище
|
|
262
|
+
if self.cache_config.enabled and self.cache_config.storage == 'file':
|
|
263
|
+
if not self.cache_config.file:
|
|
264
|
+
self.cache_config.file = f"custom_fields_cache_{self.domain}.json"
|
|
131
265
|
|
|
132
|
-
logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
|
|
266
|
+
self.logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
|
|
267
|
+
|
|
268
|
+
self.token = self.load_token()
|
|
269
|
+
self._custom_fields_mapping = None
|
|
133
270
|
|
|
134
271
|
def load_token(self):
|
|
135
|
-
|
|
272
|
+
"""
|
|
273
|
+
Загружает токен авторизации из файла или строки, проверяет его срок действия.
|
|
274
|
+
|
|
275
|
+
:return: Действительный access_token.
|
|
276
|
+
:raises Exception: Если токен не найден или истёк.
|
|
277
|
+
"""
|
|
136
278
|
data = None
|
|
137
279
|
if os.path.exists(self.token_file):
|
|
138
280
|
with open(self.token_file, 'r') as f:
|
|
139
281
|
data = json.load(f)
|
|
140
|
-
logger.debug(f"Token loaded from file: {self.token_file}")
|
|
282
|
+
self.logger.debug(f"Token loaded from file: {self.token_file}")
|
|
141
283
|
else:
|
|
142
284
|
try:
|
|
143
285
|
data = json.loads(self.token_file)
|
|
144
|
-
logger.debug("Token parsed from provided string.")
|
|
286
|
+
self.logger.debug("Token parsed from provided string.")
|
|
145
287
|
except Exception as e:
|
|
146
288
|
raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
|
|
147
289
|
|
|
@@ -152,7 +294,7 @@ class AmoCRMClient:
|
|
|
152
294
|
expires_at = float(expires_at_str)
|
|
153
295
|
|
|
154
296
|
if expires_at and time.time() < expires_at:
|
|
155
|
-
logger.debug("Token is valid.")
|
|
297
|
+
self.logger.debug("Token is valid.")
|
|
156
298
|
return data.get('access_token')
|
|
157
299
|
else:
|
|
158
300
|
raise Exception("Токен найден, но он истёк. Обновите токен.")
|
|
@@ -160,77 +302,112 @@ class AmoCRMClient:
|
|
|
160
302
|
@sleep_and_retry
|
|
161
303
|
@limits(calls=RATE_LIMIT, period=1)
|
|
162
304
|
def _make_request(self, method, endpoint, params=None, data=None):
|
|
163
|
-
|
|
305
|
+
"""
|
|
306
|
+
Выполняет HTTP-запрос к API amoCRM с учетом ограничения по скорости (rate limit).
|
|
307
|
+
|
|
308
|
+
:param method: HTTP-метод (GET, PATCH, POST, DELETE и т.д.).
|
|
309
|
+
:param endpoint: Конечная точка API (начинается с /api/v4/).
|
|
310
|
+
:param params: GET-параметры запроса.
|
|
311
|
+
:param data: Данные, отправляемые в JSON-формате.
|
|
312
|
+
:return: Ответ в формате JSON или None (если статус 204).
|
|
313
|
+
:raises Exception: При получении кода ошибки, отличного от 200/204.
|
|
314
|
+
"""
|
|
164
315
|
url = f"{self.base_url}{endpoint}"
|
|
165
316
|
headers = {
|
|
166
317
|
"Authorization": f"Bearer {self.token}",
|
|
167
318
|
"Content-Type": "application/json"
|
|
168
319
|
}
|
|
169
|
-
logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
|
|
320
|
+
self.logger.debug(f"Making {method} request to {url} with params {params} and data {data}")
|
|
170
321
|
response = requests.request(method, url, headers=headers, params=params, json=data)
|
|
171
322
|
if response.status_code not in (200, 204):
|
|
172
|
-
logger.error(f"Request error {response.status_code}: {response.text}")
|
|
323
|
+
self.logger.error(f"Request error {response.status_code}: {response.text}")
|
|
173
324
|
raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
|
|
174
325
|
if response.status_code == 204:
|
|
175
326
|
return None
|
|
176
327
|
return response.json()
|
|
177
328
|
|
|
178
|
-
def get_deal_by_id(self, deal_id):
|
|
179
|
-
|
|
329
|
+
def get_deal_by_id(self, deal_id, skip_fields_mapping=False):
|
|
330
|
+
"""
|
|
331
|
+
Получает данные сделки по её ID и возвращает объект Deal.
|
|
332
|
+
Если данные отсутствуют или имеют неверную структуру, выбрасывается исключение.
|
|
333
|
+
|
|
334
|
+
:param deal_id: ID сделки для получения
|
|
335
|
+
:param skip_fields_mapping: Если True, не загружает справочник кастомных полей
|
|
336
|
+
(используйте для работы только с ID полей)
|
|
337
|
+
:return: Объект Deal с данными сделки
|
|
338
|
+
"""
|
|
180
339
|
endpoint = f"/api/v4/leads/{deal_id}"
|
|
181
340
|
params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
|
|
182
341
|
data = self._make_request("GET", endpoint, params=params)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
342
|
+
|
|
343
|
+
# Проверяем, что получили данные и что они содержат ключ "id"
|
|
344
|
+
if not data or not isinstance(data, dict) or "id" not in data:
|
|
345
|
+
self.logger.error(f"Deal {deal_id} not found or invalid response: {data}")
|
|
346
|
+
raise Exception(f"Deal {deal_id} not found or invalid response.")
|
|
347
|
+
|
|
348
|
+
custom_config = None if skip_fields_mapping else self.get_custom_fields_mapping()
|
|
349
|
+
self.logger.debug(f"Deal {deal_id} data received (содержимое полей не выводится полностью).")
|
|
350
|
+
return Deal(data, custom_fields_config=custom_config, logger=self.logger)
|
|
186
351
|
|
|
187
352
|
def _save_custom_fields_cache(self, mapping):
|
|
188
353
|
"""
|
|
189
354
|
Сохраняет кэш кастомных полей в файл, если используется файловый кэш.
|
|
190
|
-
Если
|
|
355
|
+
Если кэширование отключено или выбран кэш в памяти, операция пропускается.
|
|
191
356
|
"""
|
|
192
|
-
if not self.
|
|
193
|
-
logger.debug("
|
|
357
|
+
if not self.cache_config.enabled:
|
|
358
|
+
self.logger.debug("Caching disabled; cache not saved.")
|
|
359
|
+
return
|
|
360
|
+
if self.cache_config.storage != 'file':
|
|
361
|
+
self.logger.debug("Using memory caching; no file cache saved.")
|
|
194
362
|
return
|
|
195
363
|
cache_data = {"last_updated": time.time(), "mapping": mapping}
|
|
196
|
-
with open(self.
|
|
364
|
+
with open(self.cache_config.file, "w") as f:
|
|
197
365
|
json.dump(cache_data, f)
|
|
198
|
-
logger.debug(f"Custom fields cache saved to {self.
|
|
366
|
+
self.logger.debug(f"Custom fields cache saved to {self.cache_config.file}")
|
|
199
367
|
|
|
200
368
|
def _load_custom_fields_cache(self):
|
|
201
369
|
"""
|
|
202
370
|
Загружает кэш кастомных полей из файла, если используется файловый кэш.
|
|
203
|
-
Если
|
|
371
|
+
Если кэширование отключено или выбран кэш в памяти, возвращает None.
|
|
204
372
|
"""
|
|
205
|
-
if not self.
|
|
206
|
-
logger.debug("
|
|
373
|
+
if not self.cache_config.enabled:
|
|
374
|
+
self.logger.debug("Caching disabled; no cache loaded.")
|
|
375
|
+
return None
|
|
376
|
+
if self.cache_config.storage != 'file':
|
|
377
|
+
self.logger.debug("Using memory caching; cache will be kept in memory only.")
|
|
207
378
|
return None
|
|
208
|
-
if os.path.exists(self.
|
|
209
|
-
with open(self.
|
|
379
|
+
if os.path.exists(self.cache_config.file):
|
|
380
|
+
with open(self.cache_config.file, "r") as f:
|
|
210
381
|
try:
|
|
211
382
|
cache_data = json.load(f)
|
|
212
|
-
logger.debug("Custom fields cache loaded successfully.")
|
|
383
|
+
self.logger.debug("Custom fields cache loaded successfully.")
|
|
213
384
|
return cache_data
|
|
214
385
|
except Exception as e:
|
|
215
|
-
logger.error(f"Error loading cache: {e}")
|
|
386
|
+
self.logger.error(f"Error loading cache: {e}")
|
|
216
387
|
return None
|
|
217
388
|
return None
|
|
218
389
|
|
|
219
|
-
def get_custom_fields_mapping(self, force_update=False
|
|
390
|
+
def get_custom_fields_mapping(self, force_update=False):
|
|
220
391
|
"""
|
|
221
392
|
Возвращает словарь отображения кастомных полей для сделок.
|
|
222
393
|
Если данные кэшированы и не устарели, возвращает кэш; иначе выполняет запросы для получения данных.
|
|
223
394
|
"""
|
|
224
|
-
if not force_update:
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
395
|
+
if not force_update and self._custom_fields_mapping is not None:
|
|
396
|
+
return self._custom_fields_mapping
|
|
397
|
+
|
|
398
|
+
cache_data = self._load_custom_fields_cache() if self.cache_config.enabled else None
|
|
399
|
+
if cache_data:
|
|
400
|
+
last_updated = cache_data.get("last_updated", 0)
|
|
401
|
+
if self.cache_config.lifetime_hours is not None:
|
|
402
|
+
if time.time() - last_updated < self.cache_config.lifetime_hours * 3600:
|
|
231
403
|
self._custom_fields_mapping = cache_data.get("mapping")
|
|
232
|
-
logger.debug("Using cached custom fields mapping.")
|
|
404
|
+
self.logger.debug("Using cached custom fields mapping.")
|
|
233
405
|
return self._custom_fields_mapping
|
|
406
|
+
else:
|
|
407
|
+
# Бесконечный кэш – не проверяем срок
|
|
408
|
+
self._custom_fields_mapping = cache_data.get("mapping")
|
|
409
|
+
self.logger.debug("Using cached custom fields mapping (infinite cache).")
|
|
410
|
+
return self._custom_fields_mapping
|
|
234
411
|
|
|
235
412
|
mapping = {}
|
|
236
413
|
page = 1
|
|
@@ -242,19 +419,23 @@ class AmoCRMClient:
|
|
|
242
419
|
for field in response["_embedded"]["custom_fields"]:
|
|
243
420
|
mapping[field["id"]] = field
|
|
244
421
|
total_pages = response.get("_page_count", page)
|
|
245
|
-
logger.debug(f"Fetched page {page} of {total_pages}")
|
|
422
|
+
self.logger.debug(f"Fetched page {page} of {total_pages}")
|
|
246
423
|
page += 1
|
|
247
424
|
else:
|
|
248
425
|
break
|
|
249
426
|
|
|
250
|
-
logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
|
|
427
|
+
self.logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
|
|
251
428
|
self._custom_fields_mapping = mapping
|
|
252
|
-
self.
|
|
429
|
+
if self.cache_config.enabled:
|
|
430
|
+
self._save_custom_fields_cache(mapping)
|
|
253
431
|
return mapping
|
|
254
432
|
|
|
255
433
|
def find_custom_field_id(self, search_term):
|
|
256
434
|
"""
|
|
257
435
|
Ищет кастомное поле по заданному названию (или части названия).
|
|
436
|
+
|
|
437
|
+
:param search_term: Строка для поиска по имени поля.
|
|
438
|
+
:return: Кортеж (field_id, field_obj) если найдено, иначе (None, None).
|
|
258
439
|
"""
|
|
259
440
|
mapping = self.get_custom_fields_mapping()
|
|
260
441
|
search_term_lower = search_term.lower().strip()
|
|
@@ -264,14 +445,26 @@ class AmoCRMClient:
|
|
|
264
445
|
else:
|
|
265
446
|
name = str(field_obj).lower().strip()
|
|
266
447
|
if search_term_lower == name or search_term_lower in name:
|
|
267
|
-
logger.debug(f"Found custom field '{name}' with id {key}")
|
|
448
|
+
self.logger.debug(f"Found custom field '{name}' with id {key}")
|
|
268
449
|
return int(key), field_obj
|
|
269
|
-
logger.debug(f"Custom field containing '{search_term}' not found.")
|
|
450
|
+
self.logger.debug(f"Custom field containing '{search_term}' not found.")
|
|
270
451
|
return None, None
|
|
271
452
|
|
|
272
453
|
def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
|
|
273
454
|
"""
|
|
274
455
|
Обновляет сделку, задавая новые значения для стандартных и кастомных полей.
|
|
456
|
+
|
|
457
|
+
Для кастомных полей:
|
|
458
|
+
- Если значение передается как целое число, оно интерпретируется как идентификатор варианта (enum_id)
|
|
459
|
+
для полей типа select.
|
|
460
|
+
- Если значение передается как строка, используется ключ "value".
|
|
461
|
+
|
|
462
|
+
:param lead_id: ID сделки, которую нужно обновить.
|
|
463
|
+
:param update_fields: Словарь с полями для обновления. Ключи могут быть стандартными или названием кастомного поля.
|
|
464
|
+
:param tags_to_add: Список тегов для добавления к сделке.
|
|
465
|
+
:param tags_to_delete: Список тегов для удаления из сделки.
|
|
466
|
+
:return: Ответ API в формате JSON.
|
|
467
|
+
:raises Exception: Если одно из кастомных полей не найдено.
|
|
275
468
|
"""
|
|
276
469
|
payload = {}
|
|
277
470
|
standard_fields = {
|
|
@@ -282,7 +475,7 @@ class AmoCRMClient:
|
|
|
282
475
|
for key, value in update_fields.items():
|
|
283
476
|
if key in standard_fields:
|
|
284
477
|
payload[key] = value
|
|
285
|
-
logger.debug(f"Standard field {key} set to {value}")
|
|
478
|
+
self.logger.debug(f"Standard field {key} set to {value}")
|
|
286
479
|
else:
|
|
287
480
|
if isinstance(value, int):
|
|
288
481
|
field_value_dict = {"enum_id": value}
|
|
@@ -291,12 +484,12 @@ class AmoCRMClient:
|
|
|
291
484
|
try:
|
|
292
485
|
field_id = int(key)
|
|
293
486
|
custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
|
|
294
|
-
logger.debug(f"Custom field by id {field_id} set to {value}")
|
|
487
|
+
self.logger.debug(f"Custom field by id {field_id} set to {value}")
|
|
295
488
|
except ValueError:
|
|
296
489
|
field_id, field_obj = self.find_custom_field_id(key)
|
|
297
490
|
if field_id is not None:
|
|
298
491
|
custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
|
|
299
|
-
logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
|
|
492
|
+
self.logger.debug(f"Custom field '{key}' found with id {field_id} set to {value}")
|
|
300
493
|
else:
|
|
301
494
|
raise Exception(f"Custom field '{key}' не найден.")
|
|
302
495
|
if custom_fields:
|
|
@@ -305,8 +498,177 @@ class AmoCRMClient:
|
|
|
305
498
|
payload["tags_to_add"] = tags_to_add
|
|
306
499
|
if tags_to_delete:
|
|
307
500
|
payload["tags_to_delete"] = tags_to_delete
|
|
308
|
-
logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
|
|
501
|
+
self.logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
|
|
309
502
|
endpoint = f"/api/v4/leads/{lead_id}"
|
|
310
503
|
response = self._make_request("PATCH", endpoint, data=payload)
|
|
311
|
-
logger.debug("Update response received.")
|
|
312
|
-
return response
|
|
504
|
+
self.logger.debug("Update response received.")
|
|
505
|
+
return response
|
|
506
|
+
|
|
507
|
+
def get_entity_notes(self, entity, entity_id, get_all=False, note_type=None, extra_params=None):
|
|
508
|
+
"""
|
|
509
|
+
Получает список примечаний для указанной сущности и её ID.
|
|
510
|
+
|
|
511
|
+
Используется эндпоинт:
|
|
512
|
+
GET /api/v4/{entity_plural}/{entity_id}/notes
|
|
513
|
+
|
|
514
|
+
:param entity: Тип сущности (например, 'lead', 'contact', 'company', 'customer' и т.д.).
|
|
515
|
+
Передаётся в единственном числе, для формирования конечной точки будет использована
|
|
516
|
+
таблица преобразования (например, 'lead' -> 'leads').
|
|
517
|
+
:param entity_id: ID сущности.
|
|
518
|
+
:param get_all: Если True, метод автоматически проходит по всем страницам пагинации.
|
|
519
|
+
:param note_type: Фильтр по типу примечания. Может быть строкой (например, 'common') или списком строк.
|
|
520
|
+
:param extra_params: Словарь дополнительных GET-параметров, если требуется.
|
|
521
|
+
:return: Список примечаний (каждый элемент – словарь с данными примечания).
|
|
522
|
+
"""
|
|
523
|
+
# Преобразуем тип сущности в форму во множественном числе (для известных типов)
|
|
524
|
+
mapping = {
|
|
525
|
+
'lead': 'leads',
|
|
526
|
+
'contact': 'contacts',
|
|
527
|
+
'company': 'companies',
|
|
528
|
+
'customer': 'customers'
|
|
529
|
+
}
|
|
530
|
+
plural = mapping.get(entity.lower(), entity.lower() + "s")
|
|
531
|
+
|
|
532
|
+
endpoint = f"/api/v4/{plural}/{entity_id}/notes"
|
|
533
|
+
params = {
|
|
534
|
+
"page": 1,
|
|
535
|
+
"limit": 250
|
|
536
|
+
}
|
|
537
|
+
if note_type is not None:
|
|
538
|
+
params["filter[note_type]"] = note_type
|
|
539
|
+
if extra_params:
|
|
540
|
+
params.update(extra_params)
|
|
541
|
+
|
|
542
|
+
notes = []
|
|
543
|
+
while True:
|
|
544
|
+
response = self._make_request("GET", endpoint, params=params)
|
|
545
|
+
if response and "_embedded" in response and "notes" in response["_embedded"]:
|
|
546
|
+
notes.extend(response["_embedded"]["notes"])
|
|
547
|
+
if not get_all:
|
|
548
|
+
break
|
|
549
|
+
total_pages = response.get("_page_count", params["page"])
|
|
550
|
+
if params["page"] >= total_pages:
|
|
551
|
+
break
|
|
552
|
+
params["page"] += 1
|
|
553
|
+
self.logger.debug(f"Retrieved {len(notes)} notes for {entity} {entity_id}")
|
|
554
|
+
return notes
|
|
555
|
+
|
|
556
|
+
def get_entity_note(self, entity, entity_id, note_id):
|
|
557
|
+
"""
|
|
558
|
+
Получает расширенную информацию по конкретному примечанию для указанной сущности.
|
|
559
|
+
|
|
560
|
+
Используется эндпоинт:
|
|
561
|
+
GET /api/v4/{entity_plural}/{entity_id}/notes/{note_id}
|
|
562
|
+
|
|
563
|
+
:param entity: Тип сущности (например, 'lead', 'contact', 'company', 'customer' и т.д.).
|
|
564
|
+
:param entity_id: ID сущности.
|
|
565
|
+
:param note_id: ID примечания.
|
|
566
|
+
:return: Словарь с полной информацией о примечании.
|
|
567
|
+
:raises Exception: При ошибке запроса.
|
|
568
|
+
"""
|
|
569
|
+
mapping = {
|
|
570
|
+
'lead': 'leads',
|
|
571
|
+
'contact': 'contacts',
|
|
572
|
+
'company': 'companies',
|
|
573
|
+
'customer': 'customers'
|
|
574
|
+
}
|
|
575
|
+
plural = mapping.get(entity.lower(), entity.lower() + "s")
|
|
576
|
+
endpoint = f"/api/v4/{plural}/{entity_id}/notes/{note_id}"
|
|
577
|
+
self.logger.debug(f"Fetching note {note_id} for {entity} {entity_id}")
|
|
578
|
+
note_data = self._make_request("GET", endpoint)
|
|
579
|
+
self.logger.debug(f"Note {note_id} for {entity} {entity_id} fetched successfully.")
|
|
580
|
+
return note_data
|
|
581
|
+
|
|
582
|
+
# Удобные обёртки для сделок и контактов:
|
|
583
|
+
def get_deal_notes(self, deal_id, **kwargs):
|
|
584
|
+
return self.get_entity_notes("lead", deal_id, **kwargs)
|
|
585
|
+
|
|
586
|
+
def get_deal_note(self, deal_id, note_id):
|
|
587
|
+
return self.get_entity_note("lead", deal_id, note_id)
|
|
588
|
+
|
|
589
|
+
def get_contact_notes(self, contact_id, **kwargs):
|
|
590
|
+
return self.get_entity_notes("contact", contact_id, **kwargs)
|
|
591
|
+
|
|
592
|
+
def get_contact_note(self, contact_id, note_id):
|
|
593
|
+
return self.get_entity_note("contact", contact_id, note_id)
|
|
594
|
+
|
|
595
|
+
def get_entity_events(self, entity, entity_id=None, get_all=False, event_type=None, extra_params=None):
|
|
596
|
+
"""
|
|
597
|
+
Получает список событий для указанной сущности.
|
|
598
|
+
Если entity_id не указан (None), возвращает события для всех сущностей данного типа.
|
|
599
|
+
|
|
600
|
+
:param entity: Тип сущности (например, 'lead', 'contact', 'company' и т.д.).
|
|
601
|
+
:param entity_id: ID сущности или None для получения событий по всем сущностям данного типа.
|
|
602
|
+
:param get_all: Если True, автоматически проходит по всем страницам пагинации.
|
|
603
|
+
:param event_type: Фильтр по типу события. Может быть строкой или списком строк.
|
|
604
|
+
:param extra_params: Словарь дополнительных GET-параметров.
|
|
605
|
+
:return: Список событий (каждый элемент – словарь с данными события).
|
|
606
|
+
"""
|
|
607
|
+
params = {
|
|
608
|
+
'page': 1,
|
|
609
|
+
'limit': 100,
|
|
610
|
+
'filter[entity]': entity,
|
|
611
|
+
}
|
|
612
|
+
# Добавляем фильтр по ID, если он указан
|
|
613
|
+
if entity_id is not None:
|
|
614
|
+
params['filter[entity_id]'] = entity_id
|
|
615
|
+
# Фильтр по типу события
|
|
616
|
+
if event_type is not None:
|
|
617
|
+
params['filter[type]'] = event_type
|
|
618
|
+
if extra_params:
|
|
619
|
+
params.update(extra_params)
|
|
620
|
+
|
|
621
|
+
events = []
|
|
622
|
+
while True:
|
|
623
|
+
response = self._make_request("GET", "/api/v4/events", params=params)
|
|
624
|
+
if response and "_embedded" in response and "events" in response["_embedded"]:
|
|
625
|
+
events.extend(response["_embedded"]["events"])
|
|
626
|
+
# Если не нужно получать все страницы, выходим
|
|
627
|
+
if not get_all:
|
|
628
|
+
break
|
|
629
|
+
total_pages = response.get("_page_count", params['page'])
|
|
630
|
+
if params['page'] >= total_pages:
|
|
631
|
+
break
|
|
632
|
+
params['page'] += 1
|
|
633
|
+
return events
|
|
634
|
+
|
|
635
|
+
# Удобные обёртки:
|
|
636
|
+
def get_deal_events(self, deal_id, **kwargs):
|
|
637
|
+
return self.get_entity_events("lead", deal_id, **kwargs)
|
|
638
|
+
|
|
639
|
+
def get_contact_events(self, contact_id, **kwargs):
|
|
640
|
+
return self.get_entity_events("contact", contact_id, **kwargs)
|
|
641
|
+
|
|
642
|
+
def get_event(self, event_id):
|
|
643
|
+
"""
|
|
644
|
+
Получает подробную информацию по конкретному событию по его ID.
|
|
645
|
+
|
|
646
|
+
Используется эндпоинт:
|
|
647
|
+
GET /api/v4/events/{event_id}
|
|
648
|
+
|
|
649
|
+
:param event_id: ID события.
|
|
650
|
+
:return: Словарь с подробной информацией о событии.
|
|
651
|
+
:raises Exception: При ошибке запроса.
|
|
652
|
+
"""
|
|
653
|
+
endpoint = f"/api/v4/events/{event_id}"
|
|
654
|
+
self.logger.debug(f"Fetching event with ID {event_id}")
|
|
655
|
+
event_data = self._make_request("GET", endpoint)
|
|
656
|
+
self.logger.debug(f"Event {event_id} details fetched successfully.")
|
|
657
|
+
return event_data
|
|
658
|
+
|
|
659
|
+
def get_pipelines(self):
|
|
660
|
+
"""
|
|
661
|
+
Получает список всех воронок и их статусов из amoCRM.
|
|
662
|
+
|
|
663
|
+
:return: Список словарей, где каждый словарь содержит данные воронки, а также, если присутствует, вложенные статусы.
|
|
664
|
+
:raises Exception: Если данные не получены или структура ответа неверна.
|
|
665
|
+
"""
|
|
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']
|
|
670
|
+
self.logger.debug(f"Получено {len(pipelines)} воронок")
|
|
671
|
+
return pipelines
|
|
672
|
+
else:
|
|
673
|
+
self.logger.error("Не удалось получить воронки из amoCRM")
|
|
674
|
+
raise Exception("Ошибка получения воронок из amoCRM")
|
amochka/etl.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
async def update_pipelines(session: AsyncSession, pipelines_data):
|
|
10
|
+
"""
|
|
11
|
+
Обновляет таблицы воронок (Pipeline) и статусов (Status) в базе данных.
|
|
12
|
+
|
|
13
|
+
:param session: Асинхронная сессия SQLAlchemy.
|
|
14
|
+
:param pipelines_data: Список воронок, полученных из API amoCRM.
|
|
15
|
+
"""
|
|
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,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
await session.execute(stmt)
|
|
89
|
+
logger.debug(f"Обновлено {len(status_values)} статусов")
|
|
90
|
+
|
|
91
|
+
logger.info(f"Обновлено {len(pipeline_values)} воронок и {len(status_values)} статусов.")
|
amochka/models.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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,18 +1,19 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: amochka
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Библиотека для работы с API amoCRM
|
|
5
|
-
Home-page:
|
|
5
|
+
Home-page:
|
|
6
6
|
Author: Timurka
|
|
7
7
|
Author-email: timurdt@gmail.com
|
|
8
|
-
License: UNKNOWN
|
|
9
|
-
Platform: UNKNOWN
|
|
10
8
|
Classifier: Programming Language :: Python :: 3
|
|
11
9
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
10
|
Classifier: Operating System :: OS Independent
|
|
13
11
|
Requires-Python: >=3.6
|
|
14
12
|
Requires-Dist: requests
|
|
15
13
|
Requires-Dist: ratelimit
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
Dynamic: author
|
|
15
|
+
Dynamic: author-email
|
|
16
|
+
Dynamic: classifier
|
|
17
|
+
Dynamic: requires-dist
|
|
18
|
+
Dynamic: requires-python
|
|
19
|
+
Dynamic: summary
|
|
@@ -0,0 +1,8 @@
|
|
|
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,,
|
amochka-0.1.3.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
amochka/__init__.py,sha256=92YIjNK3notY2wfG-l2jmGIIWjfE3ozpamc-e4LEILc,106
|
|
2
|
-
amochka/client.py,sha256=59iYwOQ90XTJvnzQCxRG8sgpbLRaZkFfYkZCN_Zl4Y8,15354
|
|
3
|
-
amochka-0.1.3.dist-info/METADATA,sha256=68jkHlxGXCk6acxCM8OVg4jluTZKy3yUdPfFOqzGK0U,445
|
|
4
|
-
amochka-0.1.3.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
5
|
-
amochka-0.1.3.dist-info/top_level.txt,sha256=y5qXFXJUECmDwO6hyupsuYcTpZKZyByeE9e-1sa2U24,8
|
|
6
|
-
amochka-0.1.3.dist-info/RECORD,,
|
|
File without changes
|