django-nativemojo 0.1.15__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 (221) hide show
  1. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/METADATA +3 -1
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/commands/serializer_admin.py +121 -1
  5. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  6. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  7. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  8. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  9. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  10. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  11. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  12. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  13. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  14. mojo/apps/account/models/__init__.py +2 -0
  15. mojo/apps/account/models/device.py +281 -0
  16. mojo/apps/account/models/group.py +294 -8
  17. mojo/apps/account/models/member.py +14 -1
  18. mojo/apps/account/models/push/__init__.py +4 -0
  19. mojo/apps/account/models/push/config.py +112 -0
  20. mojo/apps/account/models/push/delivery.py +93 -0
  21. mojo/apps/account/models/push/device.py +66 -0
  22. mojo/apps/account/models/push/template.py +99 -0
  23. mojo/apps/account/models/user.py +190 -17
  24. mojo/apps/account/rest/__init__.py +2 -0
  25. mojo/apps/account/rest/device.py +39 -0
  26. mojo/apps/account/rest/group.py +8 -0
  27. mojo/apps/account/rest/push.py +187 -0
  28. mojo/apps/account/rest/user.py +95 -5
  29. mojo/apps/account/services/__init__.py +1 -0
  30. mojo/apps/account/services/push.py +363 -0
  31. mojo/apps/aws/migrations/0001_initial.py +206 -0
  32. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  33. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  34. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  35. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  36. mojo/apps/aws/models/__init__.py +19 -0
  37. mojo/apps/aws/models/email_attachment.py +99 -0
  38. mojo/apps/aws/models/email_domain.py +218 -0
  39. mojo/apps/aws/models/email_template.py +132 -0
  40. mojo/apps/aws/models/incoming_email.py +197 -0
  41. mojo/apps/aws/models/mailbox.py +288 -0
  42. mojo/apps/aws/models/sent_message.py +175 -0
  43. mojo/apps/aws/rest/__init__.py +6 -0
  44. mojo/apps/aws/rest/email.py +33 -0
  45. mojo/apps/aws/rest/email_ops.py +183 -0
  46. mojo/apps/aws/rest/messages.py +32 -0
  47. mojo/apps/aws/rest/send.py +101 -0
  48. mojo/apps/aws/rest/sns.py +403 -0
  49. mojo/apps/aws/rest/templates.py +19 -0
  50. mojo/apps/aws/services/__init__.py +32 -0
  51. mojo/apps/aws/services/email.py +390 -0
  52. mojo/apps/aws/services/email_ops.py +548 -0
  53. mojo/apps/docit/__init__.py +6 -0
  54. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  55. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  56. mojo/apps/docit/migrations/0001_initial.py +113 -0
  57. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  58. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  59. mojo/apps/docit/models/__init__.py +17 -0
  60. mojo/apps/docit/models/asset.py +231 -0
  61. mojo/apps/docit/models/book.py +227 -0
  62. mojo/apps/docit/models/page.py +319 -0
  63. mojo/apps/docit/models/page_revision.py +203 -0
  64. mojo/apps/docit/rest/__init__.py +10 -0
  65. mojo/apps/docit/rest/asset.py +17 -0
  66. mojo/apps/docit/rest/book.py +22 -0
  67. mojo/apps/docit/rest/page.py +22 -0
  68. mojo/apps/docit/rest/page_revision.py +17 -0
  69. mojo/apps/docit/services/__init__.py +11 -0
  70. mojo/apps/docit/services/docit.py +315 -0
  71. mojo/apps/docit/services/markdown.py +44 -0
  72. mojo/apps/fileman/backends/s3.py +209 -0
  73. mojo/apps/fileman/models/file.py +45 -9
  74. mojo/apps/fileman/models/manager.py +269 -3
  75. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  76. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  77. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  78. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  79. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  80. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  81. mojo/apps/incident/models/__init__.py +1 -0
  82. mojo/apps/incident/models/event.py +35 -0
  83. mojo/apps/incident/models/incident.py +2 -0
  84. mojo/apps/incident/models/ticket.py +62 -0
  85. mojo/apps/incident/reporter.py +21 -3
  86. mojo/apps/incident/rest/__init__.py +1 -0
  87. mojo/apps/incident/rest/ticket.py +43 -0
  88. mojo/apps/jobs/__init__.py +489 -0
  89. mojo/apps/jobs/adapters.py +24 -0
  90. mojo/apps/jobs/cli.py +616 -0
  91. mojo/apps/jobs/daemon.py +370 -0
  92. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  93. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  94. mojo/apps/jobs/handlers/__init__.py +5 -0
  95. mojo/apps/jobs/handlers/webhook.py +317 -0
  96. mojo/apps/jobs/job_engine.py +734 -0
  97. mojo/apps/jobs/keys.py +203 -0
  98. mojo/apps/jobs/local_queue.py +363 -0
  99. mojo/apps/jobs/management/__init__.py +3 -0
  100. mojo/apps/jobs/management/commands/__init__.py +3 -0
  101. mojo/apps/jobs/manager.py +1327 -0
  102. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  103. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  104. mojo/apps/jobs/models/__init__.py +6 -0
  105. mojo/apps/jobs/models/job.py +441 -0
  106. mojo/apps/jobs/rest/__init__.py +2 -0
  107. mojo/apps/jobs/rest/control.py +466 -0
  108. mojo/apps/jobs/rest/jobs.py +421 -0
  109. mojo/apps/jobs/scheduler.py +571 -0
  110. mojo/apps/jobs/services/__init__.py +6 -0
  111. mojo/apps/jobs/services/job_actions.py +465 -0
  112. mojo/apps/jobs/settings.py +209 -0
  113. mojo/apps/logit/models/log.py +3 -0
  114. mojo/apps/metrics/__init__.py +8 -1
  115. mojo/apps/metrics/redis_metrics.py +198 -0
  116. mojo/apps/metrics/rest/__init__.py +3 -0
  117. mojo/apps/metrics/rest/categories.py +266 -0
  118. mojo/apps/metrics/rest/helpers.py +48 -0
  119. mojo/apps/metrics/rest/permissions.py +99 -0
  120. mojo/apps/metrics/rest/values.py +277 -0
  121. mojo/apps/metrics/utils.py +17 -0
  122. mojo/decorators/http.py +40 -1
  123. mojo/helpers/aws/__init__.py +11 -7
  124. mojo/helpers/aws/inbound_email.py +309 -0
  125. mojo/helpers/aws/kms.py +413 -0
  126. mojo/helpers/aws/ses_domain.py +959 -0
  127. mojo/helpers/crypto/__init__.py +1 -1
  128. mojo/helpers/crypto/utils.py +15 -0
  129. mojo/helpers/location/__init__.py +2 -0
  130. mojo/helpers/location/countries.py +262 -0
  131. mojo/helpers/location/geolocation.py +196 -0
  132. mojo/helpers/logit.py +37 -0
  133. mojo/helpers/redis/__init__.py +2 -0
  134. mojo/helpers/redis/adapter.py +606 -0
  135. mojo/helpers/redis/client.py +48 -0
  136. mojo/helpers/redis/pool.py +225 -0
  137. mojo/helpers/request.py +8 -0
  138. mojo/helpers/response.py +8 -0
  139. mojo/middleware/auth.py +1 -1
  140. mojo/middleware/cors.py +40 -0
  141. mojo/middleware/logging.py +131 -12
  142. mojo/middleware/mojo.py +5 -0
  143. mojo/models/rest.py +271 -57
  144. mojo/models/secrets.py +86 -0
  145. mojo/serializers/__init__.py +16 -10
  146. mojo/serializers/core/__init__.py +90 -0
  147. mojo/serializers/core/cache/__init__.py +121 -0
  148. mojo/serializers/core/cache/backends.py +518 -0
  149. mojo/serializers/core/cache/base.py +102 -0
  150. mojo/serializers/core/cache/disabled.py +181 -0
  151. mojo/serializers/core/cache/memory.py +287 -0
  152. mojo/serializers/core/cache/redis.py +533 -0
  153. mojo/serializers/core/cache/utils.py +454 -0
  154. mojo/serializers/{manager.py → core/manager.py} +53 -4
  155. mojo/serializers/core/serializer.py +475 -0
  156. mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
  157. mojo/serializers/suggested_improvements.md +388 -0
  158. testit/client.py +1 -1
  159. testit/helpers.py +14 -0
  160. testit/runner.py +23 -6
  161. django_nativemojo-0.1.15.dist-info/RECORD +0 -234
  162. mojo/apps/notify/README.md +0 -91
  163. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  164. mojo/apps/notify/admin.py +0 -52
  165. mojo/apps/notify/handlers/example_handlers.py +0 -516
  166. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  167. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  168. mojo/apps/notify/handlers/ses/message.py +0 -86
  169. mojo/apps/notify/management/commands/__init__.py +0 -1
  170. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  171. mojo/apps/notify/mod +0 -0
  172. mojo/apps/notify/models/__init__.py +0 -12
  173. mojo/apps/notify/models/account.py +0 -128
  174. mojo/apps/notify/models/attachment.py +0 -24
  175. mojo/apps/notify/models/bounce.py +0 -68
  176. mojo/apps/notify/models/complaint.py +0 -40
  177. mojo/apps/notify/models/inbox.py +0 -113
  178. mojo/apps/notify/models/inbox_message.py +0 -173
  179. mojo/apps/notify/models/outbox.py +0 -129
  180. mojo/apps/notify/models/outbox_message.py +0 -288
  181. mojo/apps/notify/models/template.py +0 -30
  182. mojo/apps/notify/providers/aws.py +0 -73
  183. mojo/apps/notify/rest/ses.py +0 -0
  184. mojo/apps/notify/utils/__init__.py +0 -2
  185. mojo/apps/notify/utils/notifications.py +0 -404
  186. mojo/apps/notify/utils/parsing.py +0 -202
  187. mojo/apps/notify/utils/render.py +0 -144
  188. mojo/apps/tasks/README.md +0 -118
  189. mojo/apps/tasks/__init__.py +0 -44
  190. mojo/apps/tasks/manager.py +0 -644
  191. mojo/apps/tasks/rest/__init__.py +0 -2
  192. mojo/apps/tasks/rest/hooks.py +0 -0
  193. mojo/apps/tasks/rest/tasks.py +0 -76
  194. mojo/apps/tasks/runner.py +0 -439
  195. mojo/apps/tasks/task.py +0 -99
  196. mojo/apps/tasks/tq_handlers.py +0 -132
  197. mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
  198. mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
  199. mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
  200. mojo/helpers/redis.py +0 -10
  201. mojo/models/meta.py +0 -262
  202. mojo/serializers/advanced/README.md +0 -363
  203. mojo/serializers/advanced/__init__.py +0 -247
  204. mojo/serializers/advanced/formats/__init__.py +0 -28
  205. mojo/serializers/advanced/formats/excel.py +0 -516
  206. mojo/serializers/advanced/formats/json.py +0 -239
  207. mojo/serializers/advanced/formats/response.py +0 -485
  208. mojo/serializers/advanced/serializer.py +0 -568
  209. mojo/serializers/optimized.py +0 -618
  210. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  213. /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
  214. /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
  215. /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
  216. /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
  217. /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
  218. /mojo/{serializers → rest}/openapi.py +0 -0
  219. /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
  220. /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
  221. /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -0,0 +1,466 @@
1
+ from mojo import decorators as md
2
+ from mojo.helpers.response import JsonResponse
3
+ from mojo.apps.jobs.models import Job
4
+ from mojo.apps.jobs.manager import get_manager
5
+ from django.utils import timezone
6
+ from django.db.models import Q
7
+ from mojo.apps.jobs.adapters import get_adapter
8
+ from mojo.apps.jobs.keys import JobKeys
9
+
10
+ from datetime import datetime
11
+
12
+
13
+ # Get runtime configuration
14
+ @md.GET('control/config')
15
+ @md.requires_perms('manage_jobs')
16
+ def on_get_config(request):
17
+ """Get current jobs system configuration."""
18
+ from django.conf import settings
19
+
20
+ config = {
21
+ 'redis_url': getattr(settings, 'JOBS_REDIS_URL', 'redis://localhost:6379/0'),
22
+ 'redis_prefix': getattr(settings, 'JOBS_REDIS_PREFIX', 'mojo:jobs'),
23
+ 'engine': {
24
+ 'max_workers': getattr(settings, 'JOBS_ENGINE_MAX_WORKERS', 10),
25
+ 'claim_buffer': getattr(settings, 'JOBS_ENGINE_CLAIM_BUFFER', 2),
26
+ 'claim_batch': getattr(settings, 'JOBS_ENGINE_CLAIM_BATCH', 5),
27
+ 'read_timeout': getattr(settings, 'JOBS_ENGINE_READ_TIMEOUT', 100),
28
+ },
29
+ 'defaults': {
30
+ 'channel': getattr(settings, 'JOBS_DEFAULT_CHANNEL', 'default'),
31
+ 'expires_sec': getattr(settings, 'JOBS_DEFAULT_EXPIRES_SEC', 900),
32
+ 'max_retries': getattr(settings, 'JOBS_DEFAULT_MAX_RETRIES', 3),
33
+ 'backoff_base': getattr(settings, 'JOBS_DEFAULT_BACKOFF_BASE', 2.0),
34
+ 'backoff_max': getattr(settings, 'JOBS_DEFAULT_BACKOFF_MAX', 3600),
35
+ },
36
+ 'limits': {
37
+ 'payload_max_bytes': getattr(settings, 'JOBS_PAYLOAD_MAX_BYTES', 1048576),
38
+ 'stream_maxlen': getattr(settings, 'JOBS_STREAM_MAXLEN', 100000),
39
+ 'local_queue_maxsize': getattr(settings, 'JOBS_LOCAL_QUEUE_MAXSIZE', 1000),
40
+ },
41
+ 'timeouts': {
42
+ 'idle_timeout_ms': getattr(settings, 'JOBS_IDLE_TIMEOUT_MS', 60000),
43
+ 'xpending_idle_ms': getattr(settings, 'JOBS_XPENDING_IDLE_MS', 60000),
44
+ 'runner_heartbeat_sec': getattr(settings, 'JOBS_RUNNER_HEARTBEAT_SEC', 5),
45
+ 'scheduler_lock_ttl_ms': getattr(settings, 'JOBS_SCHEDULER_LOCK_TTL_MS', 5000),
46
+ },
47
+ 'channels': getattr(settings, 'JOBS_CHANNELS', ['default'])
48
+ }
49
+
50
+ return JsonResponse({
51
+ 'status': True,
52
+ 'data': config
53
+ })
54
+
55
+
56
+ # Clear stuck jobs
57
+ @md.POST('control/clear-stuck')
58
+ @md.requires_perms('manage_jobs')
59
+ @md.requires_params('channel')
60
+ def on_clear_stuck_jobs(request):
61
+ """
62
+ Clear stuck jobs from a channel using JobManager methods.
63
+
64
+ Params:
65
+ channel: Channel to clear stuck jobs from
66
+ idle_threshold_ms: Consider stuck if idle longer than this (default: 60000)
67
+ """
68
+ try:
69
+ channel = request.DATA['channel']
70
+ idle_threshold_ms = int(request.DATA.get('idle_threshold_ms', 60000))
71
+
72
+ manager = get_manager()
73
+ result = manager.clear_stuck_jobs(channel, idle_threshold_ms=idle_threshold_ms)
74
+
75
+ return JsonResponse({
76
+ 'status': True,
77
+ 'message': result.get('message', f'Cleared {result.get("cleared", 0)} stuck jobs from {channel}'),
78
+ 'data': result
79
+ })
80
+
81
+ except Exception as e:
82
+ return JsonResponse({
83
+ 'status': False,
84
+ 'error': str(e)
85
+ }, status=400)
86
+
87
+
88
+ # Add a simpler manual reclaim endpoint
89
+ @md.POST('jobs/control/manual-reclaim')
90
+ @md.requires_perms('manage_jobs')
91
+ @md.requires_params('channel')
92
+ def on_manual_reclaim_jobs(request):
93
+ """
94
+ Manually reclaim all pending jobs in a channel.
95
+ Uses the clear_stuck_jobs method from JobManager.
96
+ """
97
+ try:
98
+ channel = request.DATA['channel']
99
+
100
+ manager = get_manager()
101
+ result = manager.clear_stuck_jobs(channel, idle_threshold_ms=0) # Clear all pending jobs
102
+
103
+ return JsonResponse({
104
+ 'status': True,
105
+ 'message': result.get('message', f'Manually reclaimed {result.get("cleared", 0)} jobs from {channel}'),
106
+ 'data': result
107
+ })
108
+
109
+ except Exception as e:
110
+ return JsonResponse({
111
+ 'status': False,
112
+ 'error': str(e)
113
+ }, status=400)
114
+
115
+
116
+ # Purge old job data
117
+ @md.POST('control/purge')
118
+ @md.requires_perms('manage_jobs')
119
+ @md.requires_params('days_old')
120
+ def on_purge_old_jobs(request):
121
+ """
122
+ Purge old job data via JobManager.
123
+ """
124
+ try:
125
+ days_old = int(request.DATA['days_old'])
126
+ status_filter = request.DATA.get('status')
127
+ dry_run = bool(request.DATA.get('dry_run', False))
128
+
129
+ manager = get_manager()
130
+ result = manager.purge_old_jobs(days_old=days_old, status=status_filter, dry_run=dry_run)
131
+
132
+ if result.get('status'):
133
+ return JsonResponse({
134
+ 'status': True,
135
+ 'data': result
136
+ })
137
+ else:
138
+ return JsonResponse({
139
+ 'status': False,
140
+ 'error': result.get('error', 'Unknown error')
141
+ }, status=400)
142
+
143
+ except Exception as e:
144
+ return JsonResponse({
145
+ 'status': False,
146
+ 'error': str(e)
147
+ }, status=400)
148
+
149
+
150
+ # Reset failed jobs
151
+ @md.POST('control/reset-failed')
152
+ @md.requires_perms('manage_jobs')
153
+ def on_reset_failed_jobs(request):
154
+ """
155
+ Reset failed jobs to pending status for retry and requeue via JobManager.
156
+
157
+ Params:
158
+ channel: Optional channel filter
159
+ since: Optional datetime filter (ISO format)
160
+ limit: Maximum number to reset (default: 100)
161
+ """
162
+ try:
163
+ channel = request.DATA.get('channel')
164
+ since = request.DATA.get('since')
165
+ limit = int(request.DATA.get('limit', 100))
166
+
167
+ # Build query
168
+ query = Q(status='failed')
169
+ if channel:
170
+ query &= Q(channel=channel)
171
+ if since:
172
+ since_dt = datetime.fromisoformat(since)
173
+ query &= Q(created__gte=since_dt)
174
+
175
+ # Capture affected channels for requeue
176
+ affected_channels = list(
177
+ Job.objects.filter(query).values_list('channel', flat=True).distinct()
178
+ )
179
+
180
+ # Reset to pending in bulk (select IDs first, then update)
181
+ reset_ids = list(
182
+ Job.objects.filter(query)
183
+ .order_by('-created')
184
+ .values_list('id', flat=True)[:limit]
185
+ )
186
+ reset_count = 0
187
+ if reset_ids:
188
+ reset_count = Job.objects.filter(id__in=reset_ids).update(
189
+ status='pending',
190
+ attempt=0,
191
+ last_error='',
192
+ stack_trace='',
193
+ run_at=None
194
+ )
195
+
196
+ # Requeue using JobManager
197
+ manager = get_manager()
198
+ requeue_results = []
199
+
200
+ if channel:
201
+ requeue_results.append(manager.requeue_db_pending(channel, limit=reset_count))
202
+ else:
203
+ for ch in affected_channels:
204
+ requeue_results.append(manager.requeue_db_pending(ch, limit=None))
205
+
206
+ return JsonResponse({
207
+ 'status': True,
208
+ 'message': f'Reset {reset_count} failed jobs to pending',
209
+ 'reset_count': reset_count,
210
+ 'requeue': requeue_results
211
+ })
212
+
213
+ except Exception as e:
214
+ return JsonResponse({
215
+ 'status': False,
216
+ 'error': str(e)
217
+ }, status=400)
218
+
219
+
220
+ # Clear Redis queues
221
+ @md.POST('control/clear-queue')
222
+ @md.requires_perms('manage_jobs')
223
+ @md.requires_params('channel')
224
+ def on_clear_queue(request):
225
+ """
226
+ Clear all messages from a channel's Redis queue.
227
+ WARNING: This will delete all pending jobs!
228
+
229
+ Params:
230
+ channel: Channel to clear
231
+ confirm: Must be "yes" to confirm deletion
232
+ """
233
+ try:
234
+ channel = request.DATA['channel']
235
+ confirm = request.DATA.get('confirm')
236
+
237
+ if confirm != 'yes':
238
+ return JsonResponse({
239
+ 'status': False,
240
+ 'error': 'Must confirm with confirm="yes"'
241
+ }, status=400)
242
+
243
+ manager = get_manager()
244
+ result = manager.clear_channel(channel, cancel_db_pending=True)
245
+
246
+ return JsonResponse({
247
+ 'status': result.get('status', True),
248
+ 'message': f'Cleared queue for channel {channel}',
249
+ 'data': result
250
+ })
251
+
252
+ except Exception as e:
253
+ return JsonResponse({
254
+ 'status': False,
255
+ 'error': str(e)
256
+ }, status=400)
257
+
258
+
259
+ # Get queue sizes
260
+ @md.GET('control/queue-sizes')
261
+ @md.requires_perms('view_jobs', 'manage_jobs')
262
+ def on_get_queue_sizes(request):
263
+ """Get current queue sizes for all channels via JobManager."""
264
+ try:
265
+ manager = get_manager()
266
+ result = manager.get_queue_sizes()
267
+ if result.get('status'):
268
+ return JsonResponse({
269
+ 'status': True,
270
+ 'data': result.get('data', {})
271
+ })
272
+ else:
273
+ return JsonResponse({
274
+ 'status': False,
275
+ 'error': result.get('error', 'Unknown error')
276
+ }, status=400)
277
+ except Exception as e:
278
+ return JsonResponse({
279
+ 'status': False,
280
+ 'error': str(e)
281
+ }, status=400)
282
+
283
+
284
+ # Rebuild scheduled ZSETs from DB truth
285
+ @md.POST('control/rebuild-scheduled')
286
+ @md.requires_perms('manage_jobs')
287
+ def on_rebuild_scheduled(request):
288
+ """
289
+ Rebuild Redis scheduled ZSETs from DB pending jobs with future run_at.
290
+
291
+ Params:
292
+ channel: Optional channel to restrict rebuild
293
+ limit: Optional max number of jobs per channel
294
+ """
295
+ try:
296
+ manager = get_manager()
297
+ channel = request.DATA.get('channel')
298
+ limit = request.DATA.get('limit')
299
+ limit_val = int(limit) if limit is not None else None
300
+
301
+ result = manager.rebuild_scheduled(channel=channel, limit=limit_val)
302
+
303
+ if result.get('status', True):
304
+ return JsonResponse({
305
+ 'status': True,
306
+ 'data': result
307
+ })
308
+ else:
309
+ return JsonResponse({
310
+ 'status': False,
311
+ 'error': "; ".join(result.get('errors', [])) or 'Unknown error',
312
+ 'data': result
313
+ }, status=400)
314
+ except Exception as e:
315
+ return JsonResponse({
316
+ 'status': False,
317
+ 'error': str(e)
318
+ }, status=400)
319
+
320
+
321
+ # Cleanup consumer groups and stale consumers
322
+ @md.POST('control/cleanup-consumers')
323
+ @md.requires_perms('manage_jobs')
324
+ def on_cleanup_consumers(request):
325
+ """
326
+ Cleanup Redis Stream consumer groups and consumers.
327
+
328
+ Optional params:
329
+ channel: If provided, only clean this channel
330
+ destroy_empty_groups: If true, destroys empty groups after cleanup (default: true)
331
+ """
332
+ try:
333
+ manager = get_manager()
334
+ channel = request.DATA.get('channel')
335
+ destroy = request.DATA.get('destroy_empty_groups', True)
336
+ destroy = bool(destroy) if isinstance(destroy, bool) else str(destroy).lower() in ('1', 'true', 'yes', 'on')
337
+ result = manager.cleanup_consumer_groups(channel=channel, destroy_empty_groups=destroy)
338
+ if result.get('status', True):
339
+ return JsonResponse({
340
+ 'status': True,
341
+ 'data': result
342
+ })
343
+ else:
344
+ return JsonResponse({
345
+ 'status': False,
346
+ 'error': "; ".join(result.get('errors', [])) or 'Unknown error',
347
+ 'data': result
348
+ }, status=400)
349
+ except Exception as e:
350
+ return JsonResponse({
351
+ 'status': False,
352
+ 'error': str(e)
353
+ }, status=400)
354
+
355
+
356
+ # List discovered channels (from registered streams)
357
+ @md.GET('control/channels')
358
+ @md.requires_perms('manage_jobs', 'view_jobs')
359
+ def on_get_channels(request):
360
+ """
361
+ Discover channels by scanning Redis for registered streams.
362
+ """
363
+ try:
364
+ manager = get_manager()
365
+ channels = manager.get_registered_channels()
366
+ return JsonResponse({
367
+ 'status': True,
368
+ 'data': channels
369
+ })
370
+ except Exception as e:
371
+ return JsonResponse({
372
+ 'status': False,
373
+ 'error': str(e)
374
+ }, status=400)
375
+
376
+
377
+ # Force scheduler leadership
378
+ @md.POST('control/force-scheduler-lead')
379
+ @md.requires_perms('manage_jobs')
380
+ def on_force_scheduler_lead(request):
381
+ """
382
+ Force release scheduler lock to allow a new leader.
383
+ WARNING: Only use if scheduler is stuck!
384
+ """
385
+ try:
386
+ redis = get_adapter()
387
+ keys = JobKeys()
388
+
389
+ lock_key = keys.scheduler_lock()
390
+
391
+ # Check current lock
392
+ current = redis.get(lock_key)
393
+
394
+ if not current:
395
+ return JsonResponse({
396
+ 'status': True,
397
+ 'message': 'No scheduler lock exists',
398
+ 'previous_holder': None
399
+ })
400
+
401
+ # Delete the lock
402
+ redis.delete(lock_key)
403
+
404
+ return JsonResponse({
405
+ 'status': True,
406
+ 'message': 'Scheduler lock released',
407
+ 'previous_holder': current
408
+ })
409
+
410
+ except Exception as e:
411
+ return JsonResponse({
412
+ 'status': False,
413
+ 'error': str(e)
414
+ }, status=400)
415
+
416
+
417
+ # Test job execution
418
+ @md.POST('control/test')
419
+ @md.requires_perms('manage_jobs')
420
+ def on_test_job(request):
421
+ """
422
+ Publish a test job to verify the system is working.
423
+
424
+ Params:
425
+ channel: Channel to test (default: "default")
426
+ delay: Optional delay in seconds
427
+ """
428
+ try:
429
+ from mojo.apps.jobs import publish
430
+
431
+ channel = request.DATA.get('channel', 'default')
432
+ delay = request.DATA.get('delay')
433
+
434
+ # Define a simple test function module path
435
+ # This assumes you have a test job function available
436
+ test_func = 'mojo.apps.jobs.examples.sample_jobs.generate_report'
437
+
438
+ # Publish test job
439
+ job_id = publish(
440
+ func=test_func,
441
+ payload={
442
+ 'test': True,
443
+ 'timestamp': timezone.now().isoformat(),
444
+ 'channel': channel,
445
+ 'report_type': 'test',
446
+ 'start_date': timezone.now().date().isoformat(),
447
+ 'end_date': timezone.now().date().isoformat(),
448
+ 'format': 'pdf'
449
+ },
450
+ channel=channel,
451
+ delay=int(delay) if delay else None
452
+ )
453
+
454
+ return JsonResponse({
455
+ 'status': True,
456
+ 'message': 'Test job published',
457
+ 'job_id': job_id,
458
+ 'channel': channel,
459
+ 'delayed': bool(delay)
460
+ })
461
+
462
+ except Exception as e:
463
+ return JsonResponse({
464
+ 'status': False,
465
+ 'error': str(e)
466
+ }, status=400)