django-simpletask5 0.1.4__tar.gz → 0.2.0__tar.gz
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-0.1.4 → django_simpletask5-0.2.0}/PKG-INFO +23 -1
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/README.md +22 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/admin.py +125 -50
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/defaults.py +1 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/publisher.py +27 -8
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/worker_registry.py +13 -11
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/bash_script.py +11 -3
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/python_script.py +10 -2
- django_simpletask5-0.2.0/django_simpletask5/executors/retry_timeout.py +47 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/simple_request.py +3 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/management/commands/django_simpletask_executor.py +18 -2
- django_simpletask5-0.2.0/django_simpletask5/migrations/0006_alter_cronjob_options_alter_taskexecution_created_at_and_more.py +67 -0
- django_simpletask5-0.2.0/django_simpletask5/migrations/0007_rename_task_model_to_executor_class_in_stat.py +34 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/models.py +17 -8
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/services/archive.py +3 -3
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5.egg-info/PKG-INFO +23 -1
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5.egg-info/SOURCES.txt +2 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/pyproject.toml +1 -1
- django_simpletask5-0.1.4/django_simpletask5/executors/retry_timeout.py +0 -32
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/LICENSE +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/__init__.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/apps.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/__init__.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/cronjob_registry.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/executor_scanner.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/lock.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/message_queue.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/signals.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/dashboards.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/__init__.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/archive.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/base.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/loader.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/ping_pong.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/status_check.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.po +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/management/__init__.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/management/commands/__init__.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/management/commands/django_simpletask_archive.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/management/commands/django_simpletask_crontab.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/management/commands/django_simpletask_sync_cronjobs.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/migrations/0001_initial.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/migrations/0002_taskexecution_done_tasks_count_and_more.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/migrations/0003_alter_cronjob_options_remove_cronjob_name_and_more.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/migrations/0004_alter_cronjob_is_modified_by_user.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/migrations/0005_alter_cronjob_is_modified_by_user_help.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/migrations/__init__.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/services/__init__.py +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5.egg-info/dependency_links.txt +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5.egg-info/requires.txt +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5.egg-info/top_level.txt +0 -0
- {django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-simpletask5
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Lightweight async task execution framework for Django
|
|
5
5
|
Author-email: rRR0VrFP <rrr0vrfp@qq.com>
|
|
6
6
|
Maintainer-email: rRR0VrFP <rrr0vrfp@qq.com>
|
|
@@ -177,6 +177,9 @@ DJANGO_SIMPLETASK_CRONJOB_AUTO_SYNC = True # 是否自动将代码注册的 Cro
|
|
|
177
177
|
### 安全
|
|
178
178
|
|
|
179
179
|
```python
|
|
180
|
+
# 是否启用 Python/Shell 脚本执行器(默认关闭,开启有安全风险)
|
|
181
|
+
DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS = False
|
|
182
|
+
|
|
180
183
|
# 脚本执行白名单(空列表表示不限制)
|
|
181
184
|
DJANGO_SIMPLETASK_SCRIPT_WHITELIST = ['/path/to/allowed/scripts']
|
|
182
185
|
|
|
@@ -187,6 +190,8 @@ DJANGO_SIMPLETASK_ERROR_MSG_PASSWORD = None # 错误信息加密密码
|
|
|
187
190
|
DJANGO_SIMPLETASK_CONTEXT_PASSWORD = None # 上下文数据加密密码
|
|
188
191
|
```
|
|
189
192
|
|
|
193
|
+
> 注意:`DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS` 设为 `True` 后,还需为用户/组授予 `django_simpletask5 | Cron job | Can use script executors` 权限,非超级管理员无法创建 Python/Shell 脚本执行器类型的定时任务。
|
|
194
|
+
|
|
190
195
|
## 快速开始
|
|
191
196
|
|
|
192
197
|
### 1. 定义任务模型
|
|
@@ -315,6 +320,23 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
|
|
|
315
320
|
|
|
316
321
|
## Releases
|
|
317
322
|
|
|
323
|
+
### 0.2.0
|
|
324
|
+
|
|
325
|
+
- **破坏性变更**: 移除 `Task` 模型上的自定义主键,`task_id` 不再是主键字段,改为 `unique=True` 的唯一标识字段。所有 `Task` 子模型将自动获得 Django 默认的自增 `id` 主键。已有数据库需要迁移处理。
|
|
326
|
+
- **统计维度调整**: `TaskExecutionStat` 的统计维度由 `task_model` 改为 `executor_class`,统一覆盖有模型任务和 Cron 定时任务两种场景。对应 API `_update_stats()` 按 `executor_class` 分组聚合。
|
|
327
|
+
- **Admin 优化**: CronJob 列表页移除 `executor_class` 列避免表格撑开;TaskExecutionStat 列表页新增 `executor_class` 列、`created_at` 设为只读修复详情页错误。
|
|
328
|
+
- **测试修复**: 修复 `TransactionTestCase` + `on_commit` 在 SQLite 共享内存连接下因 `TestCase` 残留原子块导致回调不执行的兼容性问题;Redis MQ / RabbitMQ E2E 集成测试全部通过。
|
|
329
|
+
|
|
330
|
+
### 0.1.5
|
|
331
|
+
|
|
332
|
+
- **安全加固** — 新增 `DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS` 配置项(默认关闭),启用后才可执行 Python/Shell 脚本;新增 `can_use_script_executors` 权限,仅有此权限或超级管理员的用户才能在 Admin 中创建脚本执行器类型的 CronJob
|
|
333
|
+
- **Bug 修复** — 修复 `RetryTimeoutExecutor` 中 lambda 晚绑定导致所有回调引用最后一个记录的问题
|
|
334
|
+
- **Admin 安全** — 操作按钮(立即执行、启用/禁用)从 GET 参数改为 POST 请求,附带 CSRF 保护
|
|
335
|
+
- **性能优化** — `RetryTimeoutExecutor` 改为批量 update + 按 ID 分发消息推送
|
|
336
|
+
- **Admin 增强** — `TaskExecutionAdmin` 新增 `executor_class` 过滤、`error_message` 搜索、批量重试失败任务、批量取消待处理任务
|
|
337
|
+
- **数据索引** — 添加 `(status, expire_time)` 和 `(is_active, next_run_time)` 复合索引;`trigger_event`、`executor_class`、`task_id`、`created_at` 添加单字段索引
|
|
338
|
+
- **代码规范** — `bash_script.py`、`python_script.py`、`simple_request.py` 补充缺失的 logger;移除废弃的 `allow_tags` 属性
|
|
339
|
+
|
|
318
340
|
### 0.1.4
|
|
319
341
|
|
|
320
342
|
- **修复打包** — `pyproject.toml` 添加 `package-data` 配置,打包时包含 locale po/mo 文件,修复 i18n 翻译不生效的问题
|
|
@@ -138,6 +138,9 @@ DJANGO_SIMPLETASK_CRONJOB_AUTO_SYNC = True # 是否自动将代码注册的 Cro
|
|
|
138
138
|
### 安全
|
|
139
139
|
|
|
140
140
|
```python
|
|
141
|
+
# 是否启用 Python/Shell 脚本执行器(默认关闭,开启有安全风险)
|
|
142
|
+
DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS = False
|
|
143
|
+
|
|
141
144
|
# 脚本执行白名单(空列表表示不限制)
|
|
142
145
|
DJANGO_SIMPLETASK_SCRIPT_WHITELIST = ['/path/to/allowed/scripts']
|
|
143
146
|
|
|
@@ -148,6 +151,8 @@ DJANGO_SIMPLETASK_ERROR_MSG_PASSWORD = None # 错误信息加密密码
|
|
|
148
151
|
DJANGO_SIMPLETASK_CONTEXT_PASSWORD = None # 上下文数据加密密码
|
|
149
152
|
```
|
|
150
153
|
|
|
154
|
+
> 注意:`DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS` 设为 `True` 后,还需为用户/组授予 `django_simpletask5 | Cron job | Can use script executors` 权限,非超级管理员无法创建 Python/Shell 脚本执行器类型的定时任务。
|
|
155
|
+
|
|
151
156
|
## 快速开始
|
|
152
157
|
|
|
153
158
|
### 1. 定义任务模型
|
|
@@ -276,6 +281,23 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
|
|
|
276
281
|
|
|
277
282
|
## Releases
|
|
278
283
|
|
|
284
|
+
### 0.2.0
|
|
285
|
+
|
|
286
|
+
- **破坏性变更**: 移除 `Task` 模型上的自定义主键,`task_id` 不再是主键字段,改为 `unique=True` 的唯一标识字段。所有 `Task` 子模型将自动获得 Django 默认的自增 `id` 主键。已有数据库需要迁移处理。
|
|
287
|
+
- **统计维度调整**: `TaskExecutionStat` 的统计维度由 `task_model` 改为 `executor_class`,统一覆盖有模型任务和 Cron 定时任务两种场景。对应 API `_update_stats()` 按 `executor_class` 分组聚合。
|
|
288
|
+
- **Admin 优化**: CronJob 列表页移除 `executor_class` 列避免表格撑开;TaskExecutionStat 列表页新增 `executor_class` 列、`created_at` 设为只读修复详情页错误。
|
|
289
|
+
- **测试修复**: 修复 `TransactionTestCase` + `on_commit` 在 SQLite 共享内存连接下因 `TestCase` 残留原子块导致回调不执行的兼容性问题;Redis MQ / RabbitMQ E2E 集成测试全部通过。
|
|
290
|
+
|
|
291
|
+
### 0.1.5
|
|
292
|
+
|
|
293
|
+
- **安全加固** — 新增 `DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS` 配置项(默认关闭),启用后才可执行 Python/Shell 脚本;新增 `can_use_script_executors` 权限,仅有此权限或超级管理员的用户才能在 Admin 中创建脚本执行器类型的 CronJob
|
|
294
|
+
- **Bug 修复** — 修复 `RetryTimeoutExecutor` 中 lambda 晚绑定导致所有回调引用最后一个记录的问题
|
|
295
|
+
- **Admin 安全** — 操作按钮(立即执行、启用/禁用)从 GET 参数改为 POST 请求,附带 CSRF 保护
|
|
296
|
+
- **性能优化** — `RetryTimeoutExecutor` 改为批量 update + 按 ID 分发消息推送
|
|
297
|
+
- **Admin 增强** — `TaskExecutionAdmin` 新增 `executor_class` 过滤、`error_message` 搜索、批量重试失败任务、批量取消待处理任务
|
|
298
|
+
- **数据索引** — 添加 `(status, expire_time)` 和 `(is_active, next_run_time)` 复合索引;`trigger_event`、`executor_class`、`task_id`、`created_at` 添加单字段索引
|
|
299
|
+
- **代码规范** — `bash_script.py`、`python_script.py`、`simple_request.py` 补充缺失的 logger;移除废弃的 `allow_tags` 属性
|
|
300
|
+
|
|
279
301
|
### 0.1.4
|
|
280
302
|
|
|
281
303
|
- **修复打包** — `pyproject.toml` 添加 `package-data` 配置,打包时包含 locale po/mo 文件,修复 i18n 翻译不生效的问题
|
|
@@ -2,8 +2,9 @@ import json
|
|
|
2
2
|
|
|
3
3
|
from django import forms
|
|
4
4
|
from django.contrib import admin
|
|
5
|
-
from django.http import HttpResponseRedirect
|
|
6
|
-
from django.urls import reverse
|
|
5
|
+
from django.http import HttpResponseRedirect, Http404
|
|
6
|
+
from django.urls import path, reverse
|
|
7
|
+
from django.utils.html import format_html, mark_safe
|
|
7
8
|
from django.utils.translation import gettext_lazy as _
|
|
8
9
|
from django_checkbox_normalize.admin import DjangoCheckboxNormalizeAdmin
|
|
9
10
|
from django_tabbed_changeform_admin.admin import DjangoTabbedChangeformAdmin
|
|
@@ -15,6 +16,11 @@ from django_simpletask5.core.executor_scanner import (
|
|
|
15
16
|
get_executor_schema_map,
|
|
16
17
|
)
|
|
17
18
|
|
|
19
|
+
SCRIPT_EXECUTOR_CLASSES = {
|
|
20
|
+
'django_simpletask5.executors.bash_script.BashScriptExecutor',
|
|
21
|
+
'django_simpletask5.executors.python_script.PythonScriptExecutor',
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
|
|
19
25
|
class ExecutorClassWidget(forms.Select):
|
|
20
26
|
def __init__(self, attrs=None):
|
|
@@ -58,6 +64,7 @@ class CronJobForm(forms.ModelForm):
|
|
|
58
64
|
)
|
|
59
65
|
|
|
60
66
|
def __init__(self, *args, **kwargs):
|
|
67
|
+
self.request = kwargs.pop('request', None)
|
|
61
68
|
super().__init__(*args, **kwargs)
|
|
62
69
|
if self.instance and self.instance.pk:
|
|
63
70
|
self.fields['uid'].disabled = True
|
|
@@ -75,6 +82,16 @@ class CronJobForm(forms.ModelForm):
|
|
|
75
82
|
except Exception:
|
|
76
83
|
pass
|
|
77
84
|
|
|
85
|
+
def clean_executor_class(self):
|
|
86
|
+
executor_class = self.cleaned_data.get("executor_class", "")
|
|
87
|
+
if executor_class in SCRIPT_EXECUTOR_CLASSES:
|
|
88
|
+
if self.request and not (self.request.user.is_superuser or self.request.user.has_perm('django_simpletask5.can_use_script_executors')):
|
|
89
|
+
raise forms.ValidationError(
|
|
90
|
+
_('You do not have permission to use script executors (Python/Shell). '
|
|
91
|
+
'Only superusers and users with the "can_use_script_executors" permission can do so.')
|
|
92
|
+
)
|
|
93
|
+
return executor_class
|
|
94
|
+
|
|
78
95
|
def clean_context(self):
|
|
79
96
|
raw = self.cleaned_data.get("context")
|
|
80
97
|
if not raw:
|
|
@@ -125,7 +142,6 @@ class TaskExecutionAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdm
|
|
|
125
142
|
|
|
126
143
|
@admin.display(description=_("Execution ID"))
|
|
127
144
|
def short_execution_id(self, obj):
|
|
128
|
-
from django.utils.html import format_html
|
|
129
145
|
full = str(obj.execution_id)
|
|
130
146
|
short = full[:8]
|
|
131
147
|
copied_text = _("Copied!")
|
|
@@ -136,8 +152,8 @@ class TaskExecutionAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdm
|
|
|
136
152
|
'setTimeout(function(){{t.textContent=o;}},800);}}.bind(this)).catch(function(){{}})">{}</span>',
|
|
137
153
|
full, full, copied_text, short
|
|
138
154
|
)
|
|
139
|
-
list_filter = ["status", "trigger_event", "created_at"]
|
|
140
|
-
search_fields = ["execution_id", "task_id", "executor_class"]
|
|
155
|
+
list_filter = ["status", "trigger_event", "executor_class", "created_at"]
|
|
156
|
+
search_fields = ["execution_id", "task_id", "executor_class", "error_message"]
|
|
141
157
|
readonly_fields = [
|
|
142
158
|
"execution_id", "task_id", "task_model", "trigger_event", "executor_class", "status",
|
|
143
159
|
"result", "error_message", "context", "total_tasks_count", "done_tasks_count",
|
|
@@ -145,6 +161,7 @@ class TaskExecutionAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdm
|
|
|
145
161
|
"started_at", "finished_at", "created_at", "updated_at",
|
|
146
162
|
]
|
|
147
163
|
ordering = ["-created_at"]
|
|
164
|
+
actions = ["retry_selected", "cancel_selected"]
|
|
148
165
|
|
|
149
166
|
def has_add_permission(self, request):
|
|
150
167
|
return False
|
|
@@ -185,6 +202,23 @@ class TaskExecutionAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdm
|
|
|
185
202
|
def retry_info(self, obj):
|
|
186
203
|
return f"{obj.retry_count}/{obj.max_retries}"
|
|
187
204
|
|
|
205
|
+
@admin.action(description=_("Retry selected failed tasks"))
|
|
206
|
+
def retry_selected(self, request, queryset):
|
|
207
|
+
from django_simpletask5.core.publisher import _publish_message
|
|
208
|
+
count = 0
|
|
209
|
+
for exec_record in queryset.filter(status__in=['failed', 'timeout']):
|
|
210
|
+
exec_record.status = 'retry'
|
|
211
|
+
exec_record.retry_count += 1
|
|
212
|
+
exec_record.save(update_fields=['status', 'retry_count', 'updated_at'])
|
|
213
|
+
_publish_message(exec_record)
|
|
214
|
+
count += 1
|
|
215
|
+
self.message_user(request, _("%d execution(s) have been re-queued.") % count)
|
|
216
|
+
|
|
217
|
+
@admin.action(description=_("Cancel selected pending tasks"))
|
|
218
|
+
def cancel_selected(self, request, queryset):
|
|
219
|
+
updated = queryset.filter(status='pending').update(status='failed')
|
|
220
|
+
self.message_user(request, _("%d execution(s) have been cancelled.") % updated)
|
|
221
|
+
|
|
188
222
|
|
|
189
223
|
@admin.register(CronJob)
|
|
190
224
|
class CronJobAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdmin):
|
|
@@ -192,7 +226,6 @@ class CronJobAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdmin):
|
|
|
192
226
|
list_display = [
|
|
193
227
|
"display_name",
|
|
194
228
|
"cron_expression",
|
|
195
|
-
"executor_class",
|
|
196
229
|
"is_active",
|
|
197
230
|
"is_modified_by_user",
|
|
198
231
|
"updated_at",
|
|
@@ -228,62 +261,102 @@ class CronJobAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdmin):
|
|
|
228
261
|
def get_queryset(self, request):
|
|
229
262
|
return super().get_queryset(request)
|
|
230
263
|
|
|
231
|
-
def
|
|
232
|
-
|
|
233
|
-
|
|
264
|
+
def get_urls(self):
|
|
265
|
+
urls = super().get_urls()
|
|
266
|
+
custom_urls = [
|
|
267
|
+
path(
|
|
268
|
+
'<path:object_id>/run/',
|
|
269
|
+
self.admin_site.admin_view(self._run_cronjob_view),
|
|
270
|
+
name='django_simpletask5_cronjob_run',
|
|
271
|
+
),
|
|
272
|
+
path(
|
|
273
|
+
'<path:object_id>/toggle-active/',
|
|
274
|
+
self.admin_site.admin_view(self._toggle_active_view),
|
|
275
|
+
name='django_simpletask5_cronjob_toggle_active',
|
|
276
|
+
),
|
|
277
|
+
]
|
|
278
|
+
return custom_urls + urls
|
|
234
279
|
|
|
235
|
-
|
|
280
|
+
def _run_cronjob_view(self, request, object_id):
|
|
281
|
+
if request.method != 'POST':
|
|
282
|
+
return HttpResponseRedirect(
|
|
283
|
+
reverse('admin:django_simpletask5_cronjob_changelist')
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
cronjob = CronJob.objects.get(pk=object_id)
|
|
287
|
+
from django_simpletask5.management.commands.django_simpletask_crontab import Command as CrontabCommand
|
|
288
|
+
cmd = CrontabCommand()
|
|
289
|
+
cmd._trigger_cronjob(cronjob)
|
|
290
|
+
self.message_user(request, _('Cronjob "%s" triggered.') % cronjob.display_name)
|
|
291
|
+
except CronJob.DoesNotExist:
|
|
292
|
+
raise Http404()
|
|
293
|
+
return HttpResponseRedirect(
|
|
294
|
+
reverse('admin:django_simpletask5_cronjob_changelist')
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _toggle_active_view(self, request, object_id):
|
|
298
|
+
if request.method != 'POST':
|
|
299
|
+
return HttpResponseRedirect(
|
|
300
|
+
reverse('admin:django_simpletask5_cronjob_changelist')
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
cronjob = CronJob.objects.get(pk=object_id)
|
|
304
|
+
cronjob.is_active = not cronjob.is_active
|
|
305
|
+
cronjob.save(update_fields=['is_active'])
|
|
306
|
+
status = _('activated') if cronjob.is_active else _('deactivated')
|
|
307
|
+
self.message_user(request, _('Cronjob "%s" %s.') % (cronjob.display_name, status))
|
|
308
|
+
except CronJob.DoesNotExist:
|
|
309
|
+
raise Http404()
|
|
310
|
+
return HttpResponseRedirect(
|
|
311
|
+
reverse('admin:django_simpletask5_cronjob_changelist')
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def action_buttons(self, obj):
|
|
315
|
+
run_url = reverse('admin:django_simpletask5_cronjob_run', args=[obj.pk])
|
|
316
|
+
toggle_url = reverse('admin:django_simpletask5_cronjob_toggle_active', args=[obj.pk])
|
|
317
|
+
csrf_token = '"{{ csrf_token }}"'
|
|
236
318
|
|
|
237
319
|
buttons = []
|
|
238
320
|
buttons.append(
|
|
239
|
-
f'<
|
|
240
|
-
f'
|
|
241
|
-
f'<
|
|
321
|
+
f'<form style="display:inline" method="post" action="{run_url}">'
|
|
322
|
+
f'<input type="hidden" name="csrfmiddlewaretoken" value={csrf_token}>'
|
|
323
|
+
f'<button type="submit" style="background:#17a2b8;color:#fff;padding:3px 8px;border-radius:3px;border:none;cursor:pointer;margin-right:4px;font-size:12px" title="{_("Execute now")}">'
|
|
324
|
+
f'<i class="ri-play-line"></i> {_("Run")}</button></form>'
|
|
242
325
|
)
|
|
243
326
|
if obj.is_active:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
)
|
|
327
|
+
btn_style = "background:#ffc107;color:#000"
|
|
328
|
+
btn_title = _("Deactivate")
|
|
329
|
+
btn_icon = "ri-pause-line"
|
|
330
|
+
btn_label = _("Deactivate")
|
|
249
331
|
else:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
332
|
+
btn_style = "background:#28a745;color:#fff"
|
|
333
|
+
btn_title = _("Activate")
|
|
334
|
+
btn_icon = "ri-play-circle-line"
|
|
335
|
+
btn_label = _("Activate")
|
|
336
|
+
buttons.append(
|
|
337
|
+
f'<form style="display:inline" method="post" action="{toggle_url}">'
|
|
338
|
+
f'<input type="hidden" name="csrfmiddlewaretoken" value={csrf_token}>'
|
|
339
|
+
f'<button type="submit" style="{btn_style};padding:3px 8px;border-radius:3px;border:none;cursor:pointer;margin-right:4px;font-size:12px" title="{btn_title}">'
|
|
340
|
+
f'<i class="{btn_icon}"></i> {btn_label}</button></form>'
|
|
341
|
+
)
|
|
255
342
|
delete_url = reverse('admin:django_simpletask5_cronjob_delete', args=[obj.pk])
|
|
256
343
|
buttons.append(
|
|
257
344
|
f'<a class="button" style="background:#dc3545;color:#fff;padding:3px 8px;border-radius:3px;text-decoration:none;margin-right:4px;font-size:12px" '
|
|
258
|
-
f'href="{delete_url}" title="
|
|
259
|
-
f'<i class="ri-delete-bin-line"></i>
|
|
345
|
+
f'href="{delete_url}" title="{_("Delete")}">'
|
|
346
|
+
f'<i class="ri-delete-bin-line"></i> {_("Delete")}</a>'
|
|
260
347
|
)
|
|
261
|
-
return
|
|
348
|
+
return mark_safe(''.join(buttons))
|
|
262
349
|
|
|
263
|
-
action_buttons.short_description = _("
|
|
264
|
-
action_buttons.allow_tags = True
|
|
350
|
+
action_buttons.short_description = _("Actions")
|
|
265
351
|
|
|
266
|
-
def
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
from django_simpletask5.management.commands.django_simpletask_crontab import Command as CrontabCommand
|
|
275
|
-
cmd = CrontabCommand()
|
|
276
|
-
cmd._trigger_cronjob(cronjob)
|
|
277
|
-
self.message_user(request, f'Cronjob "{cronjob.display_name}" triggered.')
|
|
278
|
-
elif action == 'toggle-active':
|
|
279
|
-
cronjob.is_active = not cronjob.is_active
|
|
280
|
-
cronjob.save(update_fields=['is_active'])
|
|
281
|
-
status = 'activated' if cronjob.is_active else 'deactivated'
|
|
282
|
-
self.message_user(request, f'Cronjob "{cronjob.display_name}" {status}.')
|
|
283
|
-
except CronJob.DoesNotExist:
|
|
284
|
-
self.message_user(request, f'Cronjob not found.', level='ERROR')
|
|
285
|
-
return HttpResponseRedirect(request.path)
|
|
286
|
-
return super().changelist_view(request, extra_context=extra_context)
|
|
352
|
+
def get_form(self, request, obj=None, change=False, **kwargs):
|
|
353
|
+
form_class = super().get_form(request, obj=obj, change=change, **kwargs)
|
|
354
|
+
|
|
355
|
+
class _CronJobFormWithRequest(form_class):
|
|
356
|
+
def __init__(self, *args, **kwargs):
|
|
357
|
+
kwargs['request'] = request
|
|
358
|
+
super().__init__(*args, **kwargs)
|
|
359
|
+
return _CronJobFormWithRequest
|
|
287
360
|
|
|
288
361
|
def save_model(self, request, obj, form, change):
|
|
289
362
|
if change:
|
|
@@ -344,6 +417,7 @@ class CronJobAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdmin):
|
|
|
344
417
|
class TaskExecutionStatAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdmin):
|
|
345
418
|
list_display = [
|
|
346
419
|
"date",
|
|
420
|
+
"executor_class",
|
|
347
421
|
"trigger_event",
|
|
348
422
|
"total_count",
|
|
349
423
|
"success_count",
|
|
@@ -351,11 +425,12 @@ class TaskExecutionStatAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangefor
|
|
|
351
425
|
"avg_duration_seconds",
|
|
352
426
|
]
|
|
353
427
|
list_filter = ["date", "trigger_event"]
|
|
428
|
+
readonly_fields = ["created_at"]
|
|
354
429
|
ordering = ["-date"]
|
|
355
430
|
|
|
356
431
|
fieldsets = [
|
|
357
432
|
(None, {
|
|
358
|
-
"fields": ["date", "
|
|
433
|
+
"fields": ["date", "executor_class", "trigger_event", "created_at"],
|
|
359
434
|
"classes": ["tab-basic"],
|
|
360
435
|
}),
|
|
361
436
|
(None, {
|
|
@@ -14,6 +14,7 @@ DJANGO_SIMPLETASK_CRONJOB_AUTO_SYNC = getattr(settings, 'DJANGO_SIMPLETASK_CRONJ
|
|
|
14
14
|
DJANGO_SIMPLETASK_DEFAULT_QUEUE = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_QUEUE', 'django_simpletask5.queue.default')
|
|
15
15
|
DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = getattr(settings, 'DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE', 'django_simpletask5.queue.high_priority')
|
|
16
16
|
|
|
17
|
+
DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS = getattr(settings, 'DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS', False)
|
|
17
18
|
DJANGO_SIMPLETASK_SCRIPT_WHITELIST = getattr(settings, 'DJANGO_SIMPLETASK_SCRIPT_WHITELIST', [])
|
|
18
19
|
|
|
19
20
|
DJANGO_SIMPLETASK_FIELD_CIPHER_CLASS = getattr(settings, 'DJANGO_SIMPLETASK_FIELD_CIPHER_CLASS', None)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
|
+
import time
|
|
3
4
|
|
|
4
5
|
from django.db import transaction
|
|
6
|
+
from django.utils import timezone
|
|
5
7
|
|
|
6
8
|
from django_simpletask5.models import TaskExecution
|
|
7
9
|
from django_simpletask5.core.defaults import DJANGO_SIMPLETASK_DEFAULT_QUEUE, DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS
|
|
@@ -58,16 +60,33 @@ def _publish_message(execution, routing_key=None):
|
|
|
58
60
|
'executor_class': execution.executor_class,
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
from django_simpletask5.core.message_queue import get_message_queue
|
|
64
|
+
|
|
65
|
+
for attempt in range(1, 4):
|
|
66
|
+
try:
|
|
67
|
+
mq = get_message_queue()
|
|
68
|
+
mq.publish(
|
|
69
|
+
routing_key=routing_key or DJANGO_SIMPLETASK_DEFAULT_QUEUE,
|
|
70
|
+
message=message,
|
|
71
|
+
)
|
|
72
|
+
logger.debug('Published message for execution %s', execution.execution_id)
|
|
73
|
+
return
|
|
74
|
+
except Exception:
|
|
75
|
+
if attempt < 3:
|
|
76
|
+
logger.warning('Publish attempt %d/3 failed for execution %s, retrying...', attempt, execution.execution_id)
|
|
77
|
+
time.sleep(1)
|
|
78
|
+
else:
|
|
79
|
+
logger.exception('Failed to publish message for execution %s after 3 attempts', execution.execution_id)
|
|
80
|
+
|
|
61
81
|
try:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
logger.debug('Published message for execution %s', execution.execution_id)
|
|
82
|
+
execution.refresh_from_db()
|
|
83
|
+
if execution.status not in ('success', 'failed', 'timeout'):
|
|
84
|
+
execution.status = 'failed'
|
|
85
|
+
execution.error_message = 'Failed to publish message to queue after 3 attempts'
|
|
86
|
+
execution.finished_at = timezone.now()
|
|
87
|
+
execution.save(update_fields=['status', 'error_message', 'finished_at', 'updated_at'])
|
|
69
88
|
except Exception:
|
|
70
|
-
logger.exception('Failed to
|
|
89
|
+
logger.exception('Failed to mark execution %s as failed after publish failure', execution.execution_id)
|
|
71
90
|
|
|
72
91
|
|
|
73
92
|
def _serialize_context(context):
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/worker_registry.py
RENAMED
|
@@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
|
|
|
12
12
|
WORKER_TTL = 300
|
|
13
13
|
|
|
14
14
|
_REDIS_UNAVAILABLE = False
|
|
15
|
+
_redis_lock = threading.Lock()
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def _get_redis_client():
|
|
@@ -30,17 +31,18 @@ def _get_redis_client():
|
|
|
30
31
|
|
|
31
32
|
def _safe_redis_call(fn, default=None, log_level=logging.WARNING):
|
|
32
33
|
global _REDIS_UNAVAILABLE
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
_REDIS_UNAVAILABLE
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
with _redis_lock:
|
|
35
|
+
try:
|
|
36
|
+
result = fn()
|
|
37
|
+
if _REDIS_UNAVAILABLE:
|
|
38
|
+
_REDIS_UNAVAILABLE = False
|
|
39
|
+
logger.info('Redis connection restored')
|
|
40
|
+
return result
|
|
41
|
+
except Exception as e:
|
|
42
|
+
if not _REDIS_UNAVAILABLE:
|
|
43
|
+
_REDIS_UNAVAILABLE = True
|
|
44
|
+
logger.log(log_level, 'Redis operation failed: %s', e)
|
|
45
|
+
return default
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
def _worker_key(pid, tid):
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/bash_script.py
RENAMED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import logging
|
|
2
3
|
import os
|
|
3
4
|
import subprocess
|
|
4
5
|
|
|
@@ -7,6 +8,8 @@ from django.conf import settings
|
|
|
7
8
|
from django_simpletask5.models import TaskExecution
|
|
8
9
|
from django_simpletask5.executors.base import BaseExecutor
|
|
9
10
|
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
class BashScriptExecutor(BaseExecutor):
|
|
12
15
|
@classmethod
|
|
@@ -19,6 +22,11 @@ class BashScriptExecutor(BaseExecutor):
|
|
|
19
22
|
]
|
|
20
23
|
|
|
21
24
|
def execute(self, execution: TaskExecution, task=None) -> str | None:
|
|
25
|
+
if not getattr(settings, 'DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS', False):
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
'Script executors are disabled. Set DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS = True in settings to enable.'
|
|
28
|
+
)
|
|
29
|
+
|
|
22
30
|
params = execution.get_context_dict()
|
|
23
31
|
script = params.get('script', '')
|
|
24
32
|
script_path = params.get('script_path', '')
|
|
@@ -31,12 +39,12 @@ class BashScriptExecutor(BaseExecutor):
|
|
|
31
39
|
abs_path = os.path.abspath(script_path)
|
|
32
40
|
allowed = any(abs_path.startswith(os.path.abspath(p)) for p in script_whitelist)
|
|
33
41
|
if not allowed:
|
|
34
|
-
raise
|
|
42
|
+
raise RuntimeError('Script path not in whitelist')
|
|
35
43
|
with open(script_path, 'r') as f:
|
|
36
44
|
script = f.read()
|
|
37
45
|
|
|
38
46
|
if not script:
|
|
39
|
-
raise
|
|
47
|
+
raise RuntimeError('No script provided')
|
|
40
48
|
|
|
41
49
|
cmd = ['bash', '-c', script] + list(args)
|
|
42
50
|
merged_env = os.environ.copy()
|
|
@@ -51,7 +59,7 @@ class BashScriptExecutor(BaseExecutor):
|
|
|
51
59
|
)
|
|
52
60
|
|
|
53
61
|
if result.returncode != 0:
|
|
54
|
-
raise
|
|
62
|
+
raise RuntimeError(result.stderr[:2000])
|
|
55
63
|
|
|
56
64
|
return json.dumps({
|
|
57
65
|
'stdout': result.stdout[:2000],
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/python_script.py
RENAMED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import logging
|
|
2
3
|
import os
|
|
3
4
|
|
|
4
5
|
from django.conf import settings
|
|
@@ -6,6 +7,8 @@ from django.conf import settings
|
|
|
6
7
|
from django_simpletask5.models import TaskExecution
|
|
7
8
|
from django_simpletask5.executors.base import BaseExecutor
|
|
8
9
|
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
class PythonScriptExecutor(BaseExecutor):
|
|
11
14
|
@classmethod
|
|
@@ -17,6 +20,11 @@ class PythonScriptExecutor(BaseExecutor):
|
|
|
17
20
|
]
|
|
18
21
|
|
|
19
22
|
def execute(self, execution: TaskExecution, task=None) -> str | None:
|
|
23
|
+
if not getattr(settings, 'DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS', False):
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
'Script executors are disabled. Set DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS = True in settings to enable.'
|
|
26
|
+
)
|
|
27
|
+
|
|
20
28
|
params = execution.get_context_dict()
|
|
21
29
|
script = params.get('script', '')
|
|
22
30
|
script_path = params.get('script_path', '')
|
|
@@ -28,12 +36,12 @@ class PythonScriptExecutor(BaseExecutor):
|
|
|
28
36
|
abs_path = os.path.abspath(script_path)
|
|
29
37
|
allowed = any(abs_path.startswith(os.path.abspath(p)) for p in script_whitelist)
|
|
30
38
|
if not allowed:
|
|
31
|
-
raise
|
|
39
|
+
raise RuntimeError('Script path not in whitelist')
|
|
32
40
|
with open(script_path, 'r') as f:
|
|
33
41
|
script = f.read()
|
|
34
42
|
|
|
35
43
|
if not script:
|
|
36
|
-
raise
|
|
44
|
+
raise RuntimeError('No script provided')
|
|
37
45
|
|
|
38
46
|
context = execution.get_context_dict()
|
|
39
47
|
local_vars = {'args': args, 'execution': execution, 'context': context}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from django.db import models, transaction
|
|
5
|
+
|
|
6
|
+
from django_simpletask5.models import TaskExecution
|
|
7
|
+
from django_simpletask5.executors.base import BaseExecutor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _publish_message_by_id(execution_id):
|
|
11
|
+
from django_simpletask5.core.publisher import _publish_message
|
|
12
|
+
try:
|
|
13
|
+
exec_record = TaskExecution.objects.get(pk=execution_id)
|
|
14
|
+
_publish_message(exec_record)
|
|
15
|
+
except TaskExecution.DoesNotExist:
|
|
16
|
+
logger.warning('Execution %s not found for retry publish', execution_id)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RetryTimeoutExecutor(BaseExecutor):
|
|
22
|
+
def execute(self, execution: TaskExecution, task=None) -> str | None:
|
|
23
|
+
retryable = TaskExecution.objects.filter(
|
|
24
|
+
status='timeout',
|
|
25
|
+
retry_count__lt=models.F('max_retries'),
|
|
26
|
+
)
|
|
27
|
+
ids = list(retryable.values_list('pk', flat=True))
|
|
28
|
+
|
|
29
|
+
if not ids:
|
|
30
|
+
return json.dumps({'retried': 0, 'success': True})
|
|
31
|
+
|
|
32
|
+
TaskExecution.objects.filter(pk__in=ids).update(
|
|
33
|
+
status='retry',
|
|
34
|
+
retry_count=models.F('retry_count') + 1,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from django_simpletask5.core.publisher import _publish_message
|
|
38
|
+
from functools import partial
|
|
39
|
+
for exec_id in ids:
|
|
40
|
+
transaction.on_commit(
|
|
41
|
+
partial(_publish_message_by_id, exec_id)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return json.dumps({
|
|
45
|
+
'retried': len(ids),
|
|
46
|
+
'success': True,
|
|
47
|
+
})
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/simple_request.py
RENAMED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import logging
|
|
2
3
|
|
|
3
4
|
import requests
|
|
4
5
|
|
|
5
6
|
from django_simpletask5.models import TaskExecution
|
|
6
7
|
from django_simpletask5.executors.base import BaseExecutor
|
|
7
8
|
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
8
11
|
|
|
9
12
|
class SimpleRequestExecutor(BaseExecutor):
|
|
10
13
|
@classmethod
|
|
@@ -8,7 +8,7 @@ import time
|
|
|
8
8
|
from datetime import timedelta
|
|
9
9
|
|
|
10
10
|
from django.core.management.base import BaseCommand
|
|
11
|
-
from django.db import close_old_connections
|
|
11
|
+
from django.db import close_old_connections, OperationalError, InterfaceError
|
|
12
12
|
from django.utils import timezone
|
|
13
13
|
|
|
14
14
|
from django_simpletask5.models import TaskExecution
|
|
@@ -97,6 +97,8 @@ class ExecutorWorker:
|
|
|
97
97
|
|
|
98
98
|
close_old_connections()
|
|
99
99
|
|
|
100
|
+
execution = None
|
|
101
|
+
execution_id = None
|
|
100
102
|
try:
|
|
101
103
|
body = json.loads(message) if isinstance(message, (str, bytes)) else message
|
|
102
104
|
execution_id = body.get('execution_id')
|
|
@@ -192,9 +194,23 @@ class ExecutorWorker:
|
|
|
192
194
|
'finished_at', 'updated_at',
|
|
193
195
|
])
|
|
194
196
|
|
|
197
|
+
except (OperationalError, InterfaceError) as e:
|
|
198
|
+
close_old_connections()
|
|
199
|
+
logger.warning('Transient database error processing message %s: %s', execution_id, e)
|
|
200
|
+
return False
|
|
195
201
|
except Exception as e:
|
|
196
202
|
close_old_connections()
|
|
197
|
-
logger.exception('
|
|
203
|
+
logger.exception('Unexpected error processing message: %s', e)
|
|
204
|
+
if execution is not None and execution.pk:
|
|
205
|
+
try:
|
|
206
|
+
execution.refresh_from_db()
|
|
207
|
+
if execution.status not in ('success', 'failed', 'timeout'):
|
|
208
|
+
execution.status = 'failed'
|
|
209
|
+
execution.error_message = 'Fatal: %s' % str(e)
|
|
210
|
+
execution.finished_at = timezone.now()
|
|
211
|
+
execution.save(update_fields=['status', 'error_message', 'finished_at', 'updated_at'])
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
198
214
|
return True
|
|
199
215
|
|
|
200
216
|
def _publish_retry(self, execution):
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Generated by Django 5.2.14 on 2026-06-17 12:23
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
("django_simpletask5", "0005_alter_cronjob_is_modified_by_user_help"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterModelOptions(
|
|
14
|
+
name="cronjob",
|
|
15
|
+
options={
|
|
16
|
+
"ordering": ["display_name"],
|
|
17
|
+
"permissions": [
|
|
18
|
+
(
|
|
19
|
+
"can_use_script_executors",
|
|
20
|
+
"Can use script executors (Python/Shell)",
|
|
21
|
+
)
|
|
22
|
+
],
|
|
23
|
+
"verbose_name": "Cron job",
|
|
24
|
+
"verbose_name_plural": "Cron jobs",
|
|
25
|
+
},
|
|
26
|
+
),
|
|
27
|
+
migrations.AlterField(
|
|
28
|
+
model_name="taskexecution",
|
|
29
|
+
name="created_at",
|
|
30
|
+
field=models.DateTimeField(
|
|
31
|
+
auto_now_add=True, db_index=True, verbose_name="Created at"
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
migrations.AlterField(
|
|
35
|
+
model_name="taskexecution",
|
|
36
|
+
name="executor_class",
|
|
37
|
+
field=models.CharField(
|
|
38
|
+
db_index=True, max_length=512, verbose_name="Executor class"
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
migrations.AlterField(
|
|
42
|
+
model_name="taskexecution",
|
|
43
|
+
name="task_id",
|
|
44
|
+
field=models.UUIDField(
|
|
45
|
+
blank=True, db_index=True, null=True, verbose_name="Task ID"
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
migrations.AlterField(
|
|
49
|
+
model_name="taskexecution",
|
|
50
|
+
name="trigger_event",
|
|
51
|
+
field=models.CharField(
|
|
52
|
+
db_index=True, max_length=64, verbose_name="Trigger event"
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
migrations.AddIndex(
|
|
56
|
+
model_name="cronjob",
|
|
57
|
+
index=models.Index(
|
|
58
|
+
fields=["is_active", "next_run_time"], name="idx_cronjob_active_nextrun"
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
migrations.AddIndex(
|
|
62
|
+
model_name="taskexecution",
|
|
63
|
+
index=models.Index(
|
|
64
|
+
fields=["status", "expire_time"], name="idx_exec_status_expire"
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
|
|
6
|
+
dependencies = [
|
|
7
|
+
("django_simpletask5", "0006_alter_cronjob_options_alter_taskexecution_created_at_and_more"),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.AlterUniqueTogether(
|
|
12
|
+
name="taskexecutionstat",
|
|
13
|
+
unique_together=set(),
|
|
14
|
+
),
|
|
15
|
+
migrations.RemoveField(
|
|
16
|
+
model_name="taskexecutionstat",
|
|
17
|
+
name="task_model",
|
|
18
|
+
),
|
|
19
|
+
migrations.AddField(
|
|
20
|
+
model_name="taskexecutionstat",
|
|
21
|
+
name="executor_class",
|
|
22
|
+
field=models.CharField(
|
|
23
|
+
default="",
|
|
24
|
+
max_length=512,
|
|
25
|
+
db_index=True,
|
|
26
|
+
verbose_name="Executor class",
|
|
27
|
+
),
|
|
28
|
+
preserve_default=False,
|
|
29
|
+
),
|
|
30
|
+
migrations.AlterUniqueTogether(
|
|
31
|
+
name="taskexecutionstat",
|
|
32
|
+
unique_together={("date", "executor_class", "trigger_event")},
|
|
33
|
+
),
|
|
34
|
+
]
|
|
@@ -27,7 +27,7 @@ def _safe_text_field(password_key, **kwargs):
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class Task(models.Model):
|
|
30
|
-
task_id = models.UUIDField(
|
|
30
|
+
task_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
31
31
|
|
|
32
32
|
class Meta:
|
|
33
33
|
abstract = True
|
|
@@ -108,10 +108,10 @@ class TaskExecution(models.Model):
|
|
|
108
108
|
]
|
|
109
109
|
|
|
110
110
|
execution_id = models.UUIDField(verbose_name=_('Execution ID'), primary_key=True, default=uuid.uuid4, editable=False)
|
|
111
|
-
task_id = models.UUIDField(verbose_name=_('Task ID'), null=True, blank=True)
|
|
111
|
+
task_id = models.UUIDField(verbose_name=_('Task ID'), null=True, blank=True, db_index=True)
|
|
112
112
|
task_model = models.CharField(verbose_name=_('Task model'), max_length=256, null=True, blank=True, help_text='app_label.ModelName')
|
|
113
|
-
trigger_event = models.CharField(verbose_name=_('Trigger event'), max_length=64)
|
|
114
|
-
executor_class = models.CharField(verbose_name=_('Executor class'), max_length=512)
|
|
113
|
+
trigger_event = models.CharField(verbose_name=_('Trigger event'), max_length=64, db_index=True)
|
|
114
|
+
executor_class = models.CharField(verbose_name=_('Executor class'), max_length=512, db_index=True)
|
|
115
115
|
status = models.CharField(verbose_name=_('Status'), max_length=32, choices=STATUS_CHOICES, default='pending', db_index=True)
|
|
116
116
|
result = _safe_text_field('DJANGO_SIMPLETASK_RESULT_PASSWORD', verbose_name=_('Result'), null=True, blank=True)
|
|
117
117
|
total_tasks_count = models.IntegerField(verbose_name=_('Total tasks count'), null=True, blank=True)
|
|
@@ -123,7 +123,7 @@ class TaskExecution(models.Model):
|
|
|
123
123
|
expire_time = models.DateTimeField(verbose_name=_('Expire time'), null=True, blank=True, db_index=True)
|
|
124
124
|
started_at = models.DateTimeField(verbose_name=_('Started at'), null=True, blank=True)
|
|
125
125
|
finished_at = models.DateTimeField(verbose_name=_('Finished at'), null=True, blank=True)
|
|
126
|
-
created_at = models.DateTimeField(verbose_name=_('Created at'), auto_now_add=True)
|
|
126
|
+
created_at = models.DateTimeField(verbose_name=_('Created at'), auto_now_add=True, db_index=True)
|
|
127
127
|
updated_at = models.DateTimeField(verbose_name=_('Updated at'), auto_now=True)
|
|
128
128
|
context = _safe_text_field('DJANGO_SIMPLETASK_CONTEXT_PASSWORD', verbose_name=_('Context'), null=True, blank=True)
|
|
129
129
|
|
|
@@ -132,6 +132,9 @@ class TaskExecution(models.Model):
|
|
|
132
132
|
verbose_name = _('Task execution')
|
|
133
133
|
verbose_name_plural = _('Task executions')
|
|
134
134
|
ordering = ['-created_at']
|
|
135
|
+
indexes = [
|
|
136
|
+
models.Index(fields=['status', 'expire_time'], name='idx_exec_status_expire'),
|
|
137
|
+
]
|
|
135
138
|
|
|
136
139
|
def __str__(self):
|
|
137
140
|
return f'{_("TaskExecution")}({self.execution_id}, {self.trigger_event}, {self.get_status_display()})'
|
|
@@ -227,6 +230,12 @@ class CronJob(models.Model):
|
|
|
227
230
|
verbose_name = _('Cron job')
|
|
228
231
|
verbose_name_plural = _('Cron jobs')
|
|
229
232
|
ordering = ['display_name']
|
|
233
|
+
permissions = [
|
|
234
|
+
('can_use_script_executors', _('Can use script executors (Python/Shell)')),
|
|
235
|
+
]
|
|
236
|
+
indexes = [
|
|
237
|
+
models.Index(fields=['is_active', 'next_run_time'], name='idx_cronjob_active_nextrun'),
|
|
238
|
+
]
|
|
230
239
|
|
|
231
240
|
def save(self, *args, **kwargs):
|
|
232
241
|
if self.cron_expression and (
|
|
@@ -276,7 +285,7 @@ class TaskExecutionArchive(models.Model):
|
|
|
276
285
|
|
|
277
286
|
class TaskExecutionStat(models.Model):
|
|
278
287
|
date = models.DateField(verbose_name=_('Date'))
|
|
279
|
-
|
|
288
|
+
executor_class = models.CharField(verbose_name=_('Executor class'), max_length=512, db_index=True)
|
|
280
289
|
trigger_event = models.CharField(verbose_name=_('Trigger event'), max_length=64)
|
|
281
290
|
total_count = models.IntegerField(verbose_name=_('Total count'), default=0)
|
|
282
291
|
success_count = models.IntegerField(verbose_name=_('Success count'), default=0)
|
|
@@ -289,8 +298,8 @@ class TaskExecutionStat(models.Model):
|
|
|
289
298
|
db_table = 'django_simpletask5_stat'
|
|
290
299
|
verbose_name = _('Task execution stat')
|
|
291
300
|
verbose_name_plural = _('Task execution stats')
|
|
292
|
-
unique_together = [('date', '
|
|
301
|
+
unique_together = [('date', 'executor_class', 'trigger_event')]
|
|
293
302
|
ordering = ['-date', 'trigger_event']
|
|
294
303
|
|
|
295
304
|
def __str__(self):
|
|
296
|
-
return f'Stat({self.date}, {self.trigger_event})'
|
|
305
|
+
return f'Stat({self.date}, {self.executor_class}, {self.trigger_event})'
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/services/archive.py
RENAMED
|
@@ -192,7 +192,7 @@ def delete_archive_file(archive_date):
|
|
|
192
192
|
def _update_stats(records, stat_date):
|
|
193
193
|
from django_simpletask5.models import TaskExecutionStat
|
|
194
194
|
|
|
195
|
-
stats = records.values('trigger_event', '
|
|
195
|
+
stats = records.values('trigger_event', 'executor_class').annotate(
|
|
196
196
|
total=Count('execution_id'),
|
|
197
197
|
success_count=Count('execution_id', filter=Q(status='success')),
|
|
198
198
|
failed_count=Count('execution_id', filter=Q(status='failed')),
|
|
@@ -202,7 +202,7 @@ def _update_stats(records, stat_date):
|
|
|
202
202
|
for stat in stats:
|
|
203
203
|
duration_avg = records.filter(
|
|
204
204
|
trigger_event=stat['trigger_event'],
|
|
205
|
-
|
|
205
|
+
executor_class=stat['executor_class'],
|
|
206
206
|
started_at__isnull=False,
|
|
207
207
|
finished_at__isnull=False,
|
|
208
208
|
).aggregate(
|
|
@@ -215,7 +215,7 @@ def _update_stats(records, stat_date):
|
|
|
215
215
|
|
|
216
216
|
TaskExecutionStat.objects.update_or_create(
|
|
217
217
|
date=stat_date,
|
|
218
|
-
|
|
218
|
+
executor_class=stat['executor_class'],
|
|
219
219
|
trigger_event=stat['trigger_event'],
|
|
220
220
|
defaults={
|
|
221
221
|
'total_count': stat['total'],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-simpletask5
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Lightweight async task execution framework for Django
|
|
5
5
|
Author-email: rRR0VrFP <rrr0vrfp@qq.com>
|
|
6
6
|
Maintainer-email: rRR0VrFP <rrr0vrfp@qq.com>
|
|
@@ -177,6 +177,9 @@ DJANGO_SIMPLETASK_CRONJOB_AUTO_SYNC = True # 是否自动将代码注册的 Cro
|
|
|
177
177
|
### 安全
|
|
178
178
|
|
|
179
179
|
```python
|
|
180
|
+
# 是否启用 Python/Shell 脚本执行器(默认关闭,开启有安全风险)
|
|
181
|
+
DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS = False
|
|
182
|
+
|
|
180
183
|
# 脚本执行白名单(空列表表示不限制)
|
|
181
184
|
DJANGO_SIMPLETASK_SCRIPT_WHITELIST = ['/path/to/allowed/scripts']
|
|
182
185
|
|
|
@@ -187,6 +190,8 @@ DJANGO_SIMPLETASK_ERROR_MSG_PASSWORD = None # 错误信息加密密码
|
|
|
187
190
|
DJANGO_SIMPLETASK_CONTEXT_PASSWORD = None # 上下文数据加密密码
|
|
188
191
|
```
|
|
189
192
|
|
|
193
|
+
> 注意:`DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS` 设为 `True` 后,还需为用户/组授予 `django_simpletask5 | Cron job | Can use script executors` 权限,非超级管理员无法创建 Python/Shell 脚本执行器类型的定时任务。
|
|
194
|
+
|
|
190
195
|
## 快速开始
|
|
191
196
|
|
|
192
197
|
### 1. 定义任务模型
|
|
@@ -315,6 +320,23 @@ Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保
|
|
|
315
320
|
|
|
316
321
|
## Releases
|
|
317
322
|
|
|
323
|
+
### 0.2.0
|
|
324
|
+
|
|
325
|
+
- **破坏性变更**: 移除 `Task` 模型上的自定义主键,`task_id` 不再是主键字段,改为 `unique=True` 的唯一标识字段。所有 `Task` 子模型将自动获得 Django 默认的自增 `id` 主键。已有数据库需要迁移处理。
|
|
326
|
+
- **统计维度调整**: `TaskExecutionStat` 的统计维度由 `task_model` 改为 `executor_class`,统一覆盖有模型任务和 Cron 定时任务两种场景。对应 API `_update_stats()` 按 `executor_class` 分组聚合。
|
|
327
|
+
- **Admin 优化**: CronJob 列表页移除 `executor_class` 列避免表格撑开;TaskExecutionStat 列表页新增 `executor_class` 列、`created_at` 设为只读修复详情页错误。
|
|
328
|
+
- **测试修复**: 修复 `TransactionTestCase` + `on_commit` 在 SQLite 共享内存连接下因 `TestCase` 残留原子块导致回调不执行的兼容性问题;Redis MQ / RabbitMQ E2E 集成测试全部通过。
|
|
329
|
+
|
|
330
|
+
### 0.1.5
|
|
331
|
+
|
|
332
|
+
- **安全加固** — 新增 `DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS` 配置项(默认关闭),启用后才可执行 Python/Shell 脚本;新增 `can_use_script_executors` 权限,仅有此权限或超级管理员的用户才能在 Admin 中创建脚本执行器类型的 CronJob
|
|
333
|
+
- **Bug 修复** — 修复 `RetryTimeoutExecutor` 中 lambda 晚绑定导致所有回调引用最后一个记录的问题
|
|
334
|
+
- **Admin 安全** — 操作按钮(立即执行、启用/禁用)从 GET 参数改为 POST 请求,附带 CSRF 保护
|
|
335
|
+
- **性能优化** — `RetryTimeoutExecutor` 改为批量 update + 按 ID 分发消息推送
|
|
336
|
+
- **Admin 增强** — `TaskExecutionAdmin` 新增 `executor_class` 过滤、`error_message` 搜索、批量重试失败任务、批量取消待处理任务
|
|
337
|
+
- **数据索引** — 添加 `(status, expire_time)` 和 `(is_active, next_run_time)` 复合索引;`trigger_event`、`executor_class`、`task_id`、`created_at` 添加单字段索引
|
|
338
|
+
- **代码规范** — `bash_script.py`、`python_script.py`、`simple_request.py` 补充缺失的 logger;移除废弃的 `allow_tags` 属性
|
|
339
|
+
|
|
318
340
|
### 0.1.4
|
|
319
341
|
|
|
320
342
|
- **修复打包** — `pyproject.toml` 添加 `package-data` 配置,打包时包含 locale po/mo 文件,修复 i18n 翻译不生效的问题
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5.egg-info/SOURCES.txt
RENAMED
|
@@ -43,6 +43,8 @@ django_simpletask5/migrations/0002_taskexecution_done_tasks_count_and_more.py
|
|
|
43
43
|
django_simpletask5/migrations/0003_alter_cronjob_options_remove_cronjob_name_and_more.py
|
|
44
44
|
django_simpletask5/migrations/0004_alter_cronjob_is_modified_by_user.py
|
|
45
45
|
django_simpletask5/migrations/0005_alter_cronjob_is_modified_by_user_help.py
|
|
46
|
+
django_simpletask5/migrations/0006_alter_cronjob_options_alter_taskexecution_created_at_and_more.py
|
|
47
|
+
django_simpletask5/migrations/0007_rename_task_model_to_executor_class_in_stat.py
|
|
46
48
|
django_simpletask5/migrations/__init__.py
|
|
47
49
|
django_simpletask5/services/__init__.py
|
|
48
50
|
django_simpletask5/services/archive.py
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import logging
|
|
3
|
-
|
|
4
|
-
from django.db import models, transaction
|
|
5
|
-
|
|
6
|
-
from django_simpletask5.models import TaskExecution
|
|
7
|
-
from django_simpletask5.executors.base import BaseExecutor
|
|
8
|
-
|
|
9
|
-
logger = logging.getLogger(__name__)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class RetryTimeoutExecutor(BaseExecutor):
|
|
13
|
-
def execute(self, execution: TaskExecution, task=None) -> str | None:
|
|
14
|
-
retryable = TaskExecution.objects.filter(
|
|
15
|
-
status='timeout',
|
|
16
|
-
retry_count__lt=models.F('max_retries'),
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
count = 0
|
|
20
|
-
for exec_record in retryable:
|
|
21
|
-
exec_record.status = 'retry'
|
|
22
|
-
exec_record.retry_count += 1
|
|
23
|
-
exec_record.save(update_fields=['status', 'retry_count', 'updated_at'])
|
|
24
|
-
|
|
25
|
-
from django_simpletask5.core.publisher import _publish_message
|
|
26
|
-
transaction.on_commit(lambda: _publish_message(exec_record))
|
|
27
|
-
count += 1
|
|
28
|
-
|
|
29
|
-
return json.dumps({
|
|
30
|
-
'retried': count,
|
|
31
|
-
'success': True,
|
|
32
|
-
})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/cronjob_registry.py
RENAMED
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/executor_scanner.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/core/message_queue.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/__init__.py
RENAMED
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/archive.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/loader.py
RENAMED
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/ping_pong.py
RENAMED
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/executors/status_check.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/management/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/migrations/0001_initial.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/migrations/__init__.py
RENAMED
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5/services/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5.egg-info/requires.txt
RENAMED
|
File without changes
|
{django_simpletask5-0.1.4 → django_simpletask5-0.2.0}/django_simpletask5.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|