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,72 @@
1
+ # Generated by Django 4.2.21 on 2025-08-28
2
+
3
+ from django.conf import settings
4
+ from django.db import migrations, models
5
+ import django.db.models.deletion
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ('account', '0005_group_last_activity'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='GeoLocatedIP',
17
+ fields=[
18
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
+ ('created', models.DateTimeField(auto_now_add=True)),
20
+ ('modified', models.DateTimeField(auto_now=True, db_index=True)),
21
+ ('ip_address', models.GenericIPAddressField(db_index=True, unique=True)),
22
+ ('country_code', models.CharField(blank=True, db_index=True, max_length=3, null=True)),
23
+ ('country_name', models.CharField(blank=True, max_length=100, null=True)),
24
+ ('region', models.CharField(blank=True, db_index=True, max_length=100, null=True)),
25
+ ('city', models.CharField(blank=True, max_length=100, null=True)),
26
+ ('postal_code', models.CharField(blank=True, max_length=20, null=True)),
27
+ ('latitude', models.FloatField(blank=True, null=True)),
28
+ ('longitude', models.FloatField(blank=True, null=True)),
29
+ ('timezone', models.CharField(blank=True, max_length=50, null=True)),
30
+ ('provider', models.CharField(blank=True, max_length=50, null=True)),
31
+ ('data', models.JSONField(blank=True, default=dict)),
32
+ ('expires_at', models.DateTimeField(blank=True, default=None, null=True)),
33
+ ],
34
+ options={
35
+ 'verbose_name': 'Geolocated IP',
36
+ 'verbose_name_plural': 'Geolocated IPs',
37
+ },
38
+ ),
39
+ migrations.CreateModel(
40
+ name='UserDevice',
41
+ fields=[
42
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
43
+ ('duid', models.CharField(db_index=True, max_length=255)),
44
+ ('device_info', models.JSONField(blank=True, default=dict)),
45
+ ('user_agent_hash', models.CharField(blank=True, db_index=True, max_length=64, null=True)),
46
+ ('last_ip', models.GenericIPAddressField(blank=True, null=True)),
47
+ ('first_seen', models.DateTimeField(auto_now_add=True)),
48
+ ('last_seen', models.DateTimeField(auto_now=True)),
49
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devices', to=settings.AUTH_USER_MODEL)),
50
+ ],
51
+ options={
52
+ 'ordering': ['-last_seen'],
53
+ 'unique_together': {('user', 'duid')},
54
+ },
55
+ ),
56
+ migrations.CreateModel(
57
+ name='UserDeviceLocation',
58
+ fields=[
59
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
60
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_locations_direct', to=settings.AUTH_USER_MODEL)),
61
+ ('ip_address', models.GenericIPAddressField(db_index=True)),
62
+ ('first_seen', models.DateTimeField(auto_now_add=True)),
63
+ ('last_seen', models.DateTimeField(auto_now=True)),
64
+ ('geolocation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='device_locations', to='account.geolocatedip')),
65
+ ('user_device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='account.userdevice')),
66
+ ],
67
+ options={
68
+ 'ordering': ['-last_seen'],
69
+ 'unique_together': {('user', 'user_device', 'ip_address')},
70
+ },
71
+ ),
72
+ ]
@@ -0,0 +1,16 @@
1
+ # Generated by Django 4.2.21 on 2025-08-28 16:24
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('account', '0006_add_device_tracking_models'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.DeleteModel(
14
+ name='UserDeviceLocation',
15
+ ),
16
+ ]
@@ -0,0 +1,33 @@
1
+ # Generated by Django 4.2.21 on 2025-08-28 22:30
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', '0007_delete_userdevicelocation'),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name='UserDeviceLocation',
18
+ fields=[
19
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20
+ ('ip_address', models.GenericIPAddressField(db_index=True)),
21
+ ('first_seen', models.DateTimeField(auto_now_add=True)),
22
+ ('last_seen', models.DateTimeField(auto_now=True)),
23
+ ('geolocation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='device_locations', to='account.geolocatedip')),
24
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_locations_direct', to=settings.AUTH_USER_MODEL)),
25
+ ('user_device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='account.userdevice')),
26
+ ],
27
+ options={
28
+ 'ordering': ['-last_seen'],
29
+ 'unique_together': {('user', 'user_device', 'ip_address')},
30
+ },
31
+ bases=(models.Model, mojo.models.rest.MojoModel),
32
+ ),
33
+ ]
@@ -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,281 @@
1
+ from turtledemo.chaos import g
2
+ import hashlib
3
+ from django.db import models
4
+ from mojo.helpers.settings import settings
5
+ from mojo.models import MojoModel
6
+ from mojo.helpers import dates, request as rhelper
7
+ from mojo.apps import jobs
8
+ from mojo.helpers.location.geolocation import refresh_geolocation_for_ip
9
+ from fnmatch import filter
10
+
11
+ GEOLOCATION_ALLOW_SUBNET_LOOKUP = settings.get('GEOLOCATION_ALLOW_SUBNET_LOOKUP', False)
12
+ GEOLOCATION_DEVICE_LOCATION_AGE = settings.get('GEOLOCATION_DEVICE_LOCATION_AGE', 300)
13
+ GEOLOCATION_CACHE_DURATION_DAYS = settings.get('GEOLOCATION_CACHE_DURATION_DAYS', 30)
14
+
15
+
16
+ def trigger_refresh_task(ip_address):
17
+ """
18
+ Publishes a task to refresh the geolocation data for a given IP address.
19
+ """
20
+ jobs.publish_local(refresh_geolocation_for_ip, ip_address)
21
+
22
+
23
+ class GeoLocatedIP(models.Model, MojoModel):
24
+ """
25
+ Acts as a cache to store geolocation results, reducing redundant and costly API calls.
26
+ Features a standardized, indexed schema for fast querying.
27
+ """
28
+ created = models.DateTimeField(auto_now_add=True, editable=False)
29
+ modified = models.DateTimeField(auto_now=True, db_index=True)
30
+
31
+ ip_address = models.GenericIPAddressField(db_index=True, unique=True)
32
+ subnet = models.CharField(max_length=16, db_index=True, null=True, default=None)
33
+
34
+ # Normalized and indexed fields for querying
35
+ country_code = models.CharField(max_length=3, db_index=True, null=True, blank=True)
36
+ country_name = models.CharField(max_length=100, null=True, blank=True)
37
+ region = models.CharField(max_length=100, db_index=True, null=True, blank=True)
38
+ city = models.CharField(max_length=100, null=True, blank=True)
39
+ postal_code = models.CharField(max_length=20, null=True, blank=True)
40
+ latitude = models.FloatField(null=True, blank=True)
41
+ longitude = models.FloatField(null=True, blank=True)
42
+ timezone = models.CharField(max_length=50, null=True, blank=True)
43
+
44
+ # Auditing and source tracking
45
+ provider = models.CharField(max_length=50, null=True, blank=True)
46
+ data = models.JSONField(default=dict, blank=True)
47
+ expires_at = models.DateTimeField(default=None, null=True, blank=True)
48
+
49
+ class RestMeta:
50
+ VIEW_PERMS = ['manage_users']
51
+ GRAPHS = {
52
+ 'default': {
53
+
54
+ }
55
+ }
56
+
57
+ class Meta:
58
+ verbose_name = "Geolocated IP"
59
+ verbose_name_plural = "Geolocated IPs"
60
+
61
+ def __str__(self):
62
+ return f"{self.ip_address} ({self.city}, {self.country_code})"
63
+
64
+ @property
65
+ def is_expired(self):
66
+ if self.provider == 'internal':
67
+ return False # Internal records never expire
68
+ if self.expires_at:
69
+ return dates.utcnow() > self.expires_at
70
+ return True # If no expiry is set, it needs a refresh
71
+
72
+ def refresh(self):
73
+ """
74
+ Refreshes the geolocation data for this IP by calling the geolocation
75
+ helper and updating the model instance with the returned data.
76
+ """
77
+ from mojo.helpers.location import geolocation
78
+ from datetime import timedelta
79
+
80
+ geo_data = geolocation.geolocate_ip(self.ip_address)
81
+
82
+ if not geo_data:
83
+ return False
84
+
85
+ # Update self with new data
86
+ for key, value in geo_data.items():
87
+ setattr(self, key, value)
88
+
89
+ # Set the expiration date
90
+ if self.provider == 'internal':
91
+ self.expires_at = None
92
+ else:
93
+ cache_duration_days = GEOLOCATION_CACHE_DURATION_DAYS
94
+ self.expires_at = dates.utcnow() + timedelta(days=cache_duration_days)
95
+
96
+ self.save()
97
+ return True
98
+
99
+ @classmethod
100
+ def geolocate(cls, ip_address, auto_refresh=False, subdomain_only=False):
101
+ # Extract subnet from IP address using simple string parsing
102
+ subnet = ip_address[:ip_address.rfind('.')]
103
+ geo_ip = GeoLocatedIP.objects.filter(ip_address=ip_address).first()
104
+ if not geo_ip and (GEOLOCATION_ALLOW_SUBNET_LOOKUP or subdomain_only):
105
+ geo_ip = GeoLocatedIP.objects.filter(subnet=subnet).last()
106
+ if geo_ip:
107
+ geo_ip.id = None
108
+ geo_ip.pk = None
109
+ geo_ip.ip_address = ip_address
110
+ if "subnet" not in geo_ip.provider:
111
+ geo_ip.provider = f"subnet:{geo_ip.provider}"
112
+ if not geo_ip:
113
+ geo_ip = GeoLocatedIP.objects.create(ip_address=ip_address, subnet=subnet)
114
+ if auto_refresh and geo_ip.is_expired:
115
+ geo_ip.refresh()
116
+ return geo_ip
117
+
118
+
119
+
120
+ class UserDevice(models.Model, MojoModel):
121
+ """
122
+ Represents a unique device used by a user, tracked via a device ID (duid) or
123
+ a hash of the user agent string as a fallback.
124
+ """
125
+ user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='devices')
126
+ duid = models.CharField(max_length=255, db_index=True)
127
+
128
+ device_info = models.JSONField(default=dict, blank=True)
129
+ user_agent_hash = models.CharField(max_length=64, db_index=True, null=True, blank=True)
130
+
131
+ last_ip = models.GenericIPAddressField(null=True, blank=True)
132
+ first_seen = models.DateTimeField(auto_now_add=True)
133
+ last_seen = models.DateTimeField(auto_now=True)
134
+
135
+ class RestMeta:
136
+ VIEW_PERMS = ['manage_users', 'owner']
137
+ GRAPHS = {
138
+ 'default': {
139
+ 'graphs': {
140
+ 'user': 'basic'
141
+ }
142
+ },
143
+ 'basic': {
144
+ "fields": ["duid", "last_ip", "last_seen", "device_info"],
145
+ },
146
+ 'locations': {
147
+ 'fields': ['duid', 'last_ip', 'last_seen'],
148
+ 'graphs': {
149
+ 'locations': 'default'
150
+ }
151
+ }
152
+ }
153
+
154
+ class Meta:
155
+ unique_together = ('user', 'duid')
156
+ ordering = ['-last_seen']
157
+
158
+ def __str__(self):
159
+ return f"Device {self.duid} for {self.user.username}"
160
+
161
+ @classmethod
162
+ def track(cls, request):
163
+ """
164
+ Tracks a user's device based on the incoming request. This is the primary
165
+ entry point for the device tracking system.
166
+ """
167
+ if not request.user or not request.user.is_authenticated:
168
+ return None
169
+
170
+ user = request.user
171
+ ip_address = request.ip
172
+ user_agent_str = request.user_agent
173
+ duid = request.duid
174
+
175
+ ua_hash = hashlib.sha256(user_agent_str.encode('utf-8')).hexdigest()
176
+ if not duid:
177
+ duid = f"ua-hash-{ua_hash}"
178
+
179
+ # Get or create the device
180
+ device, created = cls.objects.get_or_create(
181
+ user=user,
182
+ duid=duid,
183
+ defaults={
184
+ 'last_ip': ip_address,
185
+ 'user_agent_hash': ua_hash,
186
+ 'device_info': rhelper.parse_user_agent(user_agent_str)
187
+ }
188
+ )
189
+
190
+ # If device already existed, update its last_seen and ip
191
+ if not created:
192
+ now = dates.utcnow()
193
+ age_seconds = (now - device.last_seen).total_seconds()
194
+ is_stale = age_seconds > GEOLOCATION_DEVICE_LOCATION_AGE
195
+ if is_stale or device.last_ip != ip_address:
196
+ device.last_ip = ip_address
197
+ device.last_seen = dates.utcnow()
198
+ # Optionally update device_info if user agent has changed
199
+ if device.user_agent_hash != ua_hash:
200
+ device.user_agent_hash = ua_hash
201
+ device.device_info = rhelper.parse_user_agent(user_agent_str)
202
+ device.save()
203
+
204
+ # Track the location (IP) used by this device
205
+ UserDeviceLocation.track(device, ip_address)
206
+
207
+ return device
208
+
209
+
210
+ class UserDeviceLocation(models.Model, MojoModel):
211
+ """
212
+ A log linking a UserDevice to every IP address it uses. Geolocation is
213
+ handled asynchronously.
214
+ """
215
+ user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='device_locations_direct')
216
+ user_device = models.ForeignKey('UserDevice', on_delete=models.CASCADE, related_name='locations')
217
+ ip_address = models.GenericIPAddressField(db_index=True)
218
+ geolocation = models.ForeignKey('GeoLocatedIP', on_delete=models.SET_NULL, null=True, blank=True, related_name='device_locations')
219
+
220
+ first_seen = models.DateTimeField(auto_now_add=True)
221
+ last_seen = models.DateTimeField(auto_now=True)
222
+
223
+ class RestMeta:
224
+ VIEW_PERMS = ['manage_users']
225
+ GRAPHS = {
226
+ 'default': {
227
+ 'graphs': {
228
+ 'user': 'basic',
229
+ 'geolocation': 'default',
230
+ 'user_device': 'basic'
231
+ }
232
+ },
233
+ 'list': {
234
+ 'graphs': {
235
+ 'user': 'basic',
236
+ 'geolocation': 'default',
237
+ 'user_device': 'basic'
238
+ }
239
+ }
240
+ }
241
+
242
+ class Meta:
243
+ unique_together = ('user', 'user_device', 'ip_address')
244
+ ordering = ['-last_seen']
245
+
246
+ def __str__(self):
247
+ return f"{self.user_device} @ {self.ip_address}"
248
+
249
+ @classmethod
250
+ def track(cls, device, ip_address):
251
+ """
252
+ Creates or updates a device location entry, links it to a GeoLocatedIP record,
253
+ and triggers a background refresh if the geo data is stale.
254
+ """
255
+ # First, get or create the geolocation record for this IP.
256
+ # The actual fetching of data is handled by the background task.
257
+ geo_ip = GeoLocatedIP.geolocate(ip_address)
258
+
259
+ # Now, create the actual location event log, linking the device and the geo_ip record.
260
+ location, loc_created = cls.objects.get_or_create(
261
+ user=device.user,
262
+ user_device=device,
263
+ ip_address=ip_address,
264
+ defaults={'geolocation': geo_ip}
265
+ )
266
+
267
+ if not loc_created:
268
+ now = dates.utcnow()
269
+ age_seconds = (now - location.last_seen).total_seconds()
270
+ if age_seconds > GEOLOCATION_DEVICE_LOCATION_AGE:
271
+ location.last_seen = now
272
+ # If the location already existed but wasn't linked to a geo_ip object yet
273
+ if not location.geolocation:
274
+ location.geolocation = geo_ip
275
+ location.save(update_fields=['last_seen', 'geolocation'])
276
+
277
+ # Finally, if the geo data is stale or new, trigger a refresh.
278
+ if geo_ip.is_expired:
279
+ trigger_refresh_task(ip_address)
280
+
281
+ return location