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
@@ -0,0 +1,97 @@
1
+ # Generated by Django 4.2.23 on 2025-09-03 20:22
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+ import mojo.models.rest
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name='Job',
18
+ fields=[
19
+ ('id', models.CharField(editable=False, max_length=32, primary_key=True, serialize=False)),
20
+ ('channel', models.CharField(db_index=True, help_text='Logical queue/channel name', max_length=100)),
21
+ ('func', models.CharField(db_index=True, help_text='Registry key for the job function', max_length=255)),
22
+ ('payload', models.JSONField(blank=True, default=dict, help_text='Job arguments/data (keep small)')),
23
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ('canceled', 'Canceled'), ('expired', 'Expired')], db_index=True, default='pending', help_text='Current job status', max_length=16)),
24
+ ('run_at', models.DateTimeField(blank=True, db_index=True, help_text='When to run this job (null = immediate)', null=True)),
25
+ ('expires_at', models.DateTimeField(blank=True, db_index=True, help_text='Job expires if not run by this time', null=True)),
26
+ ('attempt', models.IntegerField(default=0, help_text='Current attempt number')),
27
+ ('max_retries', models.IntegerField(default=3, help_text='Maximum retry attempts')),
28
+ ('backoff_base', models.FloatField(default=2.0, help_text='Base for exponential backoff')),
29
+ ('backoff_max_sec', models.IntegerField(default=3600, help_text='Maximum backoff in seconds')),
30
+ ('broadcast', models.BooleanField(db_index=True, default=False, help_text='If true, all runners execute this job')),
31
+ ('cancel_requested', models.BooleanField(default=False, help_text='Cooperative cancel flag')),
32
+ ('max_exec_seconds', models.IntegerField(blank=True, help_text='Hard execution time limit', null=True)),
33
+ ('runner_id', models.CharField(blank=True, db_index=True, help_text='ID of runner currently executing', max_length=64, null=True)),
34
+ ('last_error', models.TextField(blank=True, default='', help_text='Latest error message')),
35
+ ('stack_trace', models.TextField(blank=True, default='', help_text='Latest stack trace')),
36
+ ('metadata', models.JSONField(blank=True, default=dict, help_text='Custom metadata from job execution')),
37
+ ('created', models.DateTimeField(auto_now_add=True, db_index=True)),
38
+ ('modified', models.DateTimeField(auto_now=True, db_index=True)),
39
+ ('started_at', models.DateTimeField(blank=True, help_text='When job execution started', null=True)),
40
+ ('finished_at', models.DateTimeField(blank=True, help_text='When job execution finished', null=True)),
41
+ ('idempotency_key', models.CharField(blank=True, help_text='Optional key for exactly-once semantics', max_length=64, null=True, unique=True)),
42
+ ],
43
+ options={
44
+ 'db_table': 'jobs_job',
45
+ 'ordering': ['-created'],
46
+ },
47
+ bases=(models.Model, mojo.models.rest.MojoModel),
48
+ ),
49
+ migrations.CreateModel(
50
+ name='JobEvent',
51
+ fields=[
52
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
53
+ ('channel', models.CharField(db_index=True, max_length=100)),
54
+ ('event', models.CharField(choices=[('created', 'Created'), ('queued', 'Queued'), ('scheduled', 'Scheduled'), ('running', 'Running'), ('retry', 'Retry'), ('canceled', 'Canceled'), ('completed', 'Completed'), ('failed', 'Failed'), ('expired', 'Expired'), ('claimed', 'Claimed'), ('released', 'Released')], db_index=True, help_text='Event type', max_length=24)),
55
+ ('at', models.DateTimeField(auto_now_add=True, db_index=True)),
56
+ ('runner_id', models.CharField(blank=True, db_index=True, help_text='Runner that generated this event', max_length=64, null=True)),
57
+ ('attempt', models.IntegerField(default=0, help_text='Attempt number at time of event')),
58
+ ('details', models.JSONField(blank=True, default=dict, help_text='Event-specific details (keep minimal)')),
59
+ ('created', models.DateTimeField(auto_now_add=True, db_index=True)),
60
+ ('modified', models.DateTimeField(auto_now=True, db_index=True)),
61
+ ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='jobs.job')),
62
+ ],
63
+ options={
64
+ 'db_table': 'jobs_jobevent',
65
+ 'ordering': ['-at'],
66
+ },
67
+ bases=(models.Model, mojo.models.rest.MojoModel),
68
+ ),
69
+ migrations.AddIndex(
70
+ model_name='job',
71
+ index=models.Index(fields=['channel', 'status'], name='jobs_job_channel_b6258d_idx'),
72
+ ),
73
+ migrations.AddIndex(
74
+ model_name='job',
75
+ index=models.Index(fields=['status', 'run_at'], name='jobs_job_status_f5c023_idx'),
76
+ ),
77
+ migrations.AddIndex(
78
+ model_name='job',
79
+ index=models.Index(fields=['runner_id', 'status'], name='jobs_job_runner__068502_idx'),
80
+ ),
81
+ migrations.AddIndex(
82
+ model_name='jobevent',
83
+ index=models.Index(fields=['job', '-at'], name='jobs_jobeve_job_id_4bb0aa_idx'),
84
+ ),
85
+ migrations.AddIndex(
86
+ model_name='jobevent',
87
+ index=models.Index(fields=['channel', 'event', '-at'], name='jobs_jobeve_channel_65ad24_idx'),
88
+ ),
89
+ migrations.AddIndex(
90
+ model_name='jobevent',
91
+ index=models.Index(fields=['runner_id', '-at'], name='jobs_jobeve_runner__8d884e_idx'),
92
+ ),
93
+ migrations.AddIndex(
94
+ model_name='jobevent',
95
+ index=models.Index(fields=['-at'], name='jobs_jobeve_at_67fd08_idx'),
96
+ ),
97
+ ]
@@ -0,0 +1,39 @@
1
+ # Generated by Django 4.2.23 on 2025-09-04 19:21
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+ import mojo.models.rest
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ('jobs', '0001_initial'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AlterField(
16
+ model_name='job',
17
+ name='max_retries',
18
+ field=models.IntegerField(default=0, help_text='Maximum retry attempts'),
19
+ ),
20
+ migrations.CreateModel(
21
+ name='JobLog',
22
+ fields=[
23
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24
+ ('channel', models.CharField(db_index=True, max_length=100)),
25
+ ('kind', models.CharField(choices=[('debug', 'Debug'), ('info', 'Info'), ('warn', 'Warn'), ('error', 'Error')], db_index=True, default='info', help_text='Log level/kind', max_length=16)),
26
+ ('message', models.TextField(help_text='Log message')),
27
+ ('meta', models.JSONField(blank=True, default=dict, help_text='Optional structured context')),
28
+ ('created', models.DateTimeField(auto_now_add=True, db_index=True)),
29
+ ('modified', models.DateTimeField(auto_now=True, db_index=True)),
30
+ ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='jobs.job')),
31
+ ],
32
+ options={
33
+ 'db_table': 'jobs_joblog',
34
+ 'ordering': ['-created'],
35
+ 'indexes': [models.Index(fields=['job', '-created'], name='jobs_joblog_job_id_e9a555_idx'), models.Index(fields=['channel', 'kind', '-created'], name='jobs_joblog_channel_245a26_idx'), models.Index(fields=['-created'], name='jobs_joblog_created_41aac8_idx')],
36
+ },
37
+ bases=(models.Model, mojo.models.rest.MojoModel),
38
+ ),
39
+ ]
@@ -0,0 +1,6 @@
1
+ """
2
+ Jobs models.
3
+ """
4
+ from .job import Job, JobEvent, JobLog
5
+
6
+ __all__ = ['Job', 'JobEvent', 'JobLog']
@@ -0,0 +1,441 @@
1
+ """
2
+ Job and JobEvent models for the jobs system.
3
+ """
4
+ from django.db import models
5
+ from mojo.models import MojoModel
6
+ from mojo.helpers import dates
7
+ from typing import Optional, Dict, Any
8
+
9
+
10
+ class Job(models.Model, MojoModel):
11
+ """
12
+ Represents a background job in the system.
13
+ Stores current state and metadata for job execution.
14
+ """
15
+
16
+ # Primary identifier - UUID without dashes
17
+ id = models.CharField(primary_key=True, max_length=32, editable=False)
18
+
19
+ # Job targeting
20
+ channel = models.CharField(max_length=100, db_index=True,
21
+ help_text="Logical queue/channel name")
22
+ func = models.CharField(max_length=255, db_index=True,
23
+ help_text="Registry key for the job function")
24
+ payload = models.JSONField(default=dict, blank=True,
25
+ help_text="Job arguments/data (keep small)")
26
+
27
+ # Current status
28
+ status = models.CharField(
29
+ max_length=16,
30
+ db_index=True,
31
+ choices=[
32
+ ('pending', 'Pending'),
33
+ ('running', 'Running'),
34
+ ('completed', 'Completed'),
35
+ ('failed', 'Failed'),
36
+ ('canceled', 'Canceled'),
37
+ ('expired', 'Expired')
38
+ ],
39
+ default='pending',
40
+ help_text="Current job status"
41
+ )
42
+
43
+ # Scheduling & timing
44
+ run_at = models.DateTimeField(null=True, blank=True, db_index=True,
45
+ help_text="When to run this job (null = immediate)")
46
+ expires_at = models.DateTimeField(null=True, blank=True, db_index=True,
47
+ help_text="Job expires if not run by this time")
48
+
49
+ # Retry configuration
50
+ attempt = models.IntegerField(default=0,
51
+ help_text="Current attempt number")
52
+ max_retries = models.IntegerField(default=0,
53
+ help_text="Maximum retry attempts")
54
+ backoff_base = models.FloatField(default=2.0,
55
+ help_text="Base for exponential backoff")
56
+ backoff_max_sec = models.IntegerField(default=3600,
57
+ help_text="Maximum backoff in seconds")
58
+
59
+ # Behavior flags
60
+ broadcast = models.BooleanField(default=False, db_index=True,
61
+ help_text="If true, all runners execute this job")
62
+ cancel_requested = models.BooleanField(default=False,
63
+ help_text="Cooperative cancel flag")
64
+ max_exec_seconds = models.IntegerField(null=True, blank=True,
65
+ help_text="Hard execution time limit")
66
+
67
+ # Runner tracking
68
+ runner_id = models.CharField(max_length=64, null=True, blank=True, db_index=True,
69
+ help_text="ID of runner currently executing")
70
+
71
+ # Error diagnostics (latest only)
72
+ last_error = models.TextField(blank=True, default="",
73
+ help_text="Latest error message")
74
+ stack_trace = models.TextField(blank=True, default="",
75
+ help_text="Latest stack trace")
76
+
77
+ # Additional metadata
78
+ metadata = models.JSONField(default=dict, blank=True,
79
+ help_text="Custom metadata from job execution")
80
+
81
+ # Timestamps
82
+ created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
83
+ modified = models.DateTimeField(auto_now=True, db_index=True)
84
+ started_at = models.DateTimeField(null=True, blank=True,
85
+ help_text="When job execution started")
86
+ finished_at = models.DateTimeField(null=True, blank=True,
87
+ help_text="When job execution finished")
88
+
89
+ # Idempotency support
90
+ idempotency_key = models.CharField(max_length=64, null=True, blank=True,
91
+ unique=True,
92
+ help_text="Optional key for exactly-once semantics")
93
+
94
+ class Meta:
95
+ db_table = 'jobs_job'
96
+ indexes = [
97
+ models.Index(fields=['channel', 'status']),
98
+ models.Index(fields=['status', 'run_at']),
99
+ models.Index(fields=['runner_id', 'status']),
100
+ ]
101
+ ordering = ['-created']
102
+
103
+ class RestMeta:
104
+ # Permissions - jobs system specific permissions
105
+ VIEW_PERMS = ['view_jobs', 'manage_jobs']
106
+ SAVE_PERMS = ['manage_jobs']
107
+ DELETE_PERMS = ['manage_jobs']
108
+ POST_SAVE_ACTIONS = ["cancel_request", "retry_request", "get_status", "publish_job"]
109
+
110
+ # Graphs for different use cases
111
+ GRAPHS = {
112
+ 'default': {
113
+ 'extra': ['duration_ms'],
114
+ 'fields': [
115
+ 'id', 'channel', 'func', 'status',
116
+ 'created', 'modified', 'attempt',
117
+ 'started_at', 'finished_at', 'run_at'
118
+ ]
119
+ },
120
+ 'detail': {
121
+ 'extra': ['duration_ms'],
122
+ 'fields': [
123
+ 'id', 'channel', 'func', 'payload', 'status',
124
+ 'run_at', 'expires_at', 'attempt', 'max_retries',
125
+ 'broadcast', 'cancel_requested', 'max_exec_seconds',
126
+ 'runner_id', 'last_error', 'metadata'
127
+ 'created', 'modified', 'started_at', 'finished_at'
128
+ ]
129
+ },
130
+ 'status': {
131
+ 'fields': [
132
+ 'id', 'status', 'runner_id', 'attempt',
133
+ 'started_at', 'finished_at', 'last_error'
134
+ ]
135
+ },
136
+ 'admin': {
137
+ 'fields': '__all__',
138
+ 'exclude': ['stack_trace'] # Stack traces can be large
139
+ }
140
+ }
141
+
142
+ def __str__(self):
143
+ return f"Job {self.id} ({self.func}@{self.channel}): {self.status}"
144
+
145
+ @property
146
+ def is_terminal(self) -> bool:
147
+ """Check if job is in a terminal state."""
148
+ return self.status in ('completed', 'failed', 'canceled', 'expired')
149
+
150
+ @property
151
+ def is_retriable(self) -> bool:
152
+ """Check if job can be retried."""
153
+ return self.status == 'failed' and self.attempt < self.max_retries
154
+
155
+ @property
156
+ def duration_ms(self) -> int:
157
+ """Calculate job execution duration in milliseconds."""
158
+ if self.started_at and self.finished_at:
159
+ delta = self.finished_at - self.started_at
160
+ return int(delta.total_seconds() * 1000)
161
+ return 0
162
+
163
+ @property
164
+ def is_expired(self) -> bool:
165
+ """Check if job has expired."""
166
+ return self.expires_at and dates.utcnow() > self.expires_at
167
+
168
+ def check_cancel_requested(self) -> bool:
169
+ """
170
+ Sync the cancel_requested field from the database and return updated value.
171
+
172
+ This method refreshes the cancel_requested field from the database to get
173
+ the most current cancellation status, useful for long-running jobs that
174
+ need to check for cancellation requests during execution.
175
+
176
+ Returns:
177
+ bool: Current cancel_requested value from database
178
+ """
179
+ self.refresh_from_db(fields=['cancel_requested'])
180
+ return self.cancel_requested
181
+
182
+ def on_action_cancel_request(self, value):
183
+ """
184
+ Cancel this job via REST API action.
185
+
186
+ Args:
187
+ value: Boolean indicating if cancellation is requested
188
+
189
+ Returns:
190
+ dict: Response indicating success/failure
191
+ """
192
+ if not value:
193
+ return {'status': False, 'error': 'cancel_request must be true'}
194
+
195
+ from mojo.apps.jobs.services import JobActionsService
196
+ return JobActionsService.cancel_job(self)
197
+
198
+ def on_action_retry_request(self, value):
199
+ """
200
+ Retry this failed/cancelled job via REST API action.
201
+
202
+ Args:
203
+ value: Can be boolean True or dict with 'delay' key for delayed retry
204
+
205
+ Returns:
206
+ dict: Response indicating success/failure with new job_id
207
+ """
208
+ # Parse value - can be boolean or dict with delay
209
+ delay = None
210
+ if isinstance(value, dict):
211
+ if not value.get('retry'):
212
+ return {'status': False, 'error': 'retry_request must be true or {retry: true, delay: N}'}
213
+ delay = value.get('delay')
214
+ elif not value:
215
+ return {'status': False, 'error': 'retry_request must be true or {retry: true, delay: N}'}
216
+
217
+ from mojo.apps.jobs.services import JobActionsService
218
+ return JobActionsService.retry_job(self, delay=delay)
219
+
220
+ def on_action_get_status(self, value):
221
+ """
222
+ Get detailed status of this job via REST API action.
223
+
224
+ Args:
225
+ value: Boolean (should be true)
226
+
227
+ Returns:
228
+ dict: Detailed job status information
229
+ """
230
+ if not value:
231
+ return {'status': False, 'error': 'get_status must be true'}
232
+
233
+ from mojo.apps.jobs.services import JobActionsService
234
+ return JobActionsService.get_job_status(self)
235
+
236
+ def on_action_publish_job(self, value):
237
+ """
238
+ Publish a new job using this job as a template via REST API action.
239
+
240
+ Args:
241
+ value: Dict with optional overrides for the new job:
242
+ - func: Override function path
243
+ - payload: Override payload
244
+ - channel: Override channel
245
+ - delay: Delay in seconds
246
+ - run_at: Specific run time
247
+ - max_retries: Override max retries
248
+ - broadcast: Override broadcast flag
249
+
250
+ Returns:
251
+ dict: Response with new job ID
252
+ """
253
+ if not isinstance(value, dict):
254
+ return {'status': False, 'error': 'publish_job must be a dict with job parameters'}
255
+
256
+ from mojo.apps.jobs.services import JobActionsService
257
+ return JobActionsService.publish_job_from_template(self, value)
258
+
259
+ def add_log(self, message: str, kind: str = 'info', meta: Optional[dict] = None):
260
+ """
261
+ Append a log entry for this job.
262
+
263
+ Args:
264
+ message: Log message text
265
+ kind: One of 'debug','info','warn','error' (default: 'info')
266
+ meta: Optional small dict for structured context
267
+ """
268
+ # Normalize kind to known values
269
+ kind_norm = (kind or 'info').lower()
270
+ if kind_norm not in ('debug', 'info', 'warn', 'error'):
271
+ kind_norm = 'info'
272
+
273
+ # Persist log entry
274
+ JobLog.objects.create(
275
+ job=self,
276
+ channel=self.channel,
277
+ kind=kind_norm,
278
+ message=str(message),
279
+ meta=meta or {}
280
+ )
281
+
282
+ # Touch modified for easier tracking
283
+ self.save(update_fields=['modified'])
284
+
285
+ return True
286
+
287
+
288
+ class JobEvent(models.Model, MojoModel):
289
+ """
290
+ Append-only audit log for job state transitions and events.
291
+ Kept minimal for efficient storage and querying.
292
+ """
293
+
294
+ # Link to parent job
295
+ job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='events')
296
+
297
+ # Denormalized for efficient queries
298
+ channel = models.CharField(max_length=100, db_index=True)
299
+
300
+ # Event type
301
+ event = models.CharField(
302
+ max_length=24,
303
+ db_index=True,
304
+ choices=[
305
+ ('created', 'Created'),
306
+ ('queued', 'Queued'),
307
+ ('scheduled', 'Scheduled'),
308
+ ('running', 'Running'),
309
+ ('retry', 'Retry'),
310
+ ('canceled', 'Canceled'),
311
+ ('completed', 'Completed'),
312
+ ('failed', 'Failed'),
313
+ ('expired', 'Expired'),
314
+ ('claimed', 'Claimed'),
315
+ ('released', 'Released')
316
+ ],
317
+ help_text="Event type"
318
+ )
319
+
320
+ # When it happened
321
+ at = models.DateTimeField(auto_now_add=True, db_index=True)
322
+
323
+ # Who/what triggered it
324
+ runner_id = models.CharField(max_length=64, null=True, blank=True, db_index=True,
325
+ help_text="Runner that generated this event")
326
+
327
+ # Context
328
+ attempt = models.IntegerField(default=0,
329
+ help_text="Attempt number at time of event")
330
+
331
+ # Small details only - avoid large payloads
332
+ details = models.JSONField(default=dict, blank=True,
333
+ help_text="Event-specific details (keep minimal)")
334
+
335
+ # Standard timestamps
336
+ created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
337
+ modified = models.DateTimeField(auto_now=True, db_index=True)
338
+
339
+ class Meta:
340
+ db_table = 'jobs_jobevent'
341
+ indexes = [
342
+ models.Index(fields=['job', '-at']),
343
+ models.Index(fields=['channel', 'event', '-at']),
344
+ models.Index(fields=['runner_id', '-at']),
345
+ models.Index(fields=['-at']), # For retention queries
346
+ ]
347
+ ordering = ['-at']
348
+
349
+ class RestMeta:
350
+ # Permissions - restricted to system users only
351
+ VIEW_PERMS = ['manage_jobs', 'view_jobs']
352
+ SAVE_PERMS = [] # Events are system-created only
353
+ DELETE_PERMS = ['manage_jobs']
354
+
355
+ # Graphs
356
+ GRAPHS = {
357
+ 'default': {
358
+ 'fields': [
359
+ 'id', 'event', 'at', 'runner_id', 'attempt', 'details'
360
+ ]
361
+ },
362
+ 'detail': {
363
+ 'fields': [
364
+ 'id', 'job_id', 'channel', 'event', 'at',
365
+ 'runner_id', 'attempt', 'details'
366
+ ]
367
+ },
368
+ 'timeline': {
369
+ 'fields': [
370
+ 'event', 'at', 'runner_id', 'details'
371
+ ]
372
+ }
373
+ }
374
+
375
+ def __str__(self):
376
+ return f"JobEvent {self.event} for {self.job_id} at {self.at}"
377
+
378
+
379
+ class JobLog(models.Model, MojoModel):
380
+ """
381
+ Append-only log entries for individual jobs with optional structured context.
382
+ Useful for partial failures (e.g., per-recipient send outcomes).
383
+ """
384
+
385
+ # Link to parent job
386
+ job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='logs')
387
+
388
+ # Denormalized channel for efficient filtering
389
+ channel = models.CharField(max_length=100, db_index=True)
390
+
391
+ # When it happened
392
+ created = models.DateTimeField(auto_now_add=True, db_index=True)
393
+
394
+ # Log kind/severity
395
+ kind = models.CharField(
396
+ max_length=16,
397
+ db_index=True,
398
+ choices=[
399
+ ('debug', 'Debug'),
400
+ ('info', 'Info'),
401
+ ('warn', 'Warn'),
402
+ ('error', 'Error'),
403
+ ],
404
+ default='info',
405
+ help_text="Log level/kind"
406
+ )
407
+
408
+ # Message content
409
+ message = models.TextField(help_text="Log message")
410
+
411
+ # Optional structured metadata (keep small)
412
+ meta = models.JSONField(default=dict, blank=True, help_text="Optional structured context")
413
+
414
+ # Standard timestamps
415
+ created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
416
+ modified = models.DateTimeField(auto_now=True, db_index=True)
417
+
418
+ class Meta:
419
+ db_table = 'jobs_joblog'
420
+ ordering = ['-created']
421
+ indexes = [
422
+ models.Index(fields=['job', '-created']),
423
+ models.Index(fields=['channel', 'kind', '-created']),
424
+ models.Index(fields=['-created']),
425
+ ]
426
+
427
+ class RestMeta:
428
+ VIEW_PERMS = ['manage_jobs', 'view_jobs']
429
+ SAVE_PERMS = [] # Logs should be written via add_log / system actions
430
+ DELETE_PERMS = ['manage_jobs']
431
+ GRAPHS = {
432
+ 'default': {
433
+ 'fields': ['id', 'job_id', 'created', 'kind', 'message']
434
+ },
435
+ 'detail': {
436
+ 'fields': ['id', 'job_id', 'channel', 'created', 'kind', 'message', 'meta']
437
+ }
438
+ }
439
+
440
+ def __str__(self):
441
+ return f"JobLog {self.kind} for {self.job_id} at {self.created}"
@@ -0,0 +1,2 @@
1
+ from .control import *
2
+ from .jobs import *