django-nativemojo 0.1.10__py3-none-any.whl → 0.1.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. django_nativemojo-0.1.16.dist-info/METADATA +138 -0
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/__init__.py +5 -0
  5. mojo/apps/account/management/commands/__init__.py +6 -0
  6. mojo/apps/account/management/commands/serializer_admin.py +651 -0
  7. mojo/apps/account/migrations/0004_user_avatar.py +20 -0
  8. mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
  9. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  10. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  11. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  12. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  13. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  14. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  15. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  16. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  17. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  18. mojo/apps/account/models/__init__.py +2 -0
  19. mojo/apps/account/models/device.py +281 -0
  20. mojo/apps/account/models/group.py +319 -15
  21. mojo/apps/account/models/member.py +29 -5
  22. mojo/apps/account/models/push/__init__.py +4 -0
  23. mojo/apps/account/models/push/config.py +112 -0
  24. mojo/apps/account/models/push/delivery.py +93 -0
  25. mojo/apps/account/models/push/device.py +66 -0
  26. mojo/apps/account/models/push/template.py +99 -0
  27. mojo/apps/account/models/user.py +369 -19
  28. mojo/apps/account/rest/__init__.py +2 -0
  29. mojo/apps/account/rest/device.py +39 -0
  30. mojo/apps/account/rest/group.py +9 -0
  31. mojo/apps/account/rest/push.py +187 -0
  32. mojo/apps/account/rest/user.py +100 -6
  33. mojo/apps/account/services/__init__.py +1 -0
  34. mojo/apps/account/services/push.py +363 -0
  35. mojo/apps/aws/migrations/0001_initial.py +206 -0
  36. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  37. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  38. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  39. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  40. mojo/apps/aws/models/__init__.py +19 -0
  41. mojo/apps/aws/models/email_attachment.py +99 -0
  42. mojo/apps/aws/models/email_domain.py +218 -0
  43. mojo/apps/aws/models/email_template.py +132 -0
  44. mojo/apps/aws/models/incoming_email.py +197 -0
  45. mojo/apps/aws/models/mailbox.py +288 -0
  46. mojo/apps/aws/models/sent_message.py +175 -0
  47. mojo/apps/aws/rest/__init__.py +7 -0
  48. mojo/apps/aws/rest/email.py +33 -0
  49. mojo/apps/aws/rest/email_ops.py +183 -0
  50. mojo/apps/aws/rest/messages.py +32 -0
  51. mojo/apps/aws/rest/s3.py +64 -0
  52. mojo/apps/aws/rest/send.py +101 -0
  53. mojo/apps/aws/rest/sns.py +403 -0
  54. mojo/apps/aws/rest/templates.py +19 -0
  55. mojo/apps/aws/services/__init__.py +32 -0
  56. mojo/apps/aws/services/email.py +390 -0
  57. mojo/apps/aws/services/email_ops.py +548 -0
  58. mojo/apps/docit/__init__.py +6 -0
  59. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  60. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  61. mojo/apps/docit/migrations/0001_initial.py +113 -0
  62. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  63. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  64. mojo/apps/docit/models/__init__.py +17 -0
  65. mojo/apps/docit/models/asset.py +231 -0
  66. mojo/apps/docit/models/book.py +227 -0
  67. mojo/apps/docit/models/page.py +319 -0
  68. mojo/apps/docit/models/page_revision.py +203 -0
  69. mojo/apps/docit/rest/__init__.py +10 -0
  70. mojo/apps/docit/rest/asset.py +17 -0
  71. mojo/apps/docit/rest/book.py +22 -0
  72. mojo/apps/docit/rest/page.py +22 -0
  73. mojo/apps/docit/rest/page_revision.py +17 -0
  74. mojo/apps/docit/services/__init__.py +11 -0
  75. mojo/apps/docit/services/docit.py +315 -0
  76. mojo/apps/docit/services/markdown.py +44 -0
  77. mojo/apps/fileman/README.md +8 -8
  78. mojo/apps/fileman/backends/base.py +76 -70
  79. mojo/apps/fileman/backends/filesystem.py +86 -86
  80. mojo/apps/fileman/backends/s3.py +409 -108
  81. mojo/apps/fileman/migrations/0001_initial.py +106 -0
  82. mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
  83. mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
  84. mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
  85. mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
  86. mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
  87. mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
  88. mojo/apps/fileman/migrations/0008_file_category.py +18 -0
  89. mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
  90. mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
  91. mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
  92. mojo/apps/fileman/models/__init__.py +1 -5
  93. mojo/apps/fileman/models/file.py +240 -58
  94. mojo/apps/fileman/models/manager.py +427 -31
  95. mojo/apps/fileman/models/rendition.py +118 -0
  96. mojo/apps/fileman/renderer/__init__.py +111 -0
  97. mojo/apps/fileman/renderer/audio.py +403 -0
  98. mojo/apps/fileman/renderer/base.py +205 -0
  99. mojo/apps/fileman/renderer/document.py +404 -0
  100. mojo/apps/fileman/renderer/image.py +222 -0
  101. mojo/apps/fileman/renderer/utils.py +297 -0
  102. mojo/apps/fileman/renderer/video.py +304 -0
  103. mojo/apps/fileman/rest/__init__.py +1 -18
  104. mojo/apps/fileman/rest/upload.py +22 -32
  105. mojo/apps/fileman/signals.py +58 -0
  106. mojo/apps/fileman/tasks.py +254 -0
  107. mojo/apps/fileman/utils/__init__.py +40 -16
  108. mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
  109. mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
  110. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  111. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  112. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  113. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  114. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  115. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  116. mojo/apps/incident/models/__init__.py +2 -0
  117. mojo/apps/incident/models/event.py +35 -0
  118. mojo/apps/incident/models/history.py +36 -0
  119. mojo/apps/incident/models/incident.py +3 -1
  120. mojo/apps/incident/models/ticket.py +62 -0
  121. mojo/apps/incident/reporter.py +21 -1
  122. mojo/apps/incident/rest/__init__.py +1 -0
  123. mojo/apps/incident/rest/event.py +7 -1
  124. mojo/apps/incident/rest/ticket.py +43 -0
  125. mojo/apps/jobs/__init__.py +489 -0
  126. mojo/apps/jobs/adapters.py +24 -0
  127. mojo/apps/jobs/cli.py +616 -0
  128. mojo/apps/jobs/daemon.py +370 -0
  129. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  130. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  131. mojo/apps/jobs/handlers/__init__.py +5 -0
  132. mojo/apps/jobs/handlers/webhook.py +317 -0
  133. mojo/apps/jobs/job_engine.py +734 -0
  134. mojo/apps/jobs/keys.py +203 -0
  135. mojo/apps/jobs/local_queue.py +363 -0
  136. mojo/apps/jobs/management/__init__.py +3 -0
  137. mojo/apps/jobs/management/commands/__init__.py +3 -0
  138. mojo/apps/jobs/manager.py +1327 -0
  139. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  140. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  141. mojo/apps/jobs/models/__init__.py +6 -0
  142. mojo/apps/jobs/models/job.py +441 -0
  143. mojo/apps/jobs/rest/__init__.py +2 -0
  144. mojo/apps/jobs/rest/control.py +466 -0
  145. mojo/apps/jobs/rest/jobs.py +421 -0
  146. mojo/apps/jobs/scheduler.py +571 -0
  147. mojo/apps/jobs/services/__init__.py +6 -0
  148. mojo/apps/jobs/services/job_actions.py +465 -0
  149. mojo/apps/jobs/settings.py +209 -0
  150. mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
  151. mojo/apps/logit/models/log.py +7 -1
  152. mojo/apps/metrics/__init__.py +8 -1
  153. mojo/apps/metrics/redis_metrics.py +198 -0
  154. mojo/apps/metrics/rest/__init__.py +3 -0
  155. mojo/apps/metrics/rest/categories.py +266 -0
  156. mojo/apps/metrics/rest/helpers.py +48 -0
  157. mojo/apps/metrics/rest/permissions.py +99 -0
  158. mojo/apps/metrics/rest/values.py +277 -0
  159. mojo/apps/metrics/utils.py +19 -2
  160. mojo/decorators/auth.py +6 -1
  161. mojo/decorators/http.py +47 -3
  162. mojo/helpers/aws/__init__.py +45 -0
  163. mojo/helpers/aws/ec2.py +804 -0
  164. mojo/helpers/aws/iam.py +748 -0
  165. mojo/helpers/aws/inbound_email.py +309 -0
  166. mojo/helpers/aws/kms.py +413 -0
  167. mojo/helpers/aws/s3.py +451 -11
  168. mojo/helpers/aws/ses.py +483 -0
  169. mojo/helpers/aws/ses_domain.py +959 -0
  170. mojo/helpers/aws/sns.py +461 -0
  171. mojo/helpers/crypto/__init__.py +1 -1
  172. mojo/helpers/crypto/utils.py +15 -0
  173. mojo/helpers/dates.py +18 -0
  174. mojo/helpers/location/__init__.py +2 -0
  175. mojo/helpers/location/countries.py +262 -0
  176. mojo/helpers/location/geolocation.py +196 -0
  177. mojo/helpers/logit.py +37 -0
  178. mojo/helpers/redis/__init__.py +2 -0
  179. mojo/helpers/redis/adapter.py +606 -0
  180. mojo/helpers/redis/client.py +48 -0
  181. mojo/helpers/redis/pool.py +225 -0
  182. mojo/helpers/request.py +8 -0
  183. mojo/helpers/response.py +14 -2
  184. mojo/helpers/settings/__init__.py +2 -0
  185. mojo/helpers/{settings.py → settings/helper.py} +1 -37
  186. mojo/helpers/settings/parser.py +132 -0
  187. mojo/middleware/auth.py +1 -1
  188. mojo/middleware/cors.py +40 -0
  189. mojo/middleware/logging.py +131 -12
  190. mojo/middleware/mojo.py +10 -0
  191. mojo/models/rest.py +494 -65
  192. mojo/models/secrets.py +98 -3
  193. mojo/serializers/__init__.py +106 -0
  194. mojo/serializers/core/__init__.py +90 -0
  195. mojo/serializers/core/cache/__init__.py +121 -0
  196. mojo/serializers/core/cache/backends.py +518 -0
  197. mojo/serializers/core/cache/base.py +102 -0
  198. mojo/serializers/core/cache/disabled.py +181 -0
  199. mojo/serializers/core/cache/memory.py +287 -0
  200. mojo/serializers/core/cache/redis.py +533 -0
  201. mojo/serializers/core/cache/utils.py +454 -0
  202. mojo/serializers/core/manager.py +550 -0
  203. mojo/serializers/core/serializer.py +475 -0
  204. mojo/serializers/examples/settings.py +322 -0
  205. mojo/serializers/formats/csv.py +393 -0
  206. mojo/serializers/formats/localizers.py +509 -0
  207. mojo/serializers/{models.py → simple.py} +38 -15
  208. mojo/serializers/suggested_improvements.md +388 -0
  209. testit/client.py +1 -1
  210. testit/helpers.py +35 -4
  211. testit/runner.py +23 -6
  212. django_nativemojo-0.1.10.dist-info/METADATA +0 -96
  213. django_nativemojo-0.1.10.dist-info/RECORD +0 -194
  214. mojo/apps/metrics/rest/db.py +0 -0
  215. mojo/apps/notify/README.md +0 -91
  216. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  217. mojo/apps/notify/admin.py +0 -52
  218. mojo/apps/notify/handlers/example_handlers.py +0 -516
  219. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  220. mojo/apps/notify/handlers/ses/bounce.py +0 -0
  221. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  222. mojo/apps/notify/handlers/ses/message.py +0 -86
  223. mojo/apps/notify/management/commands/__init__.py +0 -1
  224. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  225. mojo/apps/notify/mod +0 -0
  226. mojo/apps/notify/models/__init__.py +0 -12
  227. mojo/apps/notify/models/account.py +0 -128
  228. mojo/apps/notify/models/attachment.py +0 -24
  229. mojo/apps/notify/models/bounce.py +0 -68
  230. mojo/apps/notify/models/complaint.py +0 -40
  231. mojo/apps/notify/models/inbox.py +0 -113
  232. mojo/apps/notify/models/inbox_message.py +0 -173
  233. mojo/apps/notify/models/outbox.py +0 -129
  234. mojo/apps/notify/models/outbox_message.py +0 -288
  235. mojo/apps/notify/models/template.py +0 -30
  236. mojo/apps/notify/providers/aws.py +0 -73
  237. mojo/apps/notify/rest/ses.py +0 -0
  238. mojo/apps/notify/utils/__init__.py +0 -2
  239. mojo/apps/notify/utils/notifications.py +0 -404
  240. mojo/apps/notify/utils/parsing.py +0 -202
  241. mojo/apps/notify/utils/render.py +0 -144
  242. mojo/apps/tasks/README.md +0 -118
  243. mojo/apps/tasks/__init__.py +0 -11
  244. mojo/apps/tasks/manager.py +0 -489
  245. mojo/apps/tasks/rest/__init__.py +0 -2
  246. mojo/apps/tasks/rest/hooks.py +0 -0
  247. mojo/apps/tasks/rest/tasks.py +0 -62
  248. mojo/apps/tasks/runner.py +0 -174
  249. mojo/apps/tasks/tq_handlers.py +0 -14
  250. mojo/helpers/aws/setup_email.py +0 -0
  251. mojo/helpers/redis.py +0 -10
  252. mojo/models/meta.py +0 -262
  253. mojo/ws4redis/README.md +0 -174
  254. mojo/ws4redis/__init__.py +0 -2
  255. mojo/ws4redis/client.py +0 -283
  256. mojo/ws4redis/connection.py +0 -327
  257. mojo/ws4redis/exceptions.py +0 -32
  258. mojo/ws4redis/redis.py +0 -183
  259. mojo/ws4redis/servers/base.py +0 -86
  260. mojo/ws4redis/servers/django.py +0 -171
  261. mojo/ws4redis/servers/uwsgi.py +0 -63
  262. mojo/ws4redis/settings.py +0 -45
  263. mojo/ws4redis/utf8validator.py +0 -128
  264. mojo/ws4redis/websocket.py +0 -403
  265. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  266. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  267. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  268. /mojo/apps/{notify → aws}/__init__.py +0 -0
  269. /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
  270. /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
  271. /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
  272. /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
  273. /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
  274. /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
  275. /mojo/{serializers → rest}/openapi.py +0 -0
  276. /mojo/{apps/fileman/rest/__init__ → serializers/formats/__init__.py} +0 -0
@@ -0,0 +1,465 @@
1
+ """
2
+ Job Actions Service - Business logic for job operations.
3
+
4
+ Handles cancel, retry, status and other job actions separately from the model.
5
+ """
6
+ from typing import Any, Dict, Optional
7
+ from datetime import timedelta
8
+ from django.utils import timezone
9
+ from mojo.helpers import logit
10
+
11
+
12
+ class JobActionsService:
13
+ """
14
+ Service class for job action business logic.
15
+
16
+ Keeps models clean by handling complex operations here.
17
+ """
18
+
19
+ @staticmethod
20
+ def cancel_job(job) -> Dict[str, Any]:
21
+ """
22
+ Cancel a job.
23
+
24
+ Behavior:
25
+ - If job is terminal: refuse
26
+ - If job is running:
27
+ - If runner heartbeat is not alive, force cancel (status='canceled')
28
+ - If runner alive, set cancel_requested=True (cooperative cancel)
29
+ - If job is not running (e.g., pending/scheduled/failed/expired): set status='canceled'
30
+
31
+ Also attempts to remove from scheduled ZSETs when applicable.
32
+
33
+ Args:
34
+ job: Job model instance
35
+
36
+ Returns:
37
+ dict: Response with status and message
38
+ """
39
+ # Check terminal
40
+ if job.is_terminal:
41
+ return {
42
+ 'status': False,
43
+ 'error': f'Cannot cancel job in {job.status} state'
44
+ }
45
+
46
+ now = timezone.now()
47
+ previous_status = job.status
48
+ forced = False
49
+
50
+ try:
51
+ # Determine if runner is alive when job is marked running
52
+ runner_alive = False
53
+ if job.status == 'running' and job.runner_id:
54
+ from mojo.apps.jobs.adapters import get_adapter
55
+ from mojo.apps.jobs.keys import JobKeys
56
+ redis = get_adapter()
57
+ keys = JobKeys()
58
+ hb = redis.get(keys.runner_hb(job.runner_id))
59
+ runner_alive = bool(hb)
60
+
61
+ if job.status == 'running':
62
+ if runner_alive:
63
+ # Cooperative cancel for running job
64
+ job.cancel_requested = True
65
+ job.save(update_fields=['cancel_requested', 'modified'])
66
+ else:
67
+ # Force cancel stale running job
68
+ job.status = 'canceled'
69
+ job.finished_at = now
70
+ job.cancel_requested = True
71
+ job.runner_id = None
72
+ job.save(update_fields=['status', 'finished_at', 'cancel_requested', 'runner_id', 'modified'])
73
+ forced = True
74
+ else:
75
+ # Not running: cancel immediately
76
+ job.status = 'canceled'
77
+ job.finished_at = now
78
+ job.cancel_requested = True
79
+ job.runner_id = None
80
+ job.save(update_fields=['status', 'finished_at', 'cancel_requested', 'runner_id', 'modified'])
81
+
82
+ # Best-effort: remove from scheduled ZSETs if it was scheduled
83
+ try:
84
+ from mojo.apps.jobs.adapters import get_adapter
85
+ from mojo.apps.jobs.keys import JobKeys
86
+ redis = get_adapter()
87
+ keys = JobKeys()
88
+ # Remove from both sched sets; only one will match
89
+ redis.zadd # touch to appease linters; real calls below
90
+ redis.zrem = redis.get_client().zrem # ensure we have zrem via client
91
+ redis.get_client().zrem(keys.sched(job.channel), job.id)
92
+ redis.get_client().zrem(keys.sched_broadcast(job.channel), job.id)
93
+ except Exception as e:
94
+ logit.debug(f"Cancel cleanup (sched zrem) failed for {job.id}: {e}")
95
+
96
+ # Record event
97
+ from mojo.apps.jobs.models import JobEvent
98
+ JobEvent.objects.create(
99
+ job=job,
100
+ channel=job.channel,
101
+ event='canceled',
102
+ details={
103
+ 'requested_at': now.isoformat(),
104
+ 'forced': forced,
105
+ 'previous_status': previous_status
106
+ }
107
+ )
108
+
109
+ logit.info(f"Cancellation {'forced' if forced else 'requested'} for job {job.id} (prev={previous_status})")
110
+
111
+ return {
112
+ 'status': True,
113
+ 'message': f"Job {job.id} {'canceled' if job.status == 'canceled' else 'cancellation requested'}",
114
+ 'job_id': job.id,
115
+ 'forced': forced
116
+ }
117
+
118
+ except Exception as e:
119
+ logit.error(f"Failed to cancel job {job.id}: {e}")
120
+ return {
121
+ 'status': False,
122
+ 'error': f'Failed to cancel job: {str(e)}'
123
+ }
124
+
125
+ @staticmethod
126
+ def retry_job(job, delay: Optional[int] = None) -> Dict[str, Any]:
127
+ """
128
+ Retry a failed or canceled job.
129
+
130
+ Args:
131
+ job: Job model instance
132
+ delay: Optional delay in seconds before retry
133
+
134
+ Returns:
135
+ dict: Response with status and new job ID
136
+ """
137
+ # Check if job can be retried
138
+ if job.status not in ('failed', 'canceled', 'expired'):
139
+ return {
140
+ 'status': False,
141
+ 'error': f'Cannot retry job in {job.status} state'
142
+ }
143
+
144
+ # Reset job for retry
145
+ job.status = 'pending'
146
+ job.attempt = 0
147
+ job.last_error = ''
148
+ job.stack_trace = ''
149
+ job.cancel_requested = False
150
+ job.runner_id = None
151
+ job.started_at = None
152
+ job.finished_at = None
153
+
154
+ # Set run_at if delay specified
155
+ if delay:
156
+ job.run_at = timezone.now() + timedelta(seconds=int(delay))
157
+ else:
158
+ job.run_at = None
159
+
160
+ job.save()
161
+
162
+ # Re-publish to Redis
163
+ try:
164
+ from mojo.apps.jobs import publish
165
+
166
+ # Re-publish the job
167
+ new_job_id = publish(
168
+ func=job.func,
169
+ payload=job.payload,
170
+ channel=job.channel,
171
+ run_at=job.run_at,
172
+ broadcast=job.broadcast,
173
+ max_retries=job.max_retries,
174
+ backoff_base=job.backoff_base,
175
+ backoff_max=job.backoff_max_sec,
176
+ expires_at=job.expires_at,
177
+ max_exec_seconds=job.max_exec_seconds
178
+ )
179
+
180
+ # Record event
181
+ from mojo.apps.jobs.models import JobEvent
182
+ JobEvent.objects.create(
183
+ job=job,
184
+ channel=job.channel,
185
+ event='retry',
186
+ details={
187
+ 'retry_requested': True,
188
+ 'new_job_id': new_job_id,
189
+ 'delay': delay
190
+ }
191
+ )
192
+
193
+ logit.info(f"Job {job.id} retry scheduled as {new_job_id}")
194
+
195
+ return {
196
+ 'status': True,
197
+ 'message': f'Job retry scheduled',
198
+ 'original_job_id': job.id,
199
+ 'new_job_id': new_job_id,
200
+ 'delayed': delay is not None
201
+ }
202
+
203
+ except Exception as e:
204
+ logit.error(f"Failed to retry job {job.id}: {e}")
205
+ return {
206
+ 'status': False,
207
+ 'error': f'Failed to retry job: {str(e)}'
208
+ }
209
+
210
+ @staticmethod
211
+ def get_job_status(job) -> Dict[str, Any]:
212
+ """
213
+ Get detailed status of a job.
214
+
215
+ Args:
216
+ job: Job model instance
217
+
218
+ Returns:
219
+ dict: Detailed job status information
220
+ """
221
+ # Build detailed status response
222
+ status_data = {
223
+ 'id': job.id,
224
+ 'status': job.status,
225
+ 'channel': job.channel,
226
+ 'func': job.func,
227
+ 'created': job.created.isoformat() if job.created else None,
228
+ 'started_at': job.started_at.isoformat() if job.started_at else None,
229
+ 'finished_at': job.finished_at.isoformat() if job.finished_at else None,
230
+ 'attempt': job.attempt,
231
+ 'max_retries': job.max_retries,
232
+ 'last_error': job.last_error,
233
+ 'metadata': job.metadata,
234
+ 'runner_id': job.runner_id,
235
+ 'cancel_requested': job.cancel_requested,
236
+ 'duration_ms': job.duration_ms,
237
+ 'is_terminal': job.is_terminal,
238
+ 'is_retriable': job.is_retriable
239
+ }
240
+
241
+ # Add recent events
242
+ try:
243
+ events = job.events.order_by('-at')[:10]
244
+ status_data['recent_events'] = [
245
+ {
246
+ 'event': e.event,
247
+ 'at': e.at.isoformat(),
248
+ 'runner_id': e.runner_id,
249
+ 'details': e.details
250
+ }
251
+ for e in events
252
+ ]
253
+ except Exception as e:
254
+ logit.debug(f"Failed to get events for job {job.id}: {e}")
255
+ status_data['recent_events'] = []
256
+
257
+ # Check position in queue if pending and scheduled
258
+ if job.status == 'pending' and job.run_at:
259
+ try:
260
+ from mojo.apps.jobs.adapters import get_adapter
261
+ from mojo.apps.jobs.keys import JobKeys
262
+
263
+ redis = get_adapter()
264
+ keys = JobKeys()
265
+ sched_key = keys.sched(job.channel)
266
+
267
+ # Get position in scheduled queue
268
+ rank = redis.get_client().zrank(sched_key, job.id)
269
+ if rank is not None:
270
+ status_data['queue_position'] = rank + 1
271
+ except Exception as e:
272
+ logit.debug(f"Failed to get queue position for {job.id}: {e}")
273
+
274
+ return {
275
+ 'status': True,
276
+ 'data': status_data
277
+ }
278
+
279
+ @staticmethod
280
+ def pause_job(job) -> Dict[str, Any]:
281
+ """
282
+ Pause a pending job (remove from queue but keep in DB).
283
+
284
+ Args:
285
+ job: Job model instance
286
+
287
+ Returns:
288
+ dict: Response with status and message
289
+ """
290
+ if job.status != 'pending':
291
+ return {
292
+ 'status': False,
293
+ 'error': f'Cannot pause job in {job.status} state'
294
+ }
295
+
296
+ # Update status to paused (using canceled state but with metadata)
297
+ job.status = 'canceled'
298
+ job.metadata['paused'] = True
299
+ job.metadata['paused_at'] = timezone.now().isoformat()
300
+ job.save(update_fields=['status', 'metadata', 'modified'])
301
+
302
+ # Remove from Redis queue if present
303
+ try:
304
+ from mojo.apps.jobs.adapters import get_adapter
305
+ from mojo.apps.jobs.keys import JobKeys
306
+
307
+ redis = get_adapter()
308
+ keys = JobKeys()
309
+
310
+ # Remove from scheduled queue if scheduled
311
+ if job.run_at:
312
+ sched_key = keys.sched(job.channel)
313
+ redis.get_client().zrem(sched_key, job.id)
314
+
315
+ # Remove job hash
316
+ redis.delete(keys.job(job.id))
317
+
318
+ except Exception as e:
319
+ logit.warn(f"Failed to remove job {job.id} from Redis: {e}")
320
+
321
+ # Record event
322
+ from mojo.apps.jobs.models import JobEvent
323
+ JobEvent.objects.create(
324
+ job=job,
325
+ channel=job.channel,
326
+ event='canceled',
327
+ details={'paused': True}
328
+ )
329
+
330
+ logit.info(f"Job {job.id} paused")
331
+
332
+ return {
333
+ 'status': True,
334
+ 'message': f'Job {job.id} paused',
335
+ 'job_id': job.id
336
+ }
337
+
338
+ @staticmethod
339
+ def resume_job(job) -> Dict[str, Any]:
340
+ """
341
+ Resume a paused job.
342
+
343
+ Args:
344
+ job: Job model instance
345
+
346
+ Returns:
347
+ dict: Response with status and message
348
+ """
349
+ # Check if job is actually paused
350
+ if job.status != 'canceled' or not job.metadata.get('paused'):
351
+ return {
352
+ 'status': False,
353
+ 'error': 'Job is not paused'
354
+ }
355
+
356
+ # Reset to pending and clear pause metadata
357
+ job.status = 'pending'
358
+ job.metadata.pop('paused', None)
359
+ job.metadata.pop('paused_at', None)
360
+ job.metadata['resumed_at'] = timezone.now().isoformat()
361
+ job.save(update_fields=['status', 'metadata', 'modified'])
362
+
363
+ # Re-publish to Redis
364
+ try:
365
+ from mojo.apps.jobs import publish
366
+
367
+ new_job_id = publish(
368
+ func=job.func,
369
+ payload=job.payload,
370
+ channel=job.channel,
371
+ run_at=job.run_at,
372
+ broadcast=job.broadcast,
373
+ max_retries=job.max_retries,
374
+ backoff_base=job.backoff_base,
375
+ backoff_max=job.backoff_max_sec,
376
+ expires_at=job.expires_at,
377
+ max_exec_seconds=job.max_exec_seconds
378
+ )
379
+
380
+ # Record event
381
+ from mojo.apps.jobs.models import JobEvent
382
+ JobEvent.objects.create(
383
+ job=job,
384
+ channel=job.channel,
385
+ event='queued',
386
+ details={'resumed': True, 'new_job_id': new_job_id}
387
+ )
388
+
389
+ logit.info(f"Job {job.id} resumed as {new_job_id}")
390
+
391
+ return {
392
+ 'status': True,
393
+ 'message': f'Job resumed',
394
+ 'original_job_id': job.id,
395
+ 'new_job_id': new_job_id
396
+ }
397
+
398
+ except Exception as e:
399
+ logit.error(f"Failed to resume job {job.id}: {e}")
400
+ return {
401
+ 'status': False,
402
+ 'error': f'Failed to resume job: {str(e)}'
403
+ }
404
+
405
+ @staticmethod
406
+ def publish_job_from_template(job, overrides: Dict[str, Any]) -> Dict[str, Any]:
407
+ """
408
+ Publish a new job using an existing job as a template.
409
+
410
+ Args:
411
+ job: Job model instance to use as template
412
+ overrides: Dict with optional overrides for the new job
413
+
414
+ Returns:
415
+ dict: Response with new job ID
416
+ """
417
+ try:
418
+ from mojo.apps.jobs import publish
419
+
420
+ # Build parameters from template job
421
+ params = {
422
+ 'func': overrides.get('func', job.func),
423
+ 'payload': overrides.get('payload', job.payload),
424
+ 'channel': overrides.get('channel', job.channel),
425
+ 'broadcast': overrides.get('broadcast', job.broadcast),
426
+ 'max_retries': overrides.get('max_retries', job.max_retries),
427
+ 'backoff_base': overrides.get('backoff_base', job.backoff_base),
428
+ 'backoff_max': overrides.get('backoff_max', job.backoff_max_sec),
429
+ 'max_exec_seconds': overrides.get('max_exec_seconds', job.max_exec_seconds),
430
+ }
431
+
432
+ # Handle scheduling
433
+ if 'delay' in overrides:
434
+ params['delay'] = overrides['delay']
435
+ elif 'run_at' in overrides:
436
+ params['run_at'] = overrides['run_at']
437
+ elif job.run_at:
438
+ params['run_at'] = job.run_at
439
+
440
+ # Handle expiration
441
+ if 'expires_in' in overrides:
442
+ params['expires_in'] = overrides['expires_in']
443
+ elif 'expires_at' in overrides:
444
+ params['expires_at'] = overrides['expires_at']
445
+ elif job.expires_at:
446
+ params['expires_at'] = job.expires_at
447
+
448
+ # Publish the new job
449
+ new_job_id = publish(**params)
450
+
451
+ logit.info(f"Published new job {new_job_id} from template {job.id}")
452
+
453
+ return {
454
+ 'status': True,
455
+ 'message': 'Job published successfully',
456
+ 'job_id': new_job_id,
457
+ 'template_job_id': job.id
458
+ }
459
+
460
+ except Exception as e:
461
+ logit.error(f"Failed to publish job from template {job.id}: {e}")
462
+ return {
463
+ 'status': False,
464
+ 'error': f'Failed to publish job: {str(e)}'
465
+ }
@@ -0,0 +1,209 @@
1
+ """
2
+ Django-MOJO Jobs System Configuration Settings
3
+
4
+ Add these settings to your Django settings.py file to configure the jobs system.
5
+ """
6
+
7
+ # Redis Configuration
8
+ JOBS_REDIS_URL = "redis://localhost:6379/0"
9
+ JOBS_REDIS_PREFIX = "mojo:jobs"
10
+
11
+ # Engine Configuration
12
+ JOBS_ENGINE_MAX_WORKERS = 10 # Thread pool size per engine
13
+ JOBS_ENGINE_CLAIM_BUFFER = 2 # Claim up to buffer * max_workers jobs
14
+ JOBS_ENGINE_CLAIM_BATCH = 5 # Max jobs to claim in one request
15
+ JOBS_ENGINE_READ_TIMEOUT = 100 # Redis XREADGROUP timeout in ms
16
+
17
+ # Job Defaults
18
+ JOBS_DEFAULT_CHANNEL = "default"
19
+ JOBS_DEFAULT_EXPIRES_SEC = 900 # 15 minutes default expiration
20
+ JOBS_DEFAULT_MAX_RETRIES = 3
21
+ JOBS_DEFAULT_BACKOFF_BASE = 2.0 # Exponential backoff base
22
+ JOBS_DEFAULT_BACKOFF_MAX = 3600 # Max backoff 1 hour
23
+
24
+ # Limits
25
+ JOBS_PAYLOAD_MAX_BYTES = 1048576 # 1MB max payload size
26
+ JOBS_STREAM_MAXLEN = 100000 # Max messages per stream
27
+ JOBS_LOCAL_QUEUE_MAXSIZE = 1000 # Max local queue size
28
+
29
+ # Timeouts
30
+ JOBS_IDLE_TIMEOUT_MS = 60000 # Consider job stuck after 1 minute idle
31
+ JOBS_XPENDING_IDLE_MS = 60000 # Reclaim jobs idle for 1 minute
32
+ JOBS_RUNNER_HEARTBEAT_SEC = 5 # Heartbeat interval
33
+ JOBS_SCHEDULER_LOCK_TTL_MS = 5000 # Scheduler leadership lock TTL
34
+
35
+ # Webhook-specific Configuration
36
+ JOBS_WEBHOOK_MAX_RETRIES = 5 # More retries for webhooks (network issues)
37
+ JOBS_WEBHOOK_DEFAULT_TIMEOUT = 30 # Default webhook timeout in seconds
38
+ JOBS_WEBHOOK_MAX_TIMEOUT = 300 # Maximum allowed webhook timeout
39
+ JOBS_WEBHOOK_USER_AGENT = "Django-MOJO-Webhook/1.0" # Default User-Agent header
40
+
41
+ # Channels Configuration
42
+ JOBS_CHANNELS = [
43
+ 'default',
44
+ 'emails',
45
+ 'uploads',
46
+ 'webhooks',
47
+ 'maintenance',
48
+ 'reports'
49
+ ]
50
+
51
+ # Example Full Configuration
52
+ """
53
+ # In your Django settings.py:
54
+
55
+ # Basic Configuration
56
+ JOBS_REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
57
+ JOBS_ENGINE_MAX_WORKERS = 20 # Process 20 jobs in parallel
58
+
59
+ # High-throughput Configuration
60
+ JOBS_ENGINE_MAX_WORKERS = 50
61
+ JOBS_ENGINE_CLAIM_BUFFER = 3 # Can claim up to 150 jobs
62
+ JOBS_ENGINE_CLAIM_BATCH = 10 # Claim 10 at a time
63
+
64
+ # Conservative Configuration
65
+ JOBS_ENGINE_MAX_WORKERS = 5
66
+ JOBS_DEFAULT_MAX_RETRIES = 5
67
+ JOBS_DEFAULT_EXPIRES_SEC = 1800 # 30 minutes
68
+
69
+ # Channel-specific Workers
70
+ # Run different workers for different channels:
71
+ # python manage.py jobs_engine --channels emails,notifications --max-workers 20
72
+ # python manage.py jobs_engine --channels uploads --max-workers 5
73
+ # python manage.py jobs_engine --channels maintenance --max-workers 2
74
+ """
75
+
76
+ # Settings Documentation
77
+ """
78
+ Configuration Options:
79
+
80
+ JOBS_REDIS_URL
81
+ Redis connection URL. Supports standard Redis URL format.
82
+ Default: "redis://localhost:6379/0"
83
+
84
+ JOBS_REDIS_PREFIX
85
+ Prefix for all Redis keys used by the jobs system.
86
+ Default: "mojo:jobs"
87
+
88
+ JOBS_ENGINE_MAX_WORKERS
89
+ Maximum number of threads in the job engine's thread pool.
90
+ Controls how many jobs can run in parallel per engine.
91
+ Default: 10
92
+
93
+ JOBS_ENGINE_CLAIM_BUFFER
94
+ Buffer multiplier for job claiming. Engine can claim up to
95
+ max_workers * claim_buffer jobs to keep the thread pool busy.
96
+ Default: 2
97
+
98
+ JOBS_ENGINE_CLAIM_BATCH
99
+ Maximum number of jobs to claim in a single XREADGROUP call.
100
+ Prevents claiming too many jobs at once.
101
+ Default: 5
102
+
103
+ JOBS_ENGINE_READ_TIMEOUT
104
+ Timeout in milliseconds for XREADGROUP blocking reads.
105
+ Lower values = more responsive to shutdown, higher = less CPU.
106
+ Default: 100
107
+
108
+ JOBS_DEFAULT_CHANNEL
109
+ Default channel for jobs if not specified.
110
+ Default: "default"
111
+
112
+ JOBS_DEFAULT_EXPIRES_SEC
113
+ Default expiration time in seconds for jobs.
114
+ Jobs not executed within this time are marked as expired.
115
+ Default: 900 (15 minutes)
116
+
117
+ JOBS_DEFAULT_MAX_RETRIES
118
+ Default maximum retry attempts for failed jobs.
119
+ Default: 3
120
+
121
+ JOBS_DEFAULT_BACKOFF_BASE
122
+ Base for exponential backoff calculation.
123
+ Retry delay = backoff_base ^ attempt (capped at backoff_max).
124
+ Default: 2.0
125
+
126
+ JOBS_DEFAULT_BACKOFF_MAX
127
+ Maximum backoff time in seconds between retries.
128
+ Default: 3600 (1 hour)
129
+
130
+ JOBS_PAYLOAD_MAX_BYTES
131
+ Maximum size in bytes for job payloads.
132
+ Larger payloads will be rejected at publish time.
133
+ Default: 1048576 (1MB)
134
+
135
+ JOBS_STREAM_MAXLEN
136
+ Maximum length of Redis streams. Older messages are trimmed.
137
+ Uses approximate trimming for performance.
138
+ Default: 100000
139
+
140
+ JOBS_LOCAL_QUEUE_MAXSIZE
141
+ Maximum size of the local in-process job queue.
142
+ Default: 1000
143
+
144
+ JOBS_IDLE_TIMEOUT_MS
145
+ Time in milliseconds before a claimed job is considered stuck.
146
+ Used for health monitoring and potential job reclamation.
147
+ Default: 60000 (1 minute)
148
+
149
+ JOBS_XPENDING_IDLE_MS
150
+ Time in milliseconds before attempting to reclaim idle jobs
151
+ from dead/stuck workers using XCLAIM.
152
+ Default: 60000 (1 minute)
153
+
154
+ JOBS_RUNNER_HEARTBEAT_SEC
155
+ Interval in seconds between runner heartbeat updates.
156
+ Used to detect dead runners.
157
+ Default: 5
158
+
159
+ JOBS_SCHEDULER_LOCK_TTL_MS
160
+ TTL in milliseconds for the scheduler leadership lock.
161
+ Only one scheduler should be active cluster-wide.
162
+ Default: 5000 (5 seconds)
163
+
164
+ JOBS_CHANNELS
165
+ List of configured channels. Used by scheduler and manager
166
+ to know which channels to monitor.
167
+ Default: ['default']
168
+
169
+ Redis Keys (KISS approach)
170
+ With the KISS design, Redis is used for transport and timing only (Postgres is the source of truth).
171
+ - Scheduling uses two ZSETs per channel (prefixed by JOBS_REDIS_PREFIX):
172
+ • sched:{channel} for non-broadcast delayed jobs
173
+ • sched_broadcast:{channel} for broadcast delayed jobs
174
+ The ZSET score is the scheduled time in epoch milliseconds (run_at_ms).
175
+ - Immediate jobs are written directly to streams:
176
+ • stream:{channel}
177
+ • stream:{channel}:broadcast
178
+ - To pause a channel during maintenance, a pause flag key is set:
179
+ • channel:{channel}:paused (value "1" when paused)
180
+ """
181
+
182
+ # Performance Tuning Guide
183
+ """
184
+ Performance Tuning:
185
+
186
+ For High Throughput (10,000+ jobs/minute):
187
+ JOBS_ENGINE_MAX_WORKERS = 50-100
188
+ JOBS_ENGINE_CLAIM_BUFFER = 3
189
+ JOBS_ENGINE_CLAIM_BATCH = 20
190
+ JOBS_STREAM_MAXLEN = 500000
191
+ # Run multiple engine instances
192
+
193
+ For Low Latency (< 100ms pickup time):
194
+ JOBS_ENGINE_READ_TIMEOUT = 10-50
195
+ JOBS_ENGINE_CLAIM_BATCH = 1-2
196
+ JOBS_RUNNER_HEARTBEAT_SEC = 2
197
+
198
+ For Resource Constrained:
199
+ JOBS_ENGINE_MAX_WORKERS = 5
200
+ JOBS_ENGINE_CLAIM_BUFFER = 1
201
+ JOBS_PAYLOAD_MAX_BYTES = 102400 # 100KB
202
+ JOBS_STREAM_MAXLEN = 10000
203
+
204
+ For Reliability:
205
+ JOBS_DEFAULT_MAX_RETRIES = 5-10
206
+ JOBS_DEFAULT_EXPIRES_SEC = 3600 # 1 hour
207
+ JOBS_IDLE_TIMEOUT_MS = 300000 # 5 minutes
208
+ JOBS_DEFAULT_BACKOFF_MAX = 7200 # 2 hours
209
+ """
@@ -0,0 +1,18 @@
1
+ # Generated by Django 4.2.21 on 2025-08-26 16:05
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('logit', '0003_log_level'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='log',
15
+ name='level',
16
+ field=models.CharField(db_index=True, default='info', max_length=12),
17
+ ),
18
+ ]