amochka 0.1.4__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 +340 -75
- amochka/etl.py +91 -0
- amochka/models.py +50 -0
- {amochka-0.1.4.dist-info → amochka-0.1.6.dist-info}/METADATA +9 -8
- amochka-0.1.6.dist-info/RECORD +8 -0
- {amochka-0.1.4.dist-info → amochka-0.1.6.dist-info}/WHEEL +1 -1
- amochka-0.1.4.dist-info/RECORD +0 -6
- {amochka-0.1.4.dist-info → amochka-0.1.6.dist-info}/top_level.txt +0 -0
amochka/__init__.py
CHANGED
amochka/client.py
CHANGED
|
@@ -29,12 +29,13 @@ class Deal(dict):
|
|
|
29
29
|
|
|
30
30
|
Параметр custom_fields_config – словарь, где ключи – ID полей, а значения – модели полей.
|
|
31
31
|
"""
|
|
32
|
-
def __init__(self, data, custom_fields_config=None):
|
|
32
|
+
def __init__(self, data, custom_fields_config=None, logger=None):
|
|
33
33
|
super().__init__(data)
|
|
34
34
|
self._custom = {}
|
|
35
35
|
self._custom_config = custom_fields_config # сохраняем конфигурацию кастомных полей
|
|
36
|
+
self._logger = logger or logging.getLogger(__name__)
|
|
36
37
|
custom = data.get("custom_fields_values") or []
|
|
37
|
-
|
|
38
|
+
self._logger.debug(f"Processing custom_fields_values: {custom}")
|
|
38
39
|
for field in custom:
|
|
39
40
|
if isinstance(field, dict):
|
|
40
41
|
field_name = field.get("field_name")
|
|
@@ -45,19 +46,19 @@ class Deal(dict):
|
|
|
45
46
|
stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
|
|
46
47
|
# Сохраняем полную информацию (и для get() и для get_id())
|
|
47
48
|
self._custom[key_name] = {"value": stored_value, "enum_id": stored_enum_id}
|
|
48
|
-
|
|
49
|
+
self._logger.debug(f"Set custom field '{key_name}' = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
|
|
49
50
|
field_id = field.get("field_id")
|
|
50
51
|
if field_id is not None and values and isinstance(values, list) and len(values) > 0:
|
|
51
52
|
stored_value = values[0].get("value")
|
|
52
53
|
stored_enum_id = values[0].get("enum_id") # может быть None для некоторых полей
|
|
53
54
|
self._custom[int(field_id)] = {"value": stored_value, "enum_id": stored_enum_id}
|
|
54
|
-
|
|
55
|
+
self._logger.debug(f"Set custom field id {field_id} = {{'value': {stored_value}, 'enum_id': {stored_enum_id}}}")
|
|
55
56
|
if custom_fields_config:
|
|
56
57
|
for cid, field_obj in custom_fields_config.items():
|
|
57
58
|
key = field_obj.get("name", "").lower().strip() if isinstance(field_obj, dict) else str(field_obj).lower().strip()
|
|
58
59
|
if key not in self._custom:
|
|
59
60
|
self._custom[key] = None
|
|
60
|
-
|
|
61
|
+
self._logger.debug(f"Field '{key}' not found in deal data; set to None")
|
|
61
62
|
|
|
62
63
|
def __getitem__(self, key):
|
|
63
64
|
if key in super().keys():
|
|
@@ -79,16 +80,65 @@ class Deal(dict):
|
|
|
79
80
|
except KeyError:
|
|
80
81
|
return default
|
|
81
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
|
+
|
|
82
122
|
def get_id(self, key, default=None):
|
|
83
123
|
"""
|
|
84
|
-
Возвращает идентификатор выбранного варианта (enum_id) для кастомного
|
|
124
|
+
Возвращает идентификатор выбранного варианта (enum_id) для кастомного поля типа select.
|
|
125
|
+
Для полей других типов возвращает их значение, как метод get().
|
|
126
|
+
|
|
85
127
|
Если значение enum_id отсутствует в данных, производится поиск в конфигурации кастомных полей,
|
|
86
128
|
сравнение значения выполняется без учёта регистра и пробелов.
|
|
87
129
|
|
|
88
130
|
:param key: Название поля (строка) или ID поля (integer).
|
|
89
131
|
:param default: Значение по умолчанию, если enum_id не найден.
|
|
90
|
-
:return:
|
|
132
|
+
:return: Для полей типа select - идентификатор варианта (целое число).
|
|
133
|
+
Для других типов полей - значение поля.
|
|
134
|
+
Если поле не найдено - default.
|
|
91
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
|
+
|
|
92
142
|
stored = None
|
|
93
143
|
if isinstance(key, str):
|
|
94
144
|
lower_key = key.lower().strip()
|
|
@@ -115,7 +165,41 @@ class Deal(dict):
|
|
|
115
165
|
for enum in enums:
|
|
116
166
|
if enum.get("value", "").lower().strip() == stored.get("value", "").lower().strip():
|
|
117
167
|
return enum.get("id", default)
|
|
118
|
-
|
|
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)
|
|
119
203
|
|
|
120
204
|
class AmoCRMClient:
|
|
121
205
|
"""
|
|
@@ -136,50 +220,53 @@ class AmoCRMClient:
|
|
|
136
220
|
self,
|
|
137
221
|
base_url,
|
|
138
222
|
token_file=None,
|
|
139
|
-
|
|
223
|
+
cache_config=None,
|
|
140
224
|
log_level=logging.INFO,
|
|
141
|
-
disable_logging=False
|
|
142
|
-
cache_enabled=True,
|
|
143
|
-
cache_storage='file', # 'file' или 'memory'
|
|
144
|
-
cache_hours=24 # время жизни кэша в часах, или None для бесконечного кэша
|
|
225
|
+
disable_logging=False
|
|
145
226
|
):
|
|
146
227
|
"""
|
|
147
228
|
Инициализирует клиента, задавая базовый URL, токен авторизации и настройки кэша для кастомных полей.
|
|
148
229
|
|
|
149
230
|
:param base_url: Базовый URL API amoCRM.
|
|
150
231
|
:param token_file: Файл, содержащий токен авторизации.
|
|
151
|
-
:param
|
|
232
|
+
:param cache_config: Конфигурация кэширования (объект CacheConfig или None для значений по умолчанию)
|
|
152
233
|
:param log_level: Уровень логирования (например, logging.DEBUG, logging.INFO).
|
|
153
234
|
:param disable_logging: Если True, логирование будет отключено.
|
|
154
|
-
:param cache_enabled: Если False, кэширование отключается (остальные параметры игнорируются).
|
|
155
|
-
:param cache_storage: 'file' для файлового кэша, 'memory' для кэша только в оперативной памяти.
|
|
156
|
-
:param cache_hours: Время жизни кэша в часах. Если None – кэш считается бесконечным.
|
|
157
235
|
"""
|
|
158
236
|
self.base_url = base_url.rstrip('/')
|
|
159
237
|
domain = self.base_url.split("//")[-1].split(".")[0]
|
|
160
238
|
self.domain = domain
|
|
161
239
|
self.token_file = token_file or os.path.join(os.path.expanduser('~'), '.amocrm_token.json')
|
|
162
240
|
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
self.
|
|
170
|
-
|
|
171
|
-
self.cache_storage = cache_storage.lower() # 'file' или 'memory'
|
|
172
|
-
self.cache_hours = cache_hours
|
|
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 # Отключаем передачу логов в родительский логгер
|
|
173
249
|
|
|
174
|
-
self.token = self.load_token()
|
|
175
|
-
self._custom_fields_mapping = None
|
|
176
|
-
|
|
177
250
|
if disable_logging:
|
|
178
|
-
|
|
251
|
+
self.logger.setLevel(logging.CRITICAL + 1) # Выше, чем любой стандартный уровень
|
|
179
252
|
else:
|
|
180
|
-
logger.setLevel(log_level)
|
|
253
|
+
self.logger.setLevel(log_level)
|
|
181
254
|
|
|
182
|
-
|
|
255
|
+
# Настройка кэширования
|
|
256
|
+
if cache_config is None:
|
|
257
|
+
self.cache_config = CacheConfig()
|
|
258
|
+
else:
|
|
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"
|
|
265
|
+
|
|
266
|
+
self.logger.debug(f"AmoCRMClient initialized for domain {self.domain}")
|
|
267
|
+
|
|
268
|
+
self.token = self.load_token()
|
|
269
|
+
self._custom_fields_mapping = None
|
|
183
270
|
|
|
184
271
|
def load_token(self):
|
|
185
272
|
"""
|
|
@@ -192,11 +279,11 @@ class AmoCRMClient:
|
|
|
192
279
|
if os.path.exists(self.token_file):
|
|
193
280
|
with open(self.token_file, 'r') as f:
|
|
194
281
|
data = json.load(f)
|
|
195
|
-
logger.debug(f"Token loaded from file: {self.token_file}")
|
|
282
|
+
self.logger.debug(f"Token loaded from file: {self.token_file}")
|
|
196
283
|
else:
|
|
197
284
|
try:
|
|
198
285
|
data = json.loads(self.token_file)
|
|
199
|
-
logger.debug("Token parsed from provided string.")
|
|
286
|
+
self.logger.debug("Token parsed from provided string.")
|
|
200
287
|
except Exception as e:
|
|
201
288
|
raise Exception("Токен не найден и не удалось распарсить переданное содержимое.") from e
|
|
202
289
|
|
|
@@ -207,7 +294,7 @@ class AmoCRMClient:
|
|
|
207
294
|
expires_at = float(expires_at_str)
|
|
208
295
|
|
|
209
296
|
if expires_at and time.time() < expires_at:
|
|
210
|
-
logger.debug("Token is valid.")
|
|
297
|
+
self.logger.debug("Token is valid.")
|
|
211
298
|
return data.get('access_token')
|
|
212
299
|
else:
|
|
213
300
|
raise Exception("Токен найден, но он истёк. Обновите токен.")
|
|
@@ -230,64 +317,73 @@ class AmoCRMClient:
|
|
|
230
317
|
"Authorization": f"Bearer {self.token}",
|
|
231
318
|
"Content-Type": "application/json"
|
|
232
319
|
}
|
|
233
|
-
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}")
|
|
234
321
|
response = requests.request(method, url, headers=headers, params=params, json=data)
|
|
235
322
|
if response.status_code not in (200, 204):
|
|
236
|
-
logger.error(f"Request error {response.status_code}: {response.text}")
|
|
323
|
+
self.logger.error(f"Request error {response.status_code}: {response.text}")
|
|
237
324
|
raise Exception(f"Ошибка запроса: {response.status_code}, {response.text}")
|
|
238
325
|
if response.status_code == 204:
|
|
239
326
|
return None
|
|
240
327
|
return response.json()
|
|
241
328
|
|
|
242
|
-
def get_deal_by_id(self, deal_id):
|
|
329
|
+
def get_deal_by_id(self, deal_id, skip_fields_mapping=False):
|
|
243
330
|
"""
|
|
244
331
|
Получает данные сделки по её ID и возвращает объект Deal.
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
:
|
|
332
|
+
Если данные отсутствуют или имеют неверную структуру, выбрасывается исключение.
|
|
333
|
+
|
|
334
|
+
:param deal_id: ID сделки для получения
|
|
335
|
+
:param skip_fields_mapping: Если True, не загружает справочник кастомных полей
|
|
336
|
+
(используйте для работы только с ID полей)
|
|
337
|
+
:return: Объект Deal с данными сделки
|
|
248
338
|
"""
|
|
249
339
|
endpoint = f"/api/v4/leads/{deal_id}"
|
|
250
340
|
params = {'with': 'contacts,companies,catalog_elements,loss_reason,tags'}
|
|
251
341
|
data = self._make_request("GET", endpoint, params=params)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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)
|
|
255
351
|
|
|
256
352
|
def _save_custom_fields_cache(self, mapping):
|
|
257
353
|
"""
|
|
258
354
|
Сохраняет кэш кастомных полей в файл, если используется файловый кэш.
|
|
259
355
|
Если кэширование отключено или выбран кэш в памяти, операция пропускается.
|
|
260
356
|
"""
|
|
261
|
-
if not self.
|
|
262
|
-
logger.debug("Caching disabled; cache not saved.")
|
|
357
|
+
if not self.cache_config.enabled:
|
|
358
|
+
self.logger.debug("Caching disabled; cache not saved.")
|
|
263
359
|
return
|
|
264
|
-
if self.
|
|
265
|
-
logger.debug("Using memory caching; no file cache saved.")
|
|
360
|
+
if self.cache_config.storage != 'file':
|
|
361
|
+
self.logger.debug("Using memory caching; no file cache saved.")
|
|
266
362
|
return
|
|
267
363
|
cache_data = {"last_updated": time.time(), "mapping": mapping}
|
|
268
|
-
with open(self.
|
|
364
|
+
with open(self.cache_config.file, "w") as f:
|
|
269
365
|
json.dump(cache_data, f)
|
|
270
|
-
logger.debug(f"Custom fields cache saved to {self.
|
|
366
|
+
self.logger.debug(f"Custom fields cache saved to {self.cache_config.file}")
|
|
271
367
|
|
|
272
368
|
def _load_custom_fields_cache(self):
|
|
273
369
|
"""
|
|
274
370
|
Загружает кэш кастомных полей из файла, если используется файловый кэш.
|
|
275
371
|
Если кэширование отключено или выбран кэш в памяти, возвращает None.
|
|
276
372
|
"""
|
|
277
|
-
if not self.
|
|
278
|
-
logger.debug("Caching disabled; no cache loaded.")
|
|
373
|
+
if not self.cache_config.enabled:
|
|
374
|
+
self.logger.debug("Caching disabled; no cache loaded.")
|
|
279
375
|
return None
|
|
280
|
-
if self.
|
|
281
|
-
logger.debug("Using memory caching; cache will be kept in memory only.")
|
|
376
|
+
if self.cache_config.storage != 'file':
|
|
377
|
+
self.logger.debug("Using memory caching; cache will be kept in memory only.")
|
|
282
378
|
return None
|
|
283
|
-
if os.path.exists(self.
|
|
284
|
-
with open(self.
|
|
379
|
+
if os.path.exists(self.cache_config.file):
|
|
380
|
+
with open(self.cache_config.file, "r") as f:
|
|
285
381
|
try:
|
|
286
382
|
cache_data = json.load(f)
|
|
287
|
-
logger.debug("Custom fields cache loaded successfully.")
|
|
383
|
+
self.logger.debug("Custom fields cache loaded successfully.")
|
|
288
384
|
return cache_data
|
|
289
385
|
except Exception as e:
|
|
290
|
-
logger.error(f"Error loading cache: {e}")
|
|
386
|
+
self.logger.error(f"Error loading cache: {e}")
|
|
291
387
|
return None
|
|
292
388
|
return None
|
|
293
389
|
|
|
@@ -299,18 +395,18 @@ class AmoCRMClient:
|
|
|
299
395
|
if not force_update and self._custom_fields_mapping is not None:
|
|
300
396
|
return self._custom_fields_mapping
|
|
301
397
|
|
|
302
|
-
cache_data = self._load_custom_fields_cache() if self.
|
|
398
|
+
cache_data = self._load_custom_fields_cache() if self.cache_config.enabled else None
|
|
303
399
|
if cache_data:
|
|
304
400
|
last_updated = cache_data.get("last_updated", 0)
|
|
305
|
-
if self.
|
|
306
|
-
if time.time() - last_updated < self.
|
|
401
|
+
if self.cache_config.lifetime_hours is not None:
|
|
402
|
+
if time.time() - last_updated < self.cache_config.lifetime_hours * 3600:
|
|
307
403
|
self._custom_fields_mapping = cache_data.get("mapping")
|
|
308
|
-
logger.debug("Using cached custom fields mapping.")
|
|
404
|
+
self.logger.debug("Using cached custom fields mapping.")
|
|
309
405
|
return self._custom_fields_mapping
|
|
310
406
|
else:
|
|
311
407
|
# Бесконечный кэш – не проверяем срок
|
|
312
408
|
self._custom_fields_mapping = cache_data.get("mapping")
|
|
313
|
-
logger.debug("Using cached custom fields mapping (infinite cache).")
|
|
409
|
+
self.logger.debug("Using cached custom fields mapping (infinite cache).")
|
|
314
410
|
return self._custom_fields_mapping
|
|
315
411
|
|
|
316
412
|
mapping = {}
|
|
@@ -323,14 +419,14 @@ class AmoCRMClient:
|
|
|
323
419
|
for field in response["_embedded"]["custom_fields"]:
|
|
324
420
|
mapping[field["id"]] = field
|
|
325
421
|
total_pages = response.get("_page_count", page)
|
|
326
|
-
logger.debug(f"Fetched page {page} of {total_pages}")
|
|
422
|
+
self.logger.debug(f"Fetched page {page} of {total_pages}")
|
|
327
423
|
page += 1
|
|
328
424
|
else:
|
|
329
425
|
break
|
|
330
426
|
|
|
331
|
-
logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
|
|
427
|
+
self.logger.debug("Custom fields mapping fetched (содержимое маппинга не выводится полностью).")
|
|
332
428
|
self._custom_fields_mapping = mapping
|
|
333
|
-
if self.
|
|
429
|
+
if self.cache_config.enabled:
|
|
334
430
|
self._save_custom_fields_cache(mapping)
|
|
335
431
|
return mapping
|
|
336
432
|
|
|
@@ -349,9 +445,9 @@ class AmoCRMClient:
|
|
|
349
445
|
else:
|
|
350
446
|
name = str(field_obj).lower().strip()
|
|
351
447
|
if search_term_lower == name or search_term_lower in name:
|
|
352
|
-
logger.debug(f"Found custom field '{name}' with id {key}")
|
|
448
|
+
self.logger.debug(f"Found custom field '{name}' with id {key}")
|
|
353
449
|
return int(key), field_obj
|
|
354
|
-
logger.debug(f"Custom field containing '{search_term}' not found.")
|
|
450
|
+
self.logger.debug(f"Custom field containing '{search_term}' not found.")
|
|
355
451
|
return None, None
|
|
356
452
|
|
|
357
453
|
def update_lead(self, lead_id, update_fields: dict, tags_to_add: list = None, tags_to_delete: list = None):
|
|
@@ -379,7 +475,7 @@ class AmoCRMClient:
|
|
|
379
475
|
for key, value in update_fields.items():
|
|
380
476
|
if key in standard_fields:
|
|
381
477
|
payload[key] = value
|
|
382
|
-
logger.debug(f"Standard field {key} set to {value}")
|
|
478
|
+
self.logger.debug(f"Standard field {key} set to {value}")
|
|
383
479
|
else:
|
|
384
480
|
if isinstance(value, int):
|
|
385
481
|
field_value_dict = {"enum_id": value}
|
|
@@ -388,12 +484,12 @@ class AmoCRMClient:
|
|
|
388
484
|
try:
|
|
389
485
|
field_id = int(key)
|
|
390
486
|
custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
|
|
391
|
-
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}")
|
|
392
488
|
except ValueError:
|
|
393
489
|
field_id, field_obj = self.find_custom_field_id(key)
|
|
394
490
|
if field_id is not None:
|
|
395
491
|
custom_fields.append({"field_id": field_id, "values": [field_value_dict]})
|
|
396
|
-
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}")
|
|
397
493
|
else:
|
|
398
494
|
raise Exception(f"Custom field '{key}' не найден.")
|
|
399
495
|
if custom_fields:
|
|
@@ -402,8 +498,177 @@ class AmoCRMClient:
|
|
|
402
498
|
payload["tags_to_add"] = tags_to_add
|
|
403
499
|
if tags_to_delete:
|
|
404
500
|
payload["tags_to_delete"] = tags_to_delete
|
|
405
|
-
logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
|
|
501
|
+
self.logger.debug("Update payload for lead {} prepared (содержимое payload не выводится полностью).".format(lead_id))
|
|
406
502
|
endpoint = f"/api/v4/leads/{lead_id}"
|
|
407
503
|
response = self._make_request("PATCH", endpoint, data=payload)
|
|
408
|
-
logger.debug("Update response received.")
|
|
409
|
-
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.4.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
amochka/__init__.py,sha256=92YIjNK3notY2wfG-l2jmGIIWjfE3ozpamc-e4LEILc,106
|
|
2
|
-
amochka/client.py,sha256=1_CBE0p29_a3R4EDhN0fvS67pecQleMogO8WxyBdP74,22316
|
|
3
|
-
amochka-0.1.4.dist-info/METADATA,sha256=g6TRutC1_0ypL2HXVtdr7IzXC70-avj9E8_1XVTSQMY,445
|
|
4
|
-
amochka-0.1.4.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
|
|
5
|
-
amochka-0.1.4.dist-info/top_level.txt,sha256=y5qXFXJUECmDwO6hyupsuYcTpZKZyByeE9e-1sa2U24,8
|
|
6
|
-
amochka-0.1.4.dist-info/RECORD,,
|
|
File without changes
|