educommon 3.13.2__py3-none-any.whl → 3.15.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,64 @@
1
+ Приложение "Логирование изменений объектов на уровне БД"
2
+ =================================
3
+
4
+ Установка
5
+ --------------
6
+ 1. На основе абстрактного класса LogProxy из educommon/audit_log/proxies.py создать класс и реализовать его абстрактные
7
+ методы.
8
+
9
+ .. code-block:: python
10
+
11
+ class AuditLogProxy(LogProxy):
12
+ """Прокси-модель для отображения логов."""
13
+
14
+ class Meta:
15
+ proxy = True
16
+
17
+ @property
18
+ def user_fullname(self) -> str:
19
+ """Полное имя пользователя."""
20
+ pass
21
+
22
+ @property
23
+ def user_organization(self) -> str:
24
+ """Название организации, к которой привязан пользователь."""
25
+ pass
26
+
27
+
28
+ В случае использования сервисной базы для нового класса необходимо реализовать
29
+ роутер на основе ServiceDbRouterBase из educommon/django/db/routers.py и добавить его в settings.DATABASE_ROUTERS
30
+
31
+ .. code-block:: python
32
+
33
+ class AuditLogProxyDbRouter(MyServiceDbRouterBase):
34
+ """Роутер базы данных для AuditLogProxy."""
35
+
36
+ app_name = 'model_app_name'
37
+ service_db_model_names = {'AuditLogProxy'}
38
+
39
+
40
+ 2. На основе абстрактного класса AuditLogPack из educommon/audit_log/actions.py создать класс и реализовать его абстрактные
41
+ методы. В атрибуте model указать модель из пункта "1".
42
+
43
+ .. code-block:: python
44
+
45
+ class AuditPack(AuditLogPack):
46
+ """Журнал изменений."""
47
+
48
+ title = 'Журнал изменений (новый)'
49
+ model = AuditLogProxy
50
+
51
+ def _make_name_filter(self, field, value):
52
+ """Создает lookup фильтра по фамилии/имени/отчеству пользователя.
53
+
54
+ :param str field: название поля ('firstname', 'surname', 'patronymic').
55
+ :param str value: значение, по которому фильтруется queryset.
56
+ """
57
+ pass
58
+
59
+ Выполнить регистрацию нового пака с помощью контроллера основных приложений проекта.
60
+
61
+ 3. Добавить 'educommon.audit_log.middleware.AuditLogMiddleware', расположив его так, чтобы при его отработке
62
+ в request уже был определен пользователь.
63
+
64
+ 4. Создать и выполнить миграции.
@@ -1,3 +1,7 @@
1
+ from abc import (
2
+ ABCMeta,
3
+ abstractmethod,
4
+ )
1
5
  from datetime import (
2
6
  date,
3
7
  timedelta,
@@ -45,7 +49,6 @@ from educommon.audit_log.ui import (
45
49
  from educommon.audit_log.utils import (
46
50
  get_model_choices,
47
51
  make_hstore_filter,
48
- make_name_filter,
49
52
  )
50
53
  from educommon.m3 import (
51
54
  PackValidationMixin,
@@ -59,7 +62,7 @@ from educommon.utils.ui import (
59
62
  )
60
63
 
61
64
 
62
- class AuditLogPack(ViewWindowPackMixin, PackValidationMixin, ObjectPack):
65
+ class AuditLogPack(ViewWindowPackMixin, PackValidationMixin, ObjectPack, metaclass=ABCMeta):
63
66
  """Журнал изменений."""
64
67
 
65
68
  title = 'Журнал изменений'
@@ -82,64 +85,81 @@ class AuditLogPack(ViewWindowPackMixin, PackValidationMixin, ObjectPack):
82
85
  model, 'time', get_from=lambda: date.today() - timedelta(days=2), get_to=date.today
83
86
  )
84
87
 
85
- columns = [
86
- {'data_index': 'time', 'width': 140, 'header': 'Дата и время', 'sortable': True, 'filter': date_filter.filter},
87
- {
88
- 'data_index': 'user_name',
89
- 'width': 130,
90
- 'header': 'Пользователь',
91
- 'filter': ff('table__name', lookup=lambda x: make_name_filter('surname', x))
92
- & ff('table__name', lookup=lambda x: make_name_filter('firstname', x))
93
- & ff('table__name', lookup=lambda x: make_name_filter('patronymic', x)),
94
- },
95
- {
96
- 'data_index': 'operation',
97
- 'width': 60,
98
- 'header': 'Операция',
99
- 'filter': ff('operation', ask_before_deleting=False),
100
- },
101
- {
102
- 'data_index': 'model_name',
103
- 'width': 220,
104
- 'header': 'Модель объекта',
105
- 'filter': ff(
106
- 'table',
107
- control_creator=lambda: make_combo_box(
108
- data=get_model_choices(),
109
- ask_before_deleting=False,
88
+ def _generate_columns(self):
89
+ """Формирует наполнение столбцов."""
90
+ columns = [
91
+ {
92
+ 'data_index': 'time',
93
+ 'width': 140,
94
+ 'header': 'Дата и время',
95
+ 'sortable': True,
96
+ 'filter': self.date_filter.filter
97
+ },
98
+ {
99
+ 'data_index': 'user_name',
100
+ 'width': 130,
101
+ 'header': 'Пользователь',
102
+ 'filter': self.ff('table__name', lookup=lambda x: self._make_name_filter('surname', x))
103
+ & self.ff('table__name', lookup=lambda x: self._make_name_filter('firstname', x))
104
+ & self.ff('table__name', lookup=lambda x: self._make_name_filter('patronymic', x)),
105
+ },
106
+ {
107
+ 'data_index': 'operation',
108
+ 'width': 60,
109
+ 'header': 'Операция',
110
+ 'filter': self.ff('operation', ask_before_deleting=False),
111
+ },
112
+ {
113
+ 'data_index': 'model_name',
114
+ 'width': 220,
115
+ 'header': 'Модель объекта',
116
+ 'filter': self.ff(
117
+ 'table',
118
+ control_creator=lambda: make_combo_box(
119
+ data=get_model_choices(),
120
+ ask_before_deleting=False,
121
+ ),
122
+ ),
123
+ },
124
+ {
125
+ 'data_index': 'object_id',
126
+ 'width': 50,
127
+ 'header': 'Код объекта',
128
+ 'filter': self.ff('object_id'),
129
+ },
130
+ {
131
+ 'data_index': 'ip',
132
+ 'width': 60,
133
+ 'header': 'IP',
134
+ 'filter': self.ff(
135
+ 'ip',
136
+ parser_map=(GenericIPAddressField, 'str', '%s__contains'),
137
+ control_creator=ExtStringField,
110
138
  ),
111
- ),
112
- },
113
- {
114
- 'data_index': 'object_id',
115
- 'width': 50,
116
- 'header': 'Код объекта',
117
- 'filter': ff('object_id'),
118
- },
119
- {
120
- 'data_index': 'ip',
121
- 'width': 60,
122
- 'header': 'IP',
123
- 'filter': ff(
124
- 'ip',
125
- parser_map=(GenericIPAddressField, 'str', '%s__contains'),
126
- control_creator=ExtStringField,
127
- ),
128
- },
129
- {
130
- 'data_index': 'object_string',
131
- 'width': 180,
132
- 'header': 'Объект',
133
- 'filter': ff(
134
- 'data',
135
- parser_map=(HStoreField, 'str', '%s__values__icontains'),
136
- lookup=lambda x: make_hstore_filter('data', x),
137
- control_creator=ExtStringField,
138
- ),
139
- },
140
- ]
139
+ },
140
+ {
141
+ 'data_index': 'object_string',
142
+ 'width': 180,
143
+ 'header': 'Объект',
144
+ 'filter': self.ff(
145
+ 'data',
146
+ parser_map=(HStoreField, 'str', '%s__values__icontains'),
147
+ lookup=lambda x: make_hstore_filter('data', x),
148
+ control_creator=ExtStringField,
149
+ ),
150
+ },
151
+ {
152
+ 'data_index': '',
153
+ 'width': 40,
154
+ 'header': 'Имитация',
155
+ },
156
+ ]
157
+
158
+ return columns
141
159
 
142
160
  def __init__(self):
161
+ self.columns = self._generate_columns()
162
+
143
163
  super().__init__()
144
164
 
145
165
  self.view_changes_action = ViewChangeAction()
@@ -187,6 +207,14 @@ class AuditLogPack(ViewWindowPackMixin, PackValidationMixin, ObjectPack):
187
207
  menu.Item(self.title, self.list_window_action),
188
208
  )
189
209
 
210
+ @abstractmethod
211
+ def _make_name_filter(self, field, value):
212
+ """Создает lookup фильтра по фамилии/имени/отчеству пользователя.
213
+
214
+ :param str field: название поля ('firstname', 'surname', 'patronymic').
215
+ :param str value: значение, по которому фильтруется queryset.
216
+ """
217
+
190
218
 
191
219
  class ViewChangeAction(BaseAction):
192
220
  """Action для просмотра изменений."""
@@ -203,7 +231,7 @@ class ViewChangeAction(BaseAction):
203
231
  """Тело Action, вызывается при обработке запроса к серверу."""
204
232
  object_id = getattr(context, self.parent.id_param_name)
205
233
  if object_id:
206
- rows = LogProxy.objects.get(id=object_id).diff
234
+ rows = self.parent.model.objects.get(id=object_id).diff
207
235
  else:
208
236
  rows = []
209
237
 
@@ -1,9 +1,6 @@
1
1
  from educommon import (
2
2
  ioc,
3
3
  )
4
- from educommon.audit_log.actions import (
5
- AuditLogPack,
6
- )
7
4
  from educommon.audit_log.error_log.actions import (
8
5
  PostgreSQLErrorPack,
9
6
  )
@@ -13,7 +10,6 @@ def register_actions():
13
10
  """Регистрация паков и экшенов."""
14
11
  ioc.get('main_controller').packs.extend(
15
12
  (
16
- AuditLogPack(),
17
13
  PostgreSQLErrorPack(),
18
14
  )
19
15
  )
@@ -1,5 +1,10 @@
1
1
  import os
2
2
 
3
+ from abc import (
4
+ ABCMeta,
5
+ abstractmethod,
6
+ )
7
+
3
8
  from django.conf import (
4
9
  settings,
5
10
  )
@@ -36,8 +41,12 @@ from educommon.audit_log.models import (
36
41
  )
37
42
 
38
43
 
39
- class LogProxy(AuditLog):
40
- """Прокси-модель для отображения."""
44
+ class LogProxyMeta(type(AuditLog), ABCMeta):
45
+ """Метакласс для устранения конфликта при наследовании от AuditLog и использовании ABCMeta"""
46
+
47
+
48
+ class LogProxy(AuditLog, metaclass=LogProxyMeta):
49
+ """Абстрактный класс прокси-модели для отображения."""
41
50
 
42
51
  class Meta:
43
52
  proxy = True
@@ -53,15 +62,22 @@ class LogProxy(AuditLog):
53
62
  except ContentType.DoesNotExist:
54
63
  result = 'Model:{}, id:{}'.format(user_type_id, user_id)
55
64
  else:
56
- try:
57
- result = model.objects.get(id=user_id).person.fullname
58
- except model.DoesNotExist:
59
- result = f'{model._meta.verbose_name}({user_id})'
65
+ result = self.user_fullname or f'{model._meta.verbose_name}({user_id})'
60
66
  else:
61
67
  result = ''
62
68
 
63
69
  return result
64
70
 
71
+ @property
72
+ @abstractmethod
73
+ def user_fullname(self) -> str:
74
+ """Полное имя пользователя."""
75
+
76
+ @property
77
+ @abstractmethod
78
+ def user_organization(self) -> str:
79
+ """Название организации, к которой привязан пользователь."""
80
+
65
81
  @property
66
82
  def model_fullname(self):
67
83
  """Отображаемое и системное имя модели."""
educommon/audit_log/ui.py CHANGED
@@ -60,11 +60,5 @@ class ViewChangeWindow(BaseWindow):
60
60
  log_record = params['object']
61
61
  self.title = '{}: {}'.format(log_record.get_operation_display(), log_record.model_name)
62
62
  if log_record.user:
63
- self.user_field.value = '{} / {}'.format(
64
- log_record.user.person.fullname, log_record.user.person.user.username
65
- )
66
- unit = getattr(log_record.user, 'unit', None)
67
- if unit:
68
- self.unit_field.value = unit.short_name
69
- else:
70
- self.unit_field.value = ''
63
+ self.user_field.value = f'{log_record.user_fullname} / {log_record.user.username}'
64
+ self.unit_field.value = log_record.user_organization
@@ -345,28 +345,6 @@ def make_hstore_filter(field, value):
345
345
  return result
346
346
 
347
347
 
348
- def make_name_filter(field, value):
349
- """Создает lookup фильтра по фамилии/имени/отчеству пользователя.
350
-
351
- :param str field: название поля ('firstname', 'surname', 'patronymic').
352
- :param str value: значение, по которому фильтруется queryset.
353
- """
354
- ContentType = apps.get_model('contenttypes', 'ContentType')
355
- Employee = apps.get_model('employee', 'Employee')
356
- SysAdmin = apps.get_model('sysadmin', 'SysAdmin')
357
-
358
- result = None
359
- for model in (Employee, SysAdmin):
360
- type_id = ContentType.objects.get_for_model(model).id
361
- user_ids = list(model.objects.filter(**{f'person__{field}__icontains': value}).values_list('id', flat=True))
362
- qobj = Q(user_id__in=user_ids, user_type_id=type_id)
363
- if result:
364
- result |= qobj
365
- else:
366
- result = qobj
367
- return result
368
-
369
-
370
348
  class ModelRegistry:
371
349
  """Реестр моделей Django по имени таблицы.
372
350
 
@@ -447,7 +425,7 @@ def get_audit_log_context(request):
447
425
  result = {}
448
426
 
449
427
  current_user = ioc.get('get_current_user')(request)
450
- if current_user:
428
+ if current_user and current_user.is_authenticated:
451
429
  ContentType = apps.get_model('contenttypes', 'ContentType')
452
430
 
453
431
  result['user_id'] = current_user.id
educommon/utils/ui.py CHANGED
@@ -339,6 +339,33 @@ class DatetimeFilterCreator:
339
339
  return result
340
340
 
341
341
 
342
+ class FilterByDateStr(_FilterByField):
343
+ """Фильтр позволяет работать с датой в формате d.m.Y, m.Y, Y."""
344
+
345
+ def __init__(self, *args, **kwargs) -> None:
346
+ kwargs['date_str'] = True
347
+
348
+ super().__init__(*args, **kwargs)
349
+
350
+ self._lookup = self._lookup if self._lookup.endswith('__icontains') else f'{self._lookup}__icontains'
351
+
352
+ def get_q(self, params: dict) -> Q:
353
+ """Метод возвращает Q-объект, построенный на основе данных словаря.
354
+
355
+ Args:
356
+ params: Словарь с лукапами.
357
+ Returns:
358
+ Q-объект, построенный на основе данных словаря.
359
+ """
360
+
361
+ if self._uid in params:
362
+ value = '-'.join(params[self._uid].split('.')[::-1])
363
+
364
+ return Q(**{self._lookup: value})
365
+
366
+ return Q()
367
+
368
+
342
369
  def switch_window_in_read_only_mode(window):
343
370
  """Переводит окно редактирования в режим "Только для чтения".
344
371
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: educommon
3
- Version: 3.13.2
3
+ Version: 3.15.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
@@ -33,7 +33,7 @@ Requires-Dist: python-magic==0.4.15
33
33
  Requires-Dist: m3-db-utils>=0.3.13
34
34
  Requires-Dist: m3-django-compat<2,>=1.10.2
35
35
  Requires-Dist: m3-core<3,>=2.2.16
36
- Requires-Dist: m3-ui<3,>=2.2.40
36
+ Requires-Dist: m3-ui<3,>=2.2.116
37
37
  Requires-Dist: m3-objectpack<3,>=2.2.49
38
38
  Requires-Dist: m3-simple-report<2,>=1.4.1
39
39
  Requires-Dist: m3-spyne-smev<2,>=0.2.4
@@ -40,18 +40,19 @@ educommon/async_tasks/fixtures/initial_data.json,sha256=d5EGMoCY8CVJL80C8ZaPwVVU
40
40
  educommon/async_tasks/migrations/0001_initial.py,sha256=ohWQK9rzMdM6kSWs7IPmlOA5amkWdaWZ7u3MBl1uWd0,5632
41
41
  educommon/async_tasks/migrations/0002_load_initial_data.py,sha256=MMLyXnEl9KFb1jYNfDfunwoLrTLyZfSqmcUQUhr2V-M,477
42
42
  educommon/async_tasks/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
+ educommon/audit_log/README.rst,sha256=qz0PxwnAMNnSYMemX_WQzttDA7068YzPc-jHMCSRxDY,2777
43
44
  educommon/audit_log/__init__.py,sha256=fNpKUKim4ybYWxSXjJ77G0YsvtFiZcWb0x6SDT3GxMo,2773
44
- educommon/audit_log/actions.py,sha256=iC9kREnAtaMmBRJLIOcwzATBjceGojeeDUkCd5Po3GM,6231
45
- educommon/audit_log/app_meta.py,sha256=eIEgpxkZ5MTD6h1j1GrWMxyWdyoKk7rSWzHlQ6UKKEM,399
45
+ educommon/audit_log/actions.py,sha256=vsfoohNC4MOqCmi_WxuIF9jQMgYSR5BUBFaJw3eESC8,7381
46
+ educommon/audit_log/app_meta.py,sha256=2xg9OKRhAFNmE_k10J5nTnFL8CVuI9iPbEBJha_UXsU,309
46
47
  educommon/audit_log/apps.py,sha256=A27iSAEWAVr7LmBmexVDrbWm3eQ-zFq1FYGQRClMJVM,6278
47
48
  educommon/audit_log/constants.py,sha256=8C1SCiwD1s_ED9C3Hh50LMAnToGvZmF21nrv18hRLEg,903
48
49
  educommon/audit_log/helpers.py,sha256=Dp1DvuzVkhD-P37yoXGd1tvXKPJz2ZQzROS97VURrcY,727
49
50
  educommon/audit_log/middleware.py,sha256=HkxBh-1RQJnhKqckkXaMbFjJ34WgZGJssbk04wiS3ts,1140
50
51
  educommon/audit_log/models.py,sha256=LLKbnpvz89ZzZ__ZDYBXqh_Klp3z_XfSo69s-MsX-6g,13066
51
52
  educommon/audit_log/permissions.py,sha256=_0ntlLKUbJ1kYR2_YpgOxYMJNopZkrdIgxgPiHgL780,1273
52
- educommon/audit_log/proxies.py,sha256=cyNUCt8057S0I6Hj0s02av_-SuUkDVHERd35KzNQEKI,8230
53
+ educommon/audit_log/proxies.py,sha256=IVZSKpeTDTV8AXdblLo1u9yOmecPdzSvVNrgmf-sxBQ,8750
53
54
  educommon/audit_log/routers.py,sha256=FF3KLvf6_WWFuZ9VRI8AZyKDfWp_lyd0i6bcVFfIehQ,204
54
- educommon/audit_log/ui.py,sha256=E0O1JpafNO8oI67u4uKQS_gPE6WpoW-h1ZIPbe5K44w,2584
55
+ educommon/audit_log/ui.py,sha256=wuhNk6itlOuRStro6fINMQoguzuAKZibURFMedSnqCc,2394
55
56
  educommon/audit_log/error_log/__init__.py,sha256=lfAIm5GTGQ1-kRFxH1s0agSt2Oeguwj2906XNBC0qH4,97
56
57
  educommon/audit_log/error_log/actions.py,sha256=-KSy3RrBsbPWCML-gl5Hl5UGQdEsPZhoKrHq16LRmm8,2305
57
58
  educommon/audit_log/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -69,7 +70,7 @@ educommon/audit_log/migrations/0009_reinstall_audit_log.py,sha256=c95H2yamWyrCoG
69
70
  educommon/audit_log/migrations/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
70
71
  educommon/audit_log/sql/configure_audit_log.sql,sha256=M3QxNKTZbn-uNRxGDvNxE9iJh1EOQUTIho7rvc3yhlY,1511
71
72
  educommon/audit_log/sql/install_audit_log.sql,sha256=SHrZ7WaYxawUKQEpZnj9k4HTU25NvBlxX_POqZ95HU0,14107
72
- educommon/audit_log/utils/__init__.py,sha256=Mx6yOVUTE5bqoeEmZp4QOvKQg1qG2wTjSvUvzKsEcus,15731
73
+ educommon/audit_log/utils/__init__.py,sha256=FSq-L4wNOrCTqve16c4KruIxTwHZuTh1lNLNgznAcv8,14843
73
74
  educommon/audit_log/utils/operations.py,sha256=skxL7wE4Jx1XlNdPx-Pl3SfiZ1G9jBmcZrXKSQDUGzw,2555
74
75
  educommon/auth/__init__.py,sha256=xkGJgqQ5QaEU86n6tJV77ux-2bMTAKbjpVYBCDhcS0E,79
75
76
  educommon/auth/rbac/__init__.py,sha256=guO7sOX6e1Z-dqptsqXjbnMdgbSdKp2suDKJa5_pdVU,317
@@ -295,7 +296,7 @@ educommon/utils/seqtools.py,sha256=SRPlgn9KB-0cWCESfqeywfsdIbTjUopFmZYbcDQca88,4
295
296
  educommon/utils/serializer.py,sha256=R-72kNjLa5izLXy3qLmzrBWDk-feTnJR7FwNRvGG77Y,8653
296
297
  educommon/utils/storage.py,sha256=RcWAR7vxc0uZ2PODdpcNoBQGKilSaQnxxGwa0rfwlm8,2887
297
298
  educommon/utils/system.py,sha256=34mUlk7OV5pgcxAucWVWe9xQ--I_u40ZByWGLtkoGdQ,2804
298
- educommon/utils/ui.py,sha256=N_v44tYryB66uZF-k8V5fxsnVds3FMvEFKxMzb8Ph6c,16344
299
+ educommon/utils/ui.py,sha256=ZloCrrZR7kczef4IKH02OZD0olJ-h-EVvplnlp6k6XY,17270
299
300
  educommon/utils/db/__init__.py,sha256=b2hyjR6PZgP9E0LFIl8yTHgCOKO7_WEeXPqPNdbqoTg,7624
300
301
  educommon/utils/db/postgresql.py,sha256=HvSCxNy6ZuHsohckmNFbWxFBoeCh-AKSRRALAGfjEd0,2786
301
302
  educommon/utils/fonts/Arial.ttf,sha256=NcDzVZ2NtWnjbDEJW4pg1EFkPZX1kTneQOI_ragZuDM,275572
@@ -348,7 +349,7 @@ educommon/ws_log/smev/exceptions.py,sha256=VNfzNHlj5Pz8D4979d_msTkxC-RQVoMctsgoJ
348
349
  educommon/ws_log/templates/report/smev_logs.xlsx,sha256=nnYgB0Z_ix8HoxsRICjsZfFRQBdra-5Gd8nWhCxTjYg,10439
349
350
  educommon/ws_log/templates/ui-js/smev-logs-list-window.js,sha256=AGup3D8GTJSY9WdDPj0zBJeYQBFOmGgcbxPOJbKK-nY,513
350
351
  educommon/ws_log/templates/ui-js/smev-logs-report-setting-window.js,sha256=nQ7QYK9frJcE7g7kIt6INg9TlEEJAPPayBJgRaoTePA,1103
351
- educommon-3.13.2.dist-info/METADATA,sha256=7ZDIplBEd913ZsEizOh6ys-LRZQN1Qmv2JGWY0jooV4,2379
352
- educommon-3.13.2.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
353
- educommon-3.13.2.dist-info/top_level.txt,sha256=z5fbW7bz_0V1foUm_FGcZ9_MTpW3N1dBN7-kEmMowl4,10
354
- educommon-3.13.2.dist-info/RECORD,,
352
+ educommon-3.15.0.dist-info/METADATA,sha256=_8hCUYe-OqYdSUa0pDvbkIF4peIPvOZT4_SU0kXkFek,2380
353
+ educommon-3.15.0.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
354
+ educommon-3.15.0.dist-info/top_level.txt,sha256=z5fbW7bz_0V1foUm_FGcZ9_MTpW3N1dBN7-kEmMowl4,10
355
+ educommon-3.15.0.dist-info/RECORD,,