django-nativemojo 0.1.15__py3-none-any.whl → 0.1.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
  2. django_nativemojo-0.1.17.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/commands/serializer_admin.py +121 -1
  5. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  6. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  7. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  8. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  9. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  10. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  11. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  12. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  13. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  14. mojo/apps/account/models/__init__.py +2 -0
  15. mojo/apps/account/models/device.py +279 -0
  16. mojo/apps/account/models/group.py +294 -8
  17. mojo/apps/account/models/member.py +14 -1
  18. mojo/apps/account/models/push/__init__.py +4 -0
  19. mojo/apps/account/models/push/config.py +112 -0
  20. mojo/apps/account/models/push/delivery.py +93 -0
  21. mojo/apps/account/models/push/device.py +66 -0
  22. mojo/apps/account/models/push/template.py +99 -0
  23. mojo/apps/account/models/user.py +190 -17
  24. mojo/apps/account/rest/__init__.py +2 -0
  25. mojo/apps/account/rest/device.py +39 -0
  26. mojo/apps/account/rest/group.py +8 -0
  27. mojo/apps/account/rest/push.py +187 -0
  28. mojo/apps/account/rest/user.py +95 -5
  29. mojo/apps/account/services/__init__.py +1 -0
  30. mojo/apps/account/services/push.py +363 -0
  31. mojo/apps/aws/migrations/0001_initial.py +206 -0
  32. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  33. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  34. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  35. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  36. mojo/apps/aws/models/__init__.py +19 -0
  37. mojo/apps/aws/models/email_attachment.py +99 -0
  38. mojo/apps/aws/models/email_domain.py +218 -0
  39. mojo/apps/aws/models/email_template.py +132 -0
  40. mojo/apps/aws/models/incoming_email.py +197 -0
  41. mojo/apps/aws/models/mailbox.py +288 -0
  42. mojo/apps/aws/models/sent_message.py +175 -0
  43. mojo/apps/aws/rest/__init__.py +6 -0
  44. mojo/apps/aws/rest/email.py +33 -0
  45. mojo/apps/aws/rest/email_ops.py +183 -0
  46. mojo/apps/aws/rest/messages.py +32 -0
  47. mojo/apps/aws/rest/send.py +101 -0
  48. mojo/apps/aws/rest/sns.py +403 -0
  49. mojo/apps/aws/rest/templates.py +19 -0
  50. mojo/apps/aws/services/__init__.py +32 -0
  51. mojo/apps/aws/services/email.py +390 -0
  52. mojo/apps/aws/services/email_ops.py +548 -0
  53. mojo/apps/docit/__init__.py +6 -0
  54. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  55. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  56. mojo/apps/docit/migrations/0001_initial.py +113 -0
  57. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  58. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  59. mojo/apps/docit/models/__init__.py +17 -0
  60. mojo/apps/docit/models/asset.py +231 -0
  61. mojo/apps/docit/models/book.py +227 -0
  62. mojo/apps/docit/models/page.py +319 -0
  63. mojo/apps/docit/models/page_revision.py +203 -0
  64. mojo/apps/docit/rest/__init__.py +10 -0
  65. mojo/apps/docit/rest/asset.py +17 -0
  66. mojo/apps/docit/rest/book.py +22 -0
  67. mojo/apps/docit/rest/page.py +22 -0
  68. mojo/apps/docit/rest/page_revision.py +17 -0
  69. mojo/apps/docit/services/__init__.py +11 -0
  70. mojo/apps/docit/services/docit.py +315 -0
  71. mojo/apps/docit/services/markdown.py +44 -0
  72. mojo/apps/fileman/backends/s3.py +209 -0
  73. mojo/apps/fileman/models/file.py +45 -9
  74. mojo/apps/fileman/models/manager.py +269 -3
  75. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  76. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  77. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  78. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  79. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  80. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  81. mojo/apps/incident/models/__init__.py +1 -0
  82. mojo/apps/incident/models/event.py +35 -0
  83. mojo/apps/incident/models/incident.py +2 -0
  84. mojo/apps/incident/models/ticket.py +62 -0
  85. mojo/apps/incident/reporter.py +21 -3
  86. mojo/apps/incident/rest/__init__.py +1 -0
  87. mojo/apps/incident/rest/ticket.py +43 -0
  88. mojo/apps/jobs/__init__.py +489 -0
  89. mojo/apps/jobs/adapters.py +24 -0
  90. mojo/apps/jobs/cli.py +616 -0
  91. mojo/apps/jobs/daemon.py +370 -0
  92. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  93. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  94. mojo/apps/jobs/handlers/__init__.py +5 -0
  95. mojo/apps/jobs/handlers/webhook.py +317 -0
  96. mojo/apps/jobs/job_engine.py +734 -0
  97. mojo/apps/jobs/keys.py +203 -0
  98. mojo/apps/jobs/local_queue.py +363 -0
  99. mojo/apps/jobs/management/__init__.py +3 -0
  100. mojo/apps/jobs/management/commands/__init__.py +3 -0
  101. mojo/apps/jobs/manager.py +1327 -0
  102. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  103. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  104. mojo/apps/jobs/models/__init__.py +6 -0
  105. mojo/apps/jobs/models/job.py +441 -0
  106. mojo/apps/jobs/rest/__init__.py +2 -0
  107. mojo/apps/jobs/rest/control.py +466 -0
  108. mojo/apps/jobs/rest/jobs.py +421 -0
  109. mojo/apps/jobs/scheduler.py +571 -0
  110. mojo/apps/jobs/services/__init__.py +6 -0
  111. mojo/apps/jobs/services/job_actions.py +465 -0
  112. mojo/apps/jobs/settings.py +209 -0
  113. mojo/apps/logit/models/log.py +3 -0
  114. mojo/apps/metrics/__init__.py +8 -1
  115. mojo/apps/metrics/redis_metrics.py +198 -0
  116. mojo/apps/metrics/rest/__init__.py +3 -0
  117. mojo/apps/metrics/rest/categories.py +266 -0
  118. mojo/apps/metrics/rest/helpers.py +48 -0
  119. mojo/apps/metrics/rest/permissions.py +99 -0
  120. mojo/apps/metrics/rest/values.py +277 -0
  121. mojo/apps/metrics/utils.py +17 -0
  122. mojo/decorators/http.py +40 -1
  123. mojo/helpers/aws/__init__.py +11 -7
  124. mojo/helpers/aws/inbound_email.py +309 -0
  125. mojo/helpers/aws/kms.py +413 -0
  126. mojo/helpers/aws/ses_domain.py +959 -0
  127. mojo/helpers/crypto/__init__.py +1 -1
  128. mojo/helpers/crypto/utils.py +15 -0
  129. mojo/helpers/location/__init__.py +2 -0
  130. mojo/helpers/location/countries.py +262 -0
  131. mojo/helpers/location/geolocation.py +196 -0
  132. mojo/helpers/logit.py +37 -0
  133. mojo/helpers/redis/__init__.py +2 -0
  134. mojo/helpers/redis/adapter.py +606 -0
  135. mojo/helpers/redis/client.py +48 -0
  136. mojo/helpers/redis/pool.py +225 -0
  137. mojo/helpers/request.py +8 -0
  138. mojo/helpers/response.py +8 -0
  139. mojo/middleware/auth.py +1 -1
  140. mojo/middleware/cors.py +40 -0
  141. mojo/middleware/logging.py +131 -12
  142. mojo/middleware/mojo.py +5 -0
  143. mojo/models/rest.py +271 -57
  144. mojo/models/secrets.py +86 -0
  145. mojo/serializers/__init__.py +16 -10
  146. mojo/serializers/core/__init__.py +90 -0
  147. mojo/serializers/core/cache/__init__.py +121 -0
  148. mojo/serializers/core/cache/backends.py +518 -0
  149. mojo/serializers/core/cache/base.py +102 -0
  150. mojo/serializers/core/cache/disabled.py +181 -0
  151. mojo/serializers/core/cache/memory.py +287 -0
  152. mojo/serializers/core/cache/redis.py +533 -0
  153. mojo/serializers/core/cache/utils.py +454 -0
  154. mojo/serializers/{manager.py → core/manager.py} +53 -4
  155. mojo/serializers/core/serializer.py +475 -0
  156. mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
  157. mojo/serializers/suggested_improvements.md +388 -0
  158. testit/client.py +1 -1
  159. testit/helpers.py +14 -0
  160. testit/runner.py +23 -6
  161. django_nativemojo-0.1.15.dist-info/RECORD +0 -234
  162. mojo/apps/notify/README.md +0 -91
  163. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  164. mojo/apps/notify/admin.py +0 -52
  165. mojo/apps/notify/handlers/example_handlers.py +0 -516
  166. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  167. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  168. mojo/apps/notify/handlers/ses/message.py +0 -86
  169. mojo/apps/notify/management/commands/__init__.py +0 -1
  170. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  171. mojo/apps/notify/mod +0 -0
  172. mojo/apps/notify/models/__init__.py +0 -12
  173. mojo/apps/notify/models/account.py +0 -128
  174. mojo/apps/notify/models/attachment.py +0 -24
  175. mojo/apps/notify/models/bounce.py +0 -68
  176. mojo/apps/notify/models/complaint.py +0 -40
  177. mojo/apps/notify/models/inbox.py +0 -113
  178. mojo/apps/notify/models/inbox_message.py +0 -173
  179. mojo/apps/notify/models/outbox.py +0 -129
  180. mojo/apps/notify/models/outbox_message.py +0 -288
  181. mojo/apps/notify/models/template.py +0 -30
  182. mojo/apps/notify/providers/aws.py +0 -73
  183. mojo/apps/notify/rest/ses.py +0 -0
  184. mojo/apps/notify/utils/__init__.py +0 -2
  185. mojo/apps/notify/utils/notifications.py +0 -404
  186. mojo/apps/notify/utils/parsing.py +0 -202
  187. mojo/apps/notify/utils/render.py +0 -144
  188. mojo/apps/tasks/README.md +0 -118
  189. mojo/apps/tasks/__init__.py +0 -44
  190. mojo/apps/tasks/manager.py +0 -644
  191. mojo/apps/tasks/rest/__init__.py +0 -2
  192. mojo/apps/tasks/rest/hooks.py +0 -0
  193. mojo/apps/tasks/rest/tasks.py +0 -76
  194. mojo/apps/tasks/runner.py +0 -439
  195. mojo/apps/tasks/task.py +0 -99
  196. mojo/apps/tasks/tq_handlers.py +0 -132
  197. mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
  198. mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
  199. mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
  200. mojo/helpers/redis.py +0 -10
  201. mojo/models/meta.py +0 -262
  202. mojo/serializers/advanced/README.md +0 -363
  203. mojo/serializers/advanced/__init__.py +0 -247
  204. mojo/serializers/advanced/formats/__init__.py +0 -28
  205. mojo/serializers/advanced/formats/excel.py +0 -516
  206. mojo/serializers/advanced/formats/json.py +0 -239
  207. mojo/serializers/advanced/formats/response.py +0 -485
  208. mojo/serializers/advanced/serializer.py +0 -568
  209. mojo/serializers/optimized.py +0 -618
  210. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
  213. /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
  214. /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
  215. /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
  216. /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
  217. /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
  218. /mojo/{serializers → rest}/openapi.py +0 -0
  219. /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
  220. /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
  221. /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -0,0 +1,18 @@
1
+ # Generated by Django 4.2.21 on 2025-08-29 00:23
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('account', '0008_userdevicelocation'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='geolocatedip',
15
+ name='subnet',
16
+ field=models.CharField(db_index=True, default=None, max_length=16, null=True),
17
+ ),
18
+ ]
@@ -0,0 +1,20 @@
1
+ # Generated by Django 4.2.21 on 2025-08-29 03:04
2
+
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('fileman', '0011_alter_filerendition_original_file'),
11
+ ('account', '0009_geolocatedip_subnet'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name='group',
17
+ name='avatar',
18
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='fileman.file'),
19
+ ),
20
+ ]
@@ -0,0 +1,118 @@
1
+ # Generated by Django 4.2.21 on 2025-08-30 02:36
2
+
3
+ from django.conf import settings
4
+ from django.db import migrations, models
5
+ import django.db.models.deletion
6
+ import mojo.models.rest
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ dependencies = [
12
+ ('account', '0010_group_avatar'),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.AddField(
17
+ model_name='user',
18
+ name='org',
19
+ field=models.ForeignKey(blank=True, help_text='Default organization for this user', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='org_users', to='account.group'),
20
+ ),
21
+ migrations.CreateModel(
22
+ name='RegisteredDevice',
23
+ fields=[
24
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
25
+ ('created', models.DateTimeField(auto_now_add=True, db_index=True)),
26
+ ('modified', models.DateTimeField(auto_now=True, db_index=True)),
27
+ ('device_token', models.TextField(db_index=True, help_text='Push token from platform')),
28
+ ('device_id', models.CharField(db_index=True, help_text='App-provided device ID', max_length=255)),
29
+ ('platform', models.CharField(choices=[('ios', 'iOS'), ('android', 'Android'), ('web', 'Web')], db_index=True, max_length=20)),
30
+ ('app_version', models.CharField(blank=True, max_length=50)),
31
+ ('os_version', models.CharField(blank=True, max_length=50)),
32
+ ('device_name', models.CharField(blank=True, max_length=100)),
33
+ ('push_enabled', models.BooleanField(db_index=True, default=True)),
34
+ ('push_preferences', models.JSONField(blank=True, default=dict, help_text='Category-based notification preferences')),
35
+ ('is_active', models.BooleanField(db_index=True, default=True)),
36
+ ('last_seen', models.DateTimeField(auto_now=True)),
37
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registered_devices', to=settings.AUTH_USER_MODEL)),
38
+ ],
39
+ options={
40
+ 'ordering': ['-last_seen'],
41
+ 'unique_together': {('user', 'device_id'), ('device_token', 'platform')},
42
+ },
43
+ bases=(models.Model, mojo.models.rest.MojoModel),
44
+ ),
45
+ migrations.CreateModel(
46
+ name='PushConfig',
47
+ fields=[
48
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
49
+ ('mojo_secrets', models.TextField(blank=True, default=None, null=True)),
50
+ ('created', models.DateTimeField(auto_now_add=True, db_index=True)),
51
+ ('modified', models.DateTimeField(auto_now=True, db_index=True)),
52
+ ('name', models.CharField(help_text='Configuration name', max_length=100)),
53
+ ('is_active', models.BooleanField(db_index=True, default=True)),
54
+ ('apns_enabled', models.BooleanField(default=False)),
55
+ ('apns_key_id', models.CharField(blank=True, max_length=100)),
56
+ ('apns_team_id', models.CharField(blank=True, max_length=100)),
57
+ ('apns_bundle_id', models.CharField(blank=True, max_length=255)),
58
+ ('apns_key_file', models.TextField(blank=True, help_text='Encrypted via MojoSecrets')),
59
+ ('apns_use_sandbox', models.BooleanField(default=False)),
60
+ ('fcm_enabled', models.BooleanField(default=False)),
61
+ ('fcm_server_key', models.TextField(blank=True, help_text='Encrypted via MojoSecrets')),
62
+ ('fcm_sender_id', models.CharField(blank=True, max_length=100)),
63
+ ('default_sound', models.CharField(default='default', max_length=50)),
64
+ ('default_badge_count', models.IntegerField(default=1)),
65
+ ('group', models.OneToOneField(blank=True, help_text='Organization for this config. Null = system default', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='push_config', to='account.group')),
66
+ ],
67
+ options={
68
+ 'ordering': ['group__name', 'name'],
69
+ },
70
+ bases=(models.Model, mojo.models.rest.MojoModel),
71
+ ),
72
+ migrations.CreateModel(
73
+ name='NotificationTemplate',
74
+ fields=[
75
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
76
+ ('created', models.DateTimeField(auto_now_add=True, db_index=True)),
77
+ ('modified', models.DateTimeField(auto_now=True, db_index=True)),
78
+ ('name', models.CharField(db_index=True, max_length=100)),
79
+ ('title_template', models.CharField(max_length=200)),
80
+ ('body_template', models.TextField()),
81
+ ('action_url', models.URLField(blank=True, help_text='Template URL with variable support', null=True)),
82
+ ('category', models.CharField(db_index=True, default='general', max_length=50)),
83
+ ('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High')], db_index=True, default='normal', max_length=20)),
84
+ ('variables', models.JSONField(blank=True, default=dict, help_text='Expected template variables and descriptions')),
85
+ ('is_active', models.BooleanField(db_index=True, default=True)),
86
+ ('group', models.ForeignKey(blank=True, help_text='Organization for this template. Null = system template', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_templates', to='account.group')),
87
+ ],
88
+ options={
89
+ 'ordering': ['group__name', 'name'],
90
+ 'unique_together': {('group', 'name')},
91
+ },
92
+ bases=(models.Model, mojo.models.rest.MojoModel),
93
+ ),
94
+ migrations.CreateModel(
95
+ name='NotificationDelivery',
96
+ fields=[
97
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
98
+ ('created', models.DateTimeField(auto_now_add=True, db_index=True)),
99
+ ('modified', models.DateTimeField(auto_now=True, db_index=True)),
100
+ ('title', models.CharField(max_length=200)),
101
+ ('body', models.TextField()),
102
+ ('category', models.CharField(db_index=True, max_length=50)),
103
+ ('action_url', models.URLField(blank=True, null=True)),
104
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('delivered', 'Delivered'), ('failed', 'Failed')], db_index=True, default='pending', max_length=20)),
105
+ ('sent_at', models.DateTimeField(blank=True, db_index=True, null=True)),
106
+ ('delivered_at', models.DateTimeField(blank=True, null=True)),
107
+ ('error_message', models.TextField(blank=True, null=True)),
108
+ ('platform_data', models.JSONField(blank=True, default=dict, help_text='Platform-specific response data')),
109
+ ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_deliveries', to='account.registereddevice')),
110
+ ('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deliveries', to='account.notificationtemplate')),
111
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_deliveries', to=settings.AUTH_USER_MODEL)),
112
+ ],
113
+ options={
114
+ 'ordering': ['-created'],
115
+ },
116
+ bases=(models.Model, mojo.models.rest.MojoModel),
117
+ ),
118
+ ]
@@ -0,0 +1,21 @@
1
+ # Generated by Django 4.2.21 on 2025-08-30 03:28
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('account', '0011_user_org_registereddevice_pushconfig_and_more'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RemoveField(
14
+ model_name='pushconfig',
15
+ name='apns_key_file',
16
+ ),
17
+ migrations.RemoveField(
18
+ model_name='pushconfig',
19
+ name='fcm_server_key',
20
+ ),
21
+ ]
@@ -0,0 +1,28 @@
1
+ # Generated by Django 4.2.21 on 2025-08-30 04:34
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('account', '0012_remove_pushconfig_apns_key_file_and_more'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='pushconfig',
15
+ name='test_mode',
16
+ field=models.BooleanField(db_index=True, default=False, help_text='Enable test mode - fake notifications for development'),
17
+ ),
18
+ migrations.AlterField(
19
+ model_name='pushconfig',
20
+ name='apns_enabled',
21
+ field=models.BooleanField(default=False, help_text='APNS for iOS-specific needs. FCM is preferred.'),
22
+ ),
23
+ migrations.AlterField(
24
+ model_name='pushconfig',
25
+ name='fcm_enabled',
26
+ field=models.BooleanField(default=True, help_text='FCM handles both iOS and Android notifications'),
27
+ ),
28
+ ]
@@ -0,0 +1,48 @@
1
+ # Generated by Django 4.2.23 on 2025-09-02 22:50
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('account', '0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='notificationdelivery',
15
+ name='data_payload',
16
+ field=models.JSONField(blank=True, default=dict, help_text='Custom data payload sent with notification'),
17
+ ),
18
+ migrations.AddField(
19
+ model_name='notificationtemplate',
20
+ name='data_template',
21
+ field=models.JSONField(blank=True, default=dict, help_text='Template data payload with variable support'),
22
+ ),
23
+ migrations.AlterField(
24
+ model_name='notificationdelivery',
25
+ name='body',
26
+ field=models.TextField(blank=True, null=True),
27
+ ),
28
+ migrations.AlterField(
29
+ model_name='notificationdelivery',
30
+ name='title',
31
+ field=models.CharField(blank=True, max_length=200, null=True),
32
+ ),
33
+ migrations.AlterField(
34
+ model_name='notificationtemplate',
35
+ name='body_template',
36
+ field=models.TextField(blank=True, null=True),
37
+ ),
38
+ migrations.AlterField(
39
+ model_name='notificationtemplate',
40
+ name='title_template',
41
+ field=models.CharField(blank=True, max_length=200, null=True),
42
+ ),
43
+ migrations.AlterField(
44
+ model_name='notificationtemplate',
45
+ name='variables',
46
+ field=models.JSONField(blank=True, default=dict, help_text='Expected template variables and descriptions for title, body, action_url, and data_template'),
47
+ ),
48
+ ]
@@ -1,3 +1,5 @@
1
1
  from .group import Group
2
2
  from .user import User
3
3
  from .member import GroupMember
4
+ from .device import GeoLocatedIP, UserDevice, UserDeviceLocation
5
+ from .push import RegisteredDevice, PushConfig, NotificationTemplate, NotificationDelivery
@@ -0,0 +1,279 @@
1
+ import hashlib
2
+ from django.db import models
3
+ from mojo.helpers.settings import settings
4
+ from mojo.models import MojoModel
5
+ from mojo.helpers import dates, request as rhelper
6
+ from mojo.apps import jobs
7
+ from mojo.helpers.location.geolocation import refresh_geolocation_for_ip
8
+
9
+ GEOLOCATION_ALLOW_SUBNET_LOOKUP = settings.get('GEOLOCATION_ALLOW_SUBNET_LOOKUP', False)
10
+ GEOLOCATION_DEVICE_LOCATION_AGE = settings.get('GEOLOCATION_DEVICE_LOCATION_AGE', 300)
11
+ GEOLOCATION_CACHE_DURATION_DAYS = settings.get('GEOLOCATION_CACHE_DURATION_DAYS', 30)
12
+
13
+
14
+ def trigger_refresh_task(ip_address):
15
+ """
16
+ Publishes a task to refresh the geolocation data for a given IP address.
17
+ """
18
+ jobs.publish_local(refresh_geolocation_for_ip, ip_address)
19
+
20
+
21
+ class GeoLocatedIP(models.Model, MojoModel):
22
+ """
23
+ Acts as a cache to store geolocation results, reducing redundant and costly API calls.
24
+ Features a standardized, indexed schema for fast querying.
25
+ """
26
+ created = models.DateTimeField(auto_now_add=True, editable=False)
27
+ modified = models.DateTimeField(auto_now=True, db_index=True)
28
+
29
+ ip_address = models.GenericIPAddressField(db_index=True, unique=True)
30
+ subnet = models.CharField(max_length=16, db_index=True, null=True, default=None)
31
+
32
+ # Normalized and indexed fields for querying
33
+ country_code = models.CharField(max_length=3, db_index=True, null=True, blank=True)
34
+ country_name = models.CharField(max_length=100, null=True, blank=True)
35
+ region = models.CharField(max_length=100, db_index=True, null=True, blank=True)
36
+ city = models.CharField(max_length=100, null=True, blank=True)
37
+ postal_code = models.CharField(max_length=20, null=True, blank=True)
38
+ latitude = models.FloatField(null=True, blank=True)
39
+ longitude = models.FloatField(null=True, blank=True)
40
+ timezone = models.CharField(max_length=50, null=True, blank=True)
41
+
42
+ # Auditing and source tracking
43
+ provider = models.CharField(max_length=50, null=True, blank=True)
44
+ data = models.JSONField(default=dict, blank=True)
45
+ expires_at = models.DateTimeField(default=None, null=True, blank=True)
46
+
47
+ class RestMeta:
48
+ VIEW_PERMS = ['manage_users']
49
+ GRAPHS = {
50
+ 'default': {
51
+
52
+ }
53
+ }
54
+
55
+ class Meta:
56
+ verbose_name = "Geolocated IP"
57
+ verbose_name_plural = "Geolocated IPs"
58
+
59
+ def __str__(self):
60
+ return f"{self.ip_address} ({self.city}, {self.country_code})"
61
+
62
+ @property
63
+ def is_expired(self):
64
+ if self.provider == 'internal':
65
+ return False # Internal records never expire
66
+ if self.expires_at:
67
+ return dates.utcnow() > self.expires_at
68
+ return True # If no expiry is set, it needs a refresh
69
+
70
+ def refresh(self):
71
+ """
72
+ Refreshes the geolocation data for this IP by calling the geolocation
73
+ helper and updating the model instance with the returned data.
74
+ """
75
+ from mojo.helpers.location import geolocation
76
+ from datetime import timedelta
77
+
78
+ geo_data = geolocation.geolocate_ip(self.ip_address)
79
+
80
+ if not geo_data:
81
+ return False
82
+
83
+ # Update self with new data
84
+ for key, value in geo_data.items():
85
+ setattr(self, key, value)
86
+
87
+ # Set the expiration date
88
+ if self.provider == 'internal':
89
+ self.expires_at = None
90
+ else:
91
+ cache_duration_days = GEOLOCATION_CACHE_DURATION_DAYS
92
+ self.expires_at = dates.utcnow() + timedelta(days=cache_duration_days)
93
+
94
+ self.save()
95
+ return True
96
+
97
+ @classmethod
98
+ def geolocate(cls, ip_address, auto_refresh=False, subdomain_only=False):
99
+ # Extract subnet from IP address using simple string parsing
100
+ subnet = ip_address[:ip_address.rfind('.')]
101
+ geo_ip = GeoLocatedIP.objects.filter(ip_address=ip_address).first()
102
+ if not geo_ip and (GEOLOCATION_ALLOW_SUBNET_LOOKUP or subdomain_only):
103
+ geo_ip = GeoLocatedIP.objects.filter(subnet=subnet).last()
104
+ if geo_ip:
105
+ geo_ip.id = None
106
+ geo_ip.pk = None
107
+ geo_ip.ip_address = ip_address
108
+ if "subnet" not in geo_ip.provider:
109
+ geo_ip.provider = f"subnet:{geo_ip.provider}"
110
+ if not geo_ip:
111
+ geo_ip = GeoLocatedIP.objects.create(ip_address=ip_address, subnet=subnet)
112
+ if auto_refresh and geo_ip.is_expired:
113
+ geo_ip.refresh()
114
+ return geo_ip
115
+
116
+
117
+
118
+ class UserDevice(models.Model, MojoModel):
119
+ """
120
+ Represents a unique device used by a user, tracked via a device ID (duid) or
121
+ a hash of the user agent string as a fallback.
122
+ """
123
+ user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='devices')
124
+ duid = models.CharField(max_length=255, db_index=True)
125
+
126
+ device_info = models.JSONField(default=dict, blank=True)
127
+ user_agent_hash = models.CharField(max_length=64, db_index=True, null=True, blank=True)
128
+
129
+ last_ip = models.GenericIPAddressField(null=True, blank=True)
130
+ first_seen = models.DateTimeField(auto_now_add=True)
131
+ last_seen = models.DateTimeField(auto_now=True)
132
+
133
+ class RestMeta:
134
+ VIEW_PERMS = ['manage_users', 'owner']
135
+ GRAPHS = {
136
+ 'default': {
137
+ 'graphs': {
138
+ 'user': 'basic'
139
+ }
140
+ },
141
+ 'basic': {
142
+ "fields": ["duid", "last_ip", "last_seen", "device_info"],
143
+ },
144
+ 'locations': {
145
+ 'fields': ['duid', 'last_ip', 'last_seen'],
146
+ 'graphs': {
147
+ 'locations': 'default'
148
+ }
149
+ }
150
+ }
151
+
152
+ class Meta:
153
+ unique_together = ('user', 'duid')
154
+ ordering = ['-last_seen']
155
+
156
+ def __str__(self):
157
+ return f"Device {self.duid} for {self.user.username}"
158
+
159
+ @classmethod
160
+ def track(cls, request):
161
+ """
162
+ Tracks a user's device based on the incoming request. This is the primary
163
+ entry point for the device tracking system.
164
+ """
165
+ if not request.user or not request.user.is_authenticated:
166
+ return None
167
+
168
+ user = request.user
169
+ ip_address = request.ip
170
+ user_agent_str = request.user_agent
171
+ duid = request.duid
172
+
173
+ ua_hash = hashlib.sha256(user_agent_str.encode('utf-8')).hexdigest()
174
+ if not duid:
175
+ duid = f"ua-hash-{ua_hash}"
176
+
177
+ # Get or create the device
178
+ device, created = cls.objects.get_or_create(
179
+ user=user,
180
+ duid=duid,
181
+ defaults={
182
+ 'last_ip': ip_address,
183
+ 'user_agent_hash': ua_hash,
184
+ 'device_info': rhelper.parse_user_agent(user_agent_str)
185
+ }
186
+ )
187
+
188
+ # If device already existed, update its last_seen and ip
189
+ if not created:
190
+ now = dates.utcnow()
191
+ age_seconds = (now - device.last_seen).total_seconds()
192
+ is_stale = age_seconds > GEOLOCATION_DEVICE_LOCATION_AGE
193
+ if is_stale or device.last_ip != ip_address:
194
+ device.last_ip = ip_address
195
+ device.last_seen = dates.utcnow()
196
+ # Optionally update device_info if user agent has changed
197
+ if device.user_agent_hash != ua_hash:
198
+ device.user_agent_hash = ua_hash
199
+ device.device_info = rhelper.parse_user_agent(user_agent_str)
200
+ device.save()
201
+
202
+ # Track the location (IP) used by this device
203
+ UserDeviceLocation.track(device, ip_address)
204
+
205
+ return device
206
+
207
+
208
+ class UserDeviceLocation(models.Model, MojoModel):
209
+ """
210
+ A log linking a UserDevice to every IP address it uses. Geolocation is
211
+ handled asynchronously.
212
+ """
213
+ user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='device_locations_direct')
214
+ user_device = models.ForeignKey('UserDevice', on_delete=models.CASCADE, related_name='locations')
215
+ ip_address = models.GenericIPAddressField(db_index=True)
216
+ geolocation = models.ForeignKey('GeoLocatedIP', on_delete=models.SET_NULL, null=True, blank=True, related_name='device_locations')
217
+
218
+ first_seen = models.DateTimeField(auto_now_add=True)
219
+ last_seen = models.DateTimeField(auto_now=True)
220
+
221
+ class RestMeta:
222
+ VIEW_PERMS = ['manage_users']
223
+ GRAPHS = {
224
+ 'default': {
225
+ 'graphs': {
226
+ 'user': 'basic',
227
+ 'geolocation': 'default',
228
+ 'user_device': 'basic'
229
+ }
230
+ },
231
+ 'list': {
232
+ 'graphs': {
233
+ 'user': 'basic',
234
+ 'geolocation': 'default',
235
+ 'user_device': 'basic'
236
+ }
237
+ }
238
+ }
239
+
240
+ class Meta:
241
+ unique_together = ('user', 'user_device', 'ip_address')
242
+ ordering = ['-last_seen']
243
+
244
+ def __str__(self):
245
+ return f"{self.user_device} @ {self.ip_address}"
246
+
247
+ @classmethod
248
+ def track(cls, device, ip_address):
249
+ """
250
+ Creates or updates a device location entry, links it to a GeoLocatedIP record,
251
+ and triggers a background refresh if the geo data is stale.
252
+ """
253
+ # First, get or create the geolocation record for this IP.
254
+ # The actual fetching of data is handled by the background task.
255
+ geo_ip = GeoLocatedIP.geolocate(ip_address)
256
+
257
+ # Now, create the actual location event log, linking the device and the geo_ip record.
258
+ location, loc_created = cls.objects.get_or_create(
259
+ user=device.user,
260
+ user_device=device,
261
+ ip_address=ip_address,
262
+ defaults={'geolocation': geo_ip}
263
+ )
264
+
265
+ if not loc_created:
266
+ now = dates.utcnow()
267
+ age_seconds = (now - location.last_seen).total_seconds()
268
+ if age_seconds > GEOLOCATION_DEVICE_LOCATION_AGE:
269
+ location.last_seen = now
270
+ # If the location already existed but wasn't linked to a geo_ip object yet
271
+ if not location.geolocation:
272
+ location.geolocation = geo_ip
273
+ location.save(update_fields=['last_seen', 'geolocation'])
274
+
275
+ # Finally, if the geo data is stale or new, trigger a refresh.
276
+ if geo_ip.is_expired:
277
+ trigger_refresh_task(ip_address)
278
+
279
+ return location