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
mojo/apps/account/rest/user.py
CHANGED
@@ -3,7 +3,8 @@ from mojo.apps.account.utils.jwtoken import JWToken
|
|
3
3
|
# from django.http import JsonResponse
|
4
4
|
from mojo.helpers.response import JsonResponse
|
5
5
|
from mojo.apps.account.models.user import User
|
6
|
-
from mojo.helpers import dates
|
6
|
+
from mojo.helpers import dates, crypto
|
7
|
+
from mojo import errors as merrors
|
7
8
|
|
8
9
|
@md.URL('user')
|
9
10
|
@md.URL('user/<int:pk>')
|
@@ -23,7 +24,7 @@ def on_user_me(request):
|
|
23
24
|
def on_refresh_token(request):
|
24
25
|
user, error = User.validate_jwt(request.DATA.refresh_token)
|
25
26
|
if error is not None:
|
26
|
-
|
27
|
+
raise merrors.PermissionDeniedException(error, 401, 401)
|
27
28
|
# future look at keeping the refresh token the same but updating the access_token
|
28
29
|
# TODO add device id to the token as well
|
29
30
|
user.touch()
|
@@ -37,15 +38,104 @@ def on_refresh_token(request):
|
|
37
38
|
def on_user_login(request):
|
38
39
|
username = request.DATA.username
|
39
40
|
password = request.DATA.password
|
40
|
-
|
41
|
+
from django.db.models import Q
|
42
|
+
user = User.objects.filter(Q(username=username.lower().strip()) | Q(email=username.lower().strip())).last()
|
41
43
|
if user is None:
|
42
|
-
|
44
|
+
User.class_report_incident(
|
45
|
+
f"login attempt with unknown username {username}",
|
46
|
+
event_type="login:unknown",
|
47
|
+
level=8,
|
48
|
+
request=request)
|
49
|
+
raise merrors.PermissionDeniedException()
|
43
50
|
if not user.check_password(password):
|
44
51
|
# Authentication successful
|
45
52
|
user.report_incident(f"{user.username} enter an invalid password", "invalid_password")
|
46
|
-
|
53
|
+
raise merrors.PermissionDeniedException("Invalid username or password", 401, 401)
|
47
54
|
user.last_login = dates.utcnow()
|
48
55
|
user.touch()
|
49
56
|
token_package = JWToken(user.get_auth_key()).create(uid=user.id)
|
50
57
|
token_package['user'] = user.to_dict("basic")
|
51
58
|
return JsonResponse(dict(status=True, data=token_package))
|
59
|
+
|
60
|
+
|
61
|
+
@md.POST("auth/forgot")
|
62
|
+
@md.requires_params("email")
|
63
|
+
def on_user_forgot(request):
|
64
|
+
email = request.DATA.email
|
65
|
+
user = User.objects.filter(email=email.lower().strip()).last()
|
66
|
+
if user is None:
|
67
|
+
User.class_report_incident(
|
68
|
+
f"reset password with unknown email {email}",
|
69
|
+
event_type="reset:unknown",
|
70
|
+
level=8,
|
71
|
+
request=request)
|
72
|
+
else:
|
73
|
+
user.report_incident(f"{user.username} requested a password reset", "password_reset")
|
74
|
+
if request.DATA.get("method") == "code":
|
75
|
+
code = crypto.random_string(6, True, False, False)
|
76
|
+
user.set_secret("password_reset_code", code)
|
77
|
+
user.save()
|
78
|
+
user.send_template_email("password_reset_code", dict(code=code))
|
79
|
+
elif request.DATA.get("method") in ["link", "email"]:
|
80
|
+
user.send_template_email("password_reset_link", dict(token=generate_password_reset_token(user)))
|
81
|
+
else:
|
82
|
+
raise merrors.ValueException("Invalid method")
|
83
|
+
return JsonResponse(dict(status=True, message="If email in our system a reset email was sent."))
|
84
|
+
|
85
|
+
|
86
|
+
def generate_password_reset_token(user):
|
87
|
+
token = crypto.b64_encode({"uid":user.pk, "r": crypto.random_string(6, True, True, False)})
|
88
|
+
sig = crypto.sign(token, user.get_auth_key())
|
89
|
+
hex_token = token.encode("utf-8").hex() + sig[-6:]
|
90
|
+
return hex_token
|
91
|
+
|
92
|
+
|
93
|
+
def verify_password_reset_token(hex_token):
|
94
|
+
orig_token = hex_token
|
95
|
+
try:
|
96
|
+
tsig = hex_token[-6:]
|
97
|
+
hex_token = hex_token[:-6]
|
98
|
+
token = bytes.fromhex(hex_token).decode("utf-8")
|
99
|
+
obj = crypto.b64_decode(token)
|
100
|
+
if not isinstance(obj, dict) or "uid" not in obj:
|
101
|
+
raise merrors.ValueException("Invalid token")
|
102
|
+
user = User.objects.get(pk=obj["uid"])
|
103
|
+
sig = crypto.sign(token, user.get_auth_key())
|
104
|
+
if sig[-6:] != tsig:
|
105
|
+
user.report_incident(f"{user.username} invalid reset token", "invalid_reset_token")
|
106
|
+
raise merrors.ValueException("Invalid token")
|
107
|
+
return user
|
108
|
+
except Exception:
|
109
|
+
pass
|
110
|
+
User.class_report_incident(
|
111
|
+
"invalid reset token",
|
112
|
+
event_type="reset:unknown",
|
113
|
+
level=8, token=orig_token)
|
114
|
+
raise merrors.ValueException("Invalid token")
|
115
|
+
|
116
|
+
|
117
|
+
@md.POST("auth/password/reset/code")
|
118
|
+
@md.requires_params("code", "email", "new_password")
|
119
|
+
def on_user_password_reset_code(request):
|
120
|
+
code = request.DATA.get("code")
|
121
|
+
email = request.DATA.get("email")
|
122
|
+
new_password = request.DATA.get("new_password")
|
123
|
+
user = User.objects.get(email=email)
|
124
|
+
sec_code = user.get_secret("password_reset_code")
|
125
|
+
if len(sec_code) != 6 or len(code) != 6 or code != sec_code:
|
126
|
+
user.report_incident(f"{user.username} invalid password reset code", "password_reset")
|
127
|
+
raise merrors.ValueException("Invalid code")
|
128
|
+
user.set_password(new_password)
|
129
|
+
user.save()
|
130
|
+
return JsonResponse(dict(status=True, message="Password reset successful."))
|
131
|
+
|
132
|
+
|
133
|
+
@md.POST("auth/password/reset/token")
|
134
|
+
@md.requires_params("token", "new_password")
|
135
|
+
def on_user_password_reset_token(request):
|
136
|
+
token = request.DATA.get("token")
|
137
|
+
user = verify_password_reset_token(token)
|
138
|
+
new_password = request.DATA.get("new_password")
|
139
|
+
user.set_password(new_password)
|
140
|
+
user.save()
|
141
|
+
return JsonResponse(dict(status=True, message="Password reset successful."))
|
@@ -0,0 +1 @@
|
|
1
|
+
# Push notification services
|
@@ -0,0 +1,363 @@
|
|
1
|
+
from mojo.helpers.settings import settings
|
2
|
+
from mojo.helpers import logit, dates
|
3
|
+
from mojo.apps.account.models import (
|
4
|
+
PushConfig, RegisteredDevice, NotificationTemplate,
|
5
|
+
NotificationDelivery, User
|
6
|
+
)
|
7
|
+
|
8
|
+
|
9
|
+
|
10
|
+
# Optional imports - will be imported only if needed
|
11
|
+
try:
|
12
|
+
from pyfcm import FCMNotification
|
13
|
+
HAS_FCM = True
|
14
|
+
except ImportError:
|
15
|
+
HAS_FCM = False
|
16
|
+
logit.warn("pyfcm not installed - FCM notifications disabled")
|
17
|
+
|
18
|
+
try:
|
19
|
+
from apns2.client import APNsClient
|
20
|
+
from apns2.payload import Payload
|
21
|
+
from apns2.credentials import TokenCredentials
|
22
|
+
HAS_APNS = True
|
23
|
+
except ImportError:
|
24
|
+
HAS_APNS = False
|
25
|
+
logit.warn("apns2 not installed - APNS notifications disabled")
|
26
|
+
|
27
|
+
|
28
|
+
class PushNotificationService:
|
29
|
+
"""
|
30
|
+
Central push notification service for account-specific push functionality.
|
31
|
+
|
32
|
+
FCM is the primary service supporting both iOS and Android platforms.
|
33
|
+
APNS is available for iOS-specific requirements but rarely needed.
|
34
|
+
Test mode allows fake notifications for development and testing.
|
35
|
+
"""
|
36
|
+
|
37
|
+
def __init__(self, user):
|
38
|
+
self.user = user
|
39
|
+
self.config = self._get_push_config()
|
40
|
+
|
41
|
+
def _get_push_config(self):
|
42
|
+
"""Get push config for user's organization or system default."""
|
43
|
+
return PushConfig.get_for_user(self.user)
|
44
|
+
|
45
|
+
def send_notification(self, template_name=None, context=None, devices=None, user_ids=None,
|
46
|
+
title=None, body=None, category="general", action_url=None, data=None):
|
47
|
+
"""
|
48
|
+
Send notification using template or direct content.
|
49
|
+
|
50
|
+
Args:
|
51
|
+
template_name: Name of notification template (for templated sending)
|
52
|
+
context: Variables for template rendering
|
53
|
+
devices: Specific RegisteredDevice queryset/list
|
54
|
+
user_ids: List of user IDs to send to (uses their active devices)
|
55
|
+
title: Direct title (for non-templated sending)
|
56
|
+
body: Direct body (for non-templated sending)
|
57
|
+
category: Notification category
|
58
|
+
action_url: Direct action URL
|
59
|
+
data: Custom data payload dict
|
60
|
+
|
61
|
+
Returns:
|
62
|
+
List of NotificationDelivery objects
|
63
|
+
"""
|
64
|
+
if not self.config:
|
65
|
+
logit.info(f"No push config available for user {self.user.username}")
|
66
|
+
return []
|
67
|
+
|
68
|
+
# Support both templated and direct sending
|
69
|
+
template = None
|
70
|
+
if template_name:
|
71
|
+
template = self._get_template(template_name)
|
72
|
+
if not template:
|
73
|
+
logit.error(f"Template {template_name} not found")
|
74
|
+
return []
|
75
|
+
elif not (title or body or data):
|
76
|
+
logit.error("Must provide either template_name, title/body, or data payload")
|
77
|
+
return []
|
78
|
+
|
79
|
+
target_devices = self._resolve_devices(devices, user_ids)
|
80
|
+
if not target_devices:
|
81
|
+
logit.info(f"No devices to send to for template {template_name or 'direct'}")
|
82
|
+
return []
|
83
|
+
|
84
|
+
results = []
|
85
|
+
for device in target_devices:
|
86
|
+
notification_category = template.category if template else category
|
87
|
+
if self._should_send_to_device(device, notification_category):
|
88
|
+
if template:
|
89
|
+
result = self._send_to_device(device, template, context or {}, data)
|
90
|
+
else:
|
91
|
+
result = self._send_direct(device, title, body, notification_category, action_url, data)
|
92
|
+
results.append(result)
|
93
|
+
|
94
|
+
return results
|
95
|
+
|
96
|
+
def _get_template(self, template_name):
|
97
|
+
"""Get template by name, preferring user's org templates."""
|
98
|
+
# Try user's org first
|
99
|
+
if self.user.org:
|
100
|
+
template = NotificationTemplate.objects.filter(
|
101
|
+
group=self.user.org, name=template_name, is_active=True
|
102
|
+
).first()
|
103
|
+
if template:
|
104
|
+
return template
|
105
|
+
|
106
|
+
# Fallback to system templates
|
107
|
+
return NotificationTemplate.objects.filter(
|
108
|
+
group__isnull=True, name=template_name, is_active=True
|
109
|
+
).first()
|
110
|
+
|
111
|
+
def _resolve_devices(self, devices, user_ids):
|
112
|
+
"""Resolve target devices from various inputs."""
|
113
|
+
if devices is not None:
|
114
|
+
return devices
|
115
|
+
|
116
|
+
if user_ids:
|
117
|
+
users = User.objects.filter(id__in=user_ids)
|
118
|
+
return RegisteredDevice.objects.filter(
|
119
|
+
user__in=users, is_active=True, push_enabled=True
|
120
|
+
)
|
121
|
+
|
122
|
+
# Default to current user's devices
|
123
|
+
return self.user.registered_devices.filter(
|
124
|
+
is_active=True, push_enabled=True
|
125
|
+
)
|
126
|
+
|
127
|
+
def _should_send_to_device(self, device, category):
|
128
|
+
"""Check if device should receive this category of notification."""
|
129
|
+
preferences = device.push_preferences or {}
|
130
|
+
return preferences.get(category, True) # Default to enabled
|
131
|
+
|
132
|
+
def _send_to_device(self, device, template, context, custom_data=None):
|
133
|
+
"""Send notification to a specific device using template."""
|
134
|
+
title, body, action_url, template_data = template.render(context)
|
135
|
+
|
136
|
+
# Merge template data with custom data (custom data takes precedence)
|
137
|
+
merged_data = template_data.copy() if template_data else {}
|
138
|
+
if custom_data:
|
139
|
+
merged_data.update(custom_data)
|
140
|
+
|
141
|
+
delivery = NotificationDelivery.objects.create(
|
142
|
+
user=device.user,
|
143
|
+
device=device,
|
144
|
+
template=template,
|
145
|
+
title=title,
|
146
|
+
body=body,
|
147
|
+
category=template.category,
|
148
|
+
action_url=action_url,
|
149
|
+
data_payload=merged_data
|
150
|
+
)
|
151
|
+
|
152
|
+
self._attempt_delivery(delivery, device, title, body, template)
|
153
|
+
return delivery
|
154
|
+
|
155
|
+
def _send_direct(self, device, title, body, category, action_url=None, data=None):
|
156
|
+
"""Send direct notification without template."""
|
157
|
+
delivery = NotificationDelivery.objects.create(
|
158
|
+
user=device.user,
|
159
|
+
device=device,
|
160
|
+
title=title,
|
161
|
+
body=body,
|
162
|
+
category=category,
|
163
|
+
action_url=action_url,
|
164
|
+
data_payload=data or {}
|
165
|
+
)
|
166
|
+
|
167
|
+
self._attempt_delivery(delivery, device, title, body, None)
|
168
|
+
return delivery
|
169
|
+
|
170
|
+
def _attempt_delivery(self, delivery, device, title, body, template):
|
171
|
+
"""Attempt to deliver notification to device."""
|
172
|
+
try:
|
173
|
+
success = False
|
174
|
+
|
175
|
+
# Test mode - fake delivery for development/testing
|
176
|
+
if self.config.test_mode:
|
177
|
+
success = self._send_test(delivery, device, title, body, template)
|
178
|
+
|
179
|
+
# FCM is primary - supports both iOS and Android
|
180
|
+
elif self.config.fcm_enabled:
|
181
|
+
success = self._send_fcm(delivery, device, title, body, template)
|
182
|
+
|
183
|
+
# APNS fallback for iOS only (rarely needed)
|
184
|
+
elif device.platform == 'ios' and self.config.apns_enabled:
|
185
|
+
success = self._send_apns(delivery, device, title, body, template)
|
186
|
+
|
187
|
+
else:
|
188
|
+
error_msg = "No push service configured"
|
189
|
+
if not self.config.fcm_enabled and not self.config.apns_enabled:
|
190
|
+
error_msg = "No push services enabled in config"
|
191
|
+
elif device.platform not in ['ios', 'android', 'web']:
|
192
|
+
error_msg = f"Unsupported platform: {device.platform}"
|
193
|
+
delivery.mark_failed(error_msg)
|
194
|
+
return
|
195
|
+
|
196
|
+
if success:
|
197
|
+
delivery.mark_sent()
|
198
|
+
else:
|
199
|
+
delivery.mark_failed("Platform delivery failed")
|
200
|
+
|
201
|
+
except Exception as e:
|
202
|
+
error_msg = f"Push notification failed: {str(e)}"
|
203
|
+
logit.error(error_msg)
|
204
|
+
delivery.mark_failed(error_msg)
|
205
|
+
|
206
|
+
def _send_apns(self, delivery, device, title, body, template):
|
207
|
+
"""Send APNS notification to iOS device."""
|
208
|
+
if not HAS_APNS:
|
209
|
+
logit.error("APNS support not available - apns2 package not installed")
|
210
|
+
return False
|
211
|
+
|
212
|
+
try:
|
213
|
+
credentials = TokenCredentials(
|
214
|
+
auth_key=self.config.get_decrypted_apns_key(),
|
215
|
+
auth_key_id=self.config.apns_key_id,
|
216
|
+
team_id=self.config.apns_team_id
|
217
|
+
)
|
218
|
+
|
219
|
+
client = APNsClient(credentials=credentials,
|
220
|
+
use_sandbox=self.config.apns_use_sandbox)
|
221
|
+
|
222
|
+
# Build payload - handle optional title/body for silent notifications
|
223
|
+
if title or body:
|
224
|
+
alert = {}
|
225
|
+
if title:
|
226
|
+
alert['title'] = title
|
227
|
+
if body:
|
228
|
+
alert['body'] = body
|
229
|
+
payload = Payload(
|
230
|
+
alert=alert,
|
231
|
+
sound=self.config.default_sound,
|
232
|
+
badge=self.config.default_badge_count
|
233
|
+
)
|
234
|
+
else:
|
235
|
+
# Silent notification - no alert, sound, or badge
|
236
|
+
payload = Payload(
|
237
|
+
content_available=True
|
238
|
+
)
|
239
|
+
|
240
|
+
# Build custom data payload - merge custom data with action_url
|
241
|
+
custom_data = delivery.data_payload.copy() if delivery.data_payload else {}
|
242
|
+
if delivery.action_url:
|
243
|
+
custom_data['action_url'] = delivery.action_url
|
244
|
+
|
245
|
+
if custom_data:
|
246
|
+
payload.custom = custom_data
|
247
|
+
|
248
|
+
# Send notification
|
249
|
+
response = client.send_notification(
|
250
|
+
device.device_token,
|
251
|
+
payload,
|
252
|
+
self.config.apns_bundle_id
|
253
|
+
)
|
254
|
+
|
255
|
+
# Store platform response data
|
256
|
+
delivery.platform_data = {
|
257
|
+
'apns_id': response.id if hasattr(response, 'id') else None,
|
258
|
+
'status': response.status if hasattr(response, 'status') else 'sent'
|
259
|
+
}
|
260
|
+
delivery.save(update_fields=['platform_data'])
|
261
|
+
|
262
|
+
return True
|
263
|
+
|
264
|
+
except Exception as e:
|
265
|
+
logit.error(f"APNS send failed: {e}")
|
266
|
+
return False
|
267
|
+
|
268
|
+
def _send_fcm(self, delivery, device, title, body, template):
|
269
|
+
"""Send FCM notification to device (supports both iOS and Android)."""
|
270
|
+
if not HAS_FCM:
|
271
|
+
logit.error("FCM support not available - pyfcm package not installed")
|
272
|
+
return False
|
273
|
+
|
274
|
+
try:
|
275
|
+
push_service = FCMNotification(api_key=self.config.get_decrypted_fcm_key())
|
276
|
+
|
277
|
+
# Build data payload - merge custom data with action_url
|
278
|
+
data_message = delivery.data_payload.copy() if delivery.data_payload else {}
|
279
|
+
if delivery.action_url:
|
280
|
+
data_message['action_url'] = delivery.action_url
|
281
|
+
|
282
|
+
result = push_service.notify_single_device(
|
283
|
+
registration_id=device.device_token,
|
284
|
+
message_title=title,
|
285
|
+
message_body=body,
|
286
|
+
sound=self.config.default_sound if (title or body) else None,
|
287
|
+
data_message=data_message if data_message else None
|
288
|
+
)
|
289
|
+
|
290
|
+
# Store platform response data
|
291
|
+
delivery.platform_data = {
|
292
|
+
'multicast_id': result.get('multicast_id'),
|
293
|
+
'success': result.get('success', 0),
|
294
|
+
'failure': result.get('failure', 0),
|
295
|
+
'results': result.get('results', [])
|
296
|
+
}
|
297
|
+
delivery.save(update_fields=['platform_data'])
|
298
|
+
|
299
|
+
return result.get('success', 0) > 0
|
300
|
+
|
301
|
+
except Exception as e:
|
302
|
+
logit.error(f"FCM send failed: {e}")
|
303
|
+
return False
|
304
|
+
|
305
|
+
def _send_test(self, delivery, device, title, body, template):
|
306
|
+
"""Send fake notification for testing - always succeeds."""
|
307
|
+
# Build log message with optional title/body and data payload
|
308
|
+
log_parts = []
|
309
|
+
if title:
|
310
|
+
log_parts.append(f"Title: {title}")
|
311
|
+
if body:
|
312
|
+
log_parts.append(f"Body: {body}")
|
313
|
+
if delivery.data_payload:
|
314
|
+
log_parts.append(f"Data: {delivery.data_payload}")
|
315
|
+
|
316
|
+
log_message = f"TEST MODE: Fake notification to {device.platform} device '{device.device_name}'"
|
317
|
+
if log_parts:
|
318
|
+
log_message += f" - {' | '.join(log_parts)}"
|
319
|
+
|
320
|
+
logit.info(log_message)
|
321
|
+
|
322
|
+
# Store fake test data including data payload
|
323
|
+
delivery.platform_data = {
|
324
|
+
'test_mode': True,
|
325
|
+
'platform': device.platform,
|
326
|
+
'device_name': device.device_name,
|
327
|
+
'timestamp': dates.utcnow().isoformat(),
|
328
|
+
'fake_delivery': 'success',
|
329
|
+
'data_payload': delivery.data_payload or {}
|
330
|
+
}
|
331
|
+
delivery.save(update_fields=['platform_data'])
|
332
|
+
|
333
|
+
return True
|
334
|
+
|
335
|
+
|
336
|
+
# Convenience functions for easy usage
|
337
|
+
def send_push_notification(user, template_name, context=None, devices=None, user_ids=None, data=None, delay=None):
|
338
|
+
"""
|
339
|
+
Send templated push notification.
|
340
|
+
|
341
|
+
Usage:
|
342
|
+
send_push_notification(user, 'welcome', {'name': user.display_name})
|
343
|
+
send_push_notification(user, 'alert', user_ids=[1, 2, 3])
|
344
|
+
send_push_notification(user, 'order_update', {'order_id': '123'}, data={'action': 'view_order'})
|
345
|
+
"""
|
346
|
+
service = PushNotificationService(user)
|
347
|
+
return service.send_notification(template_name=template_name, context=context,
|
348
|
+
devices=devices, user_ids=user_ids, data=data)
|
349
|
+
|
350
|
+
|
351
|
+
def send_direct_notification(user, title=None, body=None, category="general", action_url=None,
|
352
|
+
data=None, devices=None, user_ids=None, delay=None):
|
353
|
+
"""
|
354
|
+
Send direct push notification without template.
|
355
|
+
|
356
|
+
Usage:
|
357
|
+
send_direct_notification(user, "Hello!", "Your order is ready", "orders")
|
358
|
+
send_direct_notification(user, "Alert", "System maintenance", user_ids=[1, 2, 3])
|
359
|
+
send_direct_notification(user, data={"action": "sync", "silent": True})
|
360
|
+
"""
|
361
|
+
service = PushNotificationService(user)
|
362
|
+
return service.send_notification(title=title, body=body, category=category,
|
363
|
+
action_url=action_url, data=data, devices=devices, user_ids=user_ids)
|