django-cfg 1.5.1__py3-none-any.whl → 1.5.3__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.

Potentially problematic release.


This version of django-cfg might be problematic. Click here for more details.

Files changed (121) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/dashboard/TRANSACTION_FIX.md +73 -0
  3. django_cfg/apps/dashboard/serializers/__init__.py +0 -12
  4. django_cfg/apps/dashboard/serializers/activity.py +1 -1
  5. django_cfg/apps/dashboard/services/__init__.py +0 -2
  6. django_cfg/apps/dashboard/services/charts_service.py +4 -3
  7. django_cfg/apps/dashboard/services/statistics_service.py +11 -2
  8. django_cfg/apps/dashboard/services/system_health_service.py +64 -106
  9. django_cfg/apps/dashboard/urls.py +0 -2
  10. django_cfg/apps/dashboard/views/__init__.py +0 -2
  11. django_cfg/apps/dashboard/views/commands_views.py +3 -6
  12. django_cfg/apps/dashboard/views/overview_views.py +14 -13
  13. django_cfg/apps/knowbase/apps.py +2 -2
  14. django_cfg/apps/maintenance/admin/api_key_admin.py +2 -3
  15. django_cfg/apps/newsletter/admin/newsletter_admin.py +12 -11
  16. django_cfg/apps/rq/__init__.py +9 -0
  17. django_cfg/apps/rq/apps.py +80 -0
  18. django_cfg/apps/rq/management/__init__.py +1 -0
  19. django_cfg/apps/rq/management/commands/__init__.py +1 -0
  20. django_cfg/apps/rq/management/commands/rqscheduler.py +31 -0
  21. django_cfg/apps/rq/management/commands/rqstats.py +33 -0
  22. django_cfg/apps/rq/management/commands/rqworker.py +31 -0
  23. django_cfg/apps/rq/management/commands/rqworker_pool.py +27 -0
  24. django_cfg/apps/rq/serializers/__init__.py +40 -0
  25. django_cfg/apps/rq/serializers/health.py +60 -0
  26. django_cfg/apps/rq/serializers/job.py +100 -0
  27. django_cfg/apps/rq/serializers/queue.py +80 -0
  28. django_cfg/apps/rq/serializers/schedule.py +178 -0
  29. django_cfg/apps/rq/serializers/testing.py +139 -0
  30. django_cfg/apps/rq/serializers/worker.py +58 -0
  31. django_cfg/apps/rq/services/__init__.py +25 -0
  32. django_cfg/apps/rq/services/config_helper.py +233 -0
  33. django_cfg/apps/rq/services/models/README.md +417 -0
  34. django_cfg/apps/rq/services/models/__init__.py +30 -0
  35. django_cfg/apps/rq/services/models/event.py +123 -0
  36. django_cfg/apps/rq/services/models/job.py +99 -0
  37. django_cfg/apps/rq/services/models/queue.py +92 -0
  38. django_cfg/apps/rq/services/models/worker.py +104 -0
  39. django_cfg/apps/rq/services/rq_converters.py +183 -0
  40. django_cfg/apps/rq/tasks/__init__.py +23 -0
  41. django_cfg/apps/rq/tasks/demo_tasks.py +284 -0
  42. django_cfg/apps/rq/urls.py +54 -0
  43. django_cfg/apps/rq/views/__init__.py +19 -0
  44. django_cfg/apps/rq/views/jobs.py +882 -0
  45. django_cfg/apps/rq/views/monitoring.py +248 -0
  46. django_cfg/apps/rq/views/queues.py +261 -0
  47. django_cfg/apps/rq/views/schedule.py +400 -0
  48. django_cfg/apps/rq/views/testing.py +761 -0
  49. django_cfg/apps/rq/views/workers.py +195 -0
  50. django_cfg/apps/urls.py +6 -7
  51. django_cfg/core/base/config_model.py +10 -26
  52. django_cfg/core/builders/apps_builder.py +4 -11
  53. django_cfg/core/generation/integration_generators/__init__.py +3 -6
  54. django_cfg/core/generation/integration_generators/django_rq.py +80 -0
  55. django_cfg/core/generation/orchestrator.py +9 -19
  56. django_cfg/core/integration/display/startup.py +6 -20
  57. django_cfg/mixins/__init__.py +2 -0
  58. django_cfg/mixins/superadmin_api.py +59 -0
  59. django_cfg/models/__init__.py +3 -3
  60. django_cfg/models/django/__init__.py +3 -3
  61. django_cfg/models/django/django_rq.py +621 -0
  62. django_cfg/models/django/revolution_legacy.py +1 -1
  63. django_cfg/modules/base.py +4 -6
  64. django_cfg/modules/django_admin/config/background_task_config.py +4 -4
  65. django_cfg/modules/django_admin/utils/html/composition.py +9 -2
  66. django_cfg/modules/django_unfold/navigation.py +1 -26
  67. django_cfg/pyproject.toml +4 -4
  68. django_cfg/registry/core.py +4 -7
  69. django_cfg/static/frontend/admin.zip +0 -0
  70. django_cfg/templates/admin/constance/includes/results_list.html +73 -0
  71. django_cfg/templates/admin/index.html +187 -62
  72. django_cfg/templatetags/django_cfg.py +61 -1
  73. {django_cfg-1.5.1.dist-info → django_cfg-1.5.3.dist-info}/METADATA +5 -6
  74. {django_cfg-1.5.1.dist-info → django_cfg-1.5.3.dist-info}/RECORD +77 -82
  75. django_cfg/apps/dashboard/permissions.py +0 -48
  76. django_cfg/apps/dashboard/serializers/django_q2.py +0 -50
  77. django_cfg/apps/dashboard/services/django_q2_service.py +0 -159
  78. django_cfg/apps/dashboard/views/django_q2_views.py +0 -79
  79. django_cfg/apps/tasks/__init__.py +0 -64
  80. django_cfg/apps/tasks/admin/__init__.py +0 -4
  81. django_cfg/apps/tasks/admin/config.py +0 -98
  82. django_cfg/apps/tasks/admin/task_log.py +0 -238
  83. django_cfg/apps/tasks/apps.py +0 -15
  84. django_cfg/apps/tasks/filters/__init__.py +0 -10
  85. django_cfg/apps/tasks/filters/task_log.py +0 -121
  86. django_cfg/apps/tasks/migrations/0001_initial.py +0 -196
  87. django_cfg/apps/tasks/migrations/0002_delete_tasklog.py +0 -16
  88. django_cfg/apps/tasks/migrations/__init__.py +0 -0
  89. django_cfg/apps/tasks/models/__init__.py +0 -4
  90. django_cfg/apps/tasks/models/task_log.py +0 -246
  91. django_cfg/apps/tasks/serializers/__init__.py +0 -28
  92. django_cfg/apps/tasks/serializers/task_log.py +0 -249
  93. django_cfg/apps/tasks/services/__init__.py +0 -10
  94. django_cfg/apps/tasks/services/client/__init__.py +0 -7
  95. django_cfg/apps/tasks/services/client/client.py +0 -234
  96. django_cfg/apps/tasks/services/config_helper.py +0 -63
  97. django_cfg/apps/tasks/services/sync.py +0 -204
  98. django_cfg/apps/tasks/urls.py +0 -16
  99. django_cfg/apps/tasks/views/__init__.py +0 -10
  100. django_cfg/apps/tasks/views/task_log.py +0 -41
  101. django_cfg/apps/tasks/views/task_log_base.py +0 -41
  102. django_cfg/apps/tasks/views/task_log_overview.py +0 -100
  103. django_cfg/apps/tasks/views/task_log_related.py +0 -41
  104. django_cfg/apps/tasks/views/task_log_stats.py +0 -91
  105. django_cfg/apps/tasks/views/task_log_timeline.py +0 -81
  106. django_cfg/core/generation/integration_generators/django_q2.py +0 -133
  107. django_cfg/core/generation/integration_generators/tasks.py +0 -88
  108. django_cfg/models/django/django_q2.py +0 -514
  109. django_cfg/models/tasks/__init__.py +0 -49
  110. django_cfg/models/tasks/backends.py +0 -122
  111. django_cfg/models/tasks/config.py +0 -209
  112. django_cfg/models/tasks/utils.py +0 -162
  113. django_cfg/modules/django_q2/README.md +0 -140
  114. django_cfg/modules/django_q2/__init__.py +0 -8
  115. django_cfg/modules/django_q2/apps.py +0 -107
  116. django_cfg/modules/django_q2/management/__init__.py +0 -0
  117. django_cfg/modules/django_q2/management/commands/__init__.py +0 -0
  118. django_cfg/modules/django_q2/management/commands/sync_django_q_schedules.py +0 -74
  119. {django_cfg-1.5.1.dist-info → django_cfg-1.5.3.dist-info}/WHEEL +0 -0
  120. {django_cfg-1.5.1.dist-info → django_cfg-1.5.3.dist-info}/entry_points.txt +0 -0
  121. {django_cfg-1.5.1.dist-info → django_cfg-1.5.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,761 @@
1
+ """
2
+ Django-RQ Testing & Simulation ViewSet.
3
+
4
+ Provides REST API endpoints for testing RQ functionality from the frontend.
5
+ """
6
+
7
+ import time
8
+ from datetime import datetime, timezone
9
+
10
+ from django_cfg.mixins import AdminAPIMixin
11
+ from django_cfg.modules.django_logging import get_logger
12
+ from drf_spectacular.utils import extend_schema, OpenApiParameter
13
+ from rest_framework import status, viewsets
14
+ from rest_framework.decorators import action
15
+ from rest_framework.response import Response
16
+
17
+ from ..serializers import (
18
+ TestScenarioSerializer,
19
+ RunDemoRequestSerializer,
20
+ StressTestRequestSerializer,
21
+ TestingActionResponseSerializer,
22
+ CleanupRequestSerializer,
23
+ )
24
+
25
+ logger = get_logger("rq.testing")
26
+
27
+
28
+ class TestingViewSet(AdminAPIMixin, viewsets.ViewSet):
29
+ """
30
+ ViewSet for RQ testing and simulation.
31
+
32
+ Provides endpoints for:
33
+ - List available test scenarios
34
+ - Run demo tasks
35
+ - Stress testing (generate N jobs)
36
+ - Schedule demo tasks
37
+ - Simulate queue load
38
+ - Cleanup test jobs
39
+ - Get test results
40
+
41
+ Requires admin authentication (JWT, Session, or Basic Auth).
42
+
43
+ Security:
44
+ - Admin-only access via AdminAPIMixin
45
+ - Rate limiting on stress test endpoints
46
+ - Resource limits on memory/CPU intensive tasks
47
+ - Can be disabled in production via config
48
+ """
49
+
50
+ # Scenario definitions
51
+ SCENARIOS = {
52
+ 'success': {
53
+ 'id': 'success',
54
+ 'name': 'Success Task',
55
+ 'description': 'Simple task that always succeeds after specified duration',
56
+ 'task_func': 'django_cfg.apps.rq.tasks.demo_tasks.demo_success_task',
57
+ 'default_args': [],
58
+ 'default_kwargs': {'duration': 2, 'message': 'Demo task completed'},
59
+ 'estimated_duration': 2,
60
+ },
61
+ 'failure': {
62
+ 'id': 'failure',
63
+ 'name': 'Failure Task',
64
+ 'description': 'Task that always fails with ValueError exception',
65
+ 'task_func': 'django_cfg.apps.rq.tasks.demo_tasks.demo_failure_task',
66
+ 'default_args': [],
67
+ 'default_kwargs': {'error_message': 'Simulated failure'},
68
+ 'estimated_duration': 1,
69
+ },
70
+ 'slow': {
71
+ 'id': 'slow',
72
+ 'name': 'Slow Task',
73
+ 'description': 'Long-running task for testing timeouts and monitoring',
74
+ 'task_func': 'django_cfg.apps.rq.tasks.demo_tasks.demo_slow_task',
75
+ 'default_args': [],
76
+ 'default_kwargs': {'duration': 30, 'step_interval': 5},
77
+ 'estimated_duration': 30,
78
+ },
79
+ 'progress': {
80
+ 'id': 'progress',
81
+ 'name': 'Progress Task',
82
+ 'description': 'Task that updates progress in job.meta for progress bar testing',
83
+ 'task_func': 'django_cfg.apps.rq.tasks.demo_tasks.demo_progress_task',
84
+ 'default_args': [],
85
+ 'default_kwargs': {'total_items': 100},
86
+ 'estimated_duration': 10,
87
+ },
88
+ 'retry': {
89
+ 'id': 'retry',
90
+ 'name': 'Retry Task',
91
+ 'description': 'Task that fails N times before succeeding (retry logic testing)',
92
+ 'task_func': 'django_cfg.apps.rq.tasks.demo_tasks.demo_retry_task',
93
+ 'default_args': [],
94
+ 'default_kwargs': {'max_attempts': 3, 'fail_until_attempt': 2},
95
+ 'estimated_duration': 3,
96
+ },
97
+ 'random': {
98
+ 'id': 'random',
99
+ 'name': 'Random Task',
100
+ 'description': 'Task with configurable success/failure probability',
101
+ 'task_func': 'django_cfg.apps.rq.tasks.demo_tasks.demo_random_task',
102
+ 'default_args': [],
103
+ 'default_kwargs': {'success_rate': 0.7},
104
+ 'estimated_duration': 2,
105
+ },
106
+ 'memory': {
107
+ 'id': 'memory',
108
+ 'name': 'Memory Intensive Task',
109
+ 'description': 'Task that allocates specified MB of memory',
110
+ 'task_func': 'django_cfg.apps.rq.tasks.demo_tasks.demo_memory_intensive_task',
111
+ 'default_args': [],
112
+ 'default_kwargs': {'mb_to_allocate': 100},
113
+ 'estimated_duration': 5,
114
+ },
115
+ 'cpu': {
116
+ 'id': 'cpu',
117
+ 'name': 'CPU Intensive Task',
118
+ 'description': 'Task with heavy CPU computation',
119
+ 'task_func': 'django_cfg.apps.rq.tasks.demo_tasks.demo_cpu_intensive_task',
120
+ 'default_args': [],
121
+ 'default_kwargs': {'iterations': 1000000},
122
+ 'estimated_duration': 10,
123
+ },
124
+ }
125
+
126
+ @extend_schema(
127
+ tags=["RQ Testing"],
128
+ summary="List test scenarios",
129
+ description="Returns list of all available test scenarios with metadata.",
130
+ responses={
131
+ 200: TestScenarioSerializer(many=True),
132
+ },
133
+ )
134
+ def list(self, request):
135
+ """List all available test scenarios."""
136
+ try:
137
+ scenarios = list(self.SCENARIOS.values())
138
+ serializer = TestScenarioSerializer(data=scenarios, many=True)
139
+ serializer.is_valid(raise_exception=True)
140
+ return Response(serializer.data)
141
+
142
+ except Exception as e:
143
+ logger.error(f"Failed to list scenarios: {e}", exc_info=True)
144
+ return Response(
145
+ {"error": "Internal server error"},
146
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
147
+ )
148
+
149
+ @extend_schema(
150
+ tags=["RQ Testing"],
151
+ summary="Run demo task",
152
+ description="Enqueue a single demo task for testing.",
153
+ request=RunDemoRequestSerializer,
154
+ responses={
155
+ 200: TestingActionResponseSerializer,
156
+ },
157
+ )
158
+ @action(detail=False, methods=["post"], url_path="run-demo")
159
+ def run_demo(self, request):
160
+ """Run a single demo task."""
161
+ try:
162
+ import django_rq
163
+
164
+ serializer = RunDemoRequestSerializer(data=request.data)
165
+ serializer.is_valid(raise_exception=True)
166
+ data = serializer.validated_data
167
+
168
+ scenario_id = data['scenario']
169
+ queue_name = data.get('queue', 'default')
170
+
171
+ # Get scenario config
172
+ scenario = self.SCENARIOS.get(scenario_id)
173
+ if not scenario:
174
+ return Response(
175
+ {"error": f"Unknown scenario: {scenario_id}"},
176
+ status=status.HTTP_400_BAD_REQUEST,
177
+ )
178
+
179
+ # Import task function
180
+ task_func_path = scenario['task_func']
181
+ module_path, func_name = task_func_path.rsplit('.', 1)
182
+
183
+ try:
184
+ import importlib
185
+ module = importlib.import_module(module_path)
186
+ task_func = getattr(module, func_name)
187
+ except (ImportError, AttributeError) as e:
188
+ logger.error(f"Failed to import task {task_func_path}: {e}")
189
+ return Response(
190
+ {"error": f"Failed to import task function: {str(e)}"},
191
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
192
+ )
193
+
194
+ # Merge kwargs (user overrides defaults)
195
+ kwargs = {**scenario['default_kwargs'], **data.get('kwargs', {})}
196
+ args = data.get('args', [])
197
+
198
+ # Get queue
199
+ queue = django_rq.get_queue(queue_name)
200
+
201
+ # Enqueue job
202
+ job = queue.enqueue(
203
+ task_func,
204
+ args=args,
205
+ kwargs=kwargs,
206
+ timeout=data.get('timeout'),
207
+ meta={
208
+ 'source': 'testing',
209
+ 'scenario': scenario_id,
210
+ 'created_via': 'testing_api',
211
+ 'timestamp': datetime.now(timezone.utc).isoformat(),
212
+ },
213
+ )
214
+
215
+ response_data = {
216
+ "success": True,
217
+ "message": f"Demo task '{scenario['name']}' enqueued successfully",
218
+ "job_ids": [job.id],
219
+ "metadata": {
220
+ "scenario": scenario_id,
221
+ "queue": queue_name,
222
+ "func": task_func_path,
223
+ "estimated_duration": scenario.get('estimated_duration'),
224
+ },
225
+ }
226
+
227
+ logger.info(f"Enqueued demo task: {scenario_id} -> {job.id}")
228
+
229
+ response_serializer = TestingActionResponseSerializer(data=response_data)
230
+ response_serializer.is_valid(raise_exception=True)
231
+ return Response(response_serializer.data)
232
+
233
+ except Exception as e:
234
+ logger.error(f"Failed to run demo task: {e}", exc_info=True)
235
+ return Response(
236
+ {"error": str(e)},
237
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
238
+ )
239
+
240
+ @extend_schema(
241
+ tags=["RQ Testing"],
242
+ summary="Stress test",
243
+ description="Generate N jobs for load testing and performance benchmarking.",
244
+ request=StressTestRequestSerializer,
245
+ responses={
246
+ 200: TestingActionResponseSerializer,
247
+ },
248
+ )
249
+ @action(detail=False, methods=["post"], url_path="stress-test")
250
+ def stress_test(self, request):
251
+ """Generate N jobs for stress testing."""
252
+ try:
253
+ import django_rq
254
+
255
+ serializer = StressTestRequestSerializer(data=request.data)
256
+ serializer.is_valid(raise_exception=True)
257
+ data = serializer.validated_data
258
+
259
+ num_jobs = data['num_jobs']
260
+ queue_name = data.get('queue', 'default')
261
+ scenario_id = data.get('scenario', 'success')
262
+ duration = data.get('duration', 2)
263
+
264
+ # Get scenario
265
+ scenario = self.SCENARIOS.get(scenario_id)
266
+ if not scenario:
267
+ return Response(
268
+ {"error": f"Unknown scenario: {scenario_id}"},
269
+ status=status.HTTP_400_BAD_REQUEST,
270
+ )
271
+
272
+ # Import task function
273
+ task_func_path = scenario['task_func']
274
+ module_path, func_name = task_func_path.rsplit('.', 1)
275
+
276
+ try:
277
+ import importlib
278
+ module = importlib.import_module(module_path)
279
+ task_func = getattr(module, func_name)
280
+ except (ImportError, AttributeError) as e:
281
+ return Response(
282
+ {"error": f"Failed to import task: {str(e)}"},
283
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
284
+ )
285
+
286
+ # Get queue
287
+ queue = django_rq.get_queue(queue_name)
288
+
289
+ # Enqueue jobs
290
+ job_ids = []
291
+ for i in range(num_jobs):
292
+ # Override duration for stress test
293
+ kwargs = {**scenario['default_kwargs'], 'duration': duration}
294
+
295
+ job = queue.enqueue(
296
+ task_func,
297
+ kwargs=kwargs,
298
+ meta={
299
+ 'source': 'testing',
300
+ 'scenario': scenario_id,
301
+ 'created_via': 'stress_test',
302
+ 'test_index': i + 1,
303
+ 'timestamp': datetime.now(timezone.utc).isoformat(),
304
+ },
305
+ )
306
+ job_ids.append(job.id)
307
+
308
+ estimated_total_time = num_jobs * duration
309
+
310
+ response_data = {
311
+ "success": True,
312
+ "message": f"Created {num_jobs} stress test jobs",
313
+ "job_ids": job_ids,
314
+ "count": num_jobs,
315
+ "metadata": {
316
+ "scenario": scenario_id,
317
+ "queue": queue_name,
318
+ "duration_per_job": duration,
319
+ "estimated_total_time": estimated_total_time,
320
+ },
321
+ }
322
+
323
+ logger.info(f"Created {num_jobs} stress test jobs for scenario '{scenario_id}'")
324
+
325
+ response_serializer = TestingActionResponseSerializer(data=response_data)
326
+ response_serializer.is_valid(raise_exception=True)
327
+ return Response(response_serializer.data)
328
+
329
+ except Exception as e:
330
+ logger.error(f"Stress test failed: {e}", exc_info=True)
331
+ return Response(
332
+ {"error": str(e)},
333
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
334
+ )
335
+
336
+ @extend_schema(
337
+ tags=["RQ Testing"],
338
+ summary="Schedule demo tasks",
339
+ description="Register demo scheduled tasks using rq-scheduler.",
340
+ request={
341
+ 'application/json': {
342
+ 'type': 'object',
343
+ 'properties': {
344
+ 'scenario': {'type': 'string'},
345
+ 'schedule_type': {'type': 'string', 'enum': ['interval', 'cron', 'one-time']},
346
+ 'interval': {'type': 'integer'},
347
+ 'cron': {'type': 'string'},
348
+ 'scheduled_time': {'type': 'string', 'format': 'date-time'},
349
+ 'queue': {'type': 'string'},
350
+ 'repeat': {'type': 'integer'},
351
+ },
352
+ 'required': ['scenario', 'schedule_type'],
353
+ }
354
+ },
355
+ responses={
356
+ 200: TestingActionResponseSerializer,
357
+ },
358
+ )
359
+ @action(detail=False, methods=["post"], url_path="schedule-demo")
360
+ def schedule_demo(self, request):
361
+ """Schedule a demo task."""
362
+ try:
363
+ import django_rq
364
+ from django.core.exceptions import ImproperlyConfigured
365
+
366
+ data = request.data
367
+ scenario_id = data.get('scenario')
368
+ schedule_type = data.get('schedule_type')
369
+ queue_name = data.get('queue', 'default')
370
+
371
+ # Validate scenario
372
+ scenario = self.SCENARIOS.get(scenario_id)
373
+ if not scenario:
374
+ return Response(
375
+ {"error": f"Unknown scenario: {scenario_id}"},
376
+ status=status.HTTP_400_BAD_REQUEST,
377
+ )
378
+
379
+ # Import task function
380
+ task_func_path = scenario['task_func']
381
+ module_path, func_name = task_func_path.rsplit('.', 1)
382
+
383
+ try:
384
+ import importlib
385
+ module = importlib.import_module(module_path)
386
+ task_func = getattr(module, func_name)
387
+ except (ImportError, AttributeError) as e:
388
+ return Response(
389
+ {"error": f"Failed to import task: {str(e)}"},
390
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
391
+ )
392
+
393
+ # Get scheduler
394
+ try:
395
+ scheduler = django_rq.get_scheduler(queue_name)
396
+ except ImproperlyConfigured:
397
+ return Response(
398
+ {"error": "rq-scheduler not installed. Install with: pip install rq-scheduler"},
399
+ status=status.HTTP_501_NOT_IMPLEMENTED,
400
+ )
401
+
402
+ # Schedule job based on type
403
+ job_kwargs = {
404
+ 'func': task_func,
405
+ 'kwargs': scenario['default_kwargs'],
406
+ 'queue_name': queue_name,
407
+ }
408
+
409
+ if data.get('repeat') is not None:
410
+ job_kwargs['repeat'] = data['repeat']
411
+
412
+ if schedule_type == 'interval':
413
+ interval = data.get('interval')
414
+ if not interval:
415
+ return Response(
416
+ {"error": "interval is required for interval schedule"},
417
+ status=status.HTTP_400_BAD_REQUEST,
418
+ )
419
+
420
+ job = scheduler.schedule(
421
+ scheduled_time=datetime.now(timezone.utc),
422
+ interval=interval,
423
+ **job_kwargs
424
+ )
425
+
426
+ elif schedule_type == 'cron':
427
+ cron_string = data.get('cron')
428
+ if not cron_string:
429
+ return Response(
430
+ {"error": "cron is required for cron schedule"},
431
+ status=status.HTTP_400_BAD_REQUEST,
432
+ )
433
+
434
+ job = scheduler.cron(cron_string, **job_kwargs)
435
+
436
+ elif schedule_type == 'one-time':
437
+ scheduled_time = data.get('scheduled_time')
438
+ if not scheduled_time:
439
+ return Response(
440
+ {"error": "scheduled_time is required for one-time schedule"},
441
+ status=status.HTTP_400_BAD_REQUEST,
442
+ )
443
+
444
+ scheduled_dt = datetime.fromisoformat(scheduled_time.replace('Z', '+00:00'))
445
+ job = scheduler.enqueue_at(scheduled_dt, **job_kwargs)
446
+
447
+ else:
448
+ return Response(
449
+ {"error": f"Invalid schedule_type: {schedule_type}"},
450
+ status=status.HTTP_400_BAD_REQUEST,
451
+ )
452
+
453
+ # Update job meta
454
+ if job:
455
+ job.meta = {
456
+ 'source': 'testing',
457
+ 'scenario': scenario_id,
458
+ 'created_via': 'schedule_demo',
459
+ 'schedule_type': schedule_type,
460
+ }
461
+ job.save_meta()
462
+
463
+ response_data = {
464
+ "success": True,
465
+ "message": f"Demo schedule registered ({schedule_type})",
466
+ "job_ids": [job.id] if job else [],
467
+ "metadata": {
468
+ "schedule_type": schedule_type,
469
+ "scenario": scenario_id,
470
+ "queue": queue_name,
471
+ },
472
+ }
473
+
474
+ logger.info(f"Scheduled demo task: {scenario_id} ({schedule_type})")
475
+
476
+ response_serializer = TestingActionResponseSerializer(data=response_data)
477
+ response_serializer.is_valid(raise_exception=True)
478
+ return Response(response_serializer.data)
479
+
480
+ except Exception as e:
481
+ logger.error(f"Failed to schedule demo: {e}", exc_info=True)
482
+ return Response(
483
+ {"error": str(e)},
484
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
485
+ )
486
+
487
+ @extend_schema(
488
+ tags=["RQ Testing"],
489
+ summary="Cleanup test jobs",
490
+ description="Clean demo jobs from registries.",
491
+ request=CleanupRequestSerializer,
492
+ parameters=[
493
+ OpenApiParameter(
494
+ name="queue",
495
+ type=str,
496
+ location=OpenApiParameter.QUERY,
497
+ required=False,
498
+ description="Queue name (empty = all queues)",
499
+ ),
500
+ OpenApiParameter(
501
+ name="registries",
502
+ type=str,
503
+ location=OpenApiParameter.QUERY,
504
+ required=False,
505
+ description="Comma-separated list of registries (failed,finished,deferred,scheduled)",
506
+ ),
507
+ OpenApiParameter(
508
+ name="delete_demo_jobs_only",
509
+ type=bool,
510
+ location=OpenApiParameter.QUERY,
511
+ required=False,
512
+ description="Only delete demo jobs (default: true)",
513
+ ),
514
+ ],
515
+ responses={
516
+ 200: TestingActionResponseSerializer,
517
+ },
518
+ )
519
+ @action(detail=False, methods=["delete"])
520
+ def cleanup(self, request):
521
+ """Cleanup test jobs from registries."""
522
+ try:
523
+ import django_rq
524
+ from django.conf import settings
525
+ from rq.job import Job
526
+ from rq.registry import (
527
+ FinishedJobRegistry,
528
+ FailedJobRegistry,
529
+ DeferredJobRegistry,
530
+ ScheduledJobRegistry,
531
+ )
532
+
533
+ queue_filter = request.query_params.get('queue')
534
+ registries_param = request.query_params.get('registries', 'failed,finished')
535
+ delete_demo_only = request.query_params.get('delete_demo_jobs_only', 'true').lower() == 'true'
536
+
537
+ # Parse registries
538
+ registry_names = [r.strip() for r in registries_param.split(',') if r.strip()]
539
+
540
+ # Get queues
541
+ if queue_filter:
542
+ queue_names = [queue_filter]
543
+ else:
544
+ queue_names = list(settings.RQ_QUEUES.keys()) if hasattr(settings, 'RQ_QUEUES') else []
545
+
546
+ total_deleted = 0
547
+ breakdown = {}
548
+
549
+ for queue_name in queue_names:
550
+ try:
551
+ queue = django_rq.get_queue(queue_name)
552
+
553
+ # Get registries to clean
554
+ registries_to_clean = {}
555
+ if 'failed' in registry_names:
556
+ registries_to_clean['failed'] = FailedJobRegistry(queue_name, connection=queue.connection)
557
+ if 'finished' in registry_names:
558
+ registries_to_clean['finished'] = FinishedJobRegistry(queue_name, connection=queue.connection)
559
+ if 'deferred' in registry_names:
560
+ registries_to_clean['deferred'] = DeferredJobRegistry(queue_name, connection=queue.connection)
561
+ if 'scheduled' in registry_names:
562
+ registries_to_clean['scheduled'] = ScheduledJobRegistry(queue_name, connection=queue.connection)
563
+
564
+ # Clean from registries
565
+ for reg_name, registry in registries_to_clean.items():
566
+ job_ids = registry.get_job_ids()
567
+ deleted_count = 0
568
+
569
+ for job_id in job_ids:
570
+ try:
571
+ job = Job.fetch(job_id, connection=queue.connection)
572
+
573
+ # Check if it's a demo job
574
+ is_demo_job = False
575
+ if job.meta and job.meta.get('source') == 'testing':
576
+ is_demo_job = True
577
+ elif job.func_name and 'demo_' in job.func_name:
578
+ is_demo_job = True
579
+
580
+ # Delete if matches criteria
581
+ if not delete_demo_only or is_demo_job:
582
+ registry.remove(job, delete_job=True)
583
+ deleted_count += 1
584
+
585
+ except Exception as e:
586
+ logger.debug(f"Failed to delete job {job_id}: {e}")
587
+
588
+ breakdown[reg_name] = breakdown.get(reg_name, 0) + deleted_count
589
+ total_deleted += deleted_count
590
+
591
+ # Also clean from queued jobs (not in registry, but in queue itself)
592
+ if 'queued' in registry_names:
593
+ queued_job_ids = queue.job_ids
594
+ deleted_count = 0
595
+
596
+ for job_id in queued_job_ids:
597
+ try:
598
+ job = Job.fetch(job_id, connection=queue.connection)
599
+
600
+ # Check if it's a demo job
601
+ is_demo_job = False
602
+ if job.meta and job.meta.get('source') == 'testing':
603
+ is_demo_job = True
604
+ elif job.func_name and 'demo_' in job.func_name:
605
+ is_demo_job = True
606
+
607
+ # Delete if matches criteria
608
+ if not delete_demo_only or is_demo_job:
609
+ job.cancel()
610
+ job.delete()
611
+ deleted_count += 1
612
+
613
+ except Exception as e:
614
+ logger.debug(f"Failed to delete queued job {job_id}: {e}")
615
+
616
+ breakdown['queued'] = breakdown.get('queued', 0) + deleted_count
617
+ total_deleted += deleted_count
618
+
619
+ except Exception as e:
620
+ logger.debug(f"Failed to clean queue {queue_name}: {e}")
621
+
622
+ response_data = {
623
+ "success": True,
624
+ "message": f"Cleaned {total_deleted} {'demo ' if delete_demo_only else ''}jobs",
625
+ "count": total_deleted,
626
+ "metadata": {
627
+ "queues_cleaned": queue_names,
628
+ "registries_cleaned": registry_names,
629
+ "demo_jobs_only": delete_demo_only,
630
+ "breakdown": breakdown,
631
+ },
632
+ }
633
+
634
+ logger.info(f"Cleaned {total_deleted} test jobs from {len(queue_names)} queues")
635
+
636
+ response_serializer = TestingActionResponseSerializer(data=response_data)
637
+ response_serializer.is_valid(raise_exception=True)
638
+ return Response(response_serializer.data)
639
+
640
+ except Exception as e:
641
+ logger.error(f"Cleanup failed: {e}", exc_info=True)
642
+ return Response(
643
+ {"error": str(e)},
644
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
645
+ )
646
+
647
+ @extend_schema(
648
+ tags=["RQ Testing"],
649
+ summary="Get test results",
650
+ description="Get aggregated results of test jobs execution.",
651
+ parameters=[
652
+ OpenApiParameter(
653
+ name="queue",
654
+ type=str,
655
+ location=OpenApiParameter.QUERY,
656
+ required=False,
657
+ description="Filter by queue",
658
+ ),
659
+ OpenApiParameter(
660
+ name="scenario",
661
+ type=str,
662
+ location=OpenApiParameter.QUERY,
663
+ required=False,
664
+ description="Filter by scenario",
665
+ ),
666
+ ],
667
+ responses={
668
+ 200: {
669
+ 'type': 'object',
670
+ 'properties': {
671
+ 'total_jobs': {'type': 'integer'},
672
+ 'by_status': {'type': 'object'},
673
+ 'by_scenario': {'type': 'object'},
674
+ },
675
+ },
676
+ },
677
+ )
678
+ @action(detail=False, methods=["get"])
679
+ def results(self, request):
680
+ """Get test execution results."""
681
+ try:
682
+ import django_rq
683
+ from django.conf import settings
684
+ from rq.job import Job
685
+ from rq.registry import (
686
+ FinishedJobRegistry,
687
+ FailedJobRegistry,
688
+ StartedJobRegistry,
689
+ )
690
+
691
+ queue_filter = request.query_params.get('queue')
692
+ scenario_filter = request.query_params.get('scenario')
693
+
694
+ queue_names = list(settings.RQ_QUEUES.keys()) if hasattr(settings, 'RQ_QUEUES') else []
695
+ if queue_filter:
696
+ queue_names = [q for q in queue_names if q == queue_filter]
697
+
698
+ total_jobs = 0
699
+ by_status = {'finished': 0, 'failed': 0, 'queued': 0, 'started': 0}
700
+ by_scenario = {}
701
+
702
+ for queue_name in queue_names:
703
+ try:
704
+ queue = django_rq.get_queue(queue_name)
705
+
706
+ # Check all registries
707
+ registries = {
708
+ 'queued': {'jobs': queue.job_ids},
709
+ 'started': {'registry': StartedJobRegistry(queue_name, connection=queue.connection)},
710
+ 'finished': {'registry': FinishedJobRegistry(queue_name, connection=queue.connection)},
711
+ 'failed': {'registry': FailedJobRegistry(queue_name, connection=queue.connection)},
712
+ }
713
+
714
+ for reg_name, reg_data in registries.items():
715
+ if 'registry' in reg_data:
716
+ job_ids = reg_data['registry'].get_job_ids()
717
+ else:
718
+ job_ids = reg_data['jobs']
719
+
720
+ for job_id in job_ids:
721
+ try:
722
+ job = Job.fetch(job_id, connection=queue.connection)
723
+
724
+ # Filter demo jobs only
725
+ if not job.meta or job.meta.get('source') != 'testing':
726
+ continue
727
+
728
+ scenario = job.meta.get('scenario', 'unknown')
729
+
730
+ # Apply scenario filter
731
+ if scenario_filter and scenario != scenario_filter:
732
+ continue
733
+
734
+ total_jobs += 1
735
+ by_status[reg_name] += 1
736
+
737
+ if scenario not in by_scenario:
738
+ by_scenario[scenario] = {'total': 0, 'finished': 0, 'failed': 0}
739
+
740
+ by_scenario[scenario]['total'] += 1
741
+ if reg_name in ['finished', 'failed']:
742
+ by_scenario[scenario][reg_name] += 1
743
+
744
+ except Exception:
745
+ continue
746
+
747
+ except Exception as e:
748
+ logger.debug(f"Failed to get results for queue {queue_name}: {e}")
749
+
750
+ return Response({
751
+ "total_jobs": total_jobs,
752
+ "by_status": by_status,
753
+ "by_scenario": by_scenario,
754
+ })
755
+
756
+ except Exception as e:
757
+ logger.error(f"Failed to get results: {e}", exc_info=True)
758
+ return Response(
759
+ {"error": "Internal server error"},
760
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
761
+ )