edu-async-tasks 1.0.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 (58) hide show
  1. edu_async_tasks/__init__.py +35 -0
  2. edu_async_tasks/config.py +57 -0
  3. edu_async_tasks/core/__init__.py +1 -0
  4. edu_async_tasks/core/adapters/__init__.py +0 -0
  5. edu_async_tasks/core/adapters/db.py +35 -0
  6. edu_async_tasks/core/apps.py +9 -0
  7. edu_async_tasks/core/domain/__init__.py +7 -0
  8. edu_async_tasks/core/domain/model.py +163 -0
  9. edu_async_tasks/core/migrations/0001_initial.py +65 -0
  10. edu_async_tasks/core/migrations/0002_task_type_and_status_data.py +115 -0
  11. edu_async_tasks/core/migrations/0003_rename_async_task__profile_c97023_idx_edu_async_t_profile_70c100_idx_and_more.py +26 -0
  12. edu_async_tasks/core/migrations/__init__.py +0 -0
  13. edu_async_tasks/core/models.py +232 -0
  14. edu_async_tasks/core/services.py +94 -0
  15. edu_async_tasks/core/tasks.py +362 -0
  16. edu_async_tasks/rest/__init__.py +0 -0
  17. edu_async_tasks/rest/apps.py +9 -0
  18. edu_async_tasks/rest/async_task_statuses/__init__.py +0 -0
  19. edu_async_tasks/rest/async_task_statuses/serializers.py +14 -0
  20. edu_async_tasks/rest/async_task_statuses/urls.py +20 -0
  21. edu_async_tasks/rest/async_task_statuses/views.py +20 -0
  22. edu_async_tasks/rest/async_task_types/__init__.py +0 -0
  23. edu_async_tasks/rest/async_task_types/serializers.py +14 -0
  24. edu_async_tasks/rest/async_task_types/urls.py +20 -0
  25. edu_async_tasks/rest/async_task_types/views.py +20 -0
  26. edu_async_tasks/rest/running_tasks/__init__.py +0 -0
  27. edu_async_tasks/rest/running_tasks/filters.py +30 -0
  28. edu_async_tasks/rest/running_tasks/serializers.py +22 -0
  29. edu_async_tasks/rest/running_tasks/tests.py +0 -0
  30. edu_async_tasks/rest/running_tasks/urls.py +12 -0
  31. edu_async_tasks/rest/running_tasks/views.py +82 -0
  32. edu_async_tasks/rest/urls.py +23 -0
  33. edu_async_tasks/rest/utils/__init__.py +0 -0
  34. edu_async_tasks/rest/utils/bootstrap.py +21 -0
  35. edu_async_tasks/rest/utils/pagination.py +10 -0
  36. edu_async_tasks-1.0.0.dist-info/METADATA +38 -0
  37. edu_async_tasks-1.0.0.dist-info/RECORD +58 -0
  38. edu_async_tasks-1.0.0.dist-info/WHEEL +5 -0
  39. edu_async_tasks-1.0.0.dist-info/top_level.txt +2 -0
  40. testapp/__init__.py +0 -0
  41. testapp/app/__init__.py +0 -0
  42. testapp/app/apps.py +13 -0
  43. testapp/app/tasks.py +50 -0
  44. testapp/app/tests/__init__.py +0 -0
  45. testapp/app/tests/test_config.py +72 -0
  46. testapp/app/tests/test_tasks.py +44 -0
  47. testapp/entrypoints/__init__.py +0 -0
  48. testapp/entrypoints/asgi.py +10 -0
  49. testapp/entrypoints/celery.py +16 -0
  50. testapp/entrypoints/wsgi.py +10 -0
  51. testapp/manage.py +24 -0
  52. testapp/persons/__init__.py +0 -0
  53. testapp/persons/migrations/0001_initial.py +30 -0
  54. testapp/persons/migrations/0002_auto_20240927_0651.py +21 -0
  55. testapp/persons/migrations/__init__.py +0 -0
  56. testapp/persons/models.py +14 -0
  57. testapp/settings.py +188 -0
  58. testapp/urls.py +23 -0
@@ -0,0 +1,35 @@
1
+ """Реестр "Асинхронные задачи"."""
2
+
3
+ from typing import (
4
+ Union,
5
+ )
6
+
7
+ from .config import (
8
+ Config,
9
+ )
10
+
11
+
12
+ __config: Union[Config, None] = None
13
+
14
+
15
+ def set_config(config: Config) -> None:
16
+ assert isinstance(config, Config)
17
+
18
+ global __config
19
+
20
+ __config = config
21
+
22
+
23
+ def get_config() -> Config:
24
+ global __config
25
+
26
+ if __config is None:
27
+ raise ValueError('Конфигурация не установлена')
28
+
29
+ return __config
30
+
31
+
32
+ def reset_config() -> None:
33
+ global __config
34
+
35
+ __config = None
@@ -0,0 +1,57 @@
1
+ from pydantic.class_validators import (
2
+ validator,
3
+ )
4
+ from pydantic.dataclasses import (
5
+ dataclass,
6
+ )
7
+ from pydantic.fields import (
8
+ Field,
9
+ )
10
+
11
+
12
+ @dataclass
13
+ class Config:
14
+ task_start_time_format: str = Field(
15
+ title='Формат времени',
16
+ default='%H:%M %d.%m.%Y'
17
+ )
18
+ task_default_user_name: str = Field(
19
+ title='Имя пользователя по-умолчанию',
20
+ default='Система'
21
+ )
22
+ default_lock_expire: int = Field( # noqa
23
+ title='Задача считается уникальной в течение',
24
+ default=30 * 60
25
+ )
26
+ filter_days_after_start: int = Field( # noqa
27
+ title='Фильтр реестра - количество дней, в течение которых были запущены задачи',
28
+ default=7
29
+ )
30
+
31
+ @validator('task_start_time_format')
32
+ @classmethod
33
+ def validate_task_start_time_format(cls, value):
34
+ if not isinstance(value, str) or not value:
35
+ raise ValueError(f'Неверное значение поля "{cls.task_start_time_format.title}"')
36
+ return value
37
+
38
+ @validator('task_default_user_name')
39
+ @classmethod
40
+ def validate_task_default_user_name(cls, value):
41
+ if not isinstance(value, str) or not value:
42
+ raise ValueError(f'Неверное значение поля "{cls.task_default_user_name.title}"')
43
+ return value
44
+
45
+ @validator('default_lock_expire')
46
+ @classmethod
47
+ def validate_default_lock_expire(cls, value):
48
+ if not isinstance(value, int) or value <= 0:
49
+ raise ValueError(f'Неверное значение поля "{cls.default_lock_expire.title}"')
50
+ return value
51
+
52
+ @validator('filter_days_after_start')
53
+ @classmethod
54
+ def validate_filter_days_after_start(cls, value):
55
+ if not isinstance(value, int) or value <= 0:
56
+ raise ValueError(f'Неверное значение поля "{cls.filter_days_after_start.title}"')
57
+ return value
@@ -0,0 +1 @@
1
+ default_app_config = __name__ + '.apps.AppConfig'
File without changes
@@ -0,0 +1,35 @@
1
+ from typing import (
2
+ TYPE_CHECKING,
3
+ )
4
+
5
+ from django.core.exceptions import (
6
+ ValidationError,
7
+ )
8
+
9
+ from ..models import (
10
+ RunningTask,
11
+ )
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from django.db.models.query import (
16
+ QuerySet,
17
+ )
18
+
19
+
20
+ class RunningTasksRepository:
21
+
22
+ def get_by_id(self, id_: str) -> RunningTask:
23
+ return RunningTask.objects.get(pk=id_)
24
+
25
+ def get_by_ids(self, *ids: str) -> 'QuerySet[RunningTask]':
26
+ """Возвращает кверисет асинхронных задач."""
27
+ try:
28
+ result = RunningTask.objects.filter(id__in=ids)
29
+ except ValidationError:
30
+ result = RunningTask.objects.none()
31
+
32
+ return result
33
+
34
+
35
+ running_tasks_repository = RunningTasksRepository()
@@ -0,0 +1,9 @@
1
+ from django.apps import (
2
+ AppConfig as AppConfigBase,
3
+ )
4
+
5
+
6
+ class AppConfig(AppConfigBase):
7
+
8
+ name = __package__
9
+ label = 'edu_async_tasks_core'
@@ -0,0 +1,7 @@
1
+ from .model import (
2
+ PeriodicTaskLocker,
3
+ TaskLocker,
4
+ TaskResult,
5
+ TaskUniqueException,
6
+ uuid_or_none,
7
+ )
@@ -0,0 +1,163 @@
1
+ from dataclasses import (
2
+ dataclass,
3
+ )
4
+ from logging import (
5
+ getLogger,
6
+ )
7
+ from typing import (
8
+ Optional,
9
+ Union,
10
+ )
11
+ from uuid import (
12
+ UUID,
13
+ )
14
+
15
+ from celery.exceptions import (
16
+ Ignore,
17
+ )
18
+ from celery.result import (
19
+ AsyncResult,
20
+ )
21
+ from django.core.cache import (
22
+ cache,
23
+ )
24
+ from django.utils.functional import (
25
+ cached_property,
26
+ )
27
+
28
+ import edu_async_tasks
29
+
30
+
31
+ @dataclass
32
+ class TaskResult:
33
+ """Результат асинхронной задачи."""
34
+
35
+ values: dict
36
+ progress: str = ''
37
+ error: str = ''
38
+ state: str = ''
39
+
40
+
41
+ def uuid_or_none(value) -> Union[UUID, None]:
42
+ """Преобразует строку в UUID или возвращает None."""
43
+ if value is None or isinstance(value, UUID):
44
+ return value
45
+
46
+ try:
47
+ return UUID(value)
48
+
49
+ except (AttributeError, ValueError):
50
+ return None
51
+
52
+
53
+ class EduAsyncTasksException(Exception):
54
+ """Базовое исключение."""
55
+
56
+
57
+ class TasksNotCancellable(EduAsyncTasksException):
58
+ def __init__(self, msg='Необходимо выбрать только выполняющиеся задачи', *args, **kwargs):
59
+ super().__init__(msg, *args, **kwargs)
60
+
61
+
62
+ class TaskUniqueException(EduAsyncTasksException):
63
+ def __init__(self, msg='Задача в очереди или выполняется', *args, **kwargs):
64
+ super().__init__(msg, *args, **kwargs)
65
+
66
+
67
+ class TaskLocker:
68
+ """Класс, отвечающий за блокировку задач."""
69
+
70
+ # формат ключа в кэше
71
+ LOCK_ID_FORMAT = 'async_task:lock:{task_name}_{params}'
72
+
73
+ DEFAULT_LOCK_MSG = 'Задача уже выполняется или находится в очереди'
74
+ DEFAULT_LOCK_VALUE = 'value'
75
+
76
+ def __init__(
77
+ self,
78
+ task_name: str = '',
79
+ params: Optional[dict] = None,
80
+ task_id: Optional[str] = None,
81
+ expire_on: Optional[int] = None,
82
+ ):
83
+ self.task_name = task_name
84
+ self.task_id = task_id
85
+ self.params = params if params is not None else {}
86
+ self.expire_on = expire_on if expire_on is not None else edu_async_tasks.get_config().default_lock_expire
87
+
88
+ @cached_property
89
+ def lock_id(self) -> str:
90
+ """Ключ в кэше."""
91
+ return self.LOCK_ID_FORMAT.format(
92
+ task_name=self.task_name,
93
+ params='&'.join(f'{k}={v}' for k, v in self.params.items())
94
+ )
95
+
96
+ def acquire_lock(self) -> str:
97
+ """Установка блокировки."""
98
+ value = self.task_id or self.DEFAULT_LOCK_VALUE
99
+ cache.set(self.lock_id, value, self.expire_on)
100
+ getLogger(__name__).debug(
101
+ f'Lock acquired for Task {self.task_name} ({self.params}) with value: {value}'
102
+ )
103
+
104
+ return self.lock_id
105
+
106
+ def delete_lock(self) -> None:
107
+ """Удаление блокировки."""
108
+ self.delete_lock_by_id(self.lock_id)
109
+
110
+ @staticmethod
111
+ def delete_lock_by_id(lock_id: str) -> bool:
112
+ """Удаление блокировки по ключу.
113
+
114
+ :param lock_id: ключ
115
+ """
116
+ return cache.delete(lock_id)
117
+
118
+ def is_locked(self) -> bool:
119
+ """Установлена ли блокировка."""
120
+ is_locked = False
121
+
122
+ value = cache.get(self.lock_id)
123
+
124
+ if value:
125
+ is_locked = True
126
+
127
+ # Возможна ситуация, когда задача по которой была выставлена блокировка
128
+ # завершила свою работу, при этом блокировка не была снята. Поэтому проверяем
129
+ # статус задачи по которой выставлялась блокировка:
130
+ if value != self.DEFAULT_LOCK_VALUE and uuid_or_none(value):
131
+ # значит в value должен быть task_id предыдущей задачи и по нему пытаемся определить её статус
132
+ async_result = AsyncResult(value)
133
+ if async_result and async_result.ready():
134
+ # задача есть, но она уже завершилась - снимаем блокировку
135
+ self.delete_lock()
136
+ is_locked = False
137
+
138
+ return is_locked
139
+
140
+ def raise_if_locked(self, message: Optional[str] = None):
141
+ """Если блокировано, то вызывает исключение.
142
+
143
+ :raises: edu_async_tasks.exceptions.TaskUniqueException
144
+ """
145
+ if self.is_locked():
146
+ getLogger(__name__).debug(f'Add failed. Task {self.task_name} currently locked ({self.params})')
147
+
148
+ raise TaskUniqueException(message or self.DEFAULT_LOCK_MSG)
149
+
150
+
151
+ class PeriodicTaskLocker(TaskLocker):
152
+ """Класс отвечающий за блокировку задач.
153
+
154
+ Переопределён для возможности игнорирования повторного запуска для уже
155
+ запущенных периодических задач.
156
+ """
157
+
158
+ def raise_if_locked(self, message: Optional[str] = None):
159
+ """Если блокировано, то вызывает исключение."""
160
+ if self.is_locked():
161
+ getLogger(__name__).debug(f'Add failed. Task {self.task_name} currently locked ({self.params})')
162
+
163
+ raise Ignore()
@@ -0,0 +1,65 @@
1
+ import django.contrib.postgres.fields.jsonb
2
+ import django.db.models.deletion
3
+ from django.db import (
4
+ migrations,
5
+ models,
6
+ )
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ initial = True
12
+
13
+ dependencies = [
14
+ ('contenttypes', '0002_remove_content_type_name'),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name='AsyncTaskStatus',
20
+ fields=[
21
+ ('title', models.TextField(verbose_name='расшифровка значения')),
22
+ ('key', models.CharField(db_index=True, max_length=512, primary_key=True, serialize=False, verbose_name='ключ')),
23
+ ('order_number', models.PositiveIntegerField(default=100000, verbose_name='Порядковый номер')),
24
+ ],
25
+ options={
26
+ 'abstract': False,
27
+ },
28
+ ),
29
+ migrations.CreateModel(
30
+ name='AsyncTaskType',
31
+ fields=[
32
+ ('title', models.TextField(verbose_name='расшифровка значения')),
33
+ ('key', models.CharField(db_index=True, max_length=512, primary_key=True, serialize=False, verbose_name='ключ')),
34
+ ('order_number', models.PositiveIntegerField(default=100000, verbose_name='Порядковый номер')),
35
+ ],
36
+ options={
37
+ 'verbose_name': 'Тип асинхронных задач',
38
+ 'verbose_name_plural': 'Типы асинхронных задач',
39
+ },
40
+ ),
41
+ migrations.CreateModel(
42
+ name='RunningTask',
43
+ fields=[
44
+ ('id', models.UUIDField(primary_key=True, serialize=False, verbose_name='ID задачи')),
45
+ ('name', models.CharField(blank=True, max_length=512, verbose_name='Наименование задачи')),
46
+ ('profile_id', models.PositiveIntegerField(blank=True, null=True)),
47
+ ('description', models.CharField(blank=True, max_length=512, verbose_name='Описание задачи')),
48
+ ('options', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, verbose_name='Дополнительные опции задачи')),
49
+ ('queued_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Дата и время помещения в очередь')),
50
+ ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата и время запуска задачи')),
51
+ ('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата и время завершения задачи')),
52
+ ('profile_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.ContentType')),
53
+ ('status', models.ForeignKey(default='PENDING', on_delete=django.db.models.deletion.PROTECT, to='edu_async_tasks_core.AsyncTaskStatus', verbose_name='Состояние задачи')),
54
+ ('task_type', models.ForeignKey(default='UNKNOWN', on_delete=django.db.models.deletion.PROTECT, to='edu_async_tasks_core.AsyncTaskType', verbose_name='Тип задачи')),
55
+ ],
56
+ options={
57
+ 'verbose_name': 'Асинхронная задача',
58
+ 'verbose_name_plural': 'Асинхронные задачи',
59
+ },
60
+ ),
61
+ migrations.AddIndex(
62
+ model_name='runningtask',
63
+ index=models.Index(fields=['profile_type', 'profile_id'], name='async_task__profile_c97023_idx'),
64
+ ),
65
+ ]
@@ -0,0 +1,115 @@
1
+ from django.db import (
2
+ migrations,
3
+ )
4
+
5
+
6
+ def init_task_types(apps, schema_editor):
7
+ """Заполнение первоначальными данными AsyncTaskType."""
8
+ AsyncTaskType = apps.get_model('edu_async_tasks_core', 'AsyncTaskType') # noqa: N806
9
+
10
+ AsyncTaskType.objects.bulk_create([
11
+ AsyncTaskType(
12
+ key='UNKNOWN',
13
+ title='Неизвестно',
14
+ ),
15
+ AsyncTaskType(
16
+ key='SYSTEM',
17
+ title='Системная',
18
+ ),
19
+ AsyncTaskType(
20
+ key='REPORT',
21
+ title='Отчет',
22
+ ),
23
+ AsyncTaskType(
24
+ key='IMPORT',
25
+ title='Импорт данных',
26
+ ),
27
+ AsyncTaskType(
28
+ key='SCHEDULE_FORMING',
29
+ title='Формирование расписания',
30
+ ),
31
+ AsyncTaskType(
32
+ key='SCHEDULE_CLEANER',
33
+ title='Очищение расписания',
34
+ ),
35
+ AsyncTaskType(
36
+ key='SCHEDULE_PATTERN_COPY',
37
+ title='Копирование шаблона расписания',
38
+ ),
39
+ AsyncTaskType(
40
+ key='ST_PRINTING',
41
+ title='Печать учебных планов',
42
+ ),
43
+ AsyncTaskType(
44
+ key='ASYNC_REQUEST',
45
+ title='Отправка асинхронных запросов',
46
+ ),
47
+ AsyncTaskType(
48
+ key='MASS_EXPULSION',
49
+ title='Массовый выпуск',
50
+ ),
51
+ AsyncTaskType(
52
+ key='AIO_CLIENT',
53
+ title='AIO клиент',
54
+ ),
55
+ ], ignore_conflicts=True)
56
+
57
+
58
+ def init_task_statuses(apps, schema_editor):
59
+ """Заполнение первоначальными данными AsyncTaskStatus."""
60
+ AsyncTaskStatus = apps.get_model('edu_async_tasks_core', 'AsyncTaskStatus') # noqa: N806
61
+
62
+ AsyncTaskStatus.objects.bulk_create([
63
+ AsyncTaskStatus(
64
+ key='PENDING',
65
+ title='В ожидании',
66
+ ),
67
+ AsyncTaskStatus(
68
+ key='RECEIVED',
69
+ title='В очереди',
70
+ ),
71
+ AsyncTaskStatus(
72
+ key='STARTED',
73
+ title='Выполняется',
74
+ ),
75
+ AsyncTaskStatus(
76
+ key='SUCCESS',
77
+ title='Успешно выполнена',
78
+ ),
79
+ AsyncTaskStatus(
80
+ key='REVOKED',
81
+ title='Остановлена',
82
+ ),
83
+ AsyncTaskStatus(
84
+ key='FAILURE',
85
+ title='Ошибка',
86
+ ),
87
+ AsyncTaskStatus(
88
+ key='RETRY',
89
+ title='Перезапуск',
90
+ ),
91
+ AsyncTaskStatus(
92
+ key='IGNORED',
93
+ title='Игнорирована',
94
+ ),
95
+ AsyncTaskStatus(
96
+ key='REJECTED',
97
+ title='Отменена',
98
+ ),
99
+ AsyncTaskStatus(
100
+ key='UNKNOWN',
101
+ title='Неизвестно',
102
+ ),
103
+ ], ignore_conflicts=True)
104
+
105
+
106
+ class Migration(migrations.Migration):
107
+
108
+ dependencies = [
109
+ ('edu_async_tasks_core', '0001_initial'),
110
+ ]
111
+
112
+ operations = [
113
+ migrations.RunPython(init_task_types, reverse_code=migrations.RunPython.noop),
114
+ migrations.RunPython(init_task_statuses, reverse_code=migrations.RunPython.noop),
115
+ ]
@@ -0,0 +1,26 @@
1
+ # Generated by Django 5.0.9 on 2024-09-25 10:08
2
+
3
+ from django.db import (
4
+ migrations,
5
+ models,
6
+ )
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ dependencies = [
12
+ ('edu_async_tasks_core', '0002_task_type_and_status_data'),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.RenameIndex(
17
+ model_name='runningtask',
18
+ new_name='edu_async_t_profile_70c100_idx',
19
+ old_name='async_task__profile_c97023_idx',
20
+ ),
21
+ migrations.AlterField(
22
+ model_name='runningtask',
23
+ name='options',
24
+ field=models.JSONField(blank=True, null=True, verbose_name='Дополнительные опции задачи'),
25
+ ),
26
+ ]
File without changes