django-nativemojo 0.1.15__py3-none-any.whl → 0.1.16__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.16.dist-info}/METADATA +3 -1
  2. django_nativemojo-0.1.16.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 +281 -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.16.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.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
@@ -1,6 +1,6 @@
1
1
  from django.db import models
2
2
  from mojo.models import MojoModel, MojoSecrets
3
- from mojo.helpers import dates
3
+ from mojo.helpers import dates, logit
4
4
  from mojo.apps import metrics
5
5
  from mojo.helpers.settings import settings
6
6
 
@@ -27,7 +27,11 @@ class Group(MojoSecrets, MojoModel):
27
27
  # JSON-based metadata field
28
28
  metadata = models.JSONField(default=dict, blank=True)
29
29
 
30
+ avatar = models.ForeignKey('fileman.File', on_delete=models.SET_NULL,
31
+ null=True, blank=True, related_name='+')
32
+
30
33
  class RestMeta:
34
+ LOG_CHANGES = True
31
35
  SEARCH_FIELDS = ["name"]
32
36
  VIEW_PERMS = ["view_groups", "manage_groups"]
33
37
  SAVE_PERMS = ["manage_groups"]
@@ -44,7 +48,10 @@ class Group(MojoSecrets, MojoModel):
44
48
  'last_activity',
45
49
  'is_active',
46
50
  'kind',
47
- ]
51
+ ],
52
+ "graphs": {
53
+ "avatar": "basic"
54
+ }
48
55
  },
49
56
  "default": {
50
57
  "fields": [
@@ -59,11 +66,27 @@ class Group(MojoSecrets, MojoModel):
59
66
  'metadata'
60
67
  ],
61
68
  "graphs": {
69
+ "avatar": "basic",
62
70
  "parent": "basic"
63
71
  }
64
72
  },
65
73
 
66
74
  }
75
+ FORMATS = {
76
+ "csv": [
77
+ "id",
78
+ "uuid",
79
+ "name",
80
+ "created",
81
+ "modified",
82
+ "last_activity",
83
+ "is_active",
84
+ "kind",
85
+ "parent.id",
86
+ "parent.name",
87
+ ("metadata.timezone", "timezone")
88
+ ]
89
+ }
67
90
 
68
91
  @property
69
92
  def timezone(self):
@@ -78,14 +101,10 @@ class Group(MojoSecrets, MojoModel):
78
101
  def __str__(self):
79
102
  return str(self.name)
80
103
 
81
- def has_permission(self, user):
82
- from mojo.account.models.member import GroupMember
83
- return GroupMember.objects.filter(user=user).last()
84
-
85
- def member_has_permission(self, user, perms, check_user=True):
104
+ def user_has_permission(self, user, perms, check_user=True):
86
105
  if check_user and user.has_permission(perms):
87
106
  return True
88
- ms = self.has_permission(user)
107
+ ms = self.get_member_for_user(user)
89
108
  if ms is not None:
90
109
  return ms.has_permission(perms)
91
110
  return False
@@ -103,9 +122,276 @@ class Group(MojoSecrets, MojoModel):
103
122
  self.metadata = self.jsonfield_as_objict("metadata")
104
123
  return self.metadata
105
124
 
125
+ def add_member(self, user):
126
+ member, created = self.members.get_or_create(user=user)
127
+ return member
128
+
106
129
  def get_member_for_user(self, user):
107
130
  return self.members.filter(user=user).last()
108
131
 
132
+ def get_children(self, is_active=True, kind=None):
133
+ """
134
+ Returns a QuerySet of all direct and indirect children of this group.
135
+ """
136
+ child_ids = self._get_all_child_ids()
137
+ queryset = Group.objects.filter(id__in=child_ids)
138
+
139
+ if is_active is not None:
140
+ queryset = queryset.filter(is_active=is_active)
141
+ if kind:
142
+ queryset = queryset.filter(kind=kind)
143
+
144
+ return queryset
145
+
146
+ def _get_all_child_ids(self, collected_ids=None):
147
+ """
148
+ Recursively collects the IDs of all children.
149
+ """
150
+ if collected_ids is None:
151
+ collected_ids = set()
152
+
153
+ # Note: self.groups is the related_name from the parent ForeignKey
154
+ children = self.groups.all()
155
+ for child in children:
156
+ if child.id not in collected_ids:
157
+ collected_ids.add(child.id)
158
+ child._get_all_child_ids(collected_ids)
159
+ return list(collected_ids)
160
+
161
+ def get_parents(self, is_active=True, kind=None):
162
+ """
163
+ Returns a QuerySet of all parents (ancestors) of this group.
164
+ """
165
+ parent_ids = []
166
+ current = self.parent
167
+ while current:
168
+ parent_ids.append(current.id)
169
+ current = current.parent
170
+
171
+ queryset = Group.objects.filter(id__in=parent_ids)
172
+
173
+ if is_active is not None:
174
+ queryset = queryset.filter(is_active=is_active)
175
+ if kind:
176
+ queryset = queryset.filter(kind=kind)
177
+
178
+ return queryset
179
+
180
+ @property
181
+ def top_most_parent(self):
182
+ """
183
+ Finds the top-most parent (root ancestor) of this group.
184
+ Returns self if the group has no parent.
185
+ """
186
+ current = self
187
+ while current.parent:
188
+ current = current.parent
189
+ return current
190
+
191
+ def is_child_of(self, parent_group):
192
+ """
193
+ Checks if this group is a descendant of the given parent_group.
194
+ """
195
+ current = self.parent
196
+ while current:
197
+ if current.id == parent_group.id:
198
+ return True
199
+ current = current.parent
200
+ return False
201
+
202
+ def is_parent_of(self, child_group):
203
+ """
204
+ Checks if this group is an ancestor of the given child_group.
205
+ """
206
+ return child_group.is_child_of(self)
207
+
208
+ def invite(self, email, context=None):
209
+ """
210
+ Invites a user to join the group.
211
+ """
212
+ from mojo.apps.account.models import User
213
+ user = User.objects.filter(email=email).last()
214
+ ms = None
215
+ if context is None:
216
+ context = {}
217
+ context['group'] = self
218
+ if user:
219
+ ms = self.add_member(user)
220
+ elif not user:
221
+ user = User(is_active=True)
222
+ user.on_rest_pre_save(dict(email=None), True)
223
+ user.save()
224
+ ms = self.add_member(user)
225
+ try:
226
+ user.send_template_email('group_invite', context)
227
+ except Exception as e:
228
+ logit.error(f"Error sending email: {e}")
229
+ return ms
230
+
231
+ def push_notification(self, title=None, body=None, data=None, **kwargs):
232
+ from mojo.apps.account.services.push import send_direct_notification
233
+ for member in self.members.filter(is_active=True):
234
+ send_direct_notification(member.user, title=title, body=body, data=data, **kwargs)
235
+
236
+ def send_email(
237
+ self,
238
+ to,
239
+ subject=None,
240
+ body_text=None,
241
+ body_html=None,
242
+ cc=None,
243
+ bcc=None,
244
+ reply_to=None,
245
+ **kwargs
246
+ ):
247
+ """Send email using mailbox determined by group's domain or system default
248
+
249
+ Args:
250
+ to: One or more recipient addresses
251
+ subject: Email subject
252
+ body_text: Optional plain text body
253
+ body_html: Optional HTML body
254
+ cc, bcc, reply_to: Optional addressing
255
+ **kwargs: Additional arguments passed to mailbox.send_email()
256
+
257
+ Returns:
258
+ SentMessage instance
259
+
260
+ Raises:
261
+ ValueError: If no mailbox can be found
262
+ """
263
+ from mojo.apps.aws.models import Mailbox
264
+
265
+ mailbox = None
266
+ domain = None
267
+
268
+ # Try to get domain from this group's metadata
269
+ if self.metadata:
270
+ domain = self.metadata.get("domain")
271
+
272
+ # If no domain, check top_most_parent's metadata
273
+ if not domain and self.top_most_parent != self:
274
+ parent_metadata = self.top_most_parent.metadata
275
+ if parent_metadata:
276
+ domain = parent_metadata.get("domain")
277
+
278
+ # Try to get mailbox from domain
279
+ if domain:
280
+ # Try domain default first
281
+ mailbox = Mailbox.get_domain_default(domain)
282
+ if not mailbox:
283
+ # Try any mailbox from that domain
284
+ mailbox = Mailbox.objects.filter(
285
+ domain__name__iexact=domain,
286
+ allow_outbound=True
287
+ ).first()
288
+
289
+ # Fall back to system default
290
+ if not mailbox:
291
+ mailbox = Mailbox.get_system_default()
292
+
293
+ if not mailbox:
294
+ raise ValueError("No mailbox available for sending email. Please configure a system default mailbox.")
295
+
296
+ return mailbox.send_email(
297
+ to=to,
298
+ subject=subject,
299
+ body_text=body_text,
300
+ body_html=body_html,
301
+ cc=cc,
302
+ bcc=bcc,
303
+ reply_to=reply_to,
304
+ **kwargs
305
+ )
306
+
307
+ def send_template_email(
308
+ self,
309
+ to,
310
+ template_name,
311
+ context=None,
312
+ cc=None,
313
+ bcc=None,
314
+ reply_to=None,
315
+ **kwargs
316
+ ):
317
+ """Send template email using mailbox determined by group's domain or system default
318
+
319
+ Args:
320
+ to: One or more recipient addresses
321
+ template_name: Name of the EmailTemplate in database
322
+ context: Template context variables (group will be added automatically)
323
+ cc, bcc, reply_to: Optional addressing
324
+ **kwargs: Additional arguments passed to mailbox.send_template_email()
325
+
326
+ Returns:
327
+ SentMessage instance
328
+
329
+ Raises:
330
+ ValueError: If no mailbox can be found or template not found
331
+ """
332
+ from mojo.apps.aws.models import Mailbox
333
+
334
+ mailbox = None
335
+ domain = None
336
+
337
+ # Try to get domain from this group's metadata
338
+ if self.metadata:
339
+ domain = self.metadata.get("domain")
340
+
341
+ # If no domain, check top_most_parent's metadata
342
+ if not domain and self.top_most_parent != self:
343
+ parent_metadata = self.top_most_parent.metadata
344
+ if parent_metadata:
345
+ domain = parent_metadata.get("domain")
346
+
347
+ # Try to get mailbox from domain
348
+ if domain:
349
+ # Try domain default first
350
+ mailbox = Mailbox.get_domain_default(domain)
351
+ if not mailbox:
352
+ # Try any mailbox from that domain
353
+ mailbox = Mailbox.objects.filter(
354
+ domain__name__iexact=domain,
355
+ allow_outbound=True
356
+ ).first()
357
+
358
+ # Fall back to system default
359
+ if not mailbox:
360
+ mailbox = Mailbox.get_system_default()
361
+
362
+ if not mailbox:
363
+ raise ValueError("No mailbox available for sending email. Please configure a system default mailbox.")
364
+
365
+ # Add group to context if not already present
366
+ if context is None:
367
+ context = {}
368
+ if 'group' not in context:
369
+ context['group'] = self
370
+
371
+ return mailbox.send_template_email(
372
+ to=to,
373
+ template_name=template_name,
374
+ context=context,
375
+ cc=cc,
376
+ bcc=bcc,
377
+ reply_to=reply_to,
378
+ **kwargs
379
+ )
380
+
381
+ def check_view_permission(self, perms, request):
382
+ # check if the user is a member of the group
383
+ if request.user.has_permission(perms):
384
+ return True
385
+ ms = self.get_member_for_user(request.user)
386
+ if ms is None:
387
+ return False
388
+ if ms.has_permission(["view_group", "manage_group"]):
389
+ return True
390
+ # we still allow the user to view the group if they are a member
391
+ # but we limit the fields they can see
392
+ request.DATA.set("graph", "basic")
393
+ return True
394
+
109
395
  @classmethod
110
396
  def on_rest_handle_list(cls, request):
111
397
  if cls.rest_check_permission(request, "VIEW_PERMS"):
@@ -27,6 +27,7 @@ class GroupMember(models.Model, MojoModel):
27
27
  class RestMeta:
28
28
  VIEW_PERMS = ["view_groups", "manage_groups"]
29
29
  SAVE_PERMS = ["manage_groups"]
30
+ CREATED_BY_OWNER_FIELD = 'created_by' # we do this to protect user
30
31
  LIST_DEFAULT_FILTERS = {
31
32
  "is_active": True
32
33
  }
@@ -41,7 +42,7 @@ class GroupMember(models.Model, MojoModel):
41
42
  'metadata'
42
43
  ],
43
44
  "graphs": {
44
- "user": "basic",
45
+ "user": "default",
45
46
  "group": "basic"
46
47
  }
47
48
  }
@@ -50,6 +51,18 @@ class GroupMember(models.Model, MojoModel):
50
51
  def __str__(self):
51
52
  return f"{self.user.username}@{self.group.name}"
52
53
 
54
+ @property
55
+ def username(self):
56
+ return self.user.username
57
+
58
+ @property
59
+ def display_name(self):
60
+ return self.user.display_name
61
+
62
+ @property
63
+ def email(self):
64
+ return self.user.email
65
+
53
66
  def can_change_permission(self, perm, value, request):
54
67
  if request.user.has_permission(["manage_groups", "manage_users"]):
55
68
  return True
@@ -0,0 +1,4 @@
1
+ from .delivery import NotificationDelivery
2
+ from .template import NotificationTemplate
3
+ from .config import PushConfig
4
+ from .device import RegisteredDevice
@@ -0,0 +1,112 @@
1
+ from django.db import models
2
+ from mojo.models import MojoModel, MojoSecrets
3
+
4
+
5
+ class PushConfig(MojoSecrets, MojoModel):
6
+ """
7
+ Push notification configuration. Can be system-wide (group=None) or org-specific.
8
+ Sensitive credentials are encrypted via MojoSecrets.
9
+
10
+ FCM is the primary service supporting both iOS and Android devices.
11
+ APNS is available for iOS-specific requirements but rarely needed.
12
+ Test mode allows fake notifications for development and testing.
13
+ """
14
+ created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
15
+ modified = models.DateTimeField(auto_now=True, db_index=True)
16
+
17
+ group = models.OneToOneField("account.Group", on_delete=models.CASCADE,
18
+ related_name="push_config", null=True, blank=True,
19
+ help_text="Organization for this config. Null = system default")
20
+
21
+ name = models.CharField(max_length=100, help_text="Configuration name")
22
+ is_active = models.BooleanField(default=True, db_index=True)
23
+
24
+ # Test/Development Mode
25
+ test_mode = models.BooleanField(default=False, db_index=True,
26
+ help_text="Enable test mode - fake notifications for development")
27
+
28
+ # FCM Configuration (Primary - supports both iOS and Android)
29
+ # APNS Configuration (iOS-specific, rarely needed - use FCM instead)
30
+ apns_enabled = models.BooleanField(default=False,
31
+ help_text="APNS for iOS-specific needs. FCM is preferred.")
32
+ apns_key_id = models.CharField(max_length=100, blank=True)
33
+ apns_team_id = models.CharField(max_length=100, blank=True)
34
+ apns_bundle_id = models.CharField(max_length=255, blank=True)
35
+ apns_use_sandbox = models.BooleanField(default=False)
36
+
37
+ # FCM Configuration (Primary - supports both iOS and Android)
38
+ fcm_enabled = models.BooleanField(default=True,
39
+ help_text="FCM handles both iOS and Android notifications")
40
+ fcm_sender_id = models.CharField(max_length=100, blank=True)
41
+
42
+ # General Settings
43
+ default_sound = models.CharField(max_length=50, default="default")
44
+ default_badge_count = models.IntegerField(default=1)
45
+
46
+ class Meta:
47
+ ordering = ['group__name', 'name']
48
+
49
+ class RestMeta:
50
+ VIEW_PERMS = ["manage_push_config", "manage_groups"]
51
+ SAVE_PERMS = ["manage_push_config", "manage_groups"]
52
+ SEARCH_FIELDS = ["name"]
53
+ LIST_DEFAULT_FILTERS = {"is_active": True}
54
+ GRAPHS = {
55
+ "basic": {
56
+ "fields": ["id", "name", "fcm_enabled", "apns_enabled", "test_mode", "default_sound", "is_active"]
57
+ },
58
+ "default": {
59
+ "exclude": ["mojo_secrets"], # Never expose encrypted secrets
60
+ "graphs": {
61
+ "group": "basic"
62
+ }
63
+ },
64
+ "full": {
65
+ "exclude": ["mojo_secrets"], # Never expose encrypted secrets
66
+ "graphs": {
67
+ "group": "default"
68
+ }
69
+ }
70
+ }
71
+
72
+ def __str__(self):
73
+ org = self.group.name if self.group else "System Default"
74
+ return f"{self.name} ({org})"
75
+
76
+ @classmethod
77
+ def get_for_user(cls, user):
78
+ """
79
+ Get push config for user. Priority: user's org config -> system default
80
+ """
81
+ if user.org:
82
+ config = cls.objects.filter(group=user.org, is_active=True).first()
83
+ if config:
84
+ return config
85
+
86
+ # Fallback to system default
87
+ return cls.objects.filter(group__isnull=True, is_active=True).first()
88
+
89
+ def set_apns_key_file(self, key_content):
90
+ """Set APNS private key file content (will be encrypted)."""
91
+ self.set_secret('apns_key_file', key_content)
92
+
93
+ def get_apns_key_file(self):
94
+ """Get decrypted APNS private key file content."""
95
+ return self.get_secret('apns_key_file', '')
96
+
97
+ def set_fcm_server_key(self, server_key):
98
+ """Set FCM server key (will be encrypted)."""
99
+ self.set_secret('fcm_server_key', server_key)
100
+
101
+ def get_fcm_server_key(self):
102
+ """Get decrypted FCM server key."""
103
+ return self.get_secret('fcm_server_key', '')
104
+
105
+ # Backwards compatibility aliases
106
+ def get_decrypted_apns_key(self):
107
+ """Get decrypted APNS key file content."""
108
+ return self.get_apns_key_file()
109
+
110
+ def get_decrypted_fcm_key(self):
111
+ """Get decrypted FCM server key."""
112
+ return self.get_fcm_server_key()
@@ -0,0 +1,93 @@
1
+ from django.db import models
2
+ from mojo.models import MojoModel
3
+
4
+
5
+ class NotificationDelivery(models.Model, MojoModel):
6
+ """
7
+ Track all push notification delivery attempts and results.
8
+ """
9
+ created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
10
+ modified = models.DateTimeField(auto_now=True, db_index=True)
11
+
12
+ user = models.ForeignKey("account.User", on_delete=models.CASCADE,
13
+ related_name="notification_deliveries")
14
+ device = models.ForeignKey("account.RegisteredDevice", on_delete=models.CASCADE,
15
+ related_name="notification_deliveries")
16
+ template = models.ForeignKey("account.NotificationTemplate", on_delete=models.SET_NULL,
17
+ null=True, blank=True, related_name="deliveries")
18
+
19
+ title = models.CharField(max_length=200, blank=True, null=True)
20
+ body = models.TextField(blank=True, null=True)
21
+ category = models.CharField(max_length=50, db_index=True)
22
+ action_url = models.URLField(blank=True, null=True)
23
+ data_payload = models.JSONField(default=dict, blank=True,
24
+ help_text="Custom data payload sent with notification")
25
+
26
+ # Delivery tracking
27
+ status = models.CharField(max_length=20, choices=[
28
+ ('pending', 'Pending'),
29
+ ('sent', 'Sent'),
30
+ ('delivered', 'Delivered'),
31
+ ('failed', 'Failed')
32
+ ], default='pending', db_index=True)
33
+
34
+ sent_at = models.DateTimeField(null=True, blank=True, db_index=True)
35
+ delivered_at = models.DateTimeField(null=True, blank=True)
36
+ error_message = models.TextField(blank=True, null=True)
37
+
38
+ # Push service specific data
39
+ platform_data = models.JSONField(default=dict, blank=True,
40
+ help_text="Platform-specific response data")
41
+
42
+ class Meta:
43
+ ordering = ['-created']
44
+
45
+ class RestMeta:
46
+ VIEW_PERMS = ["view_notifications", "manage_notifications", "owner", "manage_users"]
47
+ SAVE_PERMS = ["manage_notifications"]
48
+ SEARCH_FIELDS = ["title", "category"]
49
+ LIST_DEFAULT_FILTERS = {"status": "sent"}
50
+ GRAPHS = {
51
+ "basic": {
52
+ "fields": ["id", "title", "category", "status", "sent_at", "created"]
53
+ },
54
+ "default": {
55
+ "fields": ["id", "title", "body", "category", "action_url", "data_payload", "status",
56
+ "sent_at", "delivered_at", "error_message", "created"],
57
+ "graphs": {
58
+ "user": "basic",
59
+ "device": "basic"
60
+ }
61
+ },
62
+ "full": {
63
+ "graphs": {
64
+ "user": "default",
65
+ "device": "default",
66
+ "template": "basic"
67
+ }
68
+ }
69
+ }
70
+
71
+ def __str__(self):
72
+ display_title = self.title or f"[{self.category} data]"
73
+ return f"{display_title} -> {self.device} ({self.status})"
74
+
75
+ def mark_sent(self):
76
+ """Mark notification as sent with timestamp."""
77
+ from mojo.helpers import dates
78
+ self.status = 'sent'
79
+ self.sent_at = dates.utcnow()
80
+ self.save(update_fields=['status', 'sent_at'])
81
+
82
+ def mark_delivered(self):
83
+ """Mark notification as delivered with timestamp."""
84
+ from mojo.helpers import dates
85
+ self.status = 'delivered'
86
+ self.delivered_at = dates.utcnow()
87
+ self.save(update_fields=['status', 'delivered_at'])
88
+
89
+ def mark_failed(self, error_message):
90
+ """Mark notification as failed with error message."""
91
+ self.status = 'failed'
92
+ self.error_message = error_message
93
+ self.save(update_fields=['status', 'error_message'])
@@ -0,0 +1,66 @@
1
+ from django.db import models
2
+ from mojo.models import MojoModel
3
+
4
+
5
+ class RegisteredDevice(models.Model, MojoModel):
6
+ """
7
+ Represents a device explicitly registered for push notifications via REST API.
8
+ Separate from UserDevice which tracks browser sessions via duid/user-agent.
9
+ """
10
+ created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
11
+ modified = models.DateTimeField(auto_now=True, db_index=True)
12
+
13
+ user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='registered_devices')
14
+
15
+ # Device identification
16
+ device_token = models.TextField(db_index=True, help_text="Push token from platform")
17
+ device_id = models.CharField(max_length=255, db_index=True, help_text="App-provided device ID")
18
+ platform = models.CharField(max_length=20, choices=[
19
+ ('ios', 'iOS'),
20
+ ('android', 'Android'),
21
+ ('web', 'Web')
22
+ ], db_index=True)
23
+
24
+ # Device info
25
+ app_version = models.CharField(max_length=50, blank=True)
26
+ os_version = models.CharField(max_length=50, blank=True)
27
+ device_name = models.CharField(max_length=100, blank=True)
28
+
29
+ # Push preferences
30
+ push_enabled = models.BooleanField(default=True, db_index=True)
31
+ push_preferences = models.JSONField(default=dict, blank=True,
32
+ help_text="Category-based notification preferences")
33
+
34
+ # Status tracking
35
+ is_active = models.BooleanField(default=True, db_index=True)
36
+ last_seen = models.DateTimeField(auto_now=True)
37
+
38
+ class Meta:
39
+ unique_together = [('user', 'device_id'), ('device_token', 'platform')]
40
+ ordering = ['-last_seen']
41
+
42
+ class RestMeta:
43
+ VIEW_PERMS = ["view_devices", "manage_devices", "owner", "manage_users"]
44
+ SAVE_PERMS = ["manage_devices", "owner"]
45
+ SEARCH_FIELDS = ["device_name", "device_id"]
46
+ LIST_DEFAULT_FILTERS = {"is_active": True}
47
+ GRAPHS = {
48
+ "basic": {
49
+ "fields": ["id", "device_id", "platform", "device_name", "push_enabled", "last_seen"]
50
+ },
51
+ "default": {
52
+ "fields": ["id", "device_id", "platform", "device_name", "app_version",
53
+ "os_version", "push_enabled", "push_preferences", "last_seen"],
54
+ "graphs": {
55
+ "user": "basic"
56
+ }
57
+ },
58
+ "full": {
59
+ "graphs": {
60
+ "user": "default"
61
+ }
62
+ }
63
+ }
64
+
65
+ def __str__(self):
66
+ return f"{self.device_name or self.device_id} ({self.platform}) - {self.user.username}"