edu-rdm-integration 3.22.1__py3-none-any.whl → 3.23.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.
Files changed (33) hide show
  1. edu_rdm_integration/core/consts.py +3 -0
  2. edu_rdm_integration/pipelines/transfer/tasks.py +5 -0
  3. edu_rdm_integration/rdm_entities/models.py +5 -0
  4. edu_rdm_integration/rdm_models/models.py +29 -11
  5. edu_rdm_integration/stages/collect_data/functions/base/runners.py +7 -0
  6. edu_rdm_integration/stages/collect_data/models.py +5 -0
  7. edu_rdm_integration/stages/collect_data/registry/actions.py +3 -3
  8. edu_rdm_integration/stages/collect_data/registry/templates/ui-js/collect-command-window.js +8 -19
  9. edu_rdm_integration/stages/collect_data/registry/templates/ui-js/validators.js +0 -15
  10. edu_rdm_integration/stages/export_data/functions/base/runners.py +11 -0
  11. edu_rdm_integration/stages/export_data/migrations/0003_alter_rdmexportingdatasubstageattachment_exporting_data_sub_stage.py +22 -0
  12. edu_rdm_integration/stages/export_data/migrations/{0003_fix_fk_constraints.py → 0004_fix_fk_constraints.py} +4 -4
  13. edu_rdm_integration/stages/export_data/models.py +12 -1
  14. edu_rdm_integration/stages/export_data/registry/actions.py +5 -4
  15. edu_rdm_integration/stages/export_data/registry/templates/ui-js/create-export-command-win.js +15 -31
  16. edu_rdm_integration/stages/service/service_outdated_data/cleaners/base.py +139 -48
  17. edu_rdm_integration/stages/service/service_outdated_data/cleaners/collect_data.py +1 -8
  18. edu_rdm_integration/stages/service/service_outdated_data/cleaners/consts.py +0 -11
  19. edu_rdm_integration/stages/service/service_outdated_data/cleaners/export_data.py +23 -3
  20. edu_rdm_integration/stages/upload_data/enums.py +2 -0
  21. edu_rdm_integration/stages/upload_data/export_managers.py +3 -1
  22. edu_rdm_integration/stages/upload_data/management/commands/custom_check_upload_status.py +59 -0
  23. edu_rdm_integration/stages/upload_data/management/commands/custom_upload_files.py +45 -0
  24. edu_rdm_integration/stages/upload_data/migrations/0004_fix_fk_constraints.py +1 -1
  25. edu_rdm_integration/stages/upload_data/queues.py +50 -2
  26. edu_rdm_integration/stages/upload_data/tasks.py +2 -2
  27. edu_rdm_integration/stages/utils.py +12 -0
  28. edu_rdm_integration/templates/ui-js/collect-and-export-validators.js +54 -0
  29. {edu_rdm_integration-3.22.1.dist-info → edu_rdm_integration-3.23.0.dist-info}/METADATA +75 -61
  30. {edu_rdm_integration-3.22.1.dist-info → edu_rdm_integration-3.23.0.dist-info}/RECORD +33 -29
  31. {edu_rdm_integration-3.22.1.dist-info → edu_rdm_integration-3.23.0.dist-info}/WHEEL +0 -0
  32. {edu_rdm_integration-3.22.1.dist-info → edu_rdm_integration-3.23.0.dist-info}/licenses/LICENSE +0 -0
  33. {edu_rdm_integration-3.22.1.dist-info → edu_rdm_integration-3.23.0.dist-info}/top_level.txt +0 -0
@@ -39,3 +39,6 @@ ACADEMIC_YEAR = {
39
39
  TASK_QUEUE_NAME = 'RDM'
40
40
  FAST_TRANSFER_TASK_QUEUE_NAME = 'RDM_FAST'
41
41
  LONG_TRANSFER_TASK_QUEUE_NAME = 'RDM_LONG'
42
+
43
+ # Лаг по времени между сбором и экспортом при работе с репликам (в секундах)
44
+ PAUSE_TIME = 15
@@ -1,3 +1,4 @@
1
+ import time
1
2
  from datetime import (
2
3
  datetime,
3
4
  )
@@ -37,6 +38,7 @@ from educommon.utils.date import (
37
38
  from edu_rdm_integration.core.consts import (
38
39
  FAST_TRANSFER_TASK_QUEUE_NAME,
39
40
  LONG_TRANSFER_TASK_QUEUE_NAME,
41
+ PAUSE_TIME,
40
42
  TASK_QUEUE_NAME,
41
43
  )
42
44
  from edu_rdm_integration.core.enums import (
@@ -206,6 +208,9 @@ class BaseTransferLatestEntitiesDataPeriodicTask(BaseTransferLatestEntitiesDataM
206
208
 
207
209
  continue
208
210
 
211
+ # Лаг времени для достаки данных в реплику
212
+ time.sleep(PAUSE_TIME)
213
+
209
214
  try:
210
215
  if export_enabled:
211
216
  self._run_export_entity_data(entity_enum.key, task_id)
@@ -70,6 +70,11 @@ class RDMEntityEnum(TitledModelEnum):
70
70
 
71
71
  return model_enums
72
72
 
73
+ @classmethod
74
+ def get_choices(cls) -> list[tuple[str, str]]:
75
+ """Возвращает список кортежей из ключей и ключей перечисления сущностей."""
76
+ return [(key, key) for key in sorted(cls.get_model_enum_keys())]
77
+
73
78
  @classmethod
74
79
  def extend(
75
80
  cls,
@@ -35,15 +35,6 @@ from m3_db_utils.models import (
35
35
  class BaseRDMModel(ReprStrPreModelMixin, BaseObjectModel):
36
36
  """Базовая модель РВД."""
37
37
 
38
- collecting_sub_stage = ForeignKey(
39
- verbose_name='Подэтап сбора данных',
40
- to='edu_rdm_integration_collect_data_stage.RDMCollectingDataSubStage',
41
- on_delete=CASCADE,
42
- )
43
- operation = SmallIntegerField(
44
- verbose_name='Действие',
45
- choices=EntityLogOperation.get_choices(),
46
- )
47
38
  created = DateTimeField(
48
39
  verbose_name='Дата создания',
49
40
  auto_now_add=True,
@@ -68,10 +59,32 @@ class BaseRDMModel(ReprStrPreModelMixin, BaseObjectModel):
68
59
  abstract = True
69
60
 
70
61
 
71
- class BaseMainRDMModel(BaseRDMModel):
62
+ class BaseAdditionalRDMModel(BaseRDMModel):
63
+ """Абстрактная вспомогательная модель РВД.
64
+
65
+ Является базовым классом для моделей РВД, которые не являются основными для сущностей РВД. Для таких моделей
66
+ производится сбор данных.
67
+ """
68
+
69
+ collecting_sub_stage = ForeignKey(
70
+ verbose_name='Подэтап сбора данных',
71
+ to='edu_rdm_integration_collect_data_stage.RDMCollectingDataSubStage',
72
+ on_delete=CASCADE,
73
+ )
74
+ operation = SmallIntegerField(
75
+ verbose_name='Действие',
76
+ choices=EntityLogOperation.get_choices(),
77
+ )
78
+
79
+ class Meta:
80
+ abstract = True
81
+
82
+
83
+ class BaseMainRDMModel(BaseAdditionalRDMModel):
72
84
  """Абстрактная основная модель РВД.
73
85
 
74
- Является базовым классом для моделей РВД, которые являются основными для сущностей РВД.
86
+ Является базовым классом для моделей РВД, которые являются основными для сущностей РВД. Для таких моделей
87
+ производится сбор и выгрузка данных.
75
88
  """
76
89
 
77
90
  exporting_sub_stage = ForeignKey(
@@ -103,6 +116,11 @@ class RDMModelEnum(TitledModelEnum):
103
116
  verbose_name = 'Модель-перечисление моделей "Региональной витрины данных"'
104
117
  verbose_name_plural = 'Модели-перечисления моделей "Региональной витрины данных"'
105
118
 
119
+ @classmethod
120
+ def get_choices(cls) -> list[tuple[str, str]]:
121
+ """Возвращает список кортежей из ключей и ключей перечисления моделей."""
122
+ return [(key, key) for key in sorted(cls.get_model_enum_keys())]
123
+
106
124
  @classmethod
107
125
  def _get_model_relations(cls, model: Type['BaseRDMModel']) -> dict[str, str]:
108
126
  """Получение списка связей модели РВД."""
@@ -1,3 +1,7 @@
1
+ from itertools import (
2
+ islice,
3
+ )
4
+
1
5
  from django.conf import (
2
6
  settings,
3
7
  )
@@ -27,6 +31,9 @@ class BaseCollectingDataRunner(EduRunner):
27
31
  size=settings.RDM_COLLECT_CHUNK_SIZE,
28
32
  )
29
33
 
34
+ if settings.DEBUG and settings.RDM_COLLECT_CHUNKS_LIMIT:
35
+ raw_logs_chunks = islice(raw_logs_chunks, settings.RDM_COLLECT_CHUNKS_LIMIT)
36
+
30
37
  for chunk_index, raw_logs_chunk in enumerate(raw_logs_chunks, start=1):
31
38
  raw_logs = list(raw_logs_chunk)
32
39
  for runnable_class in self._prepare_runnable_classes():
@@ -65,6 +65,11 @@ class RDMCollectingDataStageStatus(TitledModelEnum):
65
65
  verbose_name = 'Модель-перечисление статусов этапа сбора данных'
66
66
  verbose_name_plural = 'Модели-перечисления статусов этапов сбора данных'
67
67
 
68
+ @classmethod
69
+ def get_choices(cls) -> list[tuple[str, str]]:
70
+ """Возвращает список кортежей из ключей и ключей перечисления статусов."""
71
+ return [(key, key) for key in cls.get_model_enum_keys()]
72
+
68
73
 
69
74
  class RDMCollectingDataStage(ReprStrPreModelMixin, BaseObjectModel):
70
75
  """Этап подготовки данных в рамках Функций. За работу Функции отвечает ранер менеджер."""
@@ -83,7 +83,7 @@ class BaseCollectingDataProgressPack(BaseCommandProgressPack):
83
83
  'header': 'Модель',
84
84
  'sortable': True,
85
85
  'filter': ChoicesFilter(
86
- choices=[(key, key) for key in RDMModelEnum.get_model_enum_keys()],
86
+ choices=partial(RDMModelEnum.get_choices),
87
87
  parser=str,
88
88
  lookup=lambda key: Q(model=key) if key else Q(),
89
89
  ),
@@ -93,7 +93,7 @@ class BaseCollectingDataProgressPack(BaseCommandProgressPack):
93
93
  'header': 'Статус асинхронной задачи',
94
94
  'sortable': True,
95
95
  'filter': ChoicesFilter(
96
- choices=[(value.key, value.title) for value in AsyncTaskStatus.get_model_enum_values()],
96
+ choices=partial(AsyncTaskStatus.get_choices),
97
97
  parser=str,
98
98
  lookup='task__status_id',
99
99
  ),
@@ -118,7 +118,7 @@ class BaseCollectingDataProgressPack(BaseCommandProgressPack):
118
118
  'header': 'Статус сбора',
119
119
  'sortable': True,
120
120
  'filter': ChoicesFilter(
121
- choices=[(key, key) for key in RDMCollectingDataStageStatus.get_model_enum_keys()],
121
+ choices=partial(RDMCollectingDataStageStatus.get_choices),
122
122
  parser=str,
123
123
  lookup=lambda key: Q(stage__status=key) if key else Q(),
124
124
  ),
@@ -1,7 +1,9 @@
1
1
  {% load educommon %}
2
2
  {% include "ui-js/validators.js" %}
3
+ {% include "ui-js/collect-and-export-validators.js" %}
3
4
 
4
- var win = Ext.getCmp('{{ component.client_id }}');
5
+ var logsPeriodStartField = Ext.getCmp('logs_period_started_at');
6
+ var logsPeriodEndField = Ext.getCmp('logs_period_ended_at');
5
7
 
6
8
  Ext.onReady(function() {
7
9
  // Инициализация валидаторов
@@ -41,24 +43,11 @@ function initializeValidators() {
41
43
  });
42
44
  }
43
45
 
44
- // Устанавливаем текущую дату и время как максимальное значение
45
- // и выполняем валидацию каждую секунду
46
- var validationInterval = setInterval(function() {
47
- var now = new Date();
48
-
49
- logsPeriodStartField.setMaxValue(now);
50
- logsPeriodEndField.setMaxValue(now);
51
-
52
- logsPeriodStartField.validate();
53
- logsPeriodEndField.validate();
54
- }, 1000);
55
-
56
- win.on('destroy', function() {
57
- clearInterval(validationInterval);
58
- });
59
-
60
- logsPeriodStartField.validator = logsPeriodsValidator;
61
- logsPeriodEndField.validator = logsPeriodsValidator;
46
+ // Устанавливаем время по умолчанию в календаре и доп валидатор
47
+ setupPeriodFields(
48
+ logsPeriodStartField,
49
+ logsPeriodEndField,
50
+ );
62
51
  }
63
52
 
64
53
  function initializeBatchSizeSplitByLogic() {
@@ -47,20 +47,5 @@ var instituteCountValidator = function() {
47
47
  }
48
48
 
49
49
  this.clearInvalid();
50
- return true;
51
- };
52
-
53
- var logsPeriodStartField = Ext.getCmp('logs_period_started_at');
54
- var logsPeriodEndField = Ext.getCmp('logs_period_ended_at');
55
-
56
- var logsPeriodsValidator = function () {
57
- if (
58
- logsPeriodStartField.getValue() &&
59
- logsPeriodEndField.getValue() &&
60
- logsPeriodStartField.getValue() > logsPeriodEndField.getValue()
61
- ) {
62
- return 'Дата конца периода не может быть меньше даты начала периода'
63
- };
64
-
65
50
  return true;
66
51
  };
@@ -1,3 +1,11 @@
1
+ from itertools import (
2
+ islice,
3
+ )
4
+
5
+ from django.conf import (
6
+ settings,
7
+ )
8
+
1
9
  from edu_function_tools.runners import (
2
10
  EduRunner,
3
11
  )
@@ -41,6 +49,9 @@ class BaseExportDataRunner(EduRunner):
41
49
  """Возвращает генератор запускаемых объектов."""
42
50
  model_ids_chunks = self._prepare_model_ids_chunks(*args, **kwargs)
43
51
 
52
+ if settings.DEBUG and settings.RDM_EXPORT_CHUNKS_LIMIT:
53
+ model_ids_chunks = islice(model_ids_chunks, settings.RDM_EXPORT_CHUNKS_LIMIT)
54
+
44
55
  for chunk_index, model_ids_chunk in enumerate(model_ids_chunks, start=1):
45
56
  for runnable_class in self._prepare_runnable_classes():
46
57
  runnable = runnable_class(
@@ -0,0 +1,22 @@
1
+ # Generated by Django 3.2.24 on 2025-10-17 14:11
2
+
3
+ import django.db.models.deletion
4
+ from django.db import (
5
+ migrations,
6
+ models,
7
+ )
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+
12
+ dependencies = [
13
+ ('edu_rdm_integration_export_data_stage', '0002_auto_20250704_0810'),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.AlterField(
18
+ model_name='rdmexportingdatasubstageattachment',
19
+ name='exporting_data_sub_stage',
20
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='edu_rdm_integration_export_data_stage.rdmexportingdatasubstage', verbose_name='Подэтап выгрузки данных'),
21
+ ),
22
+ ]
@@ -1,4 +1,4 @@
1
- # Generated by Django 3.2.24 on 2025-10-08 14:44
1
+ # Generated by Django 3.2.24 on 2025-10-17 14:15
2
2
 
3
3
  from django.db import (
4
4
  migrations,
@@ -28,7 +28,7 @@ def apply_fk_updates(apps, schema_editor):
28
28
  table_name=RDMExportingDataSubStageAttachment._meta.db_table,
29
29
  field_name='exporting_data_sub_stage_id',
30
30
  target_table=RDMExportingDataSubStage._meta.db_table,
31
- on_delete='CASCADE',
31
+ on_delete='SET NULL',
32
32
  )
33
33
 
34
34
  RDMExportingDataSubStageEntity = apps.get_model(
@@ -68,9 +68,9 @@ def apply_fk_updates(apps, schema_editor):
68
68
  class Migration(migrations.Migration):
69
69
 
70
70
  dependencies = [
71
- ('edu_rdm_integration_export_data_stage', '0002_auto_20250704_0810'),
71
+ ('edu_rdm_integration_export_data_stage', '0003_alter_rdmexportingdatasubstageattachment_exporting_data_sub_stage'),
72
72
  ]
73
73
 
74
74
  operations = [
75
75
  migrations.RunPython(apply_fk_updates, reverse_code=migrations.RunPython.noop),
76
- ]
76
+ ]
@@ -69,6 +69,11 @@ class RDMExportingDataStageStatus(TitledModelEnum):
69
69
  title='Завершено',
70
70
  )
71
71
 
72
+ @classmethod
73
+ def get_choices(cls) -> list[tuple[str, str]]:
74
+ """Возвращает список кортежей из ключей и ключей перечисления статусов."""
75
+ return [(key, key) for key in cls.get_model_enum_keys()]
76
+
72
77
  class Meta:
73
78
  db_table = 'rdm_exporting_data_stage_status'
74
79
  verbose_name = 'Модель-перечисление статусов этапа выгрузки данных'
@@ -163,6 +168,10 @@ class RDMExportingDataSubStageStatus(TitledModelEnum):
163
168
  )
164
169
  PROCESS_ERROR = ModelEnumValue(title='Ошибка обработки витриной')
165
170
 
171
+ IN_EXPORT = ModelEnumValue(
172
+ title='Экспортируется',
173
+ )
174
+
166
175
  class Meta:
167
176
  db_table = 'rdm_exporting_data_sub_stage_status'
168
177
  verbose_name = 'Модель-перечисление статусов подэтапа выгрузки данных'
@@ -248,7 +257,9 @@ class RDMExportingDataSubStageAttachment(ReprStrPreModelMixin, BaseObjectModel):
248
257
  exporting_data_sub_stage = ForeignKey(
249
258
  to=RDMExportingDataSubStage,
250
259
  verbose_name='Подэтап выгрузки данных',
251
- on_delete=CASCADE,
260
+ null=True,
261
+ blank=True,
262
+ on_delete=SET_NULL,
252
263
  )
253
264
 
254
265
  # TODO PYTD-22 В зависимости от принятого решения по инструменту ограничения доступа к media-файлам, нужно будет
@@ -86,7 +86,7 @@ class BaseExportingDataProgressPack(BaseCommandProgressPack):
86
86
  'header': 'Сущность',
87
87
  'sortable': True,
88
88
  'filter': ChoicesFilter(
89
- choices=[(key, key) for key in RDMEntityEnum.get_model_enum_keys()],
89
+ choices=partial(RDMEntityEnum.get_choices),
90
90
  parser=str,
91
91
  lookup=lambda key: Q(entity=key) if key else Q(),
92
92
  ),
@@ -96,7 +96,7 @@ class BaseExportingDataProgressPack(BaseCommandProgressPack):
96
96
  'header': 'Статус асинхронной задачи',
97
97
  'sortable': True,
98
98
  'filter': ChoicesFilter(
99
- choices=[(value.key, value.title) for value in AsyncTaskStatus.get_model_enum_values()],
99
+ choices=partial(AsyncTaskStatus.get_choices),
100
100
  parser=str,
101
101
  lookup='task__status_id',
102
102
  ),
@@ -131,7 +131,7 @@ class BaseExportingDataProgressPack(BaseCommandProgressPack):
131
131
  'header': 'Статус экспорта',
132
132
  'sortable': True,
133
133
  'filter': ChoicesFilter(
134
- choices=[(key, key) for key in RDMExportingDataStageStatus.get_model_enum_keys()],
134
+ choices=partial(RDMExportingDataStageStatus.get_choices),
135
135
  parser=str,
136
136
  lookup=lambda key: Q(stage__status=key) if key else Q(),
137
137
  ),
@@ -228,7 +228,8 @@ class BaseExportingDataProgressPack(BaseCommandProgressPack):
228
228
  ready_sub_stages=Subquery(
229
229
  RDMExportingDataSubStage.objects.filter(
230
230
  stage_id=OuterRef('stage_id'),
231
- status=RDMExportingDataSubStageStatus.READY_FOR_EXPORT.key,
231
+ status__in=(RDMExportingDataSubStageStatus.READY_FOR_EXPORT.key,
232
+ RDMExportingDataSubStageStatus.IN_EXPORT.key)
232
233
  )
233
234
  .annotate(
234
235
  ready_count=Func(F('id'), function='Count', output_field=IntegerField()),
@@ -1,4 +1,5 @@
1
- var win = Ext.getCmp('{{ component.client_id }}');
1
+ {% include "ui-js/collect-and-export-validators.js" %}
2
+
2
3
  var periodStartField = Ext.getCmp('period_started_at');
3
4
  var periodEndField = Ext.getCmp('period_ended_at');
4
5
 
@@ -8,34 +9,17 @@ Ext.onReady(function() {
8
9
  });
9
10
 
10
11
  function initializeValidators() {
11
- // Устанавливаем текущую дату и время как максимальное значение
12
- // и выполняем валидацию каждую секунду
13
- var validationInterval = setInterval(function() {
14
- var now = new Date();
15
-
16
- periodStartField.setMaxValue(now);
17
- periodEndField.setMaxValue(now);
18
-
19
- periodStartField.validate();
20
- periodEndField.validate();
21
- }, 1000);
22
-
23
- win.on('destroy', function() {
24
- clearInterval(validationInterval);
25
- });
26
-
27
- var periodsValidator = function () {
28
- if (
29
- periodStartField.getValue() &&
30
- periodEndField.getValue() &&
31
- periodStartField.getValue() > periodEndField.getValue()
32
- ) {
33
- return 'Дата конца периода не может быть меньше даты начала периода'
34
- };
35
-
36
- return true;
37
- };
38
-
39
- periodStartField.validator = periodsValidator;
40
- periodEndField.validator = periodsValidator;
12
+ // Установка максимальной даты (завтра 23:59:59)
13
+ const maxDate = new Date();
14
+ maxDate.setDate(maxDate.getDate() + 1);
15
+ maxDate.setHours(23, 59, 59);
16
+
17
+ periodStartField.setMaxValue(maxDate);
18
+ periodEndField.setMaxValue(maxDate);
19
+
20
+ // Устанавливаем время по умолчанию в календаре и доп валидатор
21
+ setupPeriodFields(
22
+ periodStartField,
23
+ periodEndField,
24
+ );
41
25
  }
@@ -1,8 +1,16 @@
1
+ import asyncio
1
2
  from abc import (
2
3
  ABCMeta,
3
4
  abstractmethod,
4
5
  )
6
+ from pathlib import (
7
+ Path,
8
+ )
9
+ from typing import (
10
+ TYPE_CHECKING,
11
+ )
5
12
 
13
+ import asyncpg
6
14
  from django.conf import (
7
15
  settings,
8
16
  )
@@ -15,6 +23,12 @@ from educommon import (
15
23
  )
16
24
 
17
25
 
26
+ if TYPE_CHECKING:
27
+ from asyncpg import (
28
+ Pool,
29
+ )
30
+
31
+
18
32
  class BaseServiceOutdatedDataCleaner(metaclass=ABCMeta):
19
33
  """Базовый класс уборщика устаревших сервисных данных."""
20
34
 
@@ -112,8 +126,15 @@ class BaseServiceOutdatedDataCleaner(metaclass=ABCMeta):
112
126
  """Возвращает имя таблицы в базе данных."""
113
127
  if cls.model is None:
114
128
  raise NotImplementedError('Необходимо задать атрибут "model"')
129
+
115
130
  return cls.model._meta.db_table
116
131
 
132
+ async def file_deletion_process(self, file_paths: list[str]):
133
+ """Функция для удаления файлов, связанных с удалёнными устаревшими записями.
134
+
135
+ Очистка данных производится в таблицах системных моделей РВД.
136
+ """
137
+
117
138
  def get_orphan_reference_condition(
118
139
  self,
119
140
  reference_table: str,
@@ -148,67 +169,137 @@ class BaseServiceOutdatedDataCleaner(metaclass=ABCMeta):
148
169
  )
149
170
  """
150
171
 
151
- def get_chunk_bounds(self):
172
+ def get_chunk_bounded(self):
152
173
  """Возвращает границы чанков для текущей таблицы."""
153
- sql = self.SELECT_RDM_CHUNK_BOUNDED_SQL.format(
174
+ get_chunk_bounded_sql = self.SELECT_RDM_CHUNK_BOUNDED_SQL.format(
154
175
  table_name=self.get_table_name(),
155
- chunk_size=settings.CLEANUP_MODELS_OUTDATED_DATA_CHUNK_SIZE,
156
- )
157
- with connection.cursor() as cursor:
158
- cursor.execute(sql)
159
- return cursor.fetchall()
160
-
161
- def _log_query(self, sql: str):
162
- """Логирует SQL-запрос."""
163
- try:
164
- import sqlparse
165
- sql = sqlparse.format(sql, reindent=True, strip_comments=True)
166
- except ImportError:
167
- pass
168
-
169
- logger.info(
170
- f'Запрос для удаления устаревших данных модели {self.get_table_name()}:\n{sql}\n'
176
+ chunk_size=settings.RDM_CLEANUP_MODELS_OUTDATED_DATA_CHUNK_SIZE,
171
177
  )
172
178
 
173
- def _execute_delete_sql(self, delete_sql: str) -> int:
174
- """Выполняет SQL-запрос на удаление (или только логирует в safe-режиме)."""
175
- deleted = 0
176
179
  if self._log_sql:
177
- self._log_query(delete_sql)
180
+ # Проверка на доступность sqlparse для форматирования
181
+ try:
182
+ import sqlparse
183
+ except ImportError:
184
+ sqlparse = None
178
185
 
179
- if self._safe:
186
+ if sqlparse:
187
+ # Форматирование кода
188
+ get_chunk_bounded_sql = sqlparse.format(
189
+ sql=get_chunk_bounded_sql,
190
+ reindent=True,
191
+ strip_comments=True,
192
+ )
180
193
  logger.info(
181
- f'Безопасный режим включен запрос удаления для {self.get_table_name()} не выполнен.'
194
+ f'Запрос для получения границ чанков модели {self.get_table_name()}: \n{get_chunk_bounded_sql}\n'
182
195
  )
183
- return 0
184
- else:
185
- with connection.cursor() as cursor:
186
- cursor.execute(delete_sql)
187
- result = cursor.fetchone()
188
- deleted = result[0] if result else 0
189
196
 
190
- return deleted
197
+ with connection.cursor() as cursor:
198
+ cursor.execute(get_chunk_bounded_sql)
199
+ result = cursor.fetchall()
191
200
 
192
- def run(self):
193
- """Запуск очистки данных."""
201
+ return result
202
+
203
+ async def execute_query(self, pool: 'Pool', query: str):
204
+ """Асинхронное выполнение запроса."""
205
+ async with pool.acquire() as conn:
206
+ try:
207
+ if self._safe:
208
+ logger.info(f'Запрос не будет выполнен, включен безопасный режим!\n')
209
+
210
+ if self._log_sql:
211
+ logger.info(f'{query}\n')
212
+ else:
213
+ result = await conn.fetch(query)
214
+ if not result:
215
+ return
216
+
217
+ if self._log_sql:
218
+ logger.info(f'При помощи запроса:\n{query}\n')
219
+
220
+ # Проверяем, что вернул запрос
221
+ if 'deleted_count' in result[0]:
222
+ deleted_count = result[0]['deleted_count']
223
+ self._deleted_count += deleted_count
224
+ logger.info(f'Было удалено записей: {deleted_count}')
225
+ else:
226
+ file_paths = [record['file_path'] for record in result if record.get('file_path')]
227
+ if file_paths:
228
+ await self.file_deletion_process(file_paths)
229
+ deleted_count = len(result)
230
+ self._deleted_count += deleted_count
231
+ logger.info(f'Было удалено записей с файлами: {deleted_count}')
232
+
233
+ except Exception as e:
234
+ logger.error(f'Ошибка при выполнении {query}\n{e}')
235
+
236
+ def prepare_queries(self, chunk_bounded: list[tuple[int, int, int]]) -> list[str]:
237
+ """Формирование списка запросов для удаления устаревших данных."""
238
+ queries = []
194
239
  conditions = self.get_merged_conditions()
195
240
 
196
- # Разделяем по чанкам
197
- chunk_bounded = self.get_chunk_bounds()
198
241
  for chunk_number, first_id, last_id in chunk_bounded:
199
- while True:
200
- delete_sql = self.REMOVE_OUTDATED_DATA_SQL.format(
201
- table_name=self.get_table_name(),
202
- first_id=first_id,
203
- last_id=last_id,
204
- conditions=conditions,
205
- )
206
- deleted = self._execute_delete_sql(delete_sql)
207
- self._deleted_count += deleted
242
+ remove_outdated_data_sql = self.REMOVE_OUTDATED_DATA_SQL.format(
243
+ table_name=self.get_table_name(),
244
+ first_id=first_id,
245
+ last_id=last_id,
246
+ conditions=conditions,
247
+ )
248
+
249
+ queries.append(remove_outdated_data_sql)
250
+
251
+ return queries
208
252
 
209
- if deleted < self.chunk_size:
210
- break
253
+ async def execute_queries(self, queries: list[str]) -> None:
254
+ """Асинхронное выполнение запросов."""
255
+ DB_SETTINGS = settings.DATABASES['default']
211
256
 
212
- logger.info(
213
- f'Удалено устаревших записей сервисной модели {self.model.__name__}: {self._deleted_count}'
257
+ pool = await asyncpg.create_pool(
258
+ max_size=settings.RDM_CLEANUP_MODELS_OUTDATED_DATA_POOL_SIZE,
259
+ min_size=settings.RDM_CLEANUP_MODELS_OUTDATED_DATA_POOL_SIZE,
260
+ host=DB_SETTINGS['HOST'],
261
+ port=DB_SETTINGS['PORT'],
262
+ user=DB_SETTINGS['USER'],
263
+ password=DB_SETTINGS['PASSWORD'],
264
+ database=DB_SETTINGS['NAME'],
214
265
  )
266
+
267
+ tasks = [self.execute_query(pool, query) for query in queries]
268
+
269
+ await asyncio.gather(*tasks)
270
+
271
+ def run(self):
272
+ """Запуск очистки устаревших данных."""
273
+ chunk_bounded = self.get_chunk_bounded()
274
+
275
+ queries = self.prepare_queries(chunk_bounded=chunk_bounded)
276
+
277
+ if queries:
278
+ even_loop = asyncio.new_event_loop()
279
+ try:
280
+ even_loop.run_until_complete(self.execute_queries(queries=queries))
281
+ finally:
282
+ even_loop.close()
283
+
284
+ logger.info(f'Удалено записей модели {self.model.__name__}: {self._deleted_count}')
285
+
286
+
287
+ class ServiceFileCleaner:
288
+ """Асинхронный сервис для безопасного удаления файлов из MEDIA_ROOT."""
289
+
290
+ @staticmethod
291
+ async def file_deletion_process(file_paths: list[str]) -> None:
292
+ """Удаляет указанные файлы, считая пути относительными к MEDIA_ROOT."""
293
+ media_root = Path(settings.MEDIA_ROOT).resolve()
294
+
295
+ async def delete_file(path_str: str):
296
+ path = (media_root / path_str).resolve()
297
+ try:
298
+ exists = await asyncio.to_thread(path.exists)
299
+ if exists and await asyncio.to_thread(path.is_file):
300
+ await asyncio.to_thread(path.unlink)
301
+
302
+ except Exception as e:
303
+ logger.warning(f"Не удалось удалить {path}: {e}")
304
+
305
+ await asyncio.gather(*(delete_file(path) for path in file_paths))