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
@@ -0,0 +1,99 @@
1
+ from django.db import models
2
+ from mojo.models import MojoModel
3
+
4
+
5
+ class NotificationTemplate(models.Model, MojoModel):
6
+ """
7
+ Reusable notification templates with variable substitution support.
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
+ group = models.ForeignKey("account.Group", on_delete=models.CASCADE,
13
+ related_name="notification_templates", null=True, blank=True,
14
+ help_text="Organization for this template. Null = system template")
15
+
16
+ name = models.CharField(max_length=100, db_index=True)
17
+ title_template = models.CharField(max_length=200, blank=True, null=True)
18
+ body_template = models.TextField(blank=True, null=True)
19
+ action_url = models.URLField(blank=True, null=True, help_text="Template URL with variable support")
20
+ data_template = models.JSONField(default=dict, blank=True,
21
+ help_text="Template data payload with variable support")
22
+
23
+ # Delivery preferences
24
+ category = models.CharField(max_length=50, default="general", db_index=True)
25
+ priority = models.CharField(max_length=20, choices=[
26
+ ('low', 'Low'),
27
+ ('normal', 'Normal'),
28
+ ('high', 'High')
29
+ ], default='normal', db_index=True)
30
+
31
+ # Template variables documentation
32
+ variables = models.JSONField(default=dict, blank=True,
33
+ help_text="Expected template variables and descriptions for title, body, action_url, and data_template")
34
+
35
+ is_active = models.BooleanField(default=True, db_index=True)
36
+
37
+ class Meta:
38
+ ordering = ['group__name', 'name']
39
+ unique_together = [('group', 'name')]
40
+
41
+ class RestMeta:
42
+ VIEW_PERMS = ["manage_notifications", "manage_groups", "owner", "manage_users"]
43
+ SAVE_PERMS = ["manage_notifications", "manage_groups"]
44
+ SEARCH_FIELDS = ["name", "category"]
45
+ LIST_DEFAULT_FILTERS = {"is_active": True}
46
+ GRAPHS = {
47
+ "basic": {
48
+ "fields": ["id", "name", "category", "priority", "is_active"]
49
+ },
50
+ "default": {
51
+ "fields": ["id", "name", "title_template", "body_template", "action_url",
52
+ "data_template", "category", "priority", "variables", "is_active"],
53
+ "graphs": {
54
+ "group": "basic"
55
+ }
56
+ },
57
+ "full": {
58
+ "graphs": {
59
+ "group": "default"
60
+ }
61
+ }
62
+ }
63
+
64
+ def __str__(self):
65
+ org = self.group.name if self.group else "System"
66
+ return f"{self.name} ({org})"
67
+
68
+ def clean(self):
69
+ """Validate that at least one template field is provided."""
70
+ from django.core.exceptions import ValidationError
71
+
72
+ has_title = self.title_template and self.title_template.strip()
73
+ has_body = self.body_template and self.body_template.strip()
74
+ has_data = self.data_template and bool(self.data_template)
75
+
76
+ if not (has_title or has_body or has_data):
77
+ raise ValidationError(
78
+ "Template must have at least one of: title_template, body_template, or data_template"
79
+ )
80
+
81
+ def render(self, context):
82
+ """
83
+ Render template with provided context variables.
84
+ Returns tuple of (title, body, action_url, data)
85
+ """
86
+ title = self.title_template.format(**context) if self.title_template else None
87
+ body = self.body_template.format(**context) if self.body_template else None
88
+ action_url = self.action_url.format(**context) if self.action_url else None
89
+
90
+ # Render data template with context
91
+ data = {}
92
+ if self.data_template:
93
+ for key, value in self.data_template.items():
94
+ if isinstance(value, str):
95
+ data[key] = value.format(**context)
96
+ else:
97
+ data[key] = value
98
+
99
+ return title, body, action_url, data
@@ -6,6 +6,7 @@ from mojo import errors as merrors
6
6
  from mojo.helpers import dates
7
7
  from mojo.apps.account.utils.jwtoken import JWToken
8
8
  from mojo.apps import metrics
9
+ from .device import UserDevice
9
10
  import uuid
10
11
 
11
12
  SYS_USER_PERMS_PROTECTION = {
@@ -28,6 +29,7 @@ USER_PERMS_PROTECTION.update(SYS_USER_PERMS_PROTECTION)
28
29
 
29
30
  USER_LAST_ACTIVITY_FREQ = settings.get("USER_LAST_ACTIVITY_FREQ", 300)
30
31
  METRICS_TIMEZONE = settings.get("METRICS_TIMEZONE", "America/Los_Angeles")
32
+ METRICS_TRACK_USER_ACTIVITY = settings.get("METRICS_TRACK_USER_ACTIVITY", False)
31
33
 
32
34
  class CustomUserManager(BaseUserManager):
33
35
  def create_user(self, email, password=None, **extra_fields):
@@ -62,6 +64,11 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
62
64
  phone_number = models.CharField(max_length=32, blank=True, null=True, default=None)
63
65
  is_active = models.BooleanField(default=True, db_index=True)
64
66
  display_name = models.CharField(max_length=80, blank=True, null=True, default=None)
67
+
68
+ # Organization relationship for push config resolution
69
+ org = models.ForeignKey("account.Group", on_delete=models.SET_NULL,
70
+ null=True, blank=True, related_name="org_users",
71
+ help_text="Default organization for this user")
65
72
  # key used for sessions and general authentication algs
66
73
  auth_key = models.TextField(null=True, default=None)
67
74
  onetime_code = models.TextField(null=True, default=None)
@@ -88,6 +95,8 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
88
95
  objects = CustomUserManager()
89
96
 
90
97
  class RestMeta:
98
+ LOG_CHANGES = True
99
+ POST_SAVE_ACTIONS = ['send_invite']
91
100
  NO_SHOW_FIELDS = ["password", "auth_key", "onetime_code"]
92
101
  SEARCH_FIELDS = ["username", "email", "display_name"]
93
102
  VIEW_PERMS = ["view_users", "manage_users", "owner"]
@@ -124,7 +133,8 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
124
133
  'is_active'
125
134
  ],
126
135
  "graphs": {
127
- "avatar": "basic"
136
+ "avatar": "basic",
137
+ "org": "basic"
128
138
  }
129
139
  },
130
140
  "full": {
@@ -151,6 +161,12 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
151
161
  if self.last_activity is None or dates.has_time_elsapsed(self.last_activity, seconds=USER_LAST_ACTIVITY_FREQ):
152
162
  self.last_activity = dates.utcnow()
153
163
  self.atomic_save()
164
+ if METRICS_TRACK_USER_ACTIVITY:
165
+ metrics.record(f"user_activity:{self.pk}", category="user", min_granularity="minutes")
166
+
167
+ def track(self, request):
168
+ self.touch()
169
+ UserDevice.track(request)
154
170
 
155
171
  def get_auth_key(self):
156
172
  if self.auth_key is None:
@@ -323,8 +339,16 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
323
339
  # Fall back to using the full email as username
324
340
  return self.email.lower()
325
341
 
342
+ def generate_display_name(self):
343
+ """Generate a display name from email, falling back to email if username exists."""
344
+ # Try using the part before @ as display name
345
+ # generate display name from usernames like "bob.smith", "bob_smith", "bob.smith@example.com"
346
+ # Extract the base part (before @ if email format)
347
+ base_username = self.username.split("@")[0] if "@" in self.username else self.username
348
+ # Replace underscores and dots with spaces, then title case
349
+ return base_username.replace("_", " ").replace(".", " ").title()
350
+
326
351
  def on_rest_pre_save(self, changed_fields, created):
327
- self.debug("PRE SAVE")
328
352
  creds_changed = False
329
353
  if "email" in changed_fields:
330
354
  creds_changed = True
@@ -348,29 +372,178 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
348
372
  qset = qset.exclude(pk=self.pk)
349
373
  if qset.exists():
350
374
  raise merrors.ValueException("Username already exists")
375
+ if not self.display_name:
376
+ self.display_name = self.generate_display_name()
351
377
  if self.pk is not None:
352
- # only super user can change email or username
353
- if creds_changed and not self.active_user.is_superuser:
354
- raise merrors.PermissionDeniedException("You are not allowed to change email or username")
355
- if "password" in changed_fields:
378
+ self._handle_new_user_pre_save(creds_changed, changed_fields)
379
+
380
+ def _handle_new_user_pre_save(self, creds_changed, changed_fields):
381
+ # only super user can change email or username
382
+ if creds_changed and not self.active_user.is_superuser:
383
+ raise merrors.PermissionDeniedException("You are not allowed to change email or username")
384
+ if "password" in changed_fields:
385
+ raise merrors.PermissionDeniedException("You are not allowed to change password")
386
+ if "new_password" in changed_fields:
387
+ if not self.can_change_password():
356
388
  raise merrors.PermissionDeniedException("You are not allowed to change password")
357
- if "new_password" in changed_fields:
358
- if not self.can_change_password():
359
- raise merrors.PermissionDeniedException("You are not allowed to change password")
360
- self.debug("CHANGING PASSWORD")
361
- self.log("****", kind="password:changed")
362
- if "email" in changed_fields:
363
- self.log(kind="email:changed", log=f"{changed_fields['email']} to {self.email}")
364
- if "username" in changed_fields:
365
- self.log(kind="username:changed", log=f"{changed_fields['username']} to {self.username}")
366
- self.debug("on_rest_pre_save", changed_fields, creds_changed, self.active_user.is_superuser)
367
-
389
+ self.debug("CHANGING PASSWORD")
390
+ self.log("****", kind="password:changed")
391
+ if "email" in changed_fields:
392
+ self.log(kind="email:changed", log=f"{changed_fields['email']} to {self.email}")
393
+ if "username" in changed_fields:
394
+ self.log(kind="username:changed", log=f"{changed_fields['username']} to {self.username}")
395
+ if "is_active" in changed_fields:
396
+ if not self.is_active:
397
+ metrics.record("user_deactivated", category="user", min_granularity="hours")
398
+ metrics.set_value("total_users", User.objects.filter(is_active=True).count(), account="global")
368
399
 
369
400
  def check_edit_permission(self, perms, request):
370
401
  if "owner" in perms and self.is_request_user():
371
402
  return True
372
403
  return request.user.has_permission(perms)
373
404
 
405
+ def on_action_send_invite(self, value):
406
+ self.send_invite()
407
+
408
+ def push_notification(self, title=None, body=None, data=None,
409
+ category="general", action_url=None,
410
+ devices=None, user_ids=None, delay=None):
411
+ from mojo.apps.account.services.push import send_direct_notification
412
+ send_direct_notification(
413
+ self, title=title, body=body, data=data, category=category,
414
+ action_url=action_url,
415
+ devices=devices, user_ids=user_ids, delay=delay)
416
+
417
+ def send_invite(self):
418
+ self.send_template_email(
419
+ template_name="invite",
420
+ context={"user": self.to_dict("basic")}
421
+ )
422
+
423
+ def send_email(
424
+ self,
425
+ subject=None,
426
+ body_text=None,
427
+ body_html=None,
428
+ cc=None,
429
+ bcc=None,
430
+ reply_to=None,
431
+ **kwargs
432
+ ):
433
+ """Send email to this user using mailbox determined by user's org domain or system default
434
+
435
+ Args:
436
+ subject: Email subject
437
+ body_text: Optional plain text body
438
+ body_html: Optional HTML body
439
+ cc, bcc, reply_to: Optional addressing
440
+ **kwargs: Additional arguments passed to mailbox.send_email()
441
+
442
+ Returns:
443
+ SentMessage instance
444
+
445
+ Raises:
446
+ ValueError: If no mailbox can be found
447
+ """
448
+ from mojo.apps.aws.models import Mailbox
449
+
450
+ mailbox = None
451
+
452
+ # Try to get mailbox from org domain
453
+ if self.org and hasattr(self.org, 'metadata'):
454
+ domain = self.org.metadata.get("domain")
455
+ if domain:
456
+ # Try domain default first
457
+ mailbox = Mailbox.get_domain_default(domain)
458
+ if not mailbox:
459
+ # Try any mailbox from that domain
460
+ mailbox = Mailbox.objects.filter(
461
+ domain__name__iexact=domain,
462
+ allow_outbound=True
463
+ ).first()
464
+
465
+ # Fall back to system default
466
+ if not mailbox:
467
+ mailbox = Mailbox.get_system_default()
468
+
469
+ if not mailbox:
470
+ raise ValueError("No mailbox available for sending email. Please configure a system default mailbox.")
471
+
472
+ return mailbox.send_email(
473
+ to=self.email,
474
+ subject=subject,
475
+ body_text=body_text,
476
+ body_html=body_html,
477
+ cc=cc,
478
+ bcc=bcc,
479
+ reply_to=reply_to,
480
+ **kwargs
481
+ )
482
+
483
+ def send_template_email(
484
+ self,
485
+ template_name,
486
+ context=None,
487
+ cc=None,
488
+ bcc=None,
489
+ reply_to=None,
490
+ **kwargs
491
+ ):
492
+ """Send template email to this user using mailbox determined by user's org domain or system default
493
+
494
+ Args:
495
+ template_name: Name of the EmailTemplate in database
496
+ context: Template context variables (user will be added automatically)
497
+ cc, bcc, reply_to: Optional addressing
498
+ **kwargs: Additional arguments passed to mailbox.send_template_email()
499
+
500
+ Returns:
501
+ SentMessage instance
502
+
503
+ Raises:
504
+ ValueError: If no mailbox can be found or template not found
505
+ """
506
+ from mojo.apps.aws.models import Mailbox
507
+
508
+ mailbox = None
509
+
510
+ # Try to get mailbox from org domain
511
+ if self.org and hasattr(self.org, 'metadata'):
512
+ domain = self.org.metadata.get("domain")
513
+ if domain:
514
+ # Try domain default first
515
+ mailbox = Mailbox.get_domain_default(domain)
516
+ if not mailbox:
517
+ # Try any mailbox from that domain
518
+ mailbox = Mailbox.objects.filter(
519
+ domain__name__iexact=domain,
520
+ allow_outbound=True
521
+ ).first()
522
+
523
+ # Fall back to system default
524
+ if not mailbox:
525
+ mailbox = Mailbox.get_system_default()
526
+
527
+ if not mailbox:
528
+ raise ValueError("No mailbox available for sending email. Please configure a system default mailbox.")
529
+
530
+ # Add user to context if not already present
531
+ if context is None:
532
+ context = {}
533
+ if 'user' not in context:
534
+ context['user'] = self.to_dict("basic")
535
+
536
+ return mailbox.send_template_email(
537
+ to=self.email,
538
+ template_name=template_name,
539
+ context=context,
540
+ cc=cc,
541
+ bcc=bcc,
542
+ reply_to=reply_to,
543
+ allow_unverified=True,
544
+ **kwargs
545
+ )
546
+
374
547
  @classmethod
375
548
  def validate_jwt(cls, token):
376
549
  token_manager = JWToken()
@@ -1,3 +1,5 @@
1
1
  APP_NAME = ""
2
2
  from .user import *
3
3
  from .group import *
4
+ from .device import *
5
+ from .push import *
@@ -0,0 +1,39 @@
1
+ from mojo import decorators as md
2
+ from mojo.apps.account.models.device import UserDevice, UserDeviceLocation, GeoLocatedIP
3
+
4
+
5
+ @md.URL('user/device')
6
+ @md.URL('user/device/<int:pk>')
7
+ def on_user_device(request, pk=None):
8
+ return UserDevice.on_rest_request(request, pk)
9
+
10
+
11
+ @md.GET('user/device/lookup')
12
+ @md.requires_params('duid')
13
+ def on_user_device_by_duid(request):
14
+ duid = request.DATA.get('duid')
15
+ device = UserDevice.objects.filter(duid=duid).first()
16
+ if not device:
17
+ return UserDevice.rest_error_response(request, 404, error="Device not found")
18
+ return device.on_rest_get(request)
19
+
20
+
21
+ @md.URL('user/device/location')
22
+ @md.URL('user/device/location/<int:pk>')
23
+ def on_user_device_location(request, pk=None):
24
+ return UserDeviceLocation.on_rest_request(request, pk)
25
+
26
+
27
+ @md.URL('system/geoip')
28
+ @md.URL('system/geoip/<int:pk>')
29
+ def on_geo_located_ip(request, pk=None):
30
+ return GeoLocatedIP.on_rest_request(request, pk)
31
+
32
+
33
+ @md.GET('system/geoip/lookup')
34
+ @md.requires_params('ip')
35
+ def on_geo_located_ip_lookup(request):
36
+ ip_address = request.DATA.get('ip')
37
+ auto_refresh = request.DATA.get('auto_refresh', True)
38
+ geo_ip = GeoLocatedIP.geolocate(ip_address, auto_refresh=auto_refresh)
39
+ return geo_ip.on_rest_get(request)
@@ -1,5 +1,6 @@
1
1
  from mojo import decorators as md
2
2
  from mojo.apps.account.models import Group, GroupMember
3
+ from mojo.helpers.response import JsonResponse
3
4
 
4
5
 
5
6
  @md.URL('group')
@@ -14,6 +15,13 @@ def on_group_member(request, pk=None):
14
15
  return GroupMember.on_rest_request(request, pk)
15
16
 
16
17
 
18
+ @md.POST('group/member/invite')
19
+ @md.requires_params('email', 'group')
20
+ def on_group_invite_member(request):
21
+ ms = request.group.invite(request.DATA['email'])
22
+ return ms.on_rest_get(request)
23
+
24
+
17
25
  @md.GET('group/<int:pk>/member')
18
26
  def on_group_me_member(request, pk=None):
19
27
  request.group = Group.objects.filter(pk=pk).last()
@@ -0,0 +1,187 @@
1
+ import mojo.decorators as md
2
+ from mojo.apps.account.models import (
3
+ RegisteredDevice, NotificationTemplate, PushConfig,
4
+ NotificationDelivery
5
+ )
6
+ from mojo.apps.account.services.push import (
7
+ send_push_notification, send_direct_notification
8
+ )
9
+ from mojo.helpers import response
10
+
11
+
12
+ @md.POST('account/devices/push/register')
13
+ @md.requires_auth()
14
+ @md.requires_params(['device_token', 'device_id', 'platform'])
15
+ def register_device(request):
16
+ """
17
+ Register device for push notifications.
18
+
19
+ POST /api/account/devices/push/register
20
+ {
21
+ "device_token": "...",
22
+ "device_id": "...",
23
+ "platform": "ios|android|web",
24
+ "device_name": "...",
25
+ "app_version": "...",
26
+ "os_version": "...",
27
+ "push_preferences": {"orders": true, "marketing": false}
28
+ }
29
+ """
30
+ device, created = RegisteredDevice.objects.update_or_create(
31
+ user=request.user,
32
+ device_id=request.DATA.get('device_id'),
33
+ defaults={
34
+ 'device_token': request.DATA.get('device_token'),
35
+ 'platform': request.DATA.get('platform'),
36
+ 'device_name': request.DATA.get('device_name', ''),
37
+ 'app_version': request.DATA.get('app_version', ''),
38
+ 'os_version': request.DATA.get('os_version', ''),
39
+ 'push_preferences': request.DATA.get('push_preferences', {}),
40
+ 'is_active': True,
41
+ 'push_enabled': True
42
+ }
43
+ )
44
+
45
+ return device.on_rest_get(request, 'default')
46
+
47
+
48
+ @md.URL('account/devices/push')
49
+ @md.URL('account/devices/push/<int:pk>')
50
+ def on_registered_devices(request, pk=None):
51
+ """Standard CRUD for registered devices."""
52
+ return RegisteredDevice.on_rest_request(request, pk)
53
+
54
+
55
+ @md.URL('account/devices/push/templates')
56
+ @md.URL('account/devices/push/templates/<int:pk>')
57
+ def on_notification_templates(request, pk=None):
58
+ """Standard CRUD for notification templates."""
59
+ return NotificationTemplate.on_rest_request(request, pk)
60
+
61
+
62
+ @md.URL('account/devices/push/config')
63
+ @md.URL('account/devices/push/config/<int:pk>')
64
+ def on_push_config(request, pk=None):
65
+ """Standard CRUD for push configuration."""
66
+ return PushConfig.on_rest_request(request, pk)
67
+
68
+
69
+ @md.URL('account/devices/push/deliveries')
70
+ @md.URL('account/devices/push/deliveries/<int:pk>')
71
+ def on_notification_deliveries(request, pk=None):
72
+ """Standard CRUD for notification delivery history."""
73
+ return NotificationDelivery.on_rest_request(request, pk)
74
+
75
+
76
+ @md.POST('account/devices/push/send')
77
+ @md.requires_auth()
78
+ @md.requires_perms("send_notifications")
79
+ def send_notification(request):
80
+ """
81
+ Send push notification using template or direct content.
82
+
83
+ POST /api/account/devices/push/send
84
+
85
+ Templated:
86
+ {
87
+ "template": "template_name",
88
+ "context": {"key": "value"},
89
+ "user_ids": [1, 2, 3] # optional
90
+ }
91
+
92
+ Direct:
93
+ {
94
+ "title": "Hello!",
95
+ "body": "Your order is ready",
96
+ "category": "orders",
97
+ "action_url": "myapp://orders/123",
98
+ "user_ids": [1, 2, 3] # optional
99
+ }
100
+ """
101
+ template = request.DATA.get('template')
102
+ title = request.DATA.get('title')
103
+ body = request.DATA.get('body')
104
+
105
+ if template:
106
+ # Templated sending
107
+ context = request.DATA.get('context', {})
108
+ user_ids = request.DATA.get('user_ids')
109
+ results = send_push_notification(
110
+ user=request.user,
111
+ template_name=template,
112
+ context=context,
113
+ user_ids=user_ids
114
+ )
115
+ elif title and body:
116
+ # Direct sending
117
+ category = request.DATA.get('category', 'general')
118
+ action_url = request.DATA.get('action_url')
119
+ user_ids = request.DATA.get('user_ids')
120
+ results = send_direct_notification(
121
+ user=request.user,
122
+ title=title,
123
+ body=body,
124
+ category=category,
125
+ action_url=action_url,
126
+ user_ids=user_ids
127
+ )
128
+ else:
129
+ return response.error('Must provide either template or both title and body')
130
+
131
+ return response.success({
132
+ 'success': True,
133
+ 'sent_count': len([r for r in results if r.status == 'sent']),
134
+ 'failed_count': len([r for r in results if r.status == 'failed']),
135
+ 'deliveries': [r.to_dict("basic") for r in results]
136
+ })
137
+
138
+
139
+ @md.POST('account/devices/push/test')
140
+ @md.requires_auth()
141
+ def test_push_config(request):
142
+ """
143
+ Test push configuration by sending a test notification to requesting user's devices.
144
+
145
+ POST /api/account/devices/push/test
146
+ {
147
+ "message": "Custom test message" # optional
148
+ }
149
+ """
150
+ test_message = request.DATA.get('message', 'This is a test notification')
151
+
152
+ results = send_direct_notification(
153
+ user=request.user,
154
+ title="Push Test",
155
+ body=test_message,
156
+ category="test"
157
+ )
158
+
159
+ if not results:
160
+ return response.error('No registered devices found for testing')
161
+
162
+ return response.success({
163
+ 'success': True,
164
+ 'message': 'Test notifications sent',
165
+ 'results': [r.to_dict('basic') for r in results]
166
+ })
167
+
168
+
169
+ @md.GET('account/devices/push/stats')
170
+ @md.requires_auth()
171
+ def push_stats(request):
172
+ """
173
+ Get push notification statistics for the requesting user.
174
+ """
175
+ user_deliveries = NotificationDelivery.objects.filter(user=request.user)
176
+
177
+ stats = {
178
+ 'total_sent': user_deliveries.filter(status='sent').count(),
179
+ 'total_failed': user_deliveries.filter(status='failed').count(),
180
+ 'total_pending': user_deliveries.filter(status='pending').count(),
181
+ 'registered_devices': request.user.registered_devices.filter(is_active=True).count(),
182
+ 'enabled_devices': request.user.registered_devices.filter(
183
+ is_active=True, push_enabled=True
184
+ ).count()
185
+ }
186
+
187
+ return response.success(stats)