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
@@ -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
- return JsonResponse({'error': error}, status=401)
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
- user = User.objects.filter(username=username.lower().strip()).last()
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
- return JsonResponse(dict(status=False, error="Invalid username or password", code=403))
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
- return JsonResponse(dict(status=False, error="Invalid username or password", code=401))
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)