django-nativemojo 0.1.15__py3-none-any.whl → 0.1.17__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.17.dist-info}/METADATA +3 -2
  2. django_nativemojo-0.1.17.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 +279 -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.17.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.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
@@ -1,2 +1,3 @@
1
1
  from .event import *
2
2
  from .ossec import *
3
+ from .ticket import *
@@ -0,0 +1,43 @@
1
+ from mojo import decorators as md
2
+ from mojo.apps.incident.models import Ticket, TicketNote
3
+ from mojo.helpers.response import JsonResponse
4
+
5
+ @md.URL('ticket')
6
+ @md.URL('ticket/<int:pk>')
7
+ def on_ticket(request, pk=None):
8
+ return Ticket.on_rest_request(request, pk)
9
+
10
+
11
+ @md.URL('ticket/note')
12
+ @md.URL('ticket/<int:pk>/note')
13
+ def on_ticket_note(request, pk=None):
14
+ return TicketNote.on_rest_request(request, pk)
15
+
16
+
17
+ @md.GET('stats')
18
+ @md.requires_auth()
19
+ def on_incident_stats(request, pk=None):
20
+ from mojo.apps.incident.models import Incident, Event
21
+ import datetime
22
+ recent = datetime.datetime.now() - datetime.timedelta(days=1)
23
+ events = Event.objects.filter(created__gte=recent)
24
+ incidents = Incident.objects.filter(created__gte=recent)
25
+ resp = {
26
+ 'tickets': {
27
+ 'new': Ticket.objects.filter(status='new').count(),
28
+ 'open': Ticket.objects.filter(status='open').count(),
29
+ 'paused': Ticket.objects.filter(status='paused').count()
30
+ },
31
+ 'incidents': {
32
+ 'new': Incident.objects.filter(status='new').count(),
33
+ 'open': Incident.objects.filter(status='open').count(),
34
+ 'paused': Incident.objects.filter(status='paused').count(),
35
+ 'recent': incidents.count()
36
+ },
37
+ 'events': {
38
+ 'recent': events.count(),
39
+ 'warnings': events.filter(level__lte=7).count(),
40
+ 'critical': events.filter(level__gt=7).count()
41
+ }
42
+ }
43
+ return JsonResponse(dict(status=True, data=resp))
@@ -0,0 +1,489 @@
1
+ """
2
+ Django-MOJO Jobs System - Public API
3
+
4
+ A reliable background job system for Django with Redis fast path and Postgres truth.
5
+ """
6
+ import uuid
7
+ from datetime import datetime, timedelta
8
+ from typing import Any, Callable, Dict, Optional, Union
9
+
10
+ from django.utils import timezone
11
+ from django.db import transaction
12
+
13
+ from mojo.helpers import logit
14
+ from mojo.helpers.settings import settings
15
+ from mojo.apps import metrics
16
+ from .keys import JobKeys
17
+ from .adapters import get_adapter
18
+
19
+ # Module-level settings for readability
20
+ JOB_CHANNELS = settings.get('JOBS_CHANNELS', ['default'])
21
+ JOBS_PAYLOAD_MAX_BYTES = settings.get('JOBS_PAYLOAD_MAX_BYTES', 16384)
22
+ JOBS_DEFAULT_EXPIRES_SEC = settings.get('JOBS_DEFAULT_EXPIRES_SEC', 900)
23
+ JOBS_DEFAULT_MAX_RETRIES = settings.get('JOBS_DEFAULT_MAX_RETRIES', 0)
24
+ JOBS_DEFAULT_BACKOFF_BASE = settings.get('JOBS_DEFAULT_BACKOFF_BASE', 2.0)
25
+ JOBS_DEFAULT_BACKOFF_MAX = settings.get('JOBS_DEFAULT_BACKOFF_MAX', 3600)
26
+ JOBS_STREAM_MAXLEN = settings.get('JOBS_STREAM_MAXLEN', 100000)
27
+
28
+
29
+ __all__ = [
30
+ 'publish',
31
+ 'publish_local',
32
+ 'publish_webhook',
33
+ 'cancel',
34
+ 'status',
35
+ ]
36
+
37
+
38
+ def publish(
39
+ func: Union[str, Callable],
40
+ payload: Dict[str, Any] = None,
41
+ *,
42
+ channel: str = "default",
43
+ delay: Optional[int] = None,
44
+ run_at: Optional[datetime] = None,
45
+ broadcast: bool = False,
46
+ max_retries: Optional[int] = None,
47
+ backoff_base: Optional[float] = None,
48
+ backoff_max: Optional[int] = None,
49
+ expires_in: Optional[int] = None,
50
+ expires_at: Optional[datetime] = None,
51
+ max_exec_seconds: Optional[int] = None,
52
+ idempotency_key: Optional[str] = None
53
+ ) -> str:
54
+ """
55
+ Publish a job to be executed asynchronously.
56
+
57
+ Args:
58
+ func: Job function (registered name or callable with _job_name)
59
+ payload: Data to pass to the job handler
60
+ channel: Channel to publish to (default: "default")
61
+ delay: Delay in seconds from now
62
+ run_at: Specific time to run the job (overrides delay)
63
+ broadcast: If True, all runners on the channel will execute
64
+ max_retries: Maximum retry attempts (default from settings or 3)
65
+ backoff_base: Base for exponential backoff (default 2.0)
66
+ backoff_max: Maximum backoff in seconds (default 3600)
67
+ expires_in: Seconds until job expires (default from settings)
68
+ expires_at: Specific expiration time (overrides expires_in)
69
+ max_exec_seconds: Maximum execution time before hard kill
70
+ idempotency_key: Optional key for exactly-once semantics
71
+
72
+ Returns:
73
+ Job ID (UUID string without dashes)
74
+
75
+ Raises:
76
+ ValueError: If func is not registered or arguments are invalid
77
+ RuntimeError: If publishing fails
78
+ """
79
+ from .models import Job, JobEvent
80
+
81
+ # Convert callable to module path string
82
+ if callable(func):
83
+ func_path = f"{func.__module__}.{func.__name__}"
84
+ else:
85
+ func_path = func
86
+
87
+ # Validate payload
88
+ payload = payload or {}
89
+ if not isinstance(payload, dict):
90
+ raise ValueError("Payload must be a dictionary")
91
+
92
+ # Check payload size
93
+ import json
94
+ payload_json = json.dumps(payload)
95
+ max_bytes = JOBS_PAYLOAD_MAX_BYTES
96
+ if len(payload_json.encode('utf-8')) > max_bytes:
97
+ raise ValueError(f"Payload exceeds maximum size of {max_bytes} bytes")
98
+
99
+ # Validate channel against configured channels
100
+ configured_channels = JOB_CHANNELS if isinstance(JOB_CHANNELS, list) else [JOB_CHANNELS]
101
+ if channel not in configured_channels:
102
+ raise ValueError(f"Invalid jobs channel '{channel}'. Must be one of: {', '.join(configured_channels)}")
103
+
104
+ # Generate job ID
105
+ job_id = uuid.uuid4().hex # UUID without dashes
106
+
107
+ # Calculate run_at time
108
+ now = timezone.now()
109
+ if run_at:
110
+ if timezone.is_naive(run_at):
111
+ run_at = timezone.make_aware(run_at)
112
+ elif delay:
113
+ run_at = now + timedelta(seconds=delay)
114
+ else:
115
+ run_at = None # Immediate execution
116
+
117
+ # Calculate expiration
118
+ if expires_at:
119
+ if timezone.is_naive(expires_at):
120
+ expires_at = timezone.make_aware(expires_at)
121
+ elif expires_in:
122
+ expires_at = now + timedelta(seconds=expires_in)
123
+ else:
124
+ default_expire = JOBS_DEFAULT_EXPIRES_SEC
125
+ expires_at = now + timedelta(seconds=default_expire)
126
+
127
+ # Apply defaults
128
+ if max_retries is None:
129
+ max_retries = JOBS_DEFAULT_MAX_RETRIES
130
+ if backoff_base is None:
131
+ backoff_base = JOBS_DEFAULT_BACKOFF_BASE
132
+ if backoff_max is None:
133
+ backoff_max = JOBS_DEFAULT_BACKOFF_MAX
134
+
135
+ # Create job in database
136
+ try:
137
+ with transaction.atomic():
138
+ job = Job.objects.create(
139
+ id=job_id,
140
+ channel=channel,
141
+ func=func_path,
142
+ payload=payload,
143
+ status='pending',
144
+ run_at=run_at,
145
+ expires_at=expires_at,
146
+ max_retries=max_retries,
147
+ backoff_base=backoff_base,
148
+ backoff_max_sec=backoff_max,
149
+ broadcast=broadcast,
150
+ max_exec_seconds=max_exec_seconds,
151
+ idempotency_key=idempotency_key
152
+ )
153
+
154
+ # Create initial event
155
+ JobEvent.objects.create(
156
+ job=job,
157
+ channel=channel,
158
+ event='created',
159
+ details={'func': func_path, 'channel': channel}
160
+ )
161
+
162
+ except Exception as e:
163
+ if 'UNIQUE constraint' in str(e) and idempotency_key:
164
+ # Idempotent request - return existing job ID
165
+ try:
166
+ existing = Job.objects.get(idempotency_key=idempotency_key)
167
+ logit.info(f"Idempotent job request, returning existing: {existing.id}")
168
+ return existing.id
169
+ except Job.DoesNotExist:
170
+ pass
171
+ logit.error(f"Failed to create job in database: {e}")
172
+ raise RuntimeError(f"Failed to create job: {e}")
173
+
174
+ # Mirror to Redis (Plan B: List + ZSET + Scheduled ZSET)
175
+ try:
176
+ redis = get_adapter()
177
+ keys = JobKeys()
178
+
179
+ # No per-job Redis hash (KISS): DB is source of truth
180
+
181
+ # Route based on scheduling (Plan B: List + ZSET for immediate/scheduled)
182
+ if run_at and run_at > now:
183
+ # Add to scheduled ZSET (two-ZSET routing remains)
184
+ score = run_at.timestamp() * 1000 # milliseconds
185
+ target_zset = keys.sched_broadcast(channel) if broadcast else keys.sched(channel)
186
+ redis.zadd(target_zset, {job_id: score})
187
+
188
+ # Record scheduled event
189
+ JobEvent.objects.create(
190
+ job=job,
191
+ channel=channel,
192
+ event='scheduled',
193
+ details={'run_at': run_at.isoformat()}
194
+ )
195
+
196
+ logit.info(f"Scheduled job {job_id} on {channel} for {run_at} "
197
+ f"(zset={'sched_broadcast' if broadcast else 'sched'})")
198
+ else:
199
+ # Immediate execution: enqueue to List queue (Plan B)
200
+ queue_key = keys.queue(channel)
201
+ redis.rpush(queue_key, job_id)
202
+
203
+ # Record queued event (for immediate queue)
204
+ JobEvent.objects.create(
205
+ job=job,
206
+ channel=channel,
207
+ event='queued',
208
+ details={'queue': queue_key}
209
+ )
210
+
211
+ logit.info(f"Queued job {job_id} on {channel} (broadcast={broadcast}) to queue {queue_key}")
212
+
213
+ # Emit metric
214
+
215
+ metrics.record(
216
+ slug="jobs.published",
217
+ when=now,
218
+ count=1,
219
+ category="jobs"
220
+ )
221
+
222
+ metrics.record(
223
+ slug=f"jobs.published.{channel}",
224
+ when=now,
225
+ count=1,
226
+ category="jobs"
227
+ )
228
+
229
+ except Exception as e:
230
+ logit.error(f"Failed to mirror job {job_id} to Redis: {e}")
231
+ # Mark job as failed in DB since it couldn't be queued
232
+ job.status = 'failed'
233
+ job.last_error = f"Failed to queue: {e}"
234
+ job.save(update_fields=['status', 'last_error', 'modified'])
235
+ raise RuntimeError(f"Failed to queue job: {e}")
236
+
237
+ return job_id
238
+
239
+
240
+ def publish_local(func: Union[str, Callable], *args,
241
+ run_at: Optional[datetime] = None,
242
+ delay: Optional[int] = None,
243
+ **kwargs) -> str:
244
+ """
245
+ Publish a job to the local in-process queue.
246
+
247
+ Simple approach: spawns a thread that sleeps if delay is specified,
248
+ then executes the function.
249
+
250
+ Args:
251
+ func: Job function (module path or callable)
252
+ *args: Positional arguments for the job
253
+ run_at: When to execute the job (None for immediate)
254
+ delay: Delay in seconds before execution (ignored if run_at is provided)
255
+ **kwargs: Keyword arguments for the job
256
+
257
+ Returns:
258
+ Job ID (for compatibility, though local jobs aren't persistent)
259
+
260
+ Raises:
261
+ ImportError: If function cannot be loaded
262
+ """
263
+ from .local_queue import get_local_queue
264
+ import importlib
265
+
266
+ # Resolve function
267
+ if callable(func):
268
+ func_path = f"{func.__module__}.{func.__name__}"
269
+ func_obj = func
270
+ else:
271
+ # Dynamic import
272
+ func_path = func
273
+ try:
274
+ module_path, func_name = func_path.rsplit('.', 1)
275
+ module = importlib.import_module(module_path)
276
+ func_obj = getattr(module, func_name)
277
+ except (ImportError, AttributeError, ValueError) as e:
278
+ raise ImportError(f"Cannot load local job function '{func_path}': {e}")
279
+
280
+ # Generate a pseudo job ID
281
+ job_id = f"local-{uuid.uuid4().hex[:8]}"
282
+
283
+ # Calculate run_at time
284
+ if run_at is None and delay is not None:
285
+ from django.utils import timezone
286
+ from datetime import timedelta
287
+ run_at = timezone.now() + timedelta(seconds=delay)
288
+
289
+ # Queue the job (always succeeds with new simple approach)
290
+ queue = get_local_queue()
291
+ queue.put(func_obj, args, kwargs, job_id, run_at=run_at)
292
+
293
+ if run_at:
294
+ logit.info(f"Scheduled local job {job_id} ({func_path}) for {run_at}")
295
+ else:
296
+ logit.info(f"Queued local job {job_id} ({func_path})")
297
+ return job_id
298
+
299
+
300
+ def publish_webhook(
301
+ url: str,
302
+ data: Dict[str, Any],
303
+ *,
304
+ headers: Optional[Dict[str, str]] = None,
305
+ channel: str = "webhooks",
306
+ delay: Optional[int] = None,
307
+ run_at: Optional[datetime] = None,
308
+ timeout: Optional[int] = 30,
309
+ max_retries: Optional[int] = None,
310
+ backoff_base: Optional[float] = None,
311
+ backoff_max: Optional[int] = None,
312
+ expires_in: Optional[int] = None,
313
+ expires_at: Optional[datetime] = None,
314
+ idempotency_key: Optional[str] = None,
315
+ webhook_id: Optional[str] = None
316
+ ) -> str:
317
+ """
318
+ Publish a webhook job to POST data to an external URL.
319
+
320
+ Args:
321
+ url: Target webhook URL
322
+ data: Data to POST (will be JSON encoded)
323
+ headers: Optional HTTP headers (default includes Content-Type: application/json)
324
+ channel: Channel to publish to (default: "webhooks")
325
+ delay: Delay in seconds from now
326
+ run_at: Specific time to run the webhook (overrides delay)
327
+ timeout: Request timeout in seconds (default: 30)
328
+ max_retries: Maximum retry attempts (default from settings or 5 for webhooks)
329
+ backoff_base: Base for exponential backoff (default 2.0)
330
+ backoff_max: Maximum backoff in seconds (default 3600)
331
+ expires_in: Seconds until webhook expires (default from settings)
332
+ expires_at: Specific expiration time (overrides expires_in)
333
+ idempotency_key: Optional key for exactly-once semantics
334
+ webhook_id: Optional webhook identifier for tracking
335
+
336
+ Returns:
337
+ Job ID (UUID string without dashes)
338
+
339
+ Raises:
340
+ ValueError: If URL is invalid or data cannot be serialized
341
+ RuntimeError: If publishing fails
342
+
343
+ Example:
344
+ job_id = publish_webhook(
345
+ url="https://api.example.com/webhooks/user-signup",
346
+ data={"user_id": 123, "email": "user@example.com", "event": "signup"},
347
+ headers={"Authorization": "Bearer secret"},
348
+ max_retries=3
349
+ )
350
+ """
351
+ # Validate URL
352
+ if not url or not isinstance(url, str):
353
+ raise ValueError("URL must be a non-empty string")
354
+
355
+ if not url.startswith(('http://', 'https://')):
356
+ raise ValueError("URL must start with http:// or https://")
357
+
358
+ # Validate data can be JSON serialized
359
+ import json
360
+ try:
361
+ json.dumps(data)
362
+ except (TypeError, ValueError) as e:
363
+ raise ValueError(f"Data must be JSON serializable: {e}")
364
+
365
+ # Build headers with defaults
366
+ webhook_headers = {
367
+ 'Content-Type': 'application/json',
368
+ 'User-Agent': 'Django-MOJO-Webhook/1.0'
369
+ }
370
+ if headers:
371
+ webhook_headers.update(headers)
372
+
373
+ # Build payload for webhook handler
374
+ payload = {
375
+ 'url': url,
376
+ 'data': data,
377
+ 'headers': webhook_headers,
378
+ 'timeout': timeout or 30,
379
+ 'webhook_id': webhook_id
380
+ }
381
+
382
+ # Set webhook-specific defaults
383
+ if max_retries is None:
384
+ max_retries = getattr(settings, 'JOBS_WEBHOOK_MAX_RETRIES', 5)
385
+
386
+ # Validate timeout limits
387
+ max_allowed_timeout = getattr(settings, 'JOBS_WEBHOOK_MAX_TIMEOUT', 300)
388
+ if timeout > max_allowed_timeout:
389
+ raise ValueError(f"Timeout cannot exceed {max_allowed_timeout} seconds")
390
+
391
+ # Use the main publish function with webhook handler
392
+ return publish(
393
+ func='mojo.apps.jobs.handlers.webhook.post_webhook',
394
+ payload=payload,
395
+ channel=channel,
396
+ delay=delay,
397
+ run_at=run_at,
398
+ max_retries=max_retries,
399
+ backoff_base=backoff_base,
400
+ backoff_max=backoff_max,
401
+ expires_in=expires_in,
402
+ expires_at=expires_at,
403
+ idempotency_key=idempotency_key
404
+ )
405
+
406
+
407
+ def cancel(job_id: str) -> bool:
408
+ """
409
+ Request cancellation of a job.
410
+
411
+ Sets a cooperative cancel flag that the job handler should check.
412
+ The job will only stop if it checks the flag via context.should_cancel().
413
+
414
+ Args:
415
+ job_id: Job ID to cancel
416
+
417
+ Returns:
418
+ True if cancel was requested, False if job not found or already terminal
419
+
420
+ Note:
421
+ This is a cooperative cancel. Jobs must check should_cancel() to stop.
422
+ For hard termination, use max_exec_seconds when publishing the job.
423
+ """
424
+ from .models import Job, JobEvent
425
+
426
+ try:
427
+ # Update database
428
+ job = Job.objects.get(id=job_id)
429
+
430
+ if job.is_terminal:
431
+ logit.info(f"Job {job_id} already in terminal state: {job.status}")
432
+ return False
433
+
434
+ job.cancel_requested = True
435
+ job.save(update_fields=['cancel_requested', 'modified'])
436
+
437
+ # DB-only cancellation (KISS): handlers check DB flag
438
+
439
+ # Record event
440
+ JobEvent.objects.create(
441
+ job=job,
442
+ channel=job.channel,
443
+ event='canceled',
444
+ details={'requested_at': timezone.now().isoformat()}
445
+ )
446
+
447
+ logit.info(f"Requested cancellation of job {job_id}")
448
+ return True
449
+
450
+ except Job.DoesNotExist:
451
+ logit.warn(f"Cannot cancel non-existent job: {job_id}")
452
+ return False
453
+ except Exception as e:
454
+ logit.error(f"Failed to cancel job {job_id}: {e}")
455
+ return False
456
+
457
+
458
+ def status(job_id: str) -> Optional[Dict[str, Any]]:
459
+ """
460
+ Get the current status of a job from the database (source of truth).
461
+
462
+ Args:
463
+ job_id: Job ID to check
464
+
465
+ Returns:
466
+ Status dict with keys: id, status, channel, func, created, started_at,
467
+ finished_at, attempt, last_error, metadata; or None if not found.
468
+ """
469
+ try:
470
+ from .models import Job
471
+ job = Job.objects.get(id=job_id)
472
+
473
+ return {
474
+ 'id': job.id,
475
+ 'status': job.status,
476
+ 'channel': job.channel,
477
+ 'func': job.func,
478
+ 'created': job.created.isoformat() if job.created else '',
479
+ 'started_at': job.started_at.isoformat() if job.started_at else '',
480
+ 'finished_at': job.finished_at.isoformat() if job.finished_at else '',
481
+ 'attempt': job.attempt,
482
+ 'last_error': job.last_error,
483
+ 'metadata': job.metadata
484
+ }
485
+ except Job.DoesNotExist:
486
+ return None
487
+ except Exception as e:
488
+ logit.error(f"Failed to get status from DB for {job_id}: {e}")
489
+ return None
@@ -0,0 +1,24 @@
1
+ """
2
+ Redis adapter for the jobs system.
3
+ Imports the framework-level RedisAdapter with backward compatibility.
4
+ """
5
+ # Import from framework
6
+ from mojo.helpers.redis import RedisAdapter, get_adapter as get_framework_adapter, reset_adapter
7
+
8
+
9
+ # Maintain backward compatibility for jobs module
10
+ def get_adapter() -> RedisAdapter:
11
+ """
12
+ Get the default Redis adapter instance for jobs.
13
+
14
+ Returns:
15
+ RedisAdapter instance from framework
16
+ """
17
+ return get_framework_adapter()
18
+
19
+
20
+ # Expose reset function for testing
21
+ def reset_adapter():
22
+ """Reset the default adapter (useful for testing)."""
23
+ from mojo.helpers.redis import reset_adapter as framework_reset
24
+ framework_reset()