django-nativemojo 0.1.10__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 (276) hide show
  1. django_nativemojo-0.1.16.dist-info/METADATA +138 -0
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/__init__.py +5 -0
  5. mojo/apps/account/management/commands/__init__.py +6 -0
  6. mojo/apps/account/management/commands/serializer_admin.py +651 -0
  7. mojo/apps/account/migrations/0004_user_avatar.py +20 -0
  8. mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
  9. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  10. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  11. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  12. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  13. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  14. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  15. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  16. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  17. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  18. mojo/apps/account/models/__init__.py +2 -0
  19. mojo/apps/account/models/device.py +281 -0
  20. mojo/apps/account/models/group.py +319 -15
  21. mojo/apps/account/models/member.py +29 -5
  22. mojo/apps/account/models/push/__init__.py +4 -0
  23. mojo/apps/account/models/push/config.py +112 -0
  24. mojo/apps/account/models/push/delivery.py +93 -0
  25. mojo/apps/account/models/push/device.py +66 -0
  26. mojo/apps/account/models/push/template.py +99 -0
  27. mojo/apps/account/models/user.py +369 -19
  28. mojo/apps/account/rest/__init__.py +2 -0
  29. mojo/apps/account/rest/device.py +39 -0
  30. mojo/apps/account/rest/group.py +9 -0
  31. mojo/apps/account/rest/push.py +187 -0
  32. mojo/apps/account/rest/user.py +100 -6
  33. mojo/apps/account/services/__init__.py +1 -0
  34. mojo/apps/account/services/push.py +363 -0
  35. mojo/apps/aws/migrations/0001_initial.py +206 -0
  36. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  37. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  38. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  39. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  40. mojo/apps/aws/models/__init__.py +19 -0
  41. mojo/apps/aws/models/email_attachment.py +99 -0
  42. mojo/apps/aws/models/email_domain.py +218 -0
  43. mojo/apps/aws/models/email_template.py +132 -0
  44. mojo/apps/aws/models/incoming_email.py +197 -0
  45. mojo/apps/aws/models/mailbox.py +288 -0
  46. mojo/apps/aws/models/sent_message.py +175 -0
  47. mojo/apps/aws/rest/__init__.py +7 -0
  48. mojo/apps/aws/rest/email.py +33 -0
  49. mojo/apps/aws/rest/email_ops.py +183 -0
  50. mojo/apps/aws/rest/messages.py +32 -0
  51. mojo/apps/aws/rest/s3.py +64 -0
  52. mojo/apps/aws/rest/send.py +101 -0
  53. mojo/apps/aws/rest/sns.py +403 -0
  54. mojo/apps/aws/rest/templates.py +19 -0
  55. mojo/apps/aws/services/__init__.py +32 -0
  56. mojo/apps/aws/services/email.py +390 -0
  57. mojo/apps/aws/services/email_ops.py +548 -0
  58. mojo/apps/docit/__init__.py +6 -0
  59. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  60. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  61. mojo/apps/docit/migrations/0001_initial.py +113 -0
  62. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  63. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  64. mojo/apps/docit/models/__init__.py +17 -0
  65. mojo/apps/docit/models/asset.py +231 -0
  66. mojo/apps/docit/models/book.py +227 -0
  67. mojo/apps/docit/models/page.py +319 -0
  68. mojo/apps/docit/models/page_revision.py +203 -0
  69. mojo/apps/docit/rest/__init__.py +10 -0
  70. mojo/apps/docit/rest/asset.py +17 -0
  71. mojo/apps/docit/rest/book.py +22 -0
  72. mojo/apps/docit/rest/page.py +22 -0
  73. mojo/apps/docit/rest/page_revision.py +17 -0
  74. mojo/apps/docit/services/__init__.py +11 -0
  75. mojo/apps/docit/services/docit.py +315 -0
  76. mojo/apps/docit/services/markdown.py +44 -0
  77. mojo/apps/fileman/README.md +8 -8
  78. mojo/apps/fileman/backends/base.py +76 -70
  79. mojo/apps/fileman/backends/filesystem.py +86 -86
  80. mojo/apps/fileman/backends/s3.py +409 -108
  81. mojo/apps/fileman/migrations/0001_initial.py +106 -0
  82. mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
  83. mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
  84. mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
  85. mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
  86. mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
  87. mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
  88. mojo/apps/fileman/migrations/0008_file_category.py +18 -0
  89. mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
  90. mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
  91. mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
  92. mojo/apps/fileman/models/__init__.py +1 -5
  93. mojo/apps/fileman/models/file.py +240 -58
  94. mojo/apps/fileman/models/manager.py +427 -31
  95. mojo/apps/fileman/models/rendition.py +118 -0
  96. mojo/apps/fileman/renderer/__init__.py +111 -0
  97. mojo/apps/fileman/renderer/audio.py +403 -0
  98. mojo/apps/fileman/renderer/base.py +205 -0
  99. mojo/apps/fileman/renderer/document.py +404 -0
  100. mojo/apps/fileman/renderer/image.py +222 -0
  101. mojo/apps/fileman/renderer/utils.py +297 -0
  102. mojo/apps/fileman/renderer/video.py +304 -0
  103. mojo/apps/fileman/rest/__init__.py +1 -18
  104. mojo/apps/fileman/rest/upload.py +22 -32
  105. mojo/apps/fileman/signals.py +58 -0
  106. mojo/apps/fileman/tasks.py +254 -0
  107. mojo/apps/fileman/utils/__init__.py +40 -16
  108. mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
  109. mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
  110. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  111. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  112. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  113. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  114. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  115. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  116. mojo/apps/incident/models/__init__.py +2 -0
  117. mojo/apps/incident/models/event.py +35 -0
  118. mojo/apps/incident/models/history.py +36 -0
  119. mojo/apps/incident/models/incident.py +3 -1
  120. mojo/apps/incident/models/ticket.py +62 -0
  121. mojo/apps/incident/reporter.py +21 -1
  122. mojo/apps/incident/rest/__init__.py +1 -0
  123. mojo/apps/incident/rest/event.py +7 -1
  124. mojo/apps/incident/rest/ticket.py +43 -0
  125. mojo/apps/jobs/__init__.py +489 -0
  126. mojo/apps/jobs/adapters.py +24 -0
  127. mojo/apps/jobs/cli.py +616 -0
  128. mojo/apps/jobs/daemon.py +370 -0
  129. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  130. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  131. mojo/apps/jobs/handlers/__init__.py +5 -0
  132. mojo/apps/jobs/handlers/webhook.py +317 -0
  133. mojo/apps/jobs/job_engine.py +734 -0
  134. mojo/apps/jobs/keys.py +203 -0
  135. mojo/apps/jobs/local_queue.py +363 -0
  136. mojo/apps/jobs/management/__init__.py +3 -0
  137. mojo/apps/jobs/management/commands/__init__.py +3 -0
  138. mojo/apps/jobs/manager.py +1327 -0
  139. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  140. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  141. mojo/apps/jobs/models/__init__.py +6 -0
  142. mojo/apps/jobs/models/job.py +441 -0
  143. mojo/apps/jobs/rest/__init__.py +2 -0
  144. mojo/apps/jobs/rest/control.py +466 -0
  145. mojo/apps/jobs/rest/jobs.py +421 -0
  146. mojo/apps/jobs/scheduler.py +571 -0
  147. mojo/apps/jobs/services/__init__.py +6 -0
  148. mojo/apps/jobs/services/job_actions.py +465 -0
  149. mojo/apps/jobs/settings.py +209 -0
  150. mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
  151. mojo/apps/logit/models/log.py +7 -1
  152. mojo/apps/metrics/__init__.py +8 -1
  153. mojo/apps/metrics/redis_metrics.py +198 -0
  154. mojo/apps/metrics/rest/__init__.py +3 -0
  155. mojo/apps/metrics/rest/categories.py +266 -0
  156. mojo/apps/metrics/rest/helpers.py +48 -0
  157. mojo/apps/metrics/rest/permissions.py +99 -0
  158. mojo/apps/metrics/rest/values.py +277 -0
  159. mojo/apps/metrics/utils.py +19 -2
  160. mojo/decorators/auth.py +6 -1
  161. mojo/decorators/http.py +47 -3
  162. mojo/helpers/aws/__init__.py +45 -0
  163. mojo/helpers/aws/ec2.py +804 -0
  164. mojo/helpers/aws/iam.py +748 -0
  165. mojo/helpers/aws/inbound_email.py +309 -0
  166. mojo/helpers/aws/kms.py +413 -0
  167. mojo/helpers/aws/s3.py +451 -11
  168. mojo/helpers/aws/ses.py +483 -0
  169. mojo/helpers/aws/ses_domain.py +959 -0
  170. mojo/helpers/aws/sns.py +461 -0
  171. mojo/helpers/crypto/__init__.py +1 -1
  172. mojo/helpers/crypto/utils.py +15 -0
  173. mojo/helpers/dates.py +18 -0
  174. mojo/helpers/location/__init__.py +2 -0
  175. mojo/helpers/location/countries.py +262 -0
  176. mojo/helpers/location/geolocation.py +196 -0
  177. mojo/helpers/logit.py +37 -0
  178. mojo/helpers/redis/__init__.py +2 -0
  179. mojo/helpers/redis/adapter.py +606 -0
  180. mojo/helpers/redis/client.py +48 -0
  181. mojo/helpers/redis/pool.py +225 -0
  182. mojo/helpers/request.py +8 -0
  183. mojo/helpers/response.py +14 -2
  184. mojo/helpers/settings/__init__.py +2 -0
  185. mojo/helpers/{settings.py → settings/helper.py} +1 -37
  186. mojo/helpers/settings/parser.py +132 -0
  187. mojo/middleware/auth.py +1 -1
  188. mojo/middleware/cors.py +40 -0
  189. mojo/middleware/logging.py +131 -12
  190. mojo/middleware/mojo.py +10 -0
  191. mojo/models/rest.py +494 -65
  192. mojo/models/secrets.py +98 -3
  193. mojo/serializers/__init__.py +106 -0
  194. mojo/serializers/core/__init__.py +90 -0
  195. mojo/serializers/core/cache/__init__.py +121 -0
  196. mojo/serializers/core/cache/backends.py +518 -0
  197. mojo/serializers/core/cache/base.py +102 -0
  198. mojo/serializers/core/cache/disabled.py +181 -0
  199. mojo/serializers/core/cache/memory.py +287 -0
  200. mojo/serializers/core/cache/redis.py +533 -0
  201. mojo/serializers/core/cache/utils.py +454 -0
  202. mojo/serializers/core/manager.py +550 -0
  203. mojo/serializers/core/serializer.py +475 -0
  204. mojo/serializers/examples/settings.py +322 -0
  205. mojo/serializers/formats/csv.py +393 -0
  206. mojo/serializers/formats/localizers.py +509 -0
  207. mojo/serializers/{models.py → simple.py} +38 -15
  208. mojo/serializers/suggested_improvements.md +388 -0
  209. testit/client.py +1 -1
  210. testit/helpers.py +35 -4
  211. testit/runner.py +23 -6
  212. django_nativemojo-0.1.10.dist-info/METADATA +0 -96
  213. django_nativemojo-0.1.10.dist-info/RECORD +0 -194
  214. mojo/apps/metrics/rest/db.py +0 -0
  215. mojo/apps/notify/README.md +0 -91
  216. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  217. mojo/apps/notify/admin.py +0 -52
  218. mojo/apps/notify/handlers/example_handlers.py +0 -516
  219. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  220. mojo/apps/notify/handlers/ses/bounce.py +0 -0
  221. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  222. mojo/apps/notify/handlers/ses/message.py +0 -86
  223. mojo/apps/notify/management/commands/__init__.py +0 -1
  224. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  225. mojo/apps/notify/mod +0 -0
  226. mojo/apps/notify/models/__init__.py +0 -12
  227. mojo/apps/notify/models/account.py +0 -128
  228. mojo/apps/notify/models/attachment.py +0 -24
  229. mojo/apps/notify/models/bounce.py +0 -68
  230. mojo/apps/notify/models/complaint.py +0 -40
  231. mojo/apps/notify/models/inbox.py +0 -113
  232. mojo/apps/notify/models/inbox_message.py +0 -173
  233. mojo/apps/notify/models/outbox.py +0 -129
  234. mojo/apps/notify/models/outbox_message.py +0 -288
  235. mojo/apps/notify/models/template.py +0 -30
  236. mojo/apps/notify/providers/aws.py +0 -73
  237. mojo/apps/notify/rest/ses.py +0 -0
  238. mojo/apps/notify/utils/__init__.py +0 -2
  239. mojo/apps/notify/utils/notifications.py +0 -404
  240. mojo/apps/notify/utils/parsing.py +0 -202
  241. mojo/apps/notify/utils/render.py +0 -144
  242. mojo/apps/tasks/README.md +0 -118
  243. mojo/apps/tasks/__init__.py +0 -11
  244. mojo/apps/tasks/manager.py +0 -489
  245. mojo/apps/tasks/rest/__init__.py +0 -2
  246. mojo/apps/tasks/rest/hooks.py +0 -0
  247. mojo/apps/tasks/rest/tasks.py +0 -62
  248. mojo/apps/tasks/runner.py +0 -174
  249. mojo/apps/tasks/tq_handlers.py +0 -14
  250. mojo/helpers/aws/setup_email.py +0 -0
  251. mojo/helpers/redis.py +0 -10
  252. mojo/models/meta.py +0 -262
  253. mojo/ws4redis/README.md +0 -174
  254. mojo/ws4redis/__init__.py +0 -2
  255. mojo/ws4redis/client.py +0 -283
  256. mojo/ws4redis/connection.py +0 -327
  257. mojo/ws4redis/exceptions.py +0 -32
  258. mojo/ws4redis/redis.py +0 -183
  259. mojo/ws4redis/servers/base.py +0 -86
  260. mojo/ws4redis/servers/django.py +0 -171
  261. mojo/ws4redis/servers/uwsgi.py +0 -63
  262. mojo/ws4redis/settings.py +0 -45
  263. mojo/ws4redis/utf8validator.py +0 -128
  264. mojo/ws4redis/websocket.py +0 -403
  265. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  266. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  267. {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  268. /mojo/apps/{notify → aws}/__init__.py +0 -0
  269. /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
  270. /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
  271. /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
  272. /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
  273. /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
  274. /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
  275. /mojo/{serializers → rest}/openapi.py +0 -0
  276. /mojo/{apps/fileman/rest/__init__ → serializers/formats/__init__.py} +0 -0
@@ -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}"
@@ -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
@@ -5,10 +5,31 @@ from mojo.helpers.settings import settings
5
5
  from mojo import errors as merrors
6
6
  from mojo.helpers import dates
7
7
  from mojo.apps.account.utils.jwtoken import JWToken
8
+ from mojo.apps import metrics
9
+ from .device import UserDevice
8
10
  import uuid
9
11
 
12
+ SYS_USER_PERMS_PROTECTION = {
13
+ "manage_users": "manage_users",
14
+ "manage_groups": "manage_users",
15
+ "view_logs": "manage_users",
16
+ "view_incidents": "manage_users",
17
+ "view_admin": "manage_users",
18
+ "view_taskqueue": "manage_users",
19
+ "view_global": "manage_users",
20
+ "manage_notifications": "manage_users",
21
+ "manage_files": "manage_users",
22
+ "force_single_session": "manage_users",
23
+ "file_vault": "manage_users",
24
+ "manage_aws": "manage_users"
25
+ }
26
+
10
27
  USER_PERMS_PROTECTION = settings.get("USER_PERMS_PROTECTION", {})
28
+ USER_PERMS_PROTECTION.update(SYS_USER_PERMS_PROTECTION)
29
+
11
30
  USER_LAST_ACTIVITY_FREQ = settings.get("USER_LAST_ACTIVITY_FREQ", 300)
31
+ METRICS_TIMEZONE = settings.get("METRICS_TIMEZONE", "America/Los_Angeles")
32
+ METRICS_TRACK_USER_ACTIVITY = settings.get("METRICS_TRACK_USER_ACTIVITY", False)
12
33
 
13
34
  class CustomUserManager(BaseUserManager):
14
35
  def create_user(self, email, password=None, **extra_fields):
@@ -43,6 +64,11 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
43
64
  phone_number = models.CharField(max_length=32, blank=True, null=True, default=None)
44
65
  is_active = models.BooleanField(default=True, db_index=True)
45
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")
46
72
  # key used for sessions and general authentication algs
47
73
  auth_key = models.TextField(null=True, default=None)
48
74
  onetime_code = models.TextField(null=True, default=None)
@@ -62,10 +88,15 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
62
88
  is_email_verified = models.BooleanField(default=False)
63
89
  is_phone_verified = models.BooleanField(default=False)
64
90
 
91
+ avatar = models.ForeignKey('fileman.File', on_delete=models.SET_NULL,
92
+ null=True, blank=True, related_name='+')
93
+
65
94
  USERNAME_FIELD = 'username'
66
95
  objects = CustomUserManager()
67
96
 
68
97
  class RestMeta:
98
+ LOG_CHANGES = True
99
+ POST_SAVE_ACTIONS = ['send_invite']
69
100
  NO_SHOW_FIELDS = ["password", "auth_key", "onetime_code"]
70
101
  SEARCH_FIELDS = ["username", "email", "display_name"]
71
102
  VIEW_PERMS = ["view_users", "manage_users", "owner"]
@@ -80,12 +111,13 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
80
111
  'id',
81
112
  'display_name',
82
113
  'username',
83
- 'email',
84
- 'phone_number',
85
114
  'last_login',
86
115
  'last_activity',
87
116
  'is_active'
88
- ]
117
+ ],
118
+ "graphs": {
119
+ "avatar": "basic"
120
+ }
89
121
  },
90
122
  "default": {
91
123
  "fields": [
@@ -100,7 +132,16 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
100
132
  'metadata',
101
133
  'is_active'
102
134
  ],
135
+ "graphs": {
136
+ "avatar": "basic",
137
+ "org": "basic"
138
+ }
103
139
  },
140
+ "full": {
141
+ "graphs": {
142
+ "avatar": "basic"
143
+ }
144
+ }
104
145
  }
105
146
 
106
147
  def __str__(self):
@@ -115,9 +156,17 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
115
156
 
116
157
  def touch(self):
117
158
  # can't subtract offset-naive and offset-aware datetimes
159
+ if self.last_activity and not dates.is_today(self.last_activity, METRICS_TIMEZONE):
160
+ metrics.record("user_activity_day", category="user", min_granularity="days")
118
161
  if self.last_activity is None or dates.has_time_elsapsed(self.last_activity, seconds=USER_LAST_ACTIVITY_FREQ):
119
162
  self.last_activity = dates.utcnow()
120
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)
121
170
 
122
171
  def get_auth_key(self):
123
172
  if self.auth_key is None:
@@ -125,19 +174,24 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
125
174
  self.atomic_save()
126
175
  return self.auth_key
127
176
 
128
- def set_permissions(self, value, request):
177
+ def set_username(self, value):
178
+ if not isinstance(value, str):
179
+ raise ValueError("Username must be a string")
180
+ self.username = value
181
+
182
+ def set_permissions(self, value):
129
183
  if not isinstance(value, dict):
130
184
  return
131
185
  for key in value:
132
186
  if key in USER_PERMS_PROTECTION:
133
- if not request.user.has_permission(USER_PERMS_PROTECTION[key]):
187
+ if not self.active_user.has_permission(USER_PERMS_PROTECTION[key]):
134
188
  raise merrors.PermissionDeniedException()
135
- elif not request.user.has_permission("manage_users"):
189
+ elif not self.active_user.has_permission("manage_users"):
136
190
  raise merrors.PermissionDeniedException()
137
191
  if bool(value[key]):
138
- self.add_permission(key)
192
+ self.add_permission(key, commit=False)
139
193
  else:
140
- self.remove_permission(key)
194
+ self.remove_permission(key, commit=False)
141
195
 
142
196
  def has_module_perms(self, app_label):
143
197
  """Check if user has any permissions in a given app."""
@@ -145,7 +199,7 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
145
199
 
146
200
  def has_permission(self, perm_key):
147
201
  """Check if user has a specific permission in JSON field."""
148
- if isinstance(perm_key, list):
202
+ if isinstance(perm_key, (list, set)):
149
203
  for pk in perm_key:
150
204
  if self.has_permission(pk):
151
205
  return True
@@ -154,25 +208,39 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
154
208
  return True
155
209
  return self.permissions.get(perm_key, False)
156
210
 
157
- def add_permission(self, perm_key, value=True):
211
+ def add_permission(self, perm_key, value=True, commit=True):
158
212
  """Dynamically add a permission."""
213
+ changed = False
159
214
  if isinstance(perm_key, (list, set)):
160
215
  for pk in perm_key:
161
- self.permissions[pk] = value
216
+ if self.permissions.get(pk) != value:
217
+ self.permissions[pk] = value
218
+ changed = True
162
219
  else:
163
- self.permissions[perm_key] = value
164
- self.save()
220
+ if self.permissions.get(perm_key) != value:
221
+ self.permissions[perm_key] = value
222
+ changed = True
223
+ if changed:
224
+ self.log(f"Added permission {perm_key}", "permission:added")
225
+ if commit and changed:
226
+ self.save()
165
227
 
166
- def remove_permission(self, perm_key):
228
+ def remove_permission(self, perm_key, commit=True):
167
229
  """Remove a permission."""
230
+ changed = False
168
231
  if isinstance(perm_key, (list, set)):
169
232
  for pk in perm_key:
170
233
  if pk in self.permissions:
171
234
  del self.permissions[pk]
235
+ changed = True
172
236
  else:
173
237
  if perm_key in self.permissions:
174
238
  del self.permissions[perm_key]
175
- self.save()
239
+ changed = True
240
+ if changed:
241
+ self.log(f"Removed permission {perm_key}", "permission:removed")
242
+ if commit and changed:
243
+ self.save()
176
244
 
177
245
  def remove_all_permissions(self):
178
246
  self.permissions = {}
@@ -182,18 +250,300 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
182
250
  self.set_password(value)
183
251
  self.save()
184
252
 
185
- def save(self, *args, **kwargs):
253
+ def validate_email(self):
254
+ import re
255
+ if not self.email:
256
+ raise merrors.ValueException("Email is required")
257
+ if not re.match(r"[^@]+@[^@]+\.[^@]+", str(self.email)):
258
+ raise merrors.ValueException("Invalid email format")
259
+ return True
260
+
261
+ def validate_username(self):
186
262
  if not self.username:
187
- self.username = self.email.split("@")[0]
263
+ raise merrors.ValueException("Username is required")
264
+ if len(str(self.username)) <= 2:
265
+ raise merrors.ValueException("Username must be more than 2 characters")
266
+ # Check for special characters (only allow alphanumeric, underscore, dot, and @)
267
+ import re
268
+ if not re.match(r'^[a-zA-Z0-9_.@]+$', str(self.username)):
269
+ raise merrors.ValueException("Username can only contain letters, numbers, underscores, dots, and @")
270
+ # If username contains @, it must match the email field
271
+ if '@' in str(self.username) and str(self.username) != str(self.email):
272
+ raise merrors.ValueException("Username containing @ must match the email address")
273
+ return True
274
+
275
+ def set_new_password(self, new_password):
276
+ self.debug("SET NEW PASSWORD")
277
+ # Validate password strength
278
+ if len(new_password) < 8:
279
+ raise merrors.ValueException("Password must be at least 8 characters long")
280
+
281
+ strength_score = 0
282
+
283
+ # Length contributes to strength (longer is better)
284
+ if len(new_password) >= 12:
285
+ strength_score += 2
286
+ elif len(new_password) >= 10:
287
+ strength_score += 1
288
+
289
+ # Check for mixed case
290
+ has_upper = any(c.isupper() for c in new_password)
291
+ has_lower = any(c.islower() for c in new_password)
292
+ if has_upper and has_lower:
293
+ strength_score += 1
294
+
295
+ # Check for numbers
296
+ has_numbers = any(c.isdigit() for c in new_password)
297
+ if has_numbers:
298
+ strength_score += 1
299
+
300
+ # Check for special characters
301
+ import re
302
+ has_special = bool(re.search(r'[!@#$%^&*(),.?":{}|<>]', new_password))
303
+ if has_special:
304
+ strength_score += 1
305
+
306
+ # Require minimum strength score
307
+ if strength_score < 2:
308
+ raise merrors.ValueException("Password is too weak. Use a longer password or include a mix of uppercase, lowercase, numbers, and special characters")
309
+
310
+ self.set_password(new_password)
311
+ self._set_field_change("new_password", "*", "*********")
312
+
313
+ def can_change_password(self):
314
+ if self.pk == self.active_user.pk:
315
+ return True
316
+ if self.active_user.is_superuser:
317
+ return True
318
+ if self.active_user.has_permission(["manage_users"]):
319
+ return True
320
+ return False
321
+
322
+ def generate_username_from_email(self):
323
+ """Generate a username from email, falling back to email if username exists."""
324
+ if not self.email:
325
+ raise merrors.ValueException("Email is required to generate username")
326
+
327
+ # Try using the part before @ as username
328
+ potential_username = self.email.split("@")[0].lower()
329
+
330
+ # Check if this username already exists
331
+ qset = User.objects.filter(username=potential_username)
332
+ if self.pk is not None:
333
+ qset = qset.exclude(pk=self.pk)
334
+
335
+ # If username doesn't exist, use it
336
+ if not qset.exists():
337
+ return potential_username
338
+
339
+ # Fall back to using the full email as username
340
+ return self.email.lower()
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
+
351
+ def on_rest_pre_save(self, changed_fields, created):
352
+ creds_changed = False
353
+ if "email" in changed_fields:
354
+ creds_changed = True
355
+ self.validate_email()
356
+ self.email = self.email.lower()
357
+ if not self.username:
358
+ self.username = self.generate_username_from_email()
359
+ elif "@" in self.username and self.username != self.email:
360
+ self.username = self.email
361
+ qset = User.objects.filter(email=self.email)
362
+ if self.pk is not None:
363
+ qset = qset.exclude(pk=self.pk)
364
+ if qset.exists():
365
+ raise merrors.ValueException("Email already exists")
366
+ if "username" in changed_fields:
367
+ creds_changed = True
368
+ self.validate_username()
369
+ self.username = self.username.lower()
370
+ qset = User.objects.filter(username=self.username)
371
+ if self.pk is not None:
372
+ qset = qset.exclude(pk=self.pk)
373
+ if qset.exists():
374
+ raise merrors.ValueException("Username already exists")
188
375
  if not self.display_name:
189
- self.display_name = self.username
190
- super().save(*args, **kwargs)
376
+ self.display_name = self.generate_display_name()
377
+ if self.pk is not None:
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():
388
+ raise merrors.PermissionDeniedException("You are not allowed to change password")
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")
191
399
 
192
400
  def check_edit_permission(self, perms, request):
193
401
  if "owner" in perms and self.is_request_user():
194
402
  return True
195
403
  return request.user.has_permission(perms)
196
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
+
197
547
  @classmethod
198
548
  def validate_jwt(cls, token):
199
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 *