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.
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
- django_nativemojo-0.1.17.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/commands/serializer_admin.py +121 -1
- mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
- mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
- mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
- mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
- mojo/apps/account/migrations/0010_group_avatar.py +20 -0
- mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
- mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
- mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
- mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
- mojo/apps/account/models/__init__.py +2 -0
- mojo/apps/account/models/device.py +279 -0
- mojo/apps/account/models/group.py +294 -8
- mojo/apps/account/models/member.py +14 -1
- mojo/apps/account/models/push/__init__.py +4 -0
- mojo/apps/account/models/push/config.py +112 -0
- mojo/apps/account/models/push/delivery.py +93 -0
- mojo/apps/account/models/push/device.py +66 -0
- mojo/apps/account/models/push/template.py +99 -0
- mojo/apps/account/models/user.py +190 -17
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +8 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +95 -5
- mojo/apps/account/services/__init__.py +1 -0
- mojo/apps/account/services/push.py +363 -0
- mojo/apps/aws/migrations/0001_initial.py +206 -0
- mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
- mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
- mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
- mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
- mojo/apps/aws/models/__init__.py +19 -0
- mojo/apps/aws/models/email_attachment.py +99 -0
- mojo/apps/aws/models/email_domain.py +218 -0
- mojo/apps/aws/models/email_template.py +132 -0
- mojo/apps/aws/models/incoming_email.py +197 -0
- mojo/apps/aws/models/mailbox.py +288 -0
- mojo/apps/aws/models/sent_message.py +175 -0
- mojo/apps/aws/rest/__init__.py +6 -0
- mojo/apps/aws/rest/email.py +33 -0
- mojo/apps/aws/rest/email_ops.py +183 -0
- mojo/apps/aws/rest/messages.py +32 -0
- mojo/apps/aws/rest/send.py +101 -0
- mojo/apps/aws/rest/sns.py +403 -0
- mojo/apps/aws/rest/templates.py +19 -0
- mojo/apps/aws/services/__init__.py +32 -0
- mojo/apps/aws/services/email.py +390 -0
- mojo/apps/aws/services/email_ops.py +548 -0
- mojo/apps/docit/__init__.py +6 -0
- mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
- mojo/apps/docit/markdown_plugins/toc.py +12 -0
- mojo/apps/docit/migrations/0001_initial.py +113 -0
- mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
- mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
- mojo/apps/docit/models/__init__.py +17 -0
- mojo/apps/docit/models/asset.py +231 -0
- mojo/apps/docit/models/book.py +227 -0
- mojo/apps/docit/models/page.py +319 -0
- mojo/apps/docit/models/page_revision.py +203 -0
- mojo/apps/docit/rest/__init__.py +10 -0
- mojo/apps/docit/rest/asset.py +17 -0
- mojo/apps/docit/rest/book.py +22 -0
- mojo/apps/docit/rest/page.py +22 -0
- mojo/apps/docit/rest/page_revision.py +17 -0
- mojo/apps/docit/services/__init__.py +11 -0
- mojo/apps/docit/services/docit.py +315 -0
- mojo/apps/docit/services/markdown.py +44 -0
- mojo/apps/fileman/backends/s3.py +209 -0
- mojo/apps/fileman/models/file.py +45 -9
- mojo/apps/fileman/models/manager.py +269 -3
- mojo/apps/incident/migrations/0007_event_uid.py +18 -0
- mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
- mojo/apps/incident/migrations/0009_incident_status.py +18 -0
- mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
- mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
- mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
- mojo/apps/incident/models/__init__.py +1 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/incident.py +2 -0
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -3
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/ticket.py +43 -0
- mojo/apps/jobs/__init__.py +489 -0
- mojo/apps/jobs/adapters.py +24 -0
- mojo/apps/jobs/cli.py +616 -0
- mojo/apps/jobs/daemon.py +370 -0
- mojo/apps/jobs/examples/sample_jobs.py +376 -0
- mojo/apps/jobs/examples/webhook_examples.py +203 -0
- mojo/apps/jobs/handlers/__init__.py +5 -0
- mojo/apps/jobs/handlers/webhook.py +317 -0
- mojo/apps/jobs/job_engine.py +734 -0
- mojo/apps/jobs/keys.py +203 -0
- mojo/apps/jobs/local_queue.py +363 -0
- mojo/apps/jobs/management/__init__.py +3 -0
- mojo/apps/jobs/management/commands/__init__.py +3 -0
- mojo/apps/jobs/manager.py +1327 -0
- mojo/apps/jobs/migrations/0001_initial.py +97 -0
- mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
- mojo/apps/jobs/models/__init__.py +6 -0
- mojo/apps/jobs/models/job.py +441 -0
- mojo/apps/jobs/rest/__init__.py +2 -0
- mojo/apps/jobs/rest/control.py +466 -0
- mojo/apps/jobs/rest/jobs.py +421 -0
- mojo/apps/jobs/scheduler.py +571 -0
- mojo/apps/jobs/services/__init__.py +6 -0
- mojo/apps/jobs/services/job_actions.py +465 -0
- mojo/apps/jobs/settings.py +209 -0
- mojo/apps/logit/models/log.py +3 -0
- mojo/apps/metrics/__init__.py +8 -1
- mojo/apps/metrics/redis_metrics.py +198 -0
- mojo/apps/metrics/rest/__init__.py +3 -0
- mojo/apps/metrics/rest/categories.py +266 -0
- mojo/apps/metrics/rest/helpers.py +48 -0
- mojo/apps/metrics/rest/permissions.py +99 -0
- mojo/apps/metrics/rest/values.py +277 -0
- mojo/apps/metrics/utils.py +17 -0
- mojo/decorators/http.py +40 -1
- mojo/helpers/aws/__init__.py +11 -7
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/location/__init__.py +2 -0
- mojo/helpers/location/countries.py +262 -0
- mojo/helpers/location/geolocation.py +196 -0
- mojo/helpers/logit.py +37 -0
- mojo/helpers/redis/__init__.py +2 -0
- mojo/helpers/redis/adapter.py +606 -0
- mojo/helpers/redis/client.py +48 -0
- mojo/helpers/redis/pool.py +225 -0
- mojo/helpers/request.py +8 -0
- mojo/helpers/response.py +8 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +5 -0
- mojo/models/rest.py +271 -57
- mojo/models/secrets.py +86 -0
- mojo/serializers/__init__.py +16 -10
- mojo/serializers/core/__init__.py +90 -0
- mojo/serializers/core/cache/__init__.py +121 -0
- mojo/serializers/core/cache/backends.py +518 -0
- mojo/serializers/core/cache/base.py +102 -0
- mojo/serializers/core/cache/disabled.py +181 -0
- mojo/serializers/core/cache/memory.py +287 -0
- mojo/serializers/core/cache/redis.py +533 -0
- mojo/serializers/core/cache/utils.py +454 -0
- mojo/serializers/{manager.py → core/manager.py} +53 -4
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +14 -0
- testit/runner.py +23 -6
- django_nativemojo-0.1.15.dist-info/RECORD +0 -234
- mojo/apps/notify/README.md +0 -91
- mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
- mojo/apps/notify/admin.py +0 -52
- mojo/apps/notify/handlers/example_handlers.py +0 -516
- mojo/apps/notify/handlers/ses/__init__.py +0 -25
- mojo/apps/notify/handlers/ses/complaint.py +0 -25
- mojo/apps/notify/handlers/ses/message.py +0 -86
- mojo/apps/notify/management/commands/__init__.py +0 -1
- mojo/apps/notify/management/commands/process_notifications.py +0 -370
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +0 -12
- mojo/apps/notify/models/account.py +0 -128
- mojo/apps/notify/models/attachment.py +0 -24
- mojo/apps/notify/models/bounce.py +0 -68
- mojo/apps/notify/models/complaint.py +0 -40
- mojo/apps/notify/models/inbox.py +0 -113
- mojo/apps/notify/models/inbox_message.py +0 -173
- mojo/apps/notify/models/outbox.py +0 -129
- mojo/apps/notify/models/outbox_message.py +0 -288
- mojo/apps/notify/models/template.py +0 -30
- mojo/apps/notify/providers/aws.py +0 -73
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +0 -2
- mojo/apps/notify/utils/notifications.py +0 -404
- mojo/apps/notify/utils/parsing.py +0 -202
- mojo/apps/notify/utils/render.py +0 -144
- mojo/apps/tasks/README.md +0 -118
- mojo/apps/tasks/__init__.py +0 -44
- mojo/apps/tasks/manager.py +0 -644
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -76
- mojo/apps/tasks/runner.py +0 -439
- mojo/apps/tasks/task.py +0 -99
- mojo/apps/tasks/tq_handlers.py +0 -132
- mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/serializers/advanced/README.md +0 -363
- mojo/serializers/advanced/__init__.py +0 -247
- mojo/serializers/advanced/formats/__init__.py +0 -28
- mojo/serializers/advanced/formats/excel.py +0 -516
- mojo/serializers/advanced/formats/json.py +0 -239
- mojo/serializers/advanced/formats/response.py +0 -485
- mojo/serializers/advanced/serializer.py +0 -568
- mojo/serializers/optimized.py +0 -618
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
- /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
- /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
- /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -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()
|