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.
- django_nativemojo-0.1.16.dist-info/METADATA +138 -0
- django_nativemojo-0.1.16.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/__init__.py +5 -0
- mojo/apps/account/management/commands/__init__.py +6 -0
- mojo/apps/account/management/commands/serializer_admin.py +651 -0
- mojo/apps/account/migrations/0004_user_avatar.py +20 -0
- mojo/apps/account/migrations/0005_group_last_activity.py +18 -0
- mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
- mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
- mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
- mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
- mojo/apps/account/migrations/0010_group_avatar.py +20 -0
- mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
- mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
- mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
- mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
- mojo/apps/account/models/__init__.py +2 -0
- mojo/apps/account/models/device.py +281 -0
- mojo/apps/account/models/group.py +319 -15
- mojo/apps/account/models/member.py +29 -5
- mojo/apps/account/models/push/__init__.py +4 -0
- mojo/apps/account/models/push/config.py +112 -0
- mojo/apps/account/models/push/delivery.py +93 -0
- mojo/apps/account/models/push/device.py +66 -0
- mojo/apps/account/models/push/template.py +99 -0
- mojo/apps/account/models/user.py +369 -19
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +9 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +100 -6
- mojo/apps/account/services/__init__.py +1 -0
- mojo/apps/account/services/push.py +363 -0
- mojo/apps/aws/migrations/0001_initial.py +206 -0
- mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
- mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
- mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
- mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
- mojo/apps/aws/models/__init__.py +19 -0
- mojo/apps/aws/models/email_attachment.py +99 -0
- mojo/apps/aws/models/email_domain.py +218 -0
- mojo/apps/aws/models/email_template.py +132 -0
- mojo/apps/aws/models/incoming_email.py +197 -0
- mojo/apps/aws/models/mailbox.py +288 -0
- mojo/apps/aws/models/sent_message.py +175 -0
- mojo/apps/aws/rest/__init__.py +7 -0
- mojo/apps/aws/rest/email.py +33 -0
- mojo/apps/aws/rest/email_ops.py +183 -0
- mojo/apps/aws/rest/messages.py +32 -0
- mojo/apps/aws/rest/s3.py +64 -0
- mojo/apps/aws/rest/send.py +101 -0
- mojo/apps/aws/rest/sns.py +403 -0
- mojo/apps/aws/rest/templates.py +19 -0
- mojo/apps/aws/services/__init__.py +32 -0
- mojo/apps/aws/services/email.py +390 -0
- mojo/apps/aws/services/email_ops.py +548 -0
- mojo/apps/docit/__init__.py +6 -0
- mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
- mojo/apps/docit/markdown_plugins/toc.py +12 -0
- mojo/apps/docit/migrations/0001_initial.py +113 -0
- mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
- mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
- mojo/apps/docit/models/__init__.py +17 -0
- mojo/apps/docit/models/asset.py +231 -0
- mojo/apps/docit/models/book.py +227 -0
- mojo/apps/docit/models/page.py +319 -0
- mojo/apps/docit/models/page_revision.py +203 -0
- mojo/apps/docit/rest/__init__.py +10 -0
- mojo/apps/docit/rest/asset.py +17 -0
- mojo/apps/docit/rest/book.py +22 -0
- mojo/apps/docit/rest/page.py +22 -0
- mojo/apps/docit/rest/page_revision.py +17 -0
- mojo/apps/docit/services/__init__.py +11 -0
- mojo/apps/docit/services/docit.py +315 -0
- mojo/apps/docit/services/markdown.py +44 -0
- mojo/apps/fileman/README.md +8 -8
- mojo/apps/fileman/backends/base.py +76 -70
- mojo/apps/fileman/backends/filesystem.py +86 -86
- mojo/apps/fileman/backends/s3.py +409 -108
- mojo/apps/fileman/migrations/0001_initial.py +106 -0
- mojo/apps/fileman/migrations/0002_filemanager_parent_alter_filemanager_max_file_size.py +24 -0
- mojo/apps/fileman/migrations/0003_remove_file_fileman_fil_upload__c4bc35_idx_and_more.py +25 -0
- mojo/apps/fileman/migrations/0004_remove_file_original_filename_and_more.py +39 -0
- mojo/apps/fileman/migrations/0005_alter_file_upload_token.py +18 -0
- mojo/apps/fileman/migrations/0006_file_download_url_filemanager_forever_urls.py +23 -0
- mojo/apps/fileman/migrations/0007_remove_filemanager_forever_urls_and_more.py +22 -0
- mojo/apps/fileman/migrations/0008_file_category.py +18 -0
- mojo/apps/fileman/migrations/0009_rename_file_path_file_storage_file_path.py +18 -0
- mojo/apps/fileman/migrations/0010_filerendition.py +33 -0
- mojo/apps/fileman/migrations/0011_alter_filerendition_original_file.py +19 -0
- mojo/apps/fileman/models/__init__.py +1 -5
- mojo/apps/fileman/models/file.py +240 -58
- mojo/apps/fileman/models/manager.py +427 -31
- mojo/apps/fileman/models/rendition.py +118 -0
- mojo/apps/fileman/renderer/__init__.py +111 -0
- mojo/apps/fileman/renderer/audio.py +403 -0
- mojo/apps/fileman/renderer/base.py +205 -0
- mojo/apps/fileman/renderer/document.py +404 -0
- mojo/apps/fileman/renderer/image.py +222 -0
- mojo/apps/fileman/renderer/utils.py +297 -0
- mojo/apps/fileman/renderer/video.py +304 -0
- mojo/apps/fileman/rest/__init__.py +1 -18
- mojo/apps/fileman/rest/upload.py +22 -32
- mojo/apps/fileman/signals.py +58 -0
- mojo/apps/fileman/tasks.py +254 -0
- mojo/apps/fileman/utils/__init__.py +40 -16
- mojo/apps/incident/migrations/0005_incidenthistory.py +39 -0
- mojo/apps/incident/migrations/0006_alter_incident_state.py +18 -0
- mojo/apps/incident/migrations/0007_event_uid.py +18 -0
- mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
- mojo/apps/incident/migrations/0009_incident_status.py +18 -0
- mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
- mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
- mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
- mojo/apps/incident/models/__init__.py +2 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/history.py +36 -0
- mojo/apps/incident/models/incident.py +3 -1
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -1
- mojo/apps/incident/rest/__init__.py +1 -0
- mojo/apps/incident/rest/event.py +7 -1
- mojo/apps/incident/rest/ticket.py +43 -0
- mojo/apps/jobs/__init__.py +489 -0
- mojo/apps/jobs/adapters.py +24 -0
- mojo/apps/jobs/cli.py +616 -0
- mojo/apps/jobs/daemon.py +370 -0
- mojo/apps/jobs/examples/sample_jobs.py +376 -0
- mojo/apps/jobs/examples/webhook_examples.py +203 -0
- mojo/apps/jobs/handlers/__init__.py +5 -0
- mojo/apps/jobs/handlers/webhook.py +317 -0
- mojo/apps/jobs/job_engine.py +734 -0
- mojo/apps/jobs/keys.py +203 -0
- mojo/apps/jobs/local_queue.py +363 -0
- mojo/apps/jobs/management/__init__.py +3 -0
- mojo/apps/jobs/management/commands/__init__.py +3 -0
- mojo/apps/jobs/manager.py +1327 -0
- mojo/apps/jobs/migrations/0001_initial.py +97 -0
- mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
- mojo/apps/jobs/models/__init__.py +6 -0
- mojo/apps/jobs/models/job.py +441 -0
- mojo/apps/jobs/rest/__init__.py +2 -0
- mojo/apps/jobs/rest/control.py +466 -0
- mojo/apps/jobs/rest/jobs.py +421 -0
- mojo/apps/jobs/scheduler.py +571 -0
- mojo/apps/jobs/services/__init__.py +6 -0
- mojo/apps/jobs/services/job_actions.py +465 -0
- mojo/apps/jobs/settings.py +209 -0
- mojo/apps/logit/migrations/0004_alter_log_level.py +18 -0
- mojo/apps/logit/models/log.py +7 -1
- mojo/apps/metrics/__init__.py +8 -1
- mojo/apps/metrics/redis_metrics.py +198 -0
- mojo/apps/metrics/rest/__init__.py +3 -0
- mojo/apps/metrics/rest/categories.py +266 -0
- mojo/apps/metrics/rest/helpers.py +48 -0
- mojo/apps/metrics/rest/permissions.py +99 -0
- mojo/apps/metrics/rest/values.py +277 -0
- mojo/apps/metrics/utils.py +19 -2
- mojo/decorators/auth.py +6 -1
- mojo/decorators/http.py +47 -3
- mojo/helpers/aws/__init__.py +45 -0
- mojo/helpers/aws/ec2.py +804 -0
- mojo/helpers/aws/iam.py +748 -0
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/s3.py +451 -11
- mojo/helpers/aws/ses.py +483 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/aws/sns.py +461 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -0
- mojo/helpers/dates.py +18 -0
- mojo/helpers/location/__init__.py +2 -0
- mojo/helpers/location/countries.py +262 -0
- mojo/helpers/location/geolocation.py +196 -0
- mojo/helpers/logit.py +37 -0
- mojo/helpers/redis/__init__.py +2 -0
- mojo/helpers/redis/adapter.py +606 -0
- mojo/helpers/redis/client.py +48 -0
- mojo/helpers/redis/pool.py +225 -0
- mojo/helpers/request.py +8 -0
- mojo/helpers/response.py +14 -2
- mojo/helpers/settings/__init__.py +2 -0
- mojo/helpers/{settings.py → settings/helper.py} +1 -37
- mojo/helpers/settings/parser.py +132 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +10 -0
- mojo/models/rest.py +494 -65
- mojo/models/secrets.py +98 -3
- mojo/serializers/__init__.py +106 -0
- mojo/serializers/core/__init__.py +90 -0
- mojo/serializers/core/cache/__init__.py +121 -0
- mojo/serializers/core/cache/backends.py +518 -0
- mojo/serializers/core/cache/base.py +102 -0
- mojo/serializers/core/cache/disabled.py +181 -0
- mojo/serializers/core/cache/memory.py +287 -0
- mojo/serializers/core/cache/redis.py +533 -0
- mojo/serializers/core/cache/utils.py +454 -0
- mojo/serializers/core/manager.py +550 -0
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/examples/settings.py +322 -0
- mojo/serializers/formats/csv.py +393 -0
- mojo/serializers/formats/localizers.py +509 -0
- mojo/serializers/{models.py → simple.py} +38 -15
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +35 -4
- testit/runner.py +23 -6
- django_nativemojo-0.1.10.dist-info/METADATA +0 -96
- django_nativemojo-0.1.10.dist-info/RECORD +0 -194
- mojo/apps/metrics/rest/db.py +0 -0
- mojo/apps/notify/README.md +0 -91
- mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
- mojo/apps/notify/admin.py +0 -52
- mojo/apps/notify/handlers/example_handlers.py +0 -516
- mojo/apps/notify/handlers/ses/__init__.py +0 -25
- mojo/apps/notify/handlers/ses/bounce.py +0 -0
- mojo/apps/notify/handlers/ses/complaint.py +0 -25
- mojo/apps/notify/handlers/ses/message.py +0 -86
- mojo/apps/notify/management/commands/__init__.py +0 -1
- mojo/apps/notify/management/commands/process_notifications.py +0 -370
- mojo/apps/notify/mod +0 -0
- mojo/apps/notify/models/__init__.py +0 -12
- mojo/apps/notify/models/account.py +0 -128
- mojo/apps/notify/models/attachment.py +0 -24
- mojo/apps/notify/models/bounce.py +0 -68
- mojo/apps/notify/models/complaint.py +0 -40
- mojo/apps/notify/models/inbox.py +0 -113
- mojo/apps/notify/models/inbox_message.py +0 -173
- mojo/apps/notify/models/outbox.py +0 -129
- mojo/apps/notify/models/outbox_message.py +0 -288
- mojo/apps/notify/models/template.py +0 -30
- mojo/apps/notify/providers/aws.py +0 -73
- mojo/apps/notify/rest/ses.py +0 -0
- mojo/apps/notify/utils/__init__.py +0 -2
- mojo/apps/notify/utils/notifications.py +0 -404
- mojo/apps/notify/utils/parsing.py +0 -202
- mojo/apps/notify/utils/render.py +0 -144
- mojo/apps/tasks/README.md +0 -118
- mojo/apps/tasks/__init__.py +0 -11
- mojo/apps/tasks/manager.py +0 -489
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -62
- mojo/apps/tasks/runner.py +0 -174
- mojo/apps/tasks/tq_handlers.py +0 -14
- mojo/helpers/aws/setup_email.py +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/ws4redis/README.md +0 -174
- mojo/ws4redis/__init__.py +0 -2
- mojo/ws4redis/client.py +0 -283
- mojo/ws4redis/connection.py +0 -327
- mojo/ws4redis/exceptions.py +0 -32
- mojo/ws4redis/redis.py +0 -183
- mojo/ws4redis/servers/base.py +0 -86
- mojo/ws4redis/servers/django.py +0 -171
- mojo/ws4redis/servers/uwsgi.py +0 -63
- mojo/ws4redis/settings.py +0 -45
- mojo/ws4redis/utf8validator.py +0 -128
- mojo/ws4redis/websocket.py +0 -403
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.10.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/providers → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/rest → fileman/migrations}/__init__.py +0 -0
- /mojo/{ws4redis/servers → apps/jobs/examples}/__init__.py +0 -0
- /mojo/apps/{fileman/models/render.py → jobs/migrations/__init__.py} +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /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
|
+
]
|
mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py
ADDED
@@ -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
|
+
]
|
@@ -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
|