django-nativemojo 0.1.15__py3-none-any.whl → 0.1.16__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/METADATA +3 -1
- django_nativemojo-0.1.16.dist-info/RECORD +302 -0
- mojo/__init__.py +1 -1
- mojo/apps/account/management/commands/serializer_admin.py +121 -1
- 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 +294 -8
- mojo/apps/account/models/member.py +14 -1
- 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 +190 -17
- mojo/apps/account/rest/__init__.py +2 -0
- mojo/apps/account/rest/device.py +39 -0
- mojo/apps/account/rest/group.py +8 -0
- mojo/apps/account/rest/push.py +187 -0
- mojo/apps/account/rest/user.py +95 -5
- 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 +6 -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/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/backends/s3.py +209 -0
- mojo/apps/fileman/models/file.py +45 -9
- mojo/apps/fileman/models/manager.py +269 -3
- 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 +1 -0
- mojo/apps/incident/models/event.py +35 -0
- mojo/apps/incident/models/incident.py +2 -0
- mojo/apps/incident/models/ticket.py +62 -0
- mojo/apps/incident/reporter.py +21 -3
- mojo/apps/incident/rest/__init__.py +1 -0
- 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/models/log.py +3 -0
- 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 +17 -0
- mojo/decorators/http.py +40 -1
- mojo/helpers/aws/__init__.py +11 -7
- mojo/helpers/aws/inbound_email.py +309 -0
- mojo/helpers/aws/kms.py +413 -0
- mojo/helpers/aws/ses_domain.py +959 -0
- mojo/helpers/crypto/__init__.py +1 -1
- mojo/helpers/crypto/utils.py +15 -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 +8 -0
- mojo/middleware/auth.py +1 -1
- mojo/middleware/cors.py +40 -0
- mojo/middleware/logging.py +131 -12
- mojo/middleware/mojo.py +5 -0
- mojo/models/rest.py +271 -57
- mojo/models/secrets.py +86 -0
- mojo/serializers/__init__.py +16 -10
- 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/{manager.py → core/manager.py} +53 -4
- mojo/serializers/core/serializer.py +475 -0
- mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
- mojo/serializers/suggested_improvements.md +388 -0
- testit/client.py +1 -1
- testit/helpers.py +14 -0
- testit/runner.py +23 -6
- django_nativemojo-0.1.15.dist-info/RECORD +0 -234
- 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/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 -44
- mojo/apps/tasks/manager.py +0 -644
- mojo/apps/tasks/rest/__init__.py +0 -2
- mojo/apps/tasks/rest/hooks.py +0 -0
- mojo/apps/tasks/rest/tasks.py +0 -76
- mojo/apps/tasks/runner.py +0 -439
- mojo/apps/tasks/task.py +0 -99
- mojo/apps/tasks/tq_handlers.py +0 -132
- mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
- mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
- mojo/helpers/redis.py +0 -10
- mojo/models/meta.py +0 -262
- mojo/serializers/advanced/README.md +0 -363
- mojo/serializers/advanced/__init__.py +0 -247
- mojo/serializers/advanced/formats/__init__.py +0 -28
- mojo/serializers/advanced/formats/excel.py +0 -516
- mojo/serializers/advanced/formats/json.py +0 -239
- mojo/serializers/advanced/formats/response.py +0 -485
- mojo/serializers/advanced/serializer.py +0 -568
- mojo/serializers/optimized.py +0 -618
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
- /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
- /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
- /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
- /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
- /mojo/{serializers → rest}/openapi.py +0 -0
- /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
- /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
- /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
|
+
]
|
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
|