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
mojo/apps/jobs/keys.py ADDED
@@ -0,0 +1,203 @@
1
+ """
2
+ Redis key builder for jobs system.
3
+ Centralized, prefix-aware key management.
4
+ """
5
+ from typing import Optional
6
+ from django.conf import settings
7
+
8
+
9
+ class JobKeys:
10
+ """Centralized Redis key builder for the jobs system."""
11
+
12
+ def __init__(self, prefix: Optional[str] = None):
13
+ """
14
+ Initialize with optional prefix override.
15
+
16
+ Args:
17
+ prefix: Override the default prefix from settings
18
+ """
19
+ self.prefix = prefix or getattr(settings, 'JOBS_REDIS_PREFIX', 'mojo:jobs')
20
+
21
+ # ----------------------------
22
+ # Streams (legacy/compat only)
23
+ # ----------------------------
24
+ def stream(self, channel: str) -> str:
25
+ """
26
+ Get the stream key for a channel.
27
+
28
+ Args:
29
+ channel: The channel name
30
+
31
+ Returns:
32
+ Redis key for the channel's main stream
33
+ """
34
+ return f"{self.prefix}:stream:{channel}"
35
+
36
+ def stream_broadcast(self, channel: str) -> str:
37
+ """
38
+ Get the broadcast stream key for a channel.
39
+
40
+ Args:
41
+ channel: The channel name
42
+
43
+ Returns:
44
+ Redis key for the channel's broadcast stream
45
+ """
46
+ return f"{self.prefix}:stream:{channel}:broadcast"
47
+
48
+ def group_workers(self, channel: str) -> str:
49
+ """
50
+ Get the consumer group key for workers on a channel.
51
+
52
+ Args:
53
+ channel: The channel name
54
+
55
+ Returns:
56
+ Redis consumer group name for workers
57
+ """
58
+ return f"{self.prefix}:cg:{channel}:workers"
59
+
60
+ def group_runner(self, channel: str, runner_id: str) -> str:
61
+ """
62
+ Get the consumer group key for a specific runner on broadcast stream.
63
+
64
+ Args:
65
+ channel: The channel name
66
+ runner_id: The runner's unique identifier
67
+
68
+ Returns:
69
+ Redis consumer group name for this runner
70
+ """
71
+ return f"{self.prefix}:cg:{channel}:runner:{runner_id}"
72
+
73
+ # ----------------------------
74
+ # Plan B: List + ZSET keys
75
+ # ----------------------------
76
+ def queue(self, channel: str) -> str:
77
+ """
78
+ Immediate jobs list (queue).
79
+ RPUSH to enqueue, BRPOP to claim.
80
+ """
81
+ return f"{self.prefix}:queue:{channel}"
82
+
83
+ def processing(self, channel: str) -> str:
84
+ """
85
+ In-flight tracking ZSET for visibility timeout.
86
+ ZADD on claim, ZREM on completion.
87
+ """
88
+ return f"{self.prefix}:processing:{channel}"
89
+
90
+ def sched(self, channel: str) -> str:
91
+ """
92
+ Get the scheduled jobs ZSET key for a channel.
93
+
94
+ Args:
95
+ channel: The channel name
96
+
97
+ Returns:
98
+ Redis ZSET key for scheduled/delayed jobs
99
+ """
100
+ return f"{self.prefix}:sched:{channel}"
101
+
102
+ def sched_broadcast(self, channel: str) -> str:
103
+ """
104
+ Scheduled jobs ZSET for broadcast (optional).
105
+ """
106
+ return f"{self.prefix}:sched_broadcast:{channel}"
107
+
108
+ def reaper_lock(self, channel: str) -> str:
109
+ """
110
+ Per-channel lock key for the reaper (to avoid races).
111
+ """
112
+ return f"{self.prefix}:lock:reaper:{channel}"
113
+
114
+ def channel_pause(self, channel: str) -> str:
115
+ """
116
+ Get the pause flag key for a channel.
117
+ """
118
+ return f"{self.prefix}:channel:{channel}:paused"
119
+
120
+ # ----------------------------
121
+ # Job metadata / control
122
+ # ----------------------------
123
+ def job(self, job_id: str) -> str:
124
+ """
125
+ Get the hash key for a specific job's metadata.
126
+
127
+ Args:
128
+ job_id: The job's unique identifier
129
+
130
+ Returns:
131
+ Redis hash key for job metadata
132
+ """
133
+ return f"{self.prefix}:job:{job_id}"
134
+
135
+ def runner_ctl(self, runner_id: str) -> str:
136
+ """
137
+ Get the control channel key for a runner.
138
+
139
+ Args:
140
+ runner_id: The runner's unique identifier
141
+
142
+ Returns:
143
+ Redis key for runner control messages
144
+ """
145
+ return f"{self.prefix}:runner:{runner_id}:ctl"
146
+
147
+ def runner_hb(self, runner_id: str) -> str:
148
+ """
149
+ Get the heartbeat key for a runner.
150
+
151
+ Args:
152
+ runner_id: The runner's unique identifier
153
+
154
+ Returns:
155
+ Redis key for runner heartbeat (with TTL)
156
+ """
157
+ return f"{self.prefix}:runner:{runner_id}:hb"
158
+
159
+ def scheduler_lock(self) -> str:
160
+ """
161
+ Get the scheduler leadership lock key.
162
+
163
+ Returns:
164
+ Redis key for scheduler lock
165
+ """
166
+ return f"{self.prefix}:lock:scheduler"
167
+
168
+ def stats_counter(self, metric: str) -> str:
169
+ """
170
+ Get a stats counter key.
171
+
172
+ Args:
173
+ metric: The metric name (e.g., 'published', 'completed')
174
+
175
+ Returns:
176
+ Redis key for the stats counter
177
+ """
178
+ return f"{self.prefix}:stats:{metric}"
179
+
180
+ def registry_key(self) -> str:
181
+ """
182
+ Get the job registry hash key.
183
+
184
+ Returns:
185
+ Redis key for the job function registry
186
+ """
187
+ return f"{self.prefix}:registry"
188
+
189
+ def idempotency(self, key: str) -> str:
190
+ """
191
+ Get the idempotency check key.
192
+
193
+ Args:
194
+ key: The idempotency key from the client
195
+
196
+ Returns:
197
+ Redis key for idempotency checking
198
+ """
199
+ return f"{self.prefix}:idempotent:{key}"
200
+
201
+
202
+ # Default instance for module-level use
203
+ default_keys = JobKeys()
@@ -0,0 +1,363 @@
1
+ """
2
+ Local in-process job queue for lightweight tasks.
3
+
4
+ No persistence, no retries, no distribution - just a simple
5
+ single worker thread with a queue for ultra-short work.
6
+ """
7
+ import queue
8
+ import threading
9
+ import traceback
10
+ import time
11
+ from typing import Any, Callable, Optional
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+
15
+ from django.conf import settings
16
+ from django.utils import timezone
17
+
18
+ from mojo.helpers import logit
19
+
20
+
21
+ @dataclass
22
+ class LocalJob:
23
+ """Container for a local job."""
24
+ job_id: str
25
+ func: Callable
26
+ args: tuple
27
+ kwargs: dict
28
+ delay_seconds: float = 0.0
29
+
30
+
31
+ class LocalQueue:
32
+ """
33
+ Simple in-process job queue with single worker thread.
34
+
35
+ For ultra-lightweight tasks that don't need persistence,
36
+ retries, or distributed execution. Uses time.sleep() for delays.
37
+ """
38
+
39
+ def __init__(self, maxsize: Optional[int] = None):
40
+ """
41
+ Initialize the local queue.
42
+
43
+ Args:
44
+ maxsize: Maximum queue size (default from settings or 1000)
45
+ """
46
+ if maxsize is None:
47
+ maxsize = getattr(settings, 'JOBS_LOCAL_QUEUE_MAXSIZE', 1000)
48
+
49
+ self.queue = queue.Queue(maxsize=maxsize)
50
+ self.worker_thread = None
51
+ self.stop_event = threading.Event()
52
+ self.started = False
53
+ self._lock = threading.RLock()
54
+ self._processed_count = 0
55
+ self._error_count = 0
56
+
57
+ def start(self):
58
+ """Start the worker thread."""
59
+ with self._lock:
60
+ if self.started:
61
+ return
62
+
63
+ self.stop_event.clear()
64
+ self.worker_thread = threading.Thread(
65
+ target=self._worker,
66
+ name="LocalJobWorker",
67
+ daemon=True # Dies with main process
68
+ )
69
+ self.worker_thread.start()
70
+ self.started = True
71
+ logit.info("Local job queue worker started")
72
+
73
+ def stop(self, timeout: float = 5.0):
74
+ """
75
+ Stop the worker thread gracefully.
76
+
77
+ Args:
78
+ timeout: Maximum time to wait for thread to stop
79
+ """
80
+ with self._lock:
81
+ if not self.started:
82
+ return
83
+
84
+ logit.info("Stopping local job queue worker...")
85
+ self.stop_event.set()
86
+
87
+ # Put a sentinel to unblock the worker if waiting
88
+ try:
89
+ self.queue.put_nowait(None) # Sentinel value
90
+ except queue.Full:
91
+ pass
92
+
93
+ if self.worker_thread and self.worker_thread.is_alive():
94
+ self.worker_thread.join(timeout)
95
+ if self.worker_thread.is_alive():
96
+ logit.warn("Local job worker thread did not stop cleanly")
97
+
98
+ self.started = False
99
+ logit.info(f"Local job queue stopped (processed={self._processed_count}, "
100
+ f"errors={self._error_count})")
101
+
102
+ def put(self, func: Callable, args: tuple, kwargs: dict,
103
+ job_id: str, run_at: Optional[datetime] = None) -> bool:
104
+ """
105
+ Add a job to the queue.
106
+
107
+ Args:
108
+ func: Function to execute
109
+ args: Positional arguments
110
+ kwargs: Keyword arguments
111
+ job_id: Job identifier
112
+ run_at: When to execute the job (None for immediate)
113
+
114
+ Returns:
115
+ True if queued, False if queue is full
116
+ """
117
+ if not self.started:
118
+ self.start()
119
+
120
+ # Calculate delay in seconds
121
+ delay_seconds = 0.0
122
+ if run_at:
123
+ now = timezone.now()
124
+ if run_at > now:
125
+ delay_seconds = (run_at - now).total_seconds()
126
+
127
+ job = LocalJob(
128
+ job_id=job_id,
129
+ func=func,
130
+ args=args,
131
+ kwargs=kwargs,
132
+ delay_seconds=delay_seconds
133
+ )
134
+
135
+ try:
136
+ self.queue.put_nowait(job)
137
+ return True
138
+ except queue.Full:
139
+ logit.warn(f"Local job queue is full, rejecting job {job_id}")
140
+ return False
141
+
142
+ def size(self) -> int:
143
+ """Get current queue size."""
144
+ return self.queue.qsize()
145
+
146
+ def is_empty(self) -> bool:
147
+ """Check if queue is empty."""
148
+ return self.queue.empty()
149
+
150
+ def stats(self) -> dict:
151
+ """
152
+ Get queue statistics.
153
+
154
+ Returns:
155
+ Dict with queue stats
156
+ """
157
+ with self._lock:
158
+ return {
159
+ 'size': self.size(),
160
+ 'maxsize': self.queue.maxsize,
161
+ 'processed': self._processed_count,
162
+ 'errors': self._error_count,
163
+ 'running': self.started,
164
+ 'worker_alive': self.worker_thread.is_alive() if self.worker_thread else False
165
+ }
166
+
167
+ def _worker(self):
168
+ """
169
+ Worker thread main loop.
170
+
171
+ Continuously processes jobs from the queue until stopped.
172
+ Simple approach: get job, sleep if needed, execute, repeat.
173
+ """
174
+ logit.info("Local job worker thread started")
175
+
176
+ while not self.stop_event.is_set():
177
+ try:
178
+ # Get job from queue with timeout
179
+ try:
180
+ job = self.queue.get(timeout=1.0)
181
+ except queue.Empty:
182
+ continue
183
+
184
+ # Check for shutdown sentinel
185
+ if job is None:
186
+ logit.debug("Worker received shutdown sentinel")
187
+ break
188
+
189
+ # Sleep if job has a delay
190
+ if job.delay_seconds > 0:
191
+ logit.debug(f"Job {job.job_id} sleeping for {job.delay_seconds:.2f}s")
192
+
193
+ # Sleep in small chunks to allow for quick shutdown
194
+ sleep_remaining = job.delay_seconds
195
+ while sleep_remaining > 0 and not self.stop_event.is_set():
196
+ sleep_time = min(0.1, sleep_remaining) # Sleep max 100ms at a time
197
+ time.sleep(sleep_time)
198
+ sleep_remaining -= sleep_time
199
+
200
+ # If we were asked to stop during sleep, break
201
+ if self.stop_event.is_set():
202
+ break
203
+
204
+ # Execute the job
205
+ self._execute_job(job)
206
+
207
+ with self._lock:
208
+ self._processed_count += 1
209
+
210
+ # Mark task as done for queue.join() if anyone uses it
211
+ self.queue.task_done()
212
+
213
+ except Exception as e:
214
+ # This should never happen (caught in _execute_job)
215
+ # but just in case...
216
+ logit.error(f"Unexpected error in local job worker: {e}")
217
+ with self._lock:
218
+ self._error_count += 1
219
+
220
+ logit.info("Local job worker thread exiting")
221
+
222
+ def _execute_job(self, job: LocalJob):
223
+ """
224
+ Execute a single job.
225
+
226
+ Args:
227
+ job: LocalJob to execute
228
+ """
229
+ start_time = timezone.now()
230
+
231
+ try:
232
+ # Log execution start
233
+ logit.debug(f"Executing local job {job.job_id}")
234
+
235
+ # Close old database connections before execution
236
+ from django.db import close_old_connections
237
+ close_old_connections()
238
+
239
+ # Execute the function
240
+ result = job.func(*job.args, **job.kwargs)
241
+
242
+ # Close connections after execution
243
+ close_old_connections()
244
+
245
+ # Log success
246
+ duration = (timezone.now() - start_time).total_seconds()
247
+ logit.info(f"Local job {job.job_id} completed in {duration:.2f}s")
248
+
249
+ # Emit metric
250
+ try:
251
+ from mojo.apps import metrics
252
+ metrics.record(
253
+ slug="jobs.local.completed",
254
+ when=timezone.now(),
255
+ count=1,
256
+ category="jobs"
257
+ )
258
+ metrics.record(
259
+ slug="jobs.local.duration_ms",
260
+ when=timezone.now(),
261
+ count=int(duration * 1000),
262
+ category="jobs"
263
+ )
264
+ except Exception as e:
265
+ logit.debug(f"Failed to record local job metrics: {e}")
266
+
267
+ return result
268
+
269
+ except Exception as e:
270
+ # Log error
271
+ with self._lock:
272
+ self._error_count += 1
273
+ duration = (timezone.now() - start_time).total_seconds()
274
+
275
+ error_msg = str(e)
276
+ stack = traceback.format_exc()
277
+
278
+ logit.error(f"Local job {job.job_id} failed after {duration:.2f}s: {error_msg}")
279
+ logit.debug(f"Stack trace for {job.job_id}:\n{stack}")
280
+
281
+ # Emit error metric
282
+ try:
283
+ from mojo.apps import metrics
284
+ metrics.record(
285
+ slug="jobs.local.failed",
286
+ when=timezone.now(),
287
+ count=1,
288
+ category="jobs"
289
+ )
290
+ except Exception as me:
291
+ logit.debug(f"Failed to record local job error metrics: {me}")
292
+
293
+ # Local jobs don't retry - just log and move on
294
+ return None
295
+
296
+
297
+ class LocalQueueManager:
298
+ """
299
+ Manager for local queue singleton.
300
+
301
+ Ensures only one queue instance exists per process.
302
+ """
303
+
304
+ def __init__(self):
305
+ self._queue = None
306
+ self._lock = threading.RLock()
307
+
308
+ def get_queue(self) -> LocalQueue:
309
+ """
310
+ Get or create the local queue instance.
311
+
312
+ Returns:
313
+ LocalQueue instance
314
+ """
315
+ with self._lock:
316
+ if self._queue is None:
317
+ self._queue = LocalQueue()
318
+ return self._queue
319
+
320
+ def stop_queue(self, timeout: float = 5.0):
321
+ """
322
+ Stop the local queue if running.
323
+
324
+ Args:
325
+ timeout: Maximum time to wait for stop
326
+ """
327
+ with self._lock:
328
+ if self._queue:
329
+ self._queue.stop(timeout)
330
+ self._queue = None
331
+
332
+ def reset(self):
333
+ """Reset the queue (useful for testing)."""
334
+ self.stop_queue()
335
+
336
+
337
+ # Global manager instance
338
+ _manager = LocalQueueManager()
339
+
340
+
341
+ def get_local_queue() -> LocalQueue:
342
+ """
343
+ Get the local job queue instance.
344
+
345
+ Returns:
346
+ LocalQueue singleton
347
+ """
348
+ return _manager.get_queue()
349
+
350
+
351
+ def stop_local_queue(timeout: float = 5.0):
352
+ """
353
+ Stop the local job queue.
354
+
355
+ Args:
356
+ timeout: Maximum time to wait
357
+ """
358
+ _manager.stop_queue(timeout)
359
+
360
+
361
+ def reset_local_queue():
362
+ """Reset the local queue (useful for testing)."""
363
+ _manager.reset()
@@ -0,0 +1,3 @@
1
+ """
2
+ Django management commands for the jobs system.
3
+ """
@@ -0,0 +1,3 @@
1
+ """
2
+ Django management commands for the jobs system.
3
+ """