django-simpletask5 0.2.0__tar.gz → 0.2.2__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.
Files changed (61) hide show
  1. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/PKG-INFO +102 -7
  2. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/README.md +100 -6
  3. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/__init__.py +1 -0
  4. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/admin.py +31 -14
  5. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/core/cronjob_registry.py +19 -6
  6. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/core/defaults.py +18 -2
  7. django_simpletask5-0.2.2/django_simpletask5/core/lock.py +22 -0
  8. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/core/message_queue.py +23 -25
  9. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/core/publisher.py +59 -1
  10. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/core/worker_registry.py +5 -2
  11. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/dashboards.py +28 -22
  12. django_simpletask5-0.2.2/django_simpletask5/executors/base.py +35 -0
  13. django_simpletask5-0.2.2/django_simpletask5/executors/exceptions.py +13 -0
  14. django_simpletask5-0.2.2/django_simpletask5/executors/retry_timeout.py +66 -0
  15. django_simpletask5-0.2.2/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  16. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.po +68 -36
  17. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/management/commands/django_simpletask_crontab.py +6 -2
  18. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/management/commands/django_simpletask_executor.py +143 -66
  19. django_simpletask5-0.2.2/django_simpletask5/migrations/0008_taskexecution_pickup_timeout_seconds.py +20 -0
  20. django_simpletask5-0.2.2/django_simpletask5/migrations/0009_add_cronjob_app_label.py +20 -0
  21. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/models.py +48 -1
  22. django_simpletask5-0.2.2/django_simpletask5/static/django_simpletask5/css/dashboard.css +16 -0
  23. django_simpletask5-0.2.2/django_simpletask5/static/django_simpletask5/js/select2_executor.js +89 -0
  24. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5.egg-info/PKG-INFO +102 -7
  25. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5.egg-info/SOURCES.txt +6 -1
  26. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5.egg-info/requires.txt +1 -0
  27. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/pyproject.toml +3 -2
  28. django_simpletask5-0.2.0/django_simpletask5/core/lock.py +0 -24
  29. django_simpletask5-0.2.0/django_simpletask5/executors/base.py +0 -20
  30. django_simpletask5-0.2.0/django_simpletask5/executors/retry_timeout.py +0 -47
  31. django_simpletask5-0.2.0/django_simpletask5/locale/zh_Hans/LC_MESSAGES/django.mo +0 -0
  32. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/LICENSE +0 -0
  33. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/apps.py +0 -0
  34. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/core/__init__.py +0 -0
  35. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/core/executor_scanner.py +0 -0
  36. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/core/signals.py +0 -0
  37. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/executors/__init__.py +0 -0
  38. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/executors/archive.py +0 -0
  39. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/executors/bash_script.py +0 -0
  40. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/executors/loader.py +0 -0
  41. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/executors/ping_pong.py +0 -0
  42. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/executors/python_script.py +0 -0
  43. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/executors/simple_request.py +0 -0
  44. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/executors/status_check.py +0 -0
  45. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/management/__init__.py +0 -0
  46. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/management/commands/__init__.py +0 -0
  47. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/management/commands/django_simpletask_archive.py +0 -0
  48. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/management/commands/django_simpletask_sync_cronjobs.py +0 -0
  49. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/migrations/0001_initial.py +0 -0
  50. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/migrations/0002_taskexecution_done_tasks_count_and_more.py +0 -0
  51. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/migrations/0003_alter_cronjob_options_remove_cronjob_name_and_more.py +0 -0
  52. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/migrations/0004_alter_cronjob_is_modified_by_user.py +0 -0
  53. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/migrations/0005_alter_cronjob_is_modified_by_user_help.py +0 -0
  54. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/migrations/0006_alter_cronjob_options_alter_taskexecution_created_at_and_more.py +0 -0
  55. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/migrations/0007_rename_task_model_to_executor_class_in_stat.py +0 -0
  56. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/migrations/__init__.py +0 -0
  57. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/services/__init__.py +0 -0
  58. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5/services/archive.py +0 -0
  59. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5.egg-info/dependency_links.txt +0 -0
  60. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/django_simpletask5.egg-info/top_level.txt +0 -0
  61. {django_simpletask5-0.2.0 → django_simpletask5-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-simpletask5
3
- Version: 0.2.0
3
+ Version: 0.2.2
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>
@@ -29,6 +29,7 @@ Requires-Dist: django-app-requires>=0.3.6
29
29
  Requires-Dist: django-admin-dashboards>=0.1.1
30
30
  Requires-Dist: django-checkbox-normalize>=0.2.2
31
31
  Requires-Dist: django-tabbed-changeform-admin>=0.1.9
32
+ Requires-Dist: django-static-fontawesome>=5.0.2
32
33
  Requires-Dist: cryptography>=48.0.0
33
34
  Requires-Dist: croniter>=6.2.2
34
35
  Requires-Dist: requests>=2.34.2
@@ -50,7 +51,8 @@ Dynamic: license-file
50
51
  - **自定义事件** — 通过 `task.trigger('event_name')` 触发任意事件
51
52
  - **灵活的执行器映射** — 不同事件可绑定不同的执行器类
52
53
  - **队列路由** — 不同事件可路由到不同优先级队列
53
- - **重试与超时** — 失败自动重试(指数退避),支持超时检测
54
+ - **重试与超时** — 失败自动重试(指数退避),支持执行超时与 Pickup 超时两种检测
55
+ - **拒绝回退** — 执行器可通过 `RejectException` 拒绝当前任务,消息自动重新入队等待下次调度
54
56
  - **加密字段** — 敏感数据自动加密存储
55
57
  - **Cron 调度** — 内置 crontab 守护进程,支持代码注册与数据库覆盖
56
58
  - **归档统计** — 已完成执行记录自动归档为加密 JSONL,并生成日统计
@@ -134,6 +136,19 @@ DJANGO_SIMPLETASK_RABBITMQ_PASSWORD = 'guest'
134
136
 
135
137
  ### 分布式锁
136
138
 
139
+ 默认使用 Django 缓存中的 Redis 连接,只需配置好 Django 的 `CACHES`:
140
+
141
+ ```python
142
+ CACHES = {
143
+ 'default': {
144
+ 'BACKEND': 'django_redis.cache.RedisCache',
145
+ 'LOCATION': 'redis://127.0.0.1:6379/1',
146
+ },
147
+ }
148
+ ```
149
+
150
+ 如需自定义锁引擎或连接参数:
151
+
137
152
  ```python
138
153
  DJANGO_SIMPLETASK_LOCK_CONFIG = {
139
154
  'global_lock_engine_class': 'globallock.redis_global_lock.RedisGlobalLock',
@@ -149,17 +164,22 @@ DJANGO_SIMPLETASK_LOCK_TIMEOUT = 600 # 锁超时时间(秒)
149
164
  ### 队列路由
150
165
 
151
166
  ```python
152
- DJANGO_SIMPLETASK_DEFAULT_QUEUE = 'django_simpletask5.queue.default' # 默认队列
153
- DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = 'django_simpletask5.queue.high_priority' # 高优先级队列
167
+ DJANGO_SIMPLETASK_DEFAULT_QUEUE = 'myproject:django-simpletask5:queue:default' # 默认队列
168
+ DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = 'myproject:django-simpletask5:queue:high_priority' # 高优先级队列
154
169
  ```
155
170
 
156
171
  ### 执行与重试
157
172
 
158
173
  ```python
159
- DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
160
- DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时时间(秒)
174
+ DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
175
+ DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时(秒),任务开始执行后的超时限制
176
+ DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS = 3600 # 默认 Pickup 超时(秒),等待 Worker 消费的时间限制
161
177
  ```
162
178
 
179
+ 框架区分两种超时:
180
+ - **执行超时**(`timeout_seconds`)— 任务进入 `running` 状态后,必须在指定时间内完成,否则被 `StatusCheckExecutor` 标记超时。适用于保护实际执行不 hang 死。
181
+ - **Pickup 超时**(`pickup_timeout_seconds`)— 任务创建后等待 Worker 消费的时间上限,也用于被拒绝回退后等待重新调度的保护。通常设得比执行超时更长,因为排队等待时间不可控。
182
+
163
183
  ### 归档
164
184
 
165
185
  ```python
@@ -235,6 +255,33 @@ class OrderCreateExecutor(BaseExecutor):
235
255
  return 'ok'
236
256
  ```
237
257
 
258
+ 执行器可以通过 `executor_class` 属性覆盖超时配置:
259
+
260
+ ```python
261
+ class OrderCreateExecutor(BaseExecutor):
262
+ timeout_seconds = 300 # 执行超时 5 分钟
263
+ pickup_timeout_seconds = 1800 # Pickup 超时 30 分钟
264
+ ```
265
+
266
+ ### 拒绝回退
267
+
268
+ 执行器在执行过程中遇到临时条件不满足(如资源上限),可以抛出 `RejectException` 拒绝本次执行:
269
+
270
+ ```python
271
+ from django_simpletask5.executors.exceptions import RejectException
272
+
273
+ class MyExecutor(BaseExecutor):
274
+ def execute(self, execution: TaskExecution) -> str | None:
275
+ if not self._can_proceed():
276
+ raise RejectException('Resource limit reached, try again later')
277
+ ```
278
+
279
+ 框架捕获后会:
280
+ - 调用 `message.reject(requeue=True)` 将消息放回队列尾部,下次重新调度
281
+ - `TaskExecution` 状态恢复为 `pending`,`started_at` 清空,等待下次 Worker 消费
282
+ - **不消耗**重试次数
283
+ - 刷新 `expire_time`(使用 Pickup 超时),防止被 `StatusCheckExecutor` 误回收
284
+
238
285
  ### 3. 启动 Worker
239
286
 
240
287
  默认只监听 `default` 队列,`high_priority` 队列需要单独启动 Worker 处理。
@@ -263,6 +310,31 @@ order = OrderTask.objects.get(order_id='ORD-001')
263
310
  order.trigger('refund', extra_context={'refund_amount': '50.00'})
264
311
  ```
265
312
 
313
+ ### 6. 抑制异步消息
314
+
315
+ 创建或更新 Task 时,可通过 `_skip_publish` 机制禁止触发 RabbitMQ 消息通知,不会创建 `TaskExecution` 也不发送异步消息。提供四种使用方式:
316
+
317
+ ```python
318
+ # 方式一:设置实例属性
319
+ order = OrderTask(order_id='ORD-001', customer_name='Test', amount=Decimal('100.00'))
320
+ order._skip_publish = True
321
+ order.save()
322
+
323
+ # 方式二:调用 skip_publish() 方法(支持链式调用)
324
+ order.skip_publish().save()
325
+
326
+ # 方式三:save 时传入 skip_publish=True
327
+ order.save(skip_publish=True)
328
+
329
+ # 方式四:构造或 create 时直接传入(推荐)
330
+ OrderTask.objects.create(
331
+ order_id='ORD-001', customer_name='Test', amount=Decimal('100.00'),
332
+ skip_publish=True,
333
+ )
334
+ ```
335
+
336
+ `_skip_publish` 是临时实例属性,不会持久化到数据库,后续正常操作不受影响。
337
+
266
338
  ## Cron 任务
267
339
 
268
340
  ### 在代码中定义 Cron 任务
@@ -315,11 +387,34 @@ Task 模型变更 → Django 信号 → 创建 TaskExecution 并发布到消息
315
387
  ↓
316
388
  Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保存结果
317
389
  ↓
318
- 失败时自动重试,完成后归档
390
+ 失败时自动重试/拒绝时重新入队,完成后归档
319
391
  ```
320
392
 
321
393
  ## Releases
322
394
 
395
+ ### 0.2.2
396
+
397
+ - **抑制异步消息** — 新增 `_skip_publish` 机制,创建/更新/删除 Task 时可通过 `skip_publish()` 方法、`save(skip_publish=True)`、构造时传入 `skip_publish=True` 等方式禁止触发 RabbitMQ 消息通知
398
+ - **批量创建触发异步消息** — `TaskQuerySet` + `TaskManager` 接管 `bulk_create`,批量创建 Task 后自动生成 TaskExecution 并推送 MQ;子类覆盖 `objects` 时发出警告提示
399
+ - **分布式锁默认引擎** — 默认使用 `DjangoRedisGlobalLock`,复用 Django `CACHES['default']` 配置(需为 Redis 缓存),无需手动配置 `DJANGO_SIMPLETASK_LOCK_CONFIG`
400
+ - **Redis key 命名统一** — 所有 Redis key 格式规范为 `{项目前缀}:django-simpletask5:{用途}:{id}`,项目前缀从 `ROOT_URLCONF` 自动提取,也可通过 `DJANGO_SIMPLETASK_KEY_PREFIX` 显式覆盖
401
+ - **CronJob 管理增强** — 新增 `app_label` 字段(自动检测),列表页展示并支持过滤
402
+ - **内置 CronJob 重命名** — Status Check → Task Timeout Detection,Retry Timeout → Retry Failed Tasks,Ping/Pong → Health Check 等
403
+ - **Dashboard 重做** — 新增今日失败卡片,成功率按 90%/80% 分绿色/橙色/红色三级,卡片颜色统一使用 info 蓝色
404
+ - **Pending/Running 显示修复** — 改为展示 `pending / running` 双值而非仅 pending
405
+ - **成功率显示优化** — `(22/25)` 与 `88%` 同行显示,字号缩小颜色淡化,高度对齐
406
+ - **Redis 不可达降级** — Worker 数量显示 `-` 而非 `0`,区分无数据与异常
407
+ - **按钮优化** — 改用 Font Awesome 图标,统一 `min-width` 对齐,title 改为中文描述
408
+ - **翻译补充** — 补充 Run/Activate/Deactivate 及所有内置 CronJob 名称的中文翻译
409
+ - **新增依赖** — `django-static-fontawesome` 用于按钮图标
410
+ - **示例应用** — 示例 cronjobs 模块在 AppConfig.ready() 中导入确保注册生效
411
+
412
+ ### 0.2.1
413
+
414
+ - **拒绝回退机制** — 新增 `RejectException`,执行器可抛出该异常拒绝当前任务,消息自动重新入队等待下次调度,不消耗重试次数
415
+ - **区分两种超时** — 引入 `pickup_timeout_seconds`(等待 Worker 消费超时)与 `timeout_seconds`(执行超时)两种超时配置,`expire_time` 改用 Pickup 超时计算,超时检测更精准
416
+ - **文档更新** — README 补充拒绝回退机制的使用说明和两种超时的详细解释
417
+
323
418
  ### 0.2.0
324
419
 
325
420
  - **破坏性变更**: 移除 `Task` 模型上的自定义主键,`task_id` 不再是主键字段,改为 `unique=True` 的唯一标识字段。所有 `Task` 子模型将自动获得 Django 默认的自增 `id` 主键。已有数据库需要迁移处理。
@@ -11,7 +11,8 @@
11
11
  - **自定义事件** — 通过 `task.trigger('event_name')` 触发任意事件
12
12
  - **灵活的执行器映射** — 不同事件可绑定不同的执行器类
13
13
  - **队列路由** — 不同事件可路由到不同优先级队列
14
- - **重试与超时** — 失败自动重试(指数退避),支持超时检测
14
+ - **重试与超时** — 失败自动重试(指数退避),支持执行超时与 Pickup 超时两种检测
15
+ - **拒绝回退** — 执行器可通过 `RejectException` 拒绝当前任务,消息自动重新入队等待下次调度
15
16
  - **加密字段** — 敏感数据自动加密存储
16
17
  - **Cron 调度** — 内置 crontab 守护进程,支持代码注册与数据库覆盖
17
18
  - **归档统计** — 已完成执行记录自动归档为加密 JSONL,并生成日统计
@@ -95,6 +96,19 @@ DJANGO_SIMPLETASK_RABBITMQ_PASSWORD = 'guest'
95
96
 
96
97
  ### 分布式锁
97
98
 
99
+ 默认使用 Django 缓存中的 Redis 连接,只需配置好 Django 的 `CACHES`:
100
+
101
+ ```python
102
+ CACHES = {
103
+ 'default': {
104
+ 'BACKEND': 'django_redis.cache.RedisCache',
105
+ 'LOCATION': 'redis://127.0.0.1:6379/1',
106
+ },
107
+ }
108
+ ```
109
+
110
+ 如需自定义锁引擎或连接参数:
111
+
98
112
  ```python
99
113
  DJANGO_SIMPLETASK_LOCK_CONFIG = {
100
114
  'global_lock_engine_class': 'globallock.redis_global_lock.RedisGlobalLock',
@@ -110,17 +124,22 @@ DJANGO_SIMPLETASK_LOCK_TIMEOUT = 600 # 锁超时时间(秒)
110
124
  ### 队列路由
111
125
 
112
126
  ```python
113
- DJANGO_SIMPLETASK_DEFAULT_QUEUE = 'django_simpletask5.queue.default' # 默认队列
114
- DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = 'django_simpletask5.queue.high_priority' # 高优先级队列
127
+ DJANGO_SIMPLETASK_DEFAULT_QUEUE = 'myproject:django-simpletask5:queue:default' # 默认队列
128
+ DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = 'myproject:django-simpletask5:queue:high_priority' # 高优先级队列
115
129
  ```
116
130
 
117
131
  ### 执行与重试
118
132
 
119
133
  ```python
120
- DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
121
- DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时时间(秒)
134
+ DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = 3 # 默认最大重试次数
135
+ DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = 600 # 默认执行超时(秒),任务开始执行后的超时限制
136
+ DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS = 3600 # 默认 Pickup 超时(秒),等待 Worker 消费的时间限制
122
137
  ```
123
138
 
139
+ 框架区分两种超时:
140
+ - **执行超时**(`timeout_seconds`)— 任务进入 `running` 状态后,必须在指定时间内完成,否则被 `StatusCheckExecutor` 标记超时。适用于保护实际执行不 hang 死。
141
+ - **Pickup 超时**(`pickup_timeout_seconds`)— 任务创建后等待 Worker 消费的时间上限,也用于被拒绝回退后等待重新调度的保护。通常设得比执行超时更长,因为排队等待时间不可控。
142
+
124
143
  ### 归档
125
144
 
126
145
  ```python
@@ -196,6 +215,33 @@ class OrderCreateExecutor(BaseExecutor):
196
215
  return 'ok'
197
216
  ```
198
217
 
218
+ 执行器可以通过 `executor_class` 属性覆盖超时配置:
219
+
220
+ ```python
221
+ class OrderCreateExecutor(BaseExecutor):
222
+ timeout_seconds = 300 # 执行超时 5 分钟
223
+ pickup_timeout_seconds = 1800 # Pickup 超时 30 分钟
224
+ ```
225
+
226
+ ### 拒绝回退
227
+
228
+ 执行器在执行过程中遇到临时条件不满足(如资源上限),可以抛出 `RejectException` 拒绝本次执行:
229
+
230
+ ```python
231
+ from django_simpletask5.executors.exceptions import RejectException
232
+
233
+ class MyExecutor(BaseExecutor):
234
+ def execute(self, execution: TaskExecution) -> str | None:
235
+ if not self._can_proceed():
236
+ raise RejectException('Resource limit reached, try again later')
237
+ ```
238
+
239
+ 框架捕获后会:
240
+ - 调用 `message.reject(requeue=True)` 将消息放回队列尾部,下次重新调度
241
+ - `TaskExecution` 状态恢复为 `pending`,`started_at` 清空,等待下次 Worker 消费
242
+ - **不消耗**重试次数
243
+ - 刷新 `expire_time`(使用 Pickup 超时),防止被 `StatusCheckExecutor` 误回收
244
+
199
245
  ### 3. 启动 Worker
200
246
 
201
247
  默认只监听 `default` 队列,`high_priority` 队列需要单独启动 Worker 处理。
@@ -224,6 +270,31 @@ order = OrderTask.objects.get(order_id='ORD-001')
224
270
  order.trigger('refund', extra_context={'refund_amount': '50.00'})
225
271
  ```
226
272
 
273
+ ### 6. 抑制异步消息
274
+
275
+ 创建或更新 Task 时,可通过 `_skip_publish` 机制禁止触发 RabbitMQ 消息通知,不会创建 `TaskExecution` 也不发送异步消息。提供四种使用方式:
276
+
277
+ ```python
278
+ # 方式一:设置实例属性
279
+ order = OrderTask(order_id='ORD-001', customer_name='Test', amount=Decimal('100.00'))
280
+ order._skip_publish = True
281
+ order.save()
282
+
283
+ # 方式二:调用 skip_publish() 方法(支持链式调用)
284
+ order.skip_publish().save()
285
+
286
+ # 方式三:save 时传入 skip_publish=True
287
+ order.save(skip_publish=True)
288
+
289
+ # 方式四:构造或 create 时直接传入(推荐)
290
+ OrderTask.objects.create(
291
+ order_id='ORD-001', customer_name='Test', amount=Decimal('100.00'),
292
+ skip_publish=True,
293
+ )
294
+ ```
295
+
296
+ `_skip_publish` 是临时实例属性,不会持久化到数据库,后续正常操作不受影响。
297
+
227
298
  ## Cron 任务
228
299
 
229
300
  ### 在代码中定义 Cron 任务
@@ -276,11 +347,34 @@ Task 模型变更 → Django 信号 → 创建 TaskExecution 并发布到消息
276
347
  ↓
277
348
  Worker 消费消息 → 获取分布式锁 → 加载执行器 → 执行并保存结果
278
349
  ↓
279
- 失败时自动重试,完成后归档
350
+ 失败时自动重试/拒绝时重新入队,完成后归档
280
351
  ```
281
352
 
282
353
  ## Releases
283
354
 
355
+ ### 0.2.2
356
+
357
+ - **抑制异步消息** — 新增 `_skip_publish` 机制,创建/更新/删除 Task 时可通过 `skip_publish()` 方法、`save(skip_publish=True)`、构造时传入 `skip_publish=True` 等方式禁止触发 RabbitMQ 消息通知
358
+ - **批量创建触发异步消息** — `TaskQuerySet` + `TaskManager` 接管 `bulk_create`,批量创建 Task 后自动生成 TaskExecution 并推送 MQ;子类覆盖 `objects` 时发出警告提示
359
+ - **分布式锁默认引擎** — 默认使用 `DjangoRedisGlobalLock`,复用 Django `CACHES['default']` 配置(需为 Redis 缓存),无需手动配置 `DJANGO_SIMPLETASK_LOCK_CONFIG`
360
+ - **Redis key 命名统一** — 所有 Redis key 格式规范为 `{项目前缀}:django-simpletask5:{用途}:{id}`,项目前缀从 `ROOT_URLCONF` 自动提取,也可通过 `DJANGO_SIMPLETASK_KEY_PREFIX` 显式覆盖
361
+ - **CronJob 管理增强** — 新增 `app_label` 字段(自动检测),列表页展示并支持过滤
362
+ - **内置 CronJob 重命名** — Status Check → Task Timeout Detection,Retry Timeout → Retry Failed Tasks,Ping/Pong → Health Check 等
363
+ - **Dashboard 重做** — 新增今日失败卡片,成功率按 90%/80% 分绿色/橙色/红色三级,卡片颜色统一使用 info 蓝色
364
+ - **Pending/Running 显示修复** — 改为展示 `pending / running` 双值而非仅 pending
365
+ - **成功率显示优化** — `(22/25)` 与 `88%` 同行显示,字号缩小颜色淡化,高度对齐
366
+ - **Redis 不可达降级** — Worker 数量显示 `-` 而非 `0`,区分无数据与异常
367
+ - **按钮优化** — 改用 Font Awesome 图标,统一 `min-width` 对齐,title 改为中文描述
368
+ - **翻译补充** — 补充 Run/Activate/Deactivate 及所有内置 CronJob 名称的中文翻译
369
+ - **新增依赖** — `django-static-fontawesome` 用于按钮图标
370
+ - **示例应用** — 示例 cronjobs 模块在 AppConfig.ready() 中导入确保注册生效
371
+
372
+ ### 0.2.1
373
+
374
+ - **拒绝回退机制** — 新增 `RejectException`,执行器可抛出该异常拒绝当前任务,消息自动重新入队等待下次调度,不消耗重试次数
375
+ - **区分两种超时** — 引入 `pickup_timeout_seconds`(等待 Worker 消费超时)与 `timeout_seconds`(执行超时)两种超时配置,`expire_time` 改用 Pickup 超时计算,超时检测更精准
376
+ - **文档更新** — README 补充拒绝回退机制的使用说明和两种超时的详细解释
377
+
284
378
  ### 0.2.0
285
379
 
286
380
  - **破坏性变更**: 移除 `Task` 模型上的自定义主键,`task_id` 不再是主键字段,改为 `unique=True` 的唯一标识字段。所有 `Task` 子模型将自动获得 Django 默认的自增 `id` 主键。已有数据库需要迁移处理。
@@ -3,6 +3,7 @@ default_app_config = 'django_simpletask5.apps.DjangoSimpletask5Config'
3
3
  app_requires = [
4
4
  'django_admin_dashboards',
5
5
  'django_safe_fields',
6
+ 'django_static_fontawesome',
6
7
  'django_tabbed_changeform_admin',
7
8
  'django_checkbox_normalize',
8
9
  ]
@@ -3,6 +3,7 @@ import json
3
3
  from django import forms
4
4
  from django.contrib import admin
5
5
  from django.http import HttpResponseRedirect, Http404
6
+ from django.middleware.csrf import get_token
6
7
  from django.urls import path, reverse
7
8
  from django.utils.html import format_html, mark_safe
8
9
  from django.utils.translation import gettext_lazy as _
@@ -225,6 +226,7 @@ class CronJobAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdmin):
225
226
  form = CronJobForm
226
227
  list_display = [
227
228
  "display_name",
229
+ "app_label",
228
230
  "cron_expression",
229
231
  "is_active",
230
232
  "is_modified_by_user",
@@ -232,9 +234,14 @@ class CronJobAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdmin):
232
234
  "action_buttons",
233
235
  ]
234
236
  readonly_fields = ["created_at", "updated_at"]
235
- list_filter = ["is_active", "is_modified_by_user"]
237
+ list_filter = ["is_active", "is_modified_by_user", "app_label"]
236
238
  search_fields = ["display_name", "uid", "executor_class"]
237
239
 
240
+ class Media:
241
+ css = {
242
+ "all": ("fontawesome/css/all.min.css",),
243
+ }
244
+
238
245
  fieldsets = [
239
246
  (None, {
240
247
  "fields": ["uid", "display_name", "executor_class", "cron_expression", "description", "created_at", "updated_at"],
@@ -261,6 +268,10 @@ class CronJobAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdmin):
261
268
  def get_queryset(self, request):
262
269
  return super().get_queryset(request)
263
270
 
271
+ def changelist_view(self, request, extra_context=None):
272
+ self._admin_request = request
273
+ return super().changelist_view(request, extra_context=extra_context)
274
+
264
275
  def get_urls(self):
265
276
  urls = super().get_urls()
266
277
  custom_urls = [
@@ -311,39 +322,45 @@ class CronJobAdmin(DjangoCheckboxNormalizeAdmin, DjangoTabbedChangeformAdmin):
311
322
  reverse('admin:django_simpletask5_cronjob_changelist')
312
323
  )
313
324
 
325
+ _btn_base = "padding:3px 8px;border-radius:3px;border:none;cursor:pointer;margin-right:4px;font-size:12px;min-width:68px"
326
+
327
+ def _csrf_token_input(self):
328
+ token = get_token(self._admin_request) if hasattr(self, '_admin_request') else ''
329
+ return f'<input type="hidden" name="csrfmiddlewaretoken" value="{token}">'
330
+
314
331
  def action_buttons(self, obj):
315
332
  run_url = reverse('admin:django_simpletask5_cronjob_run', args=[obj.pk])
316
333
  toggle_url = reverse('admin:django_simpletask5_cronjob_toggle_active', args=[obj.pk])
317
- csrf_token = '"{{ csrf_token }}"'
334
+ csrf_input = self._csrf_token_input()
318
335
 
319
336
  buttons = []
320
337
  buttons.append(
321
338
  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>'
339
+ f'{csrf_input}'
340
+ f'<button type="submit" style="background:#17a2b8;color:#fff;{self._btn_base}" title="{_("Click to run this cronjob")}">'
341
+ f'<i class="fas fa-play"></i> {_("Run")}</button></form>'
325
342
  )
326
343
  if obj.is_active:
327
344
  btn_style = "background:#ffc107;color:#000"
328
- btn_title = _("Deactivate")
329
- btn_icon = "ri-pause-line"
345
+ btn_title = _("Click to deactivate this cronjob")
346
+ btn_icon = "fas fa-pause"
330
347
  btn_label = _("Deactivate")
331
348
  else:
332
349
  btn_style = "background:#28a745;color:#fff"
333
- btn_title = _("Activate")
334
- btn_icon = "ri-play-circle-line"
350
+ btn_title = _("Click to activate this cronjob")
351
+ btn_icon = "fas fa-play-circle"
335
352
  btn_label = _("Activate")
336
353
  buttons.append(
337
354
  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}">'
355
+ f'{csrf_input}'
356
+ f'<button type="submit" style="{btn_style};{self._btn_base}" title="{btn_title}">'
340
357
  f'<i class="{btn_icon}"></i> {btn_label}</button></form>'
341
358
  )
342
359
  delete_url = reverse('admin:django_simpletask5_cronjob_delete', args=[obj.pk])
343
360
  buttons.append(
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" '
345
- f'href="{delete_url}" title="{_("Delete")}">'
346
- f'<i class="ri-delete-bin-line"></i> {_("Delete")}</a>'
361
+ f'<a class="button" style="background:#dc3545;color:#fff;padding:3px 8px;border-radius:3px;text-decoration:none;margin-right:4px;font-size:12px;min-width:68px;display:inline-block;text-align:center" '
362
+ f'href="{delete_url}" title="{_("Click to delete this cronjob")}">'
363
+ f'<i class="fas fa-trash"></i> {_("Delete")}</a>'
347
364
  )
348
365
  return mark_safe(''.join(buttons))
349
366
 
@@ -10,7 +10,17 @@ logger = logging.getLogger(__name__)
10
10
  _CRONJOB_REGISTRY = {}
11
11
 
12
12
 
13
- def register_cronjob(uid, display_name, cron_expression, executor_class, context=None, description='', is_active=True):
13
+ def register_cronjob(uid, display_name, cron_expression, executor_class, context=None, description='', is_active=True, app_label=''):
14
+ if not app_label:
15
+ import inspect
16
+ frame = inspect.currentframe()
17
+ try:
18
+ caller_frame = frame.f_back
19
+ caller_module = inspect.getmodule(caller_frame)
20
+ if caller_module and caller_module.__name__:
21
+ app_label = caller_module.__name__.split('.')[0]
22
+ finally:
23
+ del frame
14
24
  _CRONJOB_REGISTRY[uid] = {
15
25
  'display_name': display_name,
16
26
  'cron_expression': cron_expression,
@@ -18,6 +28,7 @@ def register_cronjob(uid, display_name, cron_expression, executor_class, context
18
28
  'context': json.dumps(context) if context else None,
19
29
  'description': description,
20
30
  'is_active': is_active,
31
+ 'app_label': app_label,
21
32
  }
22
33
 
23
34
 
@@ -56,6 +67,7 @@ def sync_cronjobs():
56
67
  cronjob.context = config.get('context')
57
68
  cronjob.description = config.get('description', '')
58
69
  cronjob.is_active = config.get('is_active', True)
70
+ cronjob.app_label = config.get('app_label', '')
59
71
  if cronjob.next_run_time is None:
60
72
  cronjob.next_run_time = _compute_next_run_time(config['cron_expression'])
61
73
  cronjob.save()
@@ -68,6 +80,7 @@ def sync_cronjobs():
68
80
  context=config.get('context'),
69
81
  description=config.get('description', ''),
70
82
  is_active=config.get('is_active', True),
83
+ app_label=config.get('app_label', ''),
71
84
  is_modified_by_user=False,
72
85
  next_run_time=_compute_next_run_time(config['cron_expression']),
73
86
  )
@@ -80,35 +93,35 @@ def register_builtin_cronjobs():
80
93
 
81
94
  register_cronjob(
82
95
  uid='simpletask_status_check',
83
- display_name=_('Status Check'),
96
+ display_name=_('Task Timeout Detection'),
84
97
  cron_expression='* * * * *',
85
98
  executor_class='django_simpletask5.executors.status_check.StatusCheckExecutor',
86
99
  description='Scan long-running pending/running tasks and mark them as timeout',
87
100
  )
88
101
  register_cronjob(
89
102
  uid='simpletask_retry_timeout',
90
- display_name=_('Retry Timeout'),
103
+ display_name=_('Retry Failed Tasks'),
91
104
  cron_expression='* * * * *',
92
105
  executor_class='django_simpletask5.executors.retry_timeout.RetryTimeoutExecutor',
93
106
  description='Retry timeout tasks that still have retry attempts remaining',
94
107
  )
95
108
  register_cronjob(
96
109
  uid='simpletask_archive',
97
- display_name=_('Archive'),
110
+ display_name=_('Execution Archive'),
98
111
  cron_expression='0 2 * * *',
99
112
  executor_class='django_simpletask5.executors.archive.ArchiveExecutor',
100
113
  description='Archive previous day TaskExecution records',
101
114
  )
102
115
  register_cronjob(
103
116
  uid='simpletask_purge_expired',
104
- display_name=_('Purge Expired'),
117
+ display_name=_('Purge Expired Records'),
105
118
  cron_expression='0 3 * * *',
106
119
  executor_class='django_simpletask5.executors.archive.PurgeExpiredExecutor',
107
120
  description='Purge expired TaskExecution records beyond retention days',
108
121
  )
109
122
  register_cronjob(
110
123
  uid='simpletask_ping_pong',
111
- display_name=_('Ping / Pong'),
124
+ display_name=_('Health Check'),
112
125
  cron_expression='* * * * *',
113
126
  executor_class='django_simpletask5.executors.ping_pong.PingPongExecutor',
114
127
  description='Ping pong health check',
@@ -1,9 +1,25 @@
1
1
  from django.conf import settings
2
2
 
3
+
4
+ def _compute_key_prefix() -> str:
5
+ custom = getattr(settings, 'DJANGO_SIMPLETASK_KEY_PREFIX', None)
6
+ if custom is not None:
7
+ return custom
8
+ root_urlconf = getattr(settings, 'ROOT_URLCONF', None)
9
+ if root_urlconf:
10
+ return root_urlconf.split('.')[0]
11
+ return 'default'
12
+
13
+
14
+ DJANGO_SIMPLETASK_KEY_PREFIX = _compute_key_prefix()
15
+
3
16
  DJANGO_SIMPLETASK_LOCK_TIMEOUT = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_TIMEOUT', 600)
17
+ DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_RENEW_INTERVAL', 60)
4
18
 
19
+ DJANGO_SIMPLETASK_SHUTDOWN_TIMEOUT = getattr(settings, 'DJANGO_SIMPLETASK_SHUTDOWN_TIMEOUT', 30)
5
20
  DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_MAX_RETRIES', 3)
6
21
  DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_TIMEOUT_SECONDS', 600)
22
+ DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_PICKUP_TIMEOUT_SECONDS', 3600)
7
23
 
8
24
  DJANGO_SIMPLETASK_ARCHIVE_PATH = getattr(settings, 'DJANGO_SIMPLETASK_ARCHIVE_PATH', 'django_simpletask5_archives')
9
25
  DJANGO_SIMPLETASK_ARCHIVE_SALT = getattr(settings, 'DJANGO_SIMPLETASK_ARCHIVE_SALT', 'django-simpletask5-archive')
@@ -11,8 +27,8 @@ DJANGO_SIMPLETASK_ARCHIVE_RETENTION_DAYS = getattr(settings, 'DJANGO_SIMPLETASK_
11
27
 
12
28
  DJANGO_SIMPLETASK_CRONJOB_AUTO_SYNC = getattr(settings, 'DJANGO_SIMPLETASK_CRONJOB_AUTO_SYNC', True)
13
29
 
14
- DJANGO_SIMPLETASK_DEFAULT_QUEUE = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_QUEUE', 'django_simpletask5.queue.default')
15
- DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = getattr(settings, 'DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE', 'django_simpletask5.queue.high_priority')
30
+ DJANGO_SIMPLETASK_DEFAULT_QUEUE = getattr(settings, 'DJANGO_SIMPLETASK_DEFAULT_QUEUE', f'{DJANGO_SIMPLETASK_KEY_PREFIX}:django-simpletask5:queue:default')
31
+ DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE = getattr(settings, 'DJANGO_SIMPLETASK_HIGH_PRIORITY_QUEUE', f'{DJANGO_SIMPLETASK_KEY_PREFIX}:django-simpletask5:queue:high_priority')
16
32
 
17
33
  DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS = getattr(settings, 'DJANGO_SIMPLETASK_ENABLE_SCRIPT_EXECUTORS', False)
18
34
  DJANGO_SIMPLETASK_SCRIPT_WHITELIST = getattr(settings, 'DJANGO_SIMPLETASK_SCRIPT_WHITELIST', [])
@@ -0,0 +1,22 @@
1
+ import logging
2
+
3
+ from django.conf import settings
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ def _default_lock_config():
9
+ return {
10
+ 'global_lock_engine_class': 'globallock.django_redis_global_lock.DjangoRedisGlobalLock',
11
+ 'global_lock_engine_options': {
12
+ 'redis-cache-name': 'default',
13
+ },
14
+ }
15
+
16
+
17
+ def get_lock_manager():
18
+ from globallock import GlobalLockManager
19
+ config = getattr(settings, 'DJANGO_SIMPLETASK_LOCK_CONFIG', None)
20
+ if config is None:
21
+ config = _default_lock_config()
22
+ return GlobalLockManager(config)
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import socket
3
+ import time
3
4
 
4
5
  from django.conf import settings
5
6
  from kombu import Connection, Exchange, Queue, Producer, Consumer
@@ -53,32 +54,29 @@ class KombuQueue:
53
54
  retry=True,
54
55
  )
55
56
 
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)
57
+ def consume(self, queue_name, callback, auto_ack=True, poll_callback=None, poll_interval=60):
58
+ with Connection(_get_broker_url()) as conn:
59
+ conn.connect()
60
+ queue = Queue(queue_name, self._exchange, routing_key=queue_name, durable=True)
63
61
 
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)
62
+ with Consumer(
63
+ conn,
64
+ queues=[queue],
65
+ callbacks=[lambda body, msg: self._on_message(body, msg, callback, auto_ack)],
66
+ accept=['json'],
67
+ ):
68
+ last_poll = time.time()
69
+ while not self._stopped:
70
+ try:
71
+ conn.drain_events(timeout=0.5)
72
+ except socket.timeout:
73
+ pass
74
+ if poll_callback and time.time() - last_poll >= poll_interval:
75
+ try:
76
+ poll_callback()
77
+ except Exception:
78
+ logger.exception('Poll callback failed')
79
+ last_poll = time.time()
82
80
 
83
81
  @staticmethod
84
82
  def _on_message(body, message, callback, auto_ack):