django-simpletask5 0.1.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.
- django_simpletask5/__init__.py +7 -0
- django_simpletask5/admin.py +205 -0
- django_simpletask5/apps.py +8 -0
- django_simpletask5/core/__init__.py +0 -0
- django_simpletask5/core/cronjob_registry.py +96 -0
- django_simpletask5/core/defaults.py +24 -0
- django_simpletask5/core/executor_scanner.py +82 -0
- django_simpletask5/core/lock.py +24 -0
- django_simpletask5/core/message_queue.py +102 -0
- django_simpletask5/core/publisher.py +93 -0
- django_simpletask5/core/signals.py +120 -0
- django_simpletask5/core/worker_registry.py +134 -0
- django_simpletask5/dashboards.py +274 -0
- django_simpletask5/executors/__init__.py +0 -0
- django_simpletask5/executors/archive.py +24 -0
- django_simpletask5/executors/base.py +10 -0
- django_simpletask5/executors/bash_script.py +61 -0
- django_simpletask5/executors/loader.py +17 -0
- django_simpletask5/executors/ping_pong.py +12 -0
- django_simpletask5/executors/python_script.py +42 -0
- django_simpletask5/executors/retry_timeout.py +32 -0
- django_simpletask5/executors/simple_request.py +47 -0
- django_simpletask5/executors/status_check.py +40 -0
- django_simpletask5/management/__init__.py +0 -0
- django_simpletask5/management/commands/__init__.py +0 -0
- django_simpletask5/management/commands/django_simpletask_crontab.py +169 -0
- django_simpletask5/management/commands/django_simpletask_executor.py +286 -0
- django_simpletask5/management/commands/django_simpletask_sync_cronjobs.py +14 -0
- django_simpletask5/migrations/0001_initial.py +283 -0
- django_simpletask5/migrations/__init__.py +0 -0
- django_simpletask5/models.py +274 -0
- django_simpletask5/services/__init__.py +0 -0
- django_simpletask5/services/archive.py +198 -0
- django_simpletask5-0.1.0.dist-info/METADATA +251 -0
- django_simpletask5-0.1.0.dist-info/RECORD +38 -0
- django_simpletask5-0.1.0.dist-info/WHEEL +5 -0
- django_simpletask5-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_simpletask5-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from django import forms
|
|
4
|
+
from django.contrib import admin
|
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from django_simpletask5.models import TaskExecution, CronJob, TaskExecutionStat, TaskExecutionArchive
|
|
9
|
+
from django_simpletask5.core.executor_scanner import (
|
|
10
|
+
get_executor_choices,
|
|
11
|
+
get_executor_schema_map,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExecutorClassWidget(forms.Select):
|
|
16
|
+
def __init__(self, attrs=None):
|
|
17
|
+
schema_map = get_executor_schema_map()
|
|
18
|
+
choices = [("", "---------")]
|
|
19
|
+
for group_name, items in get_executor_choices():
|
|
20
|
+
group_choices = [(v, v) for v, _ in items]
|
|
21
|
+
choices.append((group_name, group_choices))
|
|
22
|
+
attrs = attrs or {}
|
|
23
|
+
attrs.setdefault("class", "select2-executor")
|
|
24
|
+
attrs.setdefault("style", "width: 600px;")
|
|
25
|
+
attrs["data-schemas"] = json.dumps(schema_map, ensure_ascii=False)
|
|
26
|
+
super().__init__(attrs=attrs, choices=choices)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CronJobForm(forms.ModelForm):
|
|
30
|
+
executor_class = forms.CharField(
|
|
31
|
+
label=_("Executor class"),
|
|
32
|
+
widget=ExecutorClassWidget(),
|
|
33
|
+
)
|
|
34
|
+
context = forms.CharField(
|
|
35
|
+
label=_("Context"),
|
|
36
|
+
required=False,
|
|
37
|
+
widget=forms.Textarea(
|
|
38
|
+
attrs={
|
|
39
|
+
"rows": 8,
|
|
40
|
+
"style": "width: 600px; font-family: monospace;",
|
|
41
|
+
}
|
|
42
|
+
),
|
|
43
|
+
help_text=_("Supports JSON or YAML format. YAML is more concise and compatible with JSON standard format."),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __init__(self, *args, **kwargs):
|
|
47
|
+
super().__init__(*args, **kwargs)
|
|
48
|
+
if self.instance and self.instance.pk and self.instance.context:
|
|
49
|
+
try:
|
|
50
|
+
parsed = json.loads(self.instance.context)
|
|
51
|
+
import yaml
|
|
52
|
+
|
|
53
|
+
self.initial["context"] = yaml.dump(
|
|
54
|
+
parsed,
|
|
55
|
+
default_flow_style=False,
|
|
56
|
+
allow_unicode=True,
|
|
57
|
+
sort_keys=False,
|
|
58
|
+
).strip()
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def clean_context(self):
|
|
63
|
+
raw = self.cleaned_data.get("context")
|
|
64
|
+
if not raw:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
parsed = json.loads(raw)
|
|
68
|
+
return json.dumps(parsed, ensure_ascii=False)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
pass
|
|
71
|
+
try:
|
|
72
|
+
import yaml
|
|
73
|
+
|
|
74
|
+
parsed = yaml.safe_load(raw)
|
|
75
|
+
if parsed is None:
|
|
76
|
+
return None
|
|
77
|
+
return json.dumps(parsed, ensure_ascii=False)
|
|
78
|
+
except Exception:
|
|
79
|
+
raise forms.ValidationError(_("Invalid parameter format. Please use JSON or YAML format."))
|
|
80
|
+
|
|
81
|
+
class Meta:
|
|
82
|
+
model = CronJob
|
|
83
|
+
fields = "__all__"
|
|
84
|
+
|
|
85
|
+
class Media:
|
|
86
|
+
css = {
|
|
87
|
+
"all": ["admin/css/vendor/select2/select2.min.css"],
|
|
88
|
+
}
|
|
89
|
+
js = [
|
|
90
|
+
"admin/js/vendor/jquery/jquery.min.js",
|
|
91
|
+
"admin/js/vendor/select2/select2.full.min.js",
|
|
92
|
+
"django_simpletask5/js/select2_executor.js",
|
|
93
|
+
"admin/js/jquery.init.js",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@admin.register(TaskExecution)
|
|
98
|
+
class TaskExecutionAdmin(admin.ModelAdmin):
|
|
99
|
+
list_display = [
|
|
100
|
+
"execution_id",
|
|
101
|
+
"task_id",
|
|
102
|
+
"trigger_event",
|
|
103
|
+
"executor_class",
|
|
104
|
+
"status",
|
|
105
|
+
"retry_count",
|
|
106
|
+
"max_retries",
|
|
107
|
+
"created_at",
|
|
108
|
+
]
|
|
109
|
+
list_filter = ["status", "trigger_event", "created_at"]
|
|
110
|
+
search_fields = ["execution_id", "task_id", "executor_class"]
|
|
111
|
+
readonly_fields = ["execution_id", "task_id", "created_at", "updated_at"]
|
|
112
|
+
ordering = ["-created_at"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@admin.register(CronJob)
|
|
116
|
+
class CronJobAdmin(admin.ModelAdmin):
|
|
117
|
+
form = CronJobForm
|
|
118
|
+
list_display = [
|
|
119
|
+
"name",
|
|
120
|
+
"cron_expression",
|
|
121
|
+
"executor_class",
|
|
122
|
+
"is_active",
|
|
123
|
+
"is_modified_by_user",
|
|
124
|
+
"updated_at",
|
|
125
|
+
]
|
|
126
|
+
list_filter = ["is_active", "is_modified_by_user"]
|
|
127
|
+
search_fields = ["name", "executor_class"]
|
|
128
|
+
actions = [
|
|
129
|
+
"reset_from_code",
|
|
130
|
+
"mark_as_modified",
|
|
131
|
+
"unmark_as_modified",
|
|
132
|
+
"activate_cronjobs",
|
|
133
|
+
"deactivate_cronjobs",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
def save_model(self, request, obj, form, change):
|
|
137
|
+
if change:
|
|
138
|
+
obj.is_modified_by_user = True
|
|
139
|
+
super().save_model(request, obj, form, change)
|
|
140
|
+
|
|
141
|
+
def save_related(self, request, form, formsets, change):
|
|
142
|
+
super().save_related(request, form, formsets, change)
|
|
143
|
+
|
|
144
|
+
def reset_from_code(self, request, queryset):
|
|
145
|
+
from django_simpletask5.core.cronjob_registry import _CRONJOB_REGISTRY
|
|
146
|
+
|
|
147
|
+
updated = 0
|
|
148
|
+
for cronjob in queryset:
|
|
149
|
+
if cronjob.name in _CRONJOB_REGISTRY:
|
|
150
|
+
registered = _CRONJOB_REGISTRY[cronjob.name]
|
|
151
|
+
cronjob.cron_expression = registered["cron_expression"]
|
|
152
|
+
cronjob.executor_class = registered["executor_class"]
|
|
153
|
+
cronjob.context = registered.get("context")
|
|
154
|
+
cronjob.description = registered.get("description", "")
|
|
155
|
+
cronjob.is_modified_by_user = False
|
|
156
|
+
cronjob.save()
|
|
157
|
+
updated += 1
|
|
158
|
+
self.message_user(request, f"{updated} cronjob(s) reset from code definitions.")
|
|
159
|
+
|
|
160
|
+
reset_from_code.short_description = _("Reset from code definitions")
|
|
161
|
+
|
|
162
|
+
def mark_as_modified(self, request, queryset):
|
|
163
|
+
updated = queryset.update(is_modified_by_user=True)
|
|
164
|
+
self.message_user(request, f"{updated} cronjob(s) marked as user-modified.")
|
|
165
|
+
|
|
166
|
+
mark_as_modified.short_description = _("Mark as user-modified")
|
|
167
|
+
|
|
168
|
+
def unmark_as_modified(self, request, queryset):
|
|
169
|
+
updated = queryset.update(is_modified_by_user=False)
|
|
170
|
+
self.message_user(request, f"{updated} cronjob(s) unmarked as user-modified.")
|
|
171
|
+
|
|
172
|
+
unmark_as_modified.short_description = _("Unmark as user-modified")
|
|
173
|
+
|
|
174
|
+
def activate_cronjobs(self, request, queryset):
|
|
175
|
+
updated = queryset.update(is_active=True)
|
|
176
|
+
self.message_user(request, f"{updated} cronjob(s) activated.")
|
|
177
|
+
|
|
178
|
+
activate_cronjobs.short_description = _("Activate selected tasks")
|
|
179
|
+
|
|
180
|
+
def deactivate_cronjobs(self, request, queryset):
|
|
181
|
+
updated = queryset.update(is_active=False)
|
|
182
|
+
self.message_user(request, f"{updated} cronjob(s) deactivated.")
|
|
183
|
+
|
|
184
|
+
deactivate_cronjobs.short_description = _("Deactivate selected tasks")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@admin.register(TaskExecutionStat)
|
|
188
|
+
class TaskExecutionStatAdmin(admin.ModelAdmin):
|
|
189
|
+
list_display = [
|
|
190
|
+
"date",
|
|
191
|
+
"trigger_event",
|
|
192
|
+
"total_count",
|
|
193
|
+
"success_count",
|
|
194
|
+
"failed_count",
|
|
195
|
+
"avg_duration_seconds",
|
|
196
|
+
]
|
|
197
|
+
list_filter = ["date", "trigger_event"]
|
|
198
|
+
ordering = ["-date"]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@admin.register(TaskExecutionArchive)
|
|
202
|
+
class TaskExecutionArchiveAdmin(admin.ModelAdmin):
|
|
203
|
+
list_display = ["archive_date", "created_at"]
|
|
204
|
+
readonly_fields = ["archive_date", "file", "created_at"]
|
|
205
|
+
ordering = ["-archive_date"]
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
_CRONJOB_REGISTRY = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_cronjob(name, cron_expression, executor_class, context=None, description=''):
|
|
14
|
+
_CRONJOB_REGISTRY[name] = {
|
|
15
|
+
'cron_expression': cron_expression,
|
|
16
|
+
'executor_class': executor_class,
|
|
17
|
+
'context': json.dumps(context) if context else None,
|
|
18
|
+
'description': description,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _compute_next_run_time(cron_expression, base=None):
|
|
23
|
+
try:
|
|
24
|
+
from croniter import croniter
|
|
25
|
+
except ImportError:
|
|
26
|
+
return None
|
|
27
|
+
if base is None:
|
|
28
|
+
base = timezone.now()
|
|
29
|
+
try:
|
|
30
|
+
cron = croniter(cron_expression, base)
|
|
31
|
+
return cron.get_next(datetime)
|
|
32
|
+
except (ValueError, KeyError):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sync_cronjobs():
|
|
37
|
+
from django_simpletask5.models import CronJob
|
|
38
|
+
|
|
39
|
+
if not getattr(settings, 'DJANGO_SIMPLETASK_CRONJOB_AUTO_SYNC', True):
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
CronJob.objects.exists()
|
|
44
|
+
except Exception:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
for name, config in _CRONJOB_REGISTRY.items():
|
|
48
|
+
try:
|
|
49
|
+
cronjob = CronJob.objects.get(name=name)
|
|
50
|
+
if not cronjob.is_modified_by_user:
|
|
51
|
+
cronjob.cron_expression = config['cron_expression']
|
|
52
|
+
cronjob.executor_class = config['executor_class']
|
|
53
|
+
cronjob.context = config.get('context')
|
|
54
|
+
cronjob.description = config.get('description', '')
|
|
55
|
+
if cronjob.next_run_time is None:
|
|
56
|
+
cronjob.next_run_time = _compute_next_run_time(config['cron_expression'])
|
|
57
|
+
cronjob.save()
|
|
58
|
+
except CronJob.DoesNotExist:
|
|
59
|
+
CronJob.objects.create(
|
|
60
|
+
name=name,
|
|
61
|
+
cron_expression=config['cron_expression'],
|
|
62
|
+
executor_class=config['executor_class'],
|
|
63
|
+
context=config.get('context'),
|
|
64
|
+
description=config.get('description', ''),
|
|
65
|
+
is_modified_by_user=False,
|
|
66
|
+
next_run_time=_compute_next_run_time(config['cron_expression']),
|
|
67
|
+
)
|
|
68
|
+
except Exception:
|
|
69
|
+
logger.exception('Failed to sync cronjob %s', name)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def register_builtin_cronjobs():
|
|
73
|
+
register_cronjob(
|
|
74
|
+
name='simpletask_status_check',
|
|
75
|
+
cron_expression='*/5 * * * *',
|
|
76
|
+
executor_class='django_simpletask5.executors.status_check.StatusCheckExecutor',
|
|
77
|
+
description='Scan long-running pending/running tasks and mark them as timeout',
|
|
78
|
+
)
|
|
79
|
+
register_cronjob(
|
|
80
|
+
name='simpletask_retry_timeout',
|
|
81
|
+
cron_expression='*/10 * * * *',
|
|
82
|
+
executor_class='django_simpletask5.executors.retry_timeout.RetryTimeoutExecutor',
|
|
83
|
+
description='Retry timeout tasks that still have retry attempts remaining',
|
|
84
|
+
)
|
|
85
|
+
register_cronjob(
|
|
86
|
+
name='simpletask_archive',
|
|
87
|
+
cron_expression='0 2 * * *',
|
|
88
|
+
executor_class='django_simpletask5.executors.archive.ArchiveExecutor',
|
|
89
|
+
description='Archive previous day TaskExecution records',
|
|
90
|
+
)
|
|
91
|
+
register_cronjob(
|
|
92
|
+
name='simpletask_ping_pong',
|
|
93
|
+
cron_expression='* * * * *',
|
|
94
|
+
executor_class='django_simpletask5.executors.ping_pong.PingPongExecutor',
|
|
95
|
+
description='Ping pong health check',
|
|
96
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
|
|
3
|
+
DJANGO_SIMPLETASK_LOCK_TIMEOUT = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_TIMEOUT', 600)
|
|
4
|
+
|
|
5
|
+
DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES', 3)
|
|
6
|
+
DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS', 300)
|
|
7
|
+
|
|
8
|
+
DJANGO_SIMPLETASK_UPDATE_FILTER_POLICY = getattr(settings, 'DJANGO_SIMPLETASK_UPDATE_FILTER_POLICY', 'whitelist')
|
|
9
|
+
|
|
10
|
+
DJANGO_SIMPLETASK_ARCHIVE_PATH = getattr(settings, 'DJANGO_SIMPLETASK_ARCHIVE_PATH', 'django_simpletask5_archives')
|
|
11
|
+
DJANGO_SIMPLETASK_ARCHIVE_SALT = getattr(settings, 'DJANGO_SIMPLETASK_ARCHIVE_SALT', 'django-simpletask5-archive')
|
|
12
|
+
|
|
13
|
+
DJANGO_SIMPLETASK_CRONJOB_AUTO_SYNC = getattr(settings, 'DJANGO_SIMPLETASK_CRONJOB_AUTO_SYNC', True)
|
|
14
|
+
|
|
15
|
+
DJANGO_SIMPLETASK_DEFAULT_QUEUE = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_QUEUE', 'django_simpletask5.queue.default')
|
|
16
|
+
DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = getattr(settings, 'DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE', 'django_simpletask5.queue.high_priority')
|
|
17
|
+
|
|
18
|
+
DJANGO_SIMPLETASK_SCRIPT_WHITELIST = getattr(settings, 'DJANGO_SIMPLETASK_SCRIPT_WHITELIST', [])
|
|
19
|
+
|
|
20
|
+
DJANGO_SIMPLETASK_FIELD_CIPHER_CLASS = getattr(settings, 'DJANGO_SIMPLETASK_FIELD_CIPHER_CLASS', None)
|
|
21
|
+
|
|
22
|
+
DJANGO_SIMPLETASK_RESULT_PASSWORD = getattr(settings, 'DJANGO_SIMPLETASK_RESULT_PASSWORD', None)
|
|
23
|
+
DJANGO_SIMPLETASK_ERROR_MSG_PASSWORD = getattr(settings, 'DJANGO_SIMPLETASK_ERROR_MSG_PASSWORD', None)
|
|
24
|
+
DJANGO_SIMPLETASK_CONTEXT_PASSWORD = getattr(settings, 'DJANGO_SIMPLETASK_CONTEXT_PASSWORD', None)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import pkgutil
|
|
5
|
+
|
|
6
|
+
from django.apps import apps
|
|
7
|
+
|
|
8
|
+
from django_simpletask5.executors.base import BaseExecutor
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _scan_module(module, base_path, app_label, verbose_name, executors):
|
|
14
|
+
for name, cls in inspect.getmembers(module, inspect.isclass):
|
|
15
|
+
if cls is BaseExecutor:
|
|
16
|
+
continue
|
|
17
|
+
if not issubclass(cls, BaseExecutor):
|
|
18
|
+
continue
|
|
19
|
+
path = f'{base_path}.{name}'
|
|
20
|
+
schema = []
|
|
21
|
+
try:
|
|
22
|
+
schema = cls.get_parameter_schema()
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
executors[path] = {
|
|
26
|
+
'name': name,
|
|
27
|
+
'path': path,
|
|
28
|
+
'app_label': app_label,
|
|
29
|
+
'app_name': verbose_name,
|
|
30
|
+
'schema': schema,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _scan_package(package_name, app_label, verbose_name, executors):
|
|
35
|
+
try:
|
|
36
|
+
package = importlib.import_module(package_name)
|
|
37
|
+
except ImportError:
|
|
38
|
+
return
|
|
39
|
+
_scan_module(package, package_name, app_label, verbose_name, executors)
|
|
40
|
+
try:
|
|
41
|
+
for importer, modname, ispkg in pkgutil.iter_modules(package.__path__):
|
|
42
|
+
if modname.startswith('_'):
|
|
43
|
+
continue
|
|
44
|
+
full_name = f'{package_name}.{modname}'
|
|
45
|
+
if ispkg:
|
|
46
|
+
_scan_package(full_name, app_label, verbose_name, executors)
|
|
47
|
+
else:
|
|
48
|
+
try:
|
|
49
|
+
mod = importlib.import_module(full_name)
|
|
50
|
+
_scan_module(mod, full_name, app_label, verbose_name, executors)
|
|
51
|
+
except ImportError:
|
|
52
|
+
continue
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def discover_executors():
|
|
58
|
+
executors = {}
|
|
59
|
+
for app_config in apps.get_app_configs():
|
|
60
|
+
app_label = app_config.label
|
|
61
|
+
verbose_name = app_config.verbose_name
|
|
62
|
+
_scan_package(f'{app_config.name}.executors', app_label, verbose_name, executors)
|
|
63
|
+
return executors
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_executor_choices():
|
|
67
|
+
executors = discover_executors()
|
|
68
|
+
groups = {}
|
|
69
|
+
for path, info in executors.items():
|
|
70
|
+
group = groups.setdefault(info['app_name'], [])
|
|
71
|
+
group.append((path, f"{info['name']}"))
|
|
72
|
+
sorted_groups = sorted(groups.items(), key=lambda x: x[0])
|
|
73
|
+
choices = []
|
|
74
|
+
for group_name, items in sorted_groups:
|
|
75
|
+
sorted_items = sorted(items, key=lambda x: x[1])
|
|
76
|
+
choices.append((group_name, sorted_items))
|
|
77
|
+
return choices
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_executor_schema_map():
|
|
81
|
+
executors = discover_executors()
|
|
82
|
+
return {path: info['schema'] for path, info in executors.items()}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_lock_manager():
|
|
9
|
+
from globallock import GlobalLockManager
|
|
10
|
+
config = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_CONFIG', None)
|
|
11
|
+
if config is None:
|
|
12
|
+
raise RuntimeError(
|
|
13
|
+
'DJANGO_SIMPLETASK_LOCK_CONFIG is not configured. '
|
|
14
|
+
'Set it in Django settings, e.g.:\n'
|
|
15
|
+
'DJANGO_SIMPLETASK_LOCK_CONFIG = {\n'
|
|
16
|
+
' "global_lock_engine_class": "globallock.redis_global_lock.RedisGlobalLock",\n'
|
|
17
|
+
' "global_lock_engine_options": {\n'
|
|
18
|
+
' "host": "127.0.0.1",\n'
|
|
19
|
+
' "port": 6379,\n'
|
|
20
|
+
' "db": 0,\n'
|
|
21
|
+
' },\n'
|
|
22
|
+
'}'
|
|
23
|
+
)
|
|
24
|
+
return GlobalLockManager(config)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import socket
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from kombu import Connection, Exchange, Queue, Producer, Consumer
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_broker_url():
|
|
11
|
+
url = getattr(settings, 'DJANGO_SIMPLETASK_BROKER_URL', None)
|
|
12
|
+
if url:
|
|
13
|
+
return url
|
|
14
|
+
transport = getattr(settings, 'DJANGO_SIMPLETASK_MESSAGE_QUEUE_TYPE', 'memory')
|
|
15
|
+
if transport == 'rabbitmq':
|
|
16
|
+
host = getattr(settings, 'DJANGO_SIMPLETASK_RABBITMQ_HOST', '127.0.0.1')
|
|
17
|
+
port = getattr(settings, 'DJANGO_SIMPLETASK_RABBITMQ_PORT', 5672)
|
|
18
|
+
user = getattr(settings, 'DJANGO_SIMPLETASK_RABBITMQ_USER', 'guest')
|
|
19
|
+
pw = getattr(settings, 'DJANGO_SIMPLETASK_RABBITMQ_PASSWORD', 'guest')
|
|
20
|
+
return f'amqp://{user}:{pw}@{host}:{port}//'
|
|
21
|
+
if transport == 'redis':
|
|
22
|
+
host = getattr(settings, 'DJANGO_SIMPLETASK_REDIS_MQ_HOST', '127.0.0.1')
|
|
23
|
+
port = getattr(settings, 'DJANGO_SIMPLETASK_REDIS_MQ_PORT', 6379)
|
|
24
|
+
db = getattr(settings, 'DJANGO_SIMPLETASK_REDIS_MQ_DB', 0)
|
|
25
|
+
return f'redis://{host}:{port}/{db}'
|
|
26
|
+
return 'memory://'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class KombuQueue:
|
|
30
|
+
"""Kombu-based message queue with publish/consume support."""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self._exchange = Exchange('django_simpletask5', type='direct', durable=True)
|
|
34
|
+
self._stopped = False
|
|
35
|
+
|
|
36
|
+
def _declare(self, conn, routing_key):
|
|
37
|
+
ch = conn.channel()
|
|
38
|
+
self._exchange.declare(channel=ch)
|
|
39
|
+
queue = Queue(routing_key, self._exchange, routing_key=routing_key, durable=True)
|
|
40
|
+
queue.declare(channel=ch)
|
|
41
|
+
ch.close()
|
|
42
|
+
|
|
43
|
+
def publish(self, routing_key, message):
|
|
44
|
+
with Connection(_get_broker_url()) as conn:
|
|
45
|
+
conn.connect()
|
|
46
|
+
self._declare(conn, routing_key)
|
|
47
|
+
with Producer(conn) as producer:
|
|
48
|
+
producer.publish(
|
|
49
|
+
message,
|
|
50
|
+
exchange=self._exchange,
|
|
51
|
+
routing_key=routing_key,
|
|
52
|
+
serializer='json',
|
|
53
|
+
retry=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def consume(self, queue_name, callback, auto_ack=True):
|
|
57
|
+
retry_delay = 1
|
|
58
|
+
while not self._stopped:
|
|
59
|
+
try:
|
|
60
|
+
with Connection(_get_broker_url()) as conn:
|
|
61
|
+
conn.connect()
|
|
62
|
+
queue = Queue(queue_name, self._exchange, routing_key=queue_name, durable=True)
|
|
63
|
+
|
|
64
|
+
with Consumer(
|
|
65
|
+
conn,
|
|
66
|
+
queues=[queue],
|
|
67
|
+
callbacks=[lambda body, msg: self._on_message(body, msg, callback, auto_ack)],
|
|
68
|
+
accept=['json'],
|
|
69
|
+
):
|
|
70
|
+
retry_delay = 1
|
|
71
|
+
while not self._stopped:
|
|
72
|
+
try:
|
|
73
|
+
conn.drain_events(timeout=0.5)
|
|
74
|
+
except socket.timeout:
|
|
75
|
+
continue
|
|
76
|
+
except Exception as e:
|
|
77
|
+
if not self._stopped:
|
|
78
|
+
logger.debug('Consumer connection lost: %s, reconnecting in %ss...', e, retry_delay)
|
|
79
|
+
import time as _time
|
|
80
|
+
_time.sleep(retry_delay)
|
|
81
|
+
retry_delay = min(retry_delay * 2, 10)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _on_message(body, message, callback, auto_ack):
|
|
85
|
+
try:
|
|
86
|
+
result = callback(body)
|
|
87
|
+
if result is False:
|
|
88
|
+
message.reject(requeue=True)
|
|
89
|
+
return
|
|
90
|
+
except Exception:
|
|
91
|
+
logger.exception('Error in message callback')
|
|
92
|
+
message.reject(requeue=True)
|
|
93
|
+
return
|
|
94
|
+
if auto_ack:
|
|
95
|
+
message.ack()
|
|
96
|
+
|
|
97
|
+
def stop(self):
|
|
98
|
+
self._stopped = True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_message_queue():
|
|
102
|
+
return KombuQueue()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
from django_simpletask5.models import TaskExecution
|
|
6
|
+
from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_DEFAULT_QUEUE
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
def create_and_publish(task, trigger_event, extra_context=None):
|
|
11
|
+
executor_class_path = task.get_executor_class_path(event=trigger_event)
|
|
12
|
+
queue_name = task.get_queue_name(event=trigger_event)
|
|
13
|
+
|
|
14
|
+
context = {
|
|
15
|
+
'task_id': str(task.task_id),
|
|
16
|
+
'trigger_event': trigger_event,
|
|
17
|
+
'data': _model_to_dict(task),
|
|
18
|
+
}
|
|
19
|
+
if extra_context:
|
|
20
|
+
context.update(extra_context)
|
|
21
|
+
|
|
22
|
+
serialized_context = _serialize_context(context)
|
|
23
|
+
|
|
24
|
+
execution = TaskExecution(
|
|
25
|
+
task_id=task.task_id,
|
|
26
|
+
task_model=task._meta.label,
|
|
27
|
+
trigger_event=trigger_event,
|
|
28
|
+
executor_class=executor_class_path,
|
|
29
|
+
status='pending',
|
|
30
|
+
context=json.dumps(serialized_context, default=str) if serialized_context else None,
|
|
31
|
+
)
|
|
32
|
+
execution.save()
|
|
33
|
+
|
|
34
|
+
_publish_message(execution, routing_key=queue_name)
|
|
35
|
+
|
|
36
|
+
return execution
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _publish_message(execution, routing_key=None):
|
|
40
|
+
message = {
|
|
41
|
+
'execution_id': str(execution.execution_id),
|
|
42
|
+
'task_id': str(execution.task_id) if execution.task_id else None,
|
|
43
|
+
'trigger_event': execution.trigger_event,
|
|
44
|
+
'executor_class': execution.executor_class,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
from django_simpletask5.core.message_queue import get_message_queue
|
|
49
|
+
mq = get_message_queue()
|
|
50
|
+
mq.publish(
|
|
51
|
+
routing_key=routing_key or DJANGO_SIMPLETASK_DEFAULT_QUEUE,
|
|
52
|
+
message=message,
|
|
53
|
+
)
|
|
54
|
+
logger.debug('Published message for execution %s', execution.execution_id)
|
|
55
|
+
except Exception:
|
|
56
|
+
logger.exception('Failed to publish message for execution %s', execution.execution_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _serialize_context(context):
|
|
60
|
+
if context is None:
|
|
61
|
+
return None
|
|
62
|
+
serialized = {}
|
|
63
|
+
for key, value in context.items():
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
serialized[key] = {k: _serialize_value(v) for k, v in value.items()}
|
|
66
|
+
else:
|
|
67
|
+
serialized[key] = _serialize_value(value)
|
|
68
|
+
return serialized
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _serialize_value(value):
|
|
72
|
+
if value is None or isinstance(value, (str, bool)):
|
|
73
|
+
return value
|
|
74
|
+
return str(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _model_to_dict(instance):
|
|
78
|
+
data = {}
|
|
79
|
+
for field in instance._meta.fields:
|
|
80
|
+
value = getattr(instance, field.attname)
|
|
81
|
+
if value is None:
|
|
82
|
+
data[field.attname] = None
|
|
83
|
+
elif isinstance(value, bool):
|
|
84
|
+
data[field.attname] = value
|
|
85
|
+
elif isinstance(value, (int, float)):
|
|
86
|
+
data[field.attname] = str(value)
|
|
87
|
+
elif isinstance(value, str):
|
|
88
|
+
data[field.attname] = value
|
|
89
|
+
elif isinstance(value, bytes):
|
|
90
|
+
data[field.attname] = value.decode('utf-8', errors='replace')
|
|
91
|
+
else:
|
|
92
|
+
data[field.attname] = str(value)
|
|
93
|
+
return data
|