educommon 3.16.0__py3-none-any.whl → 3.18.0__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.
@@ -0,0 +1,357 @@
1
+ """
2
+ Команда переноса данных из локального AuditLog'а в educommon'овский.
3
+ """
4
+ import sys
5
+ from datetime import (
6
+ datetime as dt,
7
+ )
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ Generator,
11
+ Optional,
12
+ Type,
13
+ )
14
+
15
+ from dateutil.relativedelta import (
16
+ relativedelta,
17
+ )
18
+ from django.apps import (
19
+ apps,
20
+ )
21
+ from django.core.management.base import (
22
+ BaseCommand,
23
+ OutputWrapper,
24
+ )
25
+ from django.db.models import (
26
+ ManyToManyField,
27
+ Model,
28
+ )
29
+
30
+ from educommon.audit_log.models import (
31
+ AuditLog,
32
+ Table,
33
+ )
34
+ from educommon.audit_log.utils import (
35
+ get_model_by_table,
36
+ )
37
+
38
+
39
+ if TYPE_CHECKING:
40
+ from django.db.models.query import (
41
+ QuerySet,
42
+ )
43
+
44
+
45
+ PROJECT_LOCAL_AUDIT_LOG_APP = {
46
+ 'eduschl': 'web_edu_audit_log',
47
+ 'edussuz': 'audit_log_ssuz',
48
+ 'edukndg': 'audit_log_kndg'
49
+ }
50
+ LOG_OPERATION_MAP = {
51
+ 'N': AuditLog.OPERATION_CREATE,
52
+ 'I': AuditLog.OPERATION_CREATE,
53
+ 'U': AuditLog.OPERATION_UPDATE,
54
+ 'D': AuditLog.OPERATION_DELETE,
55
+ }
56
+ LOCAL_AUDIT_LOG_MODEL_NAME = 'Log'
57
+ DEFAULT_DATE_YEAR_RANGE = 1
58
+
59
+
60
+ class BulkSaver:
61
+ """Контекстный менеджер для группового сохранения записей."""
62
+
63
+ def __init__(self, bulk_size: int) -> None:
64
+ self._bulk_size = bulk_size
65
+ self._bulk_list = []
66
+
67
+ def __enter__(self):
68
+ return self
69
+
70
+ def __exit__(self, *exc):
71
+ self._bulk_save()
72
+
73
+ def save(self, audit_log: AuditLog):
74
+ self._bulk_list.append(audit_log)
75
+ if len(self._bulk_list) >= self._bulk_size:
76
+ self._bulk_save()
77
+
78
+ def _bulk_save(self):
79
+ AuditLog.objects.bulk_create(self._bulk_list)
80
+ self._bulk_list.clear()
81
+
82
+
83
+ class LogMigrator:
84
+ """Класс для миграции логов из локального AuditLog'а в educommon'овский."""
85
+
86
+ def __init__(self, stdout: OutputWrapper):
87
+ """Инициализация."""
88
+ self._stdout = stdout
89
+
90
+ @staticmethod
91
+ def get_model(model_name: str) -> Optional[Type[Model]]:
92
+ """Bозвращает класс модели по имени."""
93
+ for mod in apps.get_models():
94
+ if mod.__name__ == model_name and mod.__module__.find('django') == -1:
95
+ return mod
96
+
97
+ return None
98
+
99
+ def _get_educommon_table(self, model_name: str, create: bool = False) -> tuple[Optional[Table], str]:
100
+ """Возвращает таблицу, отслеживаемую системой аудита."""
101
+ model_cls = self.get_model(model_name)
102
+
103
+ if not model_cls:
104
+ return None, f'Не найдено Django моделей с именем {model_name}'
105
+ try:
106
+ return Table.objects.get(name=model_cls._meta.db_table), ''
107
+ except Table.DoesNotExist:
108
+ is_loggable_mixin_use = getattr(model_cls, 'need_to_log', False)
109
+
110
+ if not is_loggable_mixin_use:
111
+ return None, (
112
+ f'В таблицу {model_name} не добавлен LoggableModelMixin'
113
+ )
114
+
115
+ if create:
116
+ return Table.objects.create(
117
+ name=model_cls._meta.db_table,
118
+ schema='public',
119
+ logged=True,
120
+ ), ''
121
+
122
+ return None, (
123
+ f'В educommon не найдено таблицы с именем {model_cls._meta.db_table}. '
124
+ 'Таблицу можно создать, указав флаг force_create_educommon_table'
125
+ )
126
+
127
+ @staticmethod
128
+ def _get_fields_map(table: Table) -> dict[str, str]:
129
+ """Возвращает словарь соответствия поля модели и его названия."""
130
+ return {
131
+ field.name: field.attname
132
+ for field in get_model_by_table(table)._meta.get_fields()
133
+ if field.concrete and not isinstance(field, ManyToManyField)
134
+ }
135
+
136
+ def _get_local_audit_log_query(
137
+ self,
138
+ project: str,
139
+ model_name: str,
140
+ date_from: dt,
141
+ date_to: dt
142
+ ) -> tuple[Optional['QuerySet'], str]:
143
+ """Возвращает QuerySet с локальными логами."""
144
+ if not (audit_log_app_name := PROJECT_LOCAL_AUDIT_LOG_APP.get(project)):
145
+ return None, 'Неизвестное название проекта.'
146
+
147
+ audit_log_model = apps.get_model(audit_log_app_name, LOCAL_AUDIT_LOG_MODEL_NAME)
148
+
149
+ if not audit_log_model:
150
+ return None, 'Не найдена локальная модель логов.'
151
+
152
+ return audit_log_model.objects.filter(
153
+ model=model_name,
154
+ date__lt=date_to,
155
+ date__gte=date_from,
156
+ ), ''
157
+
158
+ @staticmethod
159
+ def _prepare_local_logs(query: 'QuerySet') -> Generator[tuple, None, None]:
160
+ """Подготавливает данные логов для дальнейшего использования."""
161
+ query = query.values_list(
162
+ 'user_id',
163
+ 'date',
164
+ 'model_id',
165
+ 'object_json',
166
+ 'ip',
167
+ 'operation',
168
+ ).order_by(
169
+ 'date',
170
+ )
171
+ for user_id, date_, model_id, object_json, ip, operation in query.iterator():
172
+ yield user_id, date_, model_id, ip, operation, object_json
173
+
174
+ @staticmethod
175
+ def _prepare_object_dict(fields_map: dict, object_dict: Optional[dict]) -> dict[str, str]:
176
+ """Подготавливает данные о объекте для AuditLog.
177
+
178
+ В основном требуется только для переименования ForeignKey-полей вида
179
+ `period` в `period_id`
180
+ """
181
+ if not object_dict:
182
+ return {}
183
+
184
+ filled_field_names = set(fields_map).intersection(object_dict)
185
+ return {
186
+ fields_map[field_name]: object_dict[field_name]
187
+ for field_name in filled_field_names
188
+ }
189
+
190
+ def process(
191
+ self,
192
+ model_name: str,
193
+ project: str,
194
+ bulk_save_size: int,
195
+ date_from: Optional[dt] = None,
196
+ date_to: Optional[dt] = None,
197
+ force_create_educommon_table: bool = False,
198
+ ):
199
+ """Перенос записей из локального AuditLog'а в educommon."""
200
+ table, error = self._get_educommon_table(model_name, force_create_educommon_table)
201
+ if error:
202
+ self._stdout.write(error)
203
+ return
204
+
205
+ fields_map = self._get_fields_map(table)
206
+
207
+ first_audit_log_date = self._get_first_audit_log_date(table)
208
+ date_to = dt.combine((date_to or dt.today()), dt.max.time())
209
+
210
+ date_to = min(date_to, first_audit_log_date)
211
+ date_from = date_from or (date_to - relativedelta(years=DEFAULT_DATE_YEAR_RANGE))
212
+
213
+ self._stdout.write(
214
+ f'Поиск записей в локальном Auditlog с {date_from} по {date_to}... ',
215
+ ending='',
216
+ )
217
+
218
+ local_audit_log_query, error = self._get_local_audit_log_query(
219
+ project=project,
220
+ model_name=model_name,
221
+ date_from=date_from,
222
+ date_to=date_to,
223
+ )
224
+ if error:
225
+ self._stdout.write(error)
226
+ return
227
+
228
+ total_count = local_audit_log_query.count()
229
+ self._stdout.write(f'Найдено {total_count} запись(ей).')
230
+
231
+ local_logs = self._prepare_local_logs(local_audit_log_query)
232
+ self._stdout.write(f'Подготовка к работе... (занимает некоторое время)', ending='\r',)
233
+ with BulkSaver(bulk_save_size) as bulk:
234
+ for count, (user_id, date_, object_id, ip, operation, data_dict) in enumerate(local_logs, start=1):
235
+ object_dict = self._prepare_object_dict(fields_map, data_dict[0].get('fields', {}))
236
+
237
+ operation = LOG_OPERATION_MAP[operation]
238
+
239
+ if operation == AuditLog.OPERATION_UPDATE:
240
+ changes = object_dict
241
+ else:
242
+ # Если объект быз создан или удалён, то изменений нет.
243
+ changes = {}
244
+
245
+ bulk.save(AuditLog(
246
+ user_id=user_id,
247
+ ip=ip,
248
+ time=date_,
249
+ table_id=table.id,
250
+ data=object_dict,
251
+ changes=changes,
252
+ object_id=object_id,
253
+ operation=operation,
254
+ ))
255
+
256
+ self._stdout.write(
257
+ f'Обработано {(count / total_count) * 100:5.2f}% запись(ей)... (Последняя от {date_})',
258
+ ending='\r',
259
+ )
260
+
261
+ def _get_first_audit_log_date(self, table: str) -> dt:
262
+ """Возвращает первую дату/время появления audit_log по переданной таблице.
263
+
264
+ В случае отсуствия логов прекращает выполнение команды за отсутствием данных для переноса.
265
+ """
266
+ first_log = AuditLog.objects.filter(
267
+ table=table,
268
+ ).order_by('time').first()
269
+
270
+ if not first_log:
271
+ self._stdout.write('Не найдены локальные логи переданной модели. Завершение команды.')
272
+ sys.exit()
273
+
274
+ return first_log.time
275
+
276
+
277
+ class Command(BaseCommand):
278
+ """Команда переноса данных из локального AuditLog'а в educommon'овский."""
279
+
280
+ help = (
281
+ 'Команда для переноса данных из локального AuditLog`а в educommon.audit_log.models.AuditLog.\n'
282
+ 'Пример использования:\n'
283
+ 'audit_log_migrate_data --project eduschl --model_name Mark --date_from 10.10.2024'
284
+ )
285
+
286
+ @staticmethod
287
+ def _get_date(date_string: str) -> dt:
288
+ return dt.strptime(date_string, '%d.%m.%Y')
289
+
290
+ def add_arguments(self, parser):
291
+ parser.add_argument(
292
+ '--model_name',
293
+ type=str,
294
+ help='Название модели из локального лога',
295
+ )
296
+ parser.add_argument(
297
+ '--project',
298
+ type=str,
299
+ help='Наименование (код) продукта, в котором применяется команда',
300
+ )
301
+ parser.add_argument(
302
+ '--date_from',
303
+ type=self._get_date,
304
+ required=False,
305
+ help=(
306
+ 'Дата, с которой будут переноситься логи в формате ДД.ММ.ГГГГ. '
307
+ 'Значение по умолчанию - на год раньше даты, указанной в --date_to'
308
+ ),
309
+ )
310
+ parser.add_argument(
311
+ '--date_to',
312
+ type=self._get_date,
313
+ required=False,
314
+ help=(
315
+ 'Дата, по которую будут переноситься логи в формате ДД.ММ.ГГГГ.'
316
+ 'Значение по умолчанию - текущая дата.'
317
+ ),
318
+ )
319
+ parser.add_argument(
320
+ '--force_create_educommon_table',
321
+ action='store_true',
322
+ default=False,
323
+ help=(
324
+ 'Создание отслеживаемой таблицы (educommon.audit_log.models.Table), '
325
+ 'если она еще не была создана'
326
+ ),
327
+ )
328
+ parser.add_argument(
329
+ '--bulk_save_size',
330
+ type=int,
331
+ default=500,
332
+ help='По сколько записей за раз будет сохраняться. По умолчанию 500',
333
+ )
334
+
335
+ def handle(
336
+ self,
337
+ model_name: str,
338
+ project: str,
339
+ date_from: Optional[dt],
340
+ date_to: Optional[dt],
341
+ force_create_educommon_table: bool,
342
+ bulk_save_size: int,
343
+ *args,
344
+ **options,
345
+ ):
346
+ """Выполнение переноса записей."""
347
+ self.stdout.write('Начало работы команды.')
348
+ LogMigrator(self.stdout).process(
349
+ model_name=model_name,
350
+ project=project,
351
+ date_from=date_from,
352
+ date_to=date_to,
353
+ force_create_educommon_table=force_create_educommon_table,
354
+ bulk_save_size=bulk_save_size,
355
+ )
356
+
357
+ self.stdout.write(self.style.SUCCESS('Выполнение команды завершено.'))
@@ -0,0 +1,24 @@
1
+ # Generated by Django 3.2.24 on 2025-05-16 12:04
2
+
3
+ from django.db import (
4
+ migrations,
5
+ models,
6
+ )
7
+ from django.utils import (
8
+ timezone,
9
+ )
10
+
11
+
12
+ class Migration(migrations.Migration):
13
+
14
+ dependencies = [
15
+ ('audit_log', '0009_reinstall_audit_log'),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.AlterField(
20
+ model_name='auditlog',
21
+ name='time',
22
+ field=models.DateTimeField(db_index=True, default=timezone.now, verbose_name='Дата, время'),
23
+ ),
24
+ ]
@@ -41,6 +41,9 @@ from django.db.backends.signals import (
41
41
  from django.dispatch.dispatcher import (
42
42
  receiver,
43
43
  )
44
+ from django.utils import (
45
+ timezone,
46
+ )
44
47
 
45
48
  from educommon.audit_log.utils import (
46
49
  get_audit_log_context,
@@ -115,7 +118,7 @@ class AuditLog(ReadOnlyMixin, BaseModel):
115
118
  verbose_name='Тип пользователя',
116
119
  )
117
120
  ip = models.GenericIPAddressField(null=True, verbose_name='IP адрес')
118
- time = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Дата, время')
121
+ time = models.DateTimeField(default=timezone.now, db_index=True, verbose_name='Дата, время')
119
122
  table = models.ForeignKey(
120
123
  Table,
121
124
  verbose_name='Таблица',
@@ -40,6 +40,7 @@ from django.db.transaction import (
40
40
  from django.http import (
41
41
  HttpRequest,
42
42
  )
43
+
43
44
  from m3_django_compat import (
44
45
  get_related,
45
46
  )
@@ -52,6 +53,9 @@ from educommon.audit_log.constants import (
52
53
  PG_LOCK_ID,
53
54
  SQL_FILES_DIR,
54
55
  )
56
+ from educommon.logger import (
57
+ error as logger_error,
58
+ )
55
59
  from educommon.utils.misc import (
56
60
  cached_property,
57
61
  )
@@ -99,6 +103,19 @@ def get_need_to_log_table_names() -> Set[str]:
99
103
  return table_names
100
104
 
101
105
 
106
+ def get_table_names_for_app_labels(app_labels: Iterable[str]) -> Set[str]:
107
+ """Возвращает множество с именами таблиц для указанных приложений."""
108
+ tables = set()
109
+ for app_label in app_labels:
110
+ try:
111
+ app_config = apps.get_app_config(app_label)
112
+ tables.update(model._meta.db_table for model in app_config.get_models())
113
+ except LookupError:
114
+ continue
115
+
116
+ return tables
117
+
118
+
102
119
  def update_or_create_tables(need_to_log_table_names: Iterable[str]) -> bool:
103
120
  """Создаёт записи Table для отслеживаемых таблиц, либо меняет флаг logged.
104
121
 
@@ -109,6 +126,21 @@ def update_or_create_tables(need_to_log_table_names: Iterable[str]) -> bool:
109
126
  need_to_log_table_names = set(need_to_log_table_names)
110
127
  existed_table_names = set(Table.objects.filter(schema='public').values_list('name', flat=True))
111
128
 
129
+ # Таблицы сервисных приложений, которые не должны отслеживаться аудит-логом
130
+ allowed_service_apps_labels = getattr(settings, 'ALLOWED_SERVICE_APPS_LABELS', [])
131
+ service_table_names = get_table_names_for_app_labels(allowed_service_apps_labels)
132
+
133
+ # Проверка конфликтных таблиц: должны логироваться, но находятся в сервисных приложениях
134
+ conflicting_tables = need_to_log_table_names & service_table_names
135
+ if conflicting_tables:
136
+ tables = ', '.join(sorted(conflicting_tables))
137
+ error_msg = (
138
+ f'Невозможно включить логирование для сервисных таблиц: {tables}. '
139
+ 'Исключите их из отслеживания или перенесите таблицы в основную БД.'
140
+ )
141
+ logger_error(error_msg)
142
+ raise ValueError(error_msg)
143
+
112
144
  to_create_table_names = need_to_log_table_names - existed_table_names
113
145
  to_disable_table_names = existed_table_names - need_to_log_table_names
114
146
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: educommon
3
- Version: 3.16.0
3
+ Version: 3.18.0
4
4
  Summary: Общая кодовая база для проектов БЦ Образование
5
5
  Author-email: BARS Group <education_dev@bars-open.ru>
6
6
  Project-URL: Homepage, https://stash.bars-open.ru/projects/EDUBASE/repos/educommon/browse
@@ -48,7 +48,7 @@ educommon/audit_log/apps.py,sha256=A27iSAEWAVr7LmBmexVDrbWm3eQ-zFq1FYGQRClMJVM,6
48
48
  educommon/audit_log/constants.py,sha256=8C1SCiwD1s_ED9C3Hh50LMAnToGvZmF21nrv18hRLEg,903
49
49
  educommon/audit_log/helpers.py,sha256=Dp1DvuzVkhD-P37yoXGd1tvXKPJz2ZQzROS97VURrcY,727
50
50
  educommon/audit_log/middleware.py,sha256=HkxBh-1RQJnhKqckkXaMbFjJ34WgZGJssbk04wiS3ts,1140
51
- educommon/audit_log/models.py,sha256=LLKbnpvz89ZzZ__ZDYBXqh_Klp3z_XfSo69s-MsX-6g,13066
51
+ educommon/audit_log/models.py,sha256=wWVJAb7EsluER4FiZQjk3Povw5N__F-X9iCcZNFNjGY,13112
52
52
  educommon/audit_log/permissions.py,sha256=_0ntlLKUbJ1kYR2_YpgOxYMJNopZkrdIgxgPiHgL780,1273
53
53
  educommon/audit_log/proxies.py,sha256=IVZSKpeTDTV8AXdblLo1u9yOmecPdzSvVNrgmf-sxBQ,8750
54
54
  educommon/audit_log/routers.py,sha256=FF3KLvf6_WWFuZ9VRI8AZyKDfWp_lyd0i6bcVFfIehQ,204
@@ -57,6 +57,7 @@ educommon/audit_log/error_log/__init__.py,sha256=lfAIm5GTGQ1-kRFxH1s0agSt2Oeguwj
57
57
  educommon/audit_log/error_log/actions.py,sha256=-KSy3RrBsbPWCML-gl5Hl5UGQdEsPZhoKrHq16LRmm8,2305
58
58
  educommon/audit_log/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
59
  educommon/audit_log/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
+ educommon/audit_log/management/commands/audit_log_migrate_data.py,sha256=J_ZIpamGVBcotc-6cYsz37AJADEEyuiUs-VVyKON47o,12695
60
61
  educommon/audit_log/management/commands/reinstall_audit_log.py,sha256=HHpUeQwG_SuSsaxwZIq66piNhip_GHHQmAzpEzOFgaM,3573
61
62
  educommon/audit_log/migrations/0001_initial.py,sha256=HDhvBNyVSx_NlFmyA-t_ooFo_TiKf0UHNCZp1GOpLA8,6115
62
63
  educommon/audit_log/migrations/0002_install_audit_log.py,sha256=kAhtd1Xz8b6g33wmxBQyRBJIl-LGJdOc7yFy5yhoRJ8,2825
@@ -67,10 +68,11 @@ educommon/audit_log/migrations/0006_auto_20200806_1707.py,sha256=as1GDzH6UBF8_8g
67
68
  educommon/audit_log/migrations/0007_create_selective_tables_function.py,sha256=h65nZHknxORAArKXTtblaRHH9iEmUMT8t4dAtEp6ii0,871
68
69
  educommon/audit_log/migrations/0008_table_logged.py,sha256=mvBPtLGxgtwOIImIZ616yifmcUsEFbCjWo5NkhTX3Q4,476
69
70
  educommon/audit_log/migrations/0009_reinstall_audit_log.py,sha256=c95H2yamWyrCoGDby6M_VGh41p2MuftC_xjmk_nqqQ8,286
71
+ educommon/audit_log/migrations/0010_alter_auditlog_time.py,sha256=XmujmdgNADMy4OhL4O7CIK6KDKtW2sSyfxLYWEGmtvI,509
70
72
  educommon/audit_log/migrations/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
71
73
  educommon/audit_log/sql/configure_audit_log.sql,sha256=M3QxNKTZbn-uNRxGDvNxE9iJh1EOQUTIho7rvc3yhlY,1511
72
74
  educommon/audit_log/sql/install_audit_log.sql,sha256=SHrZ7WaYxawUKQEpZnj9k4HTU25NvBlxX_POqZ95HU0,14107
73
- educommon/audit_log/utils/__init__.py,sha256=FSq-L4wNOrCTqve16c4KruIxTwHZuTh1lNLNgznAcv8,14843
75
+ educommon/audit_log/utils/__init__.py,sha256=14BHRbKdnvHNgomM-R__GqLk1N32ww_GN2xy81Ph_gs,16388
74
76
  educommon/audit_log/utils/operations.py,sha256=skxL7wE4Jx1XlNdPx-Pl3SfiZ1G9jBmcZrXKSQDUGzw,2555
75
77
  educommon/auth/__init__.py,sha256=xkGJgqQ5QaEU86n6tJV77ux-2bMTAKbjpVYBCDhcS0E,79
76
78
  educommon/auth/rbac/__init__.py,sha256=guO7sOX6e1Z-dqptsqXjbnMdgbSdKp2suDKJa5_pdVU,317
@@ -349,7 +351,7 @@ educommon/ws_log/smev/exceptions.py,sha256=VNfzNHlj5Pz8D4979d_msTkxC-RQVoMctsgoJ
349
351
  educommon/ws_log/templates/report/smev_logs.xlsx,sha256=nnYgB0Z_ix8HoxsRICjsZfFRQBdra-5Gd8nWhCxTjYg,10439
350
352
  educommon/ws_log/templates/ui-js/smev-logs-list-window.js,sha256=AGup3D8GTJSY9WdDPj0zBJeYQBFOmGgcbxPOJbKK-nY,513
351
353
  educommon/ws_log/templates/ui-js/smev-logs-report-setting-window.js,sha256=nQ7QYK9frJcE7g7kIt6INg9TlEEJAPPayBJgRaoTePA,1103
352
- educommon-3.16.0.dist-info/METADATA,sha256=ZYDGTR3SSXHFsU4TUGcu2hlppLguZu2-ZbEx1kXk17M,2380
353
- educommon-3.16.0.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
354
- educommon-3.16.0.dist-info/top_level.txt,sha256=z5fbW7bz_0V1foUm_FGcZ9_MTpW3N1dBN7-kEmMowl4,10
355
- educommon-3.16.0.dist-info/RECORD,,
354
+ educommon-3.18.0.dist-info/METADATA,sha256=cKDmyFTkng12PZ-N2rVp62H3esRyCz9mHRLwenDTlLU,2380
355
+ educommon-3.18.0.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
356
+ educommon-3.18.0.dist-info/top_level.txt,sha256=z5fbW7bz_0V1foUm_FGcZ9_MTpW3N1dBN7-kEmMowl4,10
357
+ educommon-3.18.0.dist-info/RECORD,,