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,66 @@
|
|
1
|
+
from django.db import models
|
2
|
+
from mojo.models import MojoModel
|
3
|
+
|
4
|
+
|
5
|
+
class RegisteredDevice(models.Model, MojoModel):
|
6
|
+
"""
|
7
|
+
Represents a device explicitly registered for push notifications via REST API.
|
8
|
+
Separate from UserDevice which tracks browser sessions via duid/user-agent.
|
9
|
+
"""
|
10
|
+
created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
|
11
|
+
modified = models.DateTimeField(auto_now=True, db_index=True)
|
12
|
+
|
13
|
+
user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='registered_devices')
|
14
|
+
|
15
|
+
# Device identification
|
16
|
+
device_token = models.TextField(db_index=True, help_text="Push token from platform")
|
17
|
+
device_id = models.CharField(max_length=255, db_index=True, help_text="App-provided device ID")
|
18
|
+
platform = models.CharField(max_length=20, choices=[
|
19
|
+
('ios', 'iOS'),
|
20
|
+
('android', 'Android'),
|
21
|
+
('web', 'Web')
|
22
|
+
], db_index=True)
|
23
|
+
|
24
|
+
# Device info
|
25
|
+
app_version = models.CharField(max_length=50, blank=True)
|
26
|
+
os_version = models.CharField(max_length=50, blank=True)
|
27
|
+
device_name = models.CharField(max_length=100, blank=True)
|
28
|
+
|
29
|
+
# Push preferences
|
30
|
+
push_enabled = models.BooleanField(default=True, db_index=True)
|
31
|
+
push_preferences = models.JSONField(default=dict, blank=True,
|
32
|
+
help_text="Category-based notification preferences")
|
33
|
+
|
34
|
+
# Status tracking
|
35
|
+
is_active = models.BooleanField(default=True, db_index=True)
|
36
|
+
last_seen = models.DateTimeField(auto_now=True)
|
37
|
+
|
38
|
+
class Meta:
|
39
|
+
unique_together = [('user', 'device_id'), ('device_token', 'platform')]
|
40
|
+
ordering = ['-last_seen']
|
41
|
+
|
42
|
+
class RestMeta:
|
43
|
+
VIEW_PERMS = ["view_devices", "manage_devices", "owner", "manage_users"]
|
44
|
+
SAVE_PERMS = ["manage_devices", "owner"]
|
45
|
+
SEARCH_FIELDS = ["device_name", "device_id"]
|
46
|
+
LIST_DEFAULT_FILTERS = {"is_active": True}
|
47
|
+
GRAPHS = {
|
48
|
+
"basic": {
|
49
|
+
"fields": ["id", "device_id", "platform", "device_name", "push_enabled", "last_seen"]
|
50
|
+
},
|
51
|
+
"default": {
|
52
|
+
"fields": ["id", "device_id", "platform", "device_name", "app_version",
|
53
|
+
"os_version", "push_enabled", "push_preferences", "last_seen"],
|
54
|
+
"graphs": {
|
55
|
+
"user": "basic"
|
56
|
+
}
|
57
|
+
},
|
58
|
+
"full": {
|
59
|
+
"graphs": {
|
60
|
+
"user": "default"
|
61
|
+
}
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
def __str__(self):
|
66
|
+
return f"{self.device_name or self.device_id} ({self.platform}) - {self.user.username}"
|
@@ -0,0 +1,99 @@
|
|
1
|
+
from django.db import models
|
2
|
+
from mojo.models import MojoModel
|
3
|
+
|
4
|
+
|
5
|
+
class NotificationTemplate(models.Model, MojoModel):
|
6
|
+
"""
|
7
|
+
Reusable notification templates with variable substitution support.
|
8
|
+
"""
|
9
|
+
created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
|
10
|
+
modified = models.DateTimeField(auto_now=True, db_index=True)
|
11
|
+
|
12
|
+
group = models.ForeignKey("account.Group", on_delete=models.CASCADE,
|
13
|
+
related_name="notification_templates", null=True, blank=True,
|
14
|
+
help_text="Organization for this template. Null = system template")
|
15
|
+
|
16
|
+
name = models.CharField(max_length=100, db_index=True)
|
17
|
+
title_template = models.CharField(max_length=200, blank=True, null=True)
|
18
|
+
body_template = models.TextField(blank=True, null=True)
|
19
|
+
action_url = models.URLField(blank=True, null=True, help_text="Template URL with variable support")
|
20
|
+
data_template = models.JSONField(default=dict, blank=True,
|
21
|
+
help_text="Template data payload with variable support")
|
22
|
+
|
23
|
+
# Delivery preferences
|
24
|
+
category = models.CharField(max_length=50, default="general", db_index=True)
|
25
|
+
priority = models.CharField(max_length=20, choices=[
|
26
|
+
('low', 'Low'),
|
27
|
+
('normal', 'Normal'),
|
28
|
+
('high', 'High')
|
29
|
+
], default='normal', db_index=True)
|
30
|
+
|
31
|
+
# Template variables documentation
|
32
|
+
variables = models.JSONField(default=dict, blank=True,
|
33
|
+
help_text="Expected template variables and descriptions for title, body, action_url, and data_template")
|
34
|
+
|
35
|
+
is_active = models.BooleanField(default=True, db_index=True)
|
36
|
+
|
37
|
+
class Meta:
|
38
|
+
ordering = ['group__name', 'name']
|
39
|
+
unique_together = [('group', 'name')]
|
40
|
+
|
41
|
+
class RestMeta:
|
42
|
+
VIEW_PERMS = ["manage_notifications", "manage_groups", "owner", "manage_users"]
|
43
|
+
SAVE_PERMS = ["manage_notifications", "manage_groups"]
|
44
|
+
SEARCH_FIELDS = ["name", "category"]
|
45
|
+
LIST_DEFAULT_FILTERS = {"is_active": True}
|
46
|
+
GRAPHS = {
|
47
|
+
"basic": {
|
48
|
+
"fields": ["id", "name", "category", "priority", "is_active"]
|
49
|
+
},
|
50
|
+
"default": {
|
51
|
+
"fields": ["id", "name", "title_template", "body_template", "action_url",
|
52
|
+
"data_template", "category", "priority", "variables", "is_active"],
|
53
|
+
"graphs": {
|
54
|
+
"group": "basic"
|
55
|
+
}
|
56
|
+
},
|
57
|
+
"full": {
|
58
|
+
"graphs": {
|
59
|
+
"group": "default"
|
60
|
+
}
|
61
|
+
}
|
62
|
+
}
|
63
|
+
|
64
|
+
def __str__(self):
|
65
|
+
org = self.group.name if self.group else "System"
|
66
|
+
return f"{self.name} ({org})"
|
67
|
+
|
68
|
+
def clean(self):
|
69
|
+
"""Validate that at least one template field is provided."""
|
70
|
+
from django.core.exceptions import ValidationError
|
71
|
+
|
72
|
+
has_title = self.title_template and self.title_template.strip()
|
73
|
+
has_body = self.body_template and self.body_template.strip()
|
74
|
+
has_data = self.data_template and bool(self.data_template)
|
75
|
+
|
76
|
+
if not (has_title or has_body or has_data):
|
77
|
+
raise ValidationError(
|
78
|
+
"Template must have at least one of: title_template, body_template, or data_template"
|
79
|
+
)
|
80
|
+
|
81
|
+
def render(self, context):
|
82
|
+
"""
|
83
|
+
Render template with provided context variables.
|
84
|
+
Returns tuple of (title, body, action_url, data)
|
85
|
+
"""
|
86
|
+
title = self.title_template.format(**context) if self.title_template else None
|
87
|
+
body = self.body_template.format(**context) if self.body_template else None
|
88
|
+
action_url = self.action_url.format(**context) if self.action_url else None
|
89
|
+
|
90
|
+
# Render data template with context
|
91
|
+
data = {}
|
92
|
+
if self.data_template:
|
93
|
+
for key, value in self.data_template.items():
|
94
|
+
if isinstance(value, str):
|
95
|
+
data[key] = value.format(**context)
|
96
|
+
else:
|
97
|
+
data[key] = value
|
98
|
+
|
99
|
+
return title, body, action_url, data
|
mojo/apps/account/models/user.py
CHANGED
@@ -5,10 +5,31 @@ from mojo.helpers.settings import settings
|
|
5
5
|
from mojo import errors as merrors
|
6
6
|
from mojo.helpers import dates
|
7
7
|
from mojo.apps.account.utils.jwtoken import JWToken
|
8
|
+
from mojo.apps import metrics
|
9
|
+
from .device import UserDevice
|
8
10
|
import uuid
|
9
11
|
|
12
|
+
SYS_USER_PERMS_PROTECTION = {
|
13
|
+
"manage_users": "manage_users",
|
14
|
+
"manage_groups": "manage_users",
|
15
|
+
"view_logs": "manage_users",
|
16
|
+
"view_incidents": "manage_users",
|
17
|
+
"view_admin": "manage_users",
|
18
|
+
"view_taskqueue": "manage_users",
|
19
|
+
"view_global": "manage_users",
|
20
|
+
"manage_notifications": "manage_users",
|
21
|
+
"manage_files": "manage_users",
|
22
|
+
"force_single_session": "manage_users",
|
23
|
+
"file_vault": "manage_users",
|
24
|
+
"manage_aws": "manage_users"
|
25
|
+
}
|
26
|
+
|
10
27
|
USER_PERMS_PROTECTION = settings.get("USER_PERMS_PROTECTION", {})
|
28
|
+
USER_PERMS_PROTECTION.update(SYS_USER_PERMS_PROTECTION)
|
29
|
+
|
11
30
|
USER_LAST_ACTIVITY_FREQ = settings.get("USER_LAST_ACTIVITY_FREQ", 300)
|
31
|
+
METRICS_TIMEZONE = settings.get("METRICS_TIMEZONE", "America/Los_Angeles")
|
32
|
+
METRICS_TRACK_USER_ACTIVITY = settings.get("METRICS_TRACK_USER_ACTIVITY", False)
|
12
33
|
|
13
34
|
class CustomUserManager(BaseUserManager):
|
14
35
|
def create_user(self, email, password=None, **extra_fields):
|
@@ -43,6 +64,11 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
|
|
43
64
|
phone_number = models.CharField(max_length=32, blank=True, null=True, default=None)
|
44
65
|
is_active = models.BooleanField(default=True, db_index=True)
|
45
66
|
display_name = models.CharField(max_length=80, blank=True, null=True, default=None)
|
67
|
+
|
68
|
+
# Organization relationship for push config resolution
|
69
|
+
org = models.ForeignKey("account.Group", on_delete=models.SET_NULL,
|
70
|
+
null=True, blank=True, related_name="org_users",
|
71
|
+
help_text="Default organization for this user")
|
46
72
|
# key used for sessions and general authentication algs
|
47
73
|
auth_key = models.TextField(null=True, default=None)
|
48
74
|
onetime_code = models.TextField(null=True, default=None)
|
@@ -62,10 +88,15 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
|
|
62
88
|
is_email_verified = models.BooleanField(default=False)
|
63
89
|
is_phone_verified = models.BooleanField(default=False)
|
64
90
|
|
91
|
+
avatar = models.ForeignKey('fileman.File', on_delete=models.SET_NULL,
|
92
|
+
null=True, blank=True, related_name='+')
|
93
|
+
|
65
94
|
USERNAME_FIELD = 'username'
|
66
95
|
objects = CustomUserManager()
|
67
96
|
|
68
97
|
class RestMeta:
|
98
|
+
LOG_CHANGES = True
|
99
|
+
POST_SAVE_ACTIONS = ['send_invite']
|
69
100
|
NO_SHOW_FIELDS = ["password", "auth_key", "onetime_code"]
|
70
101
|
SEARCH_FIELDS = ["username", "email", "display_name"]
|
71
102
|
VIEW_PERMS = ["view_users", "manage_users", "owner"]
|
@@ -80,12 +111,13 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
|
|
80
111
|
'id',
|
81
112
|
'display_name',
|
82
113
|
'username',
|
83
|
-
'email',
|
84
|
-
'phone_number',
|
85
114
|
'last_login',
|
86
115
|
'last_activity',
|
87
116
|
'is_active'
|
88
|
-
]
|
117
|
+
],
|
118
|
+
"graphs": {
|
119
|
+
"avatar": "basic"
|
120
|
+
}
|
89
121
|
},
|
90
122
|
"default": {
|
91
123
|
"fields": [
|
@@ -100,7 +132,16 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
|
|
100
132
|
'metadata',
|
101
133
|
'is_active'
|
102
134
|
],
|
135
|
+
"graphs": {
|
136
|
+
"avatar": "basic",
|
137
|
+
"org": "basic"
|
138
|
+
}
|
103
139
|
},
|
140
|
+
"full": {
|
141
|
+
"graphs": {
|
142
|
+
"avatar": "basic"
|
143
|
+
}
|
144
|
+
}
|
104
145
|
}
|
105
146
|
|
106
147
|
def __str__(self):
|
@@ -115,9 +156,17 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
|
|
115
156
|
|
116
157
|
def touch(self):
|
117
158
|
# can't subtract offset-naive and offset-aware datetimes
|
159
|
+
if self.last_activity and not dates.is_today(self.last_activity, METRICS_TIMEZONE):
|
160
|
+
metrics.record("user_activity_day", category="user", min_granularity="days")
|
118
161
|
if self.last_activity is None or dates.has_time_elsapsed(self.last_activity, seconds=USER_LAST_ACTIVITY_FREQ):
|
119
162
|
self.last_activity = dates.utcnow()
|
120
163
|
self.atomic_save()
|
164
|
+
if METRICS_TRACK_USER_ACTIVITY:
|
165
|
+
metrics.record(f"user_activity:{self.pk}", category="user", min_granularity="minutes")
|
166
|
+
|
167
|
+
def track(self, request):
|
168
|
+
self.touch()
|
169
|
+
UserDevice.track(request)
|
121
170
|
|
122
171
|
def get_auth_key(self):
|
123
172
|
if self.auth_key is None:
|
@@ -125,19 +174,24 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
|
|
125
174
|
self.atomic_save()
|
126
175
|
return self.auth_key
|
127
176
|
|
128
|
-
def
|
177
|
+
def set_username(self, value):
|
178
|
+
if not isinstance(value, str):
|
179
|
+
raise ValueError("Username must be a string")
|
180
|
+
self.username = value
|
181
|
+
|
182
|
+
def set_permissions(self, value):
|
129
183
|
if not isinstance(value, dict):
|
130
184
|
return
|
131
185
|
for key in value:
|
132
186
|
if key in USER_PERMS_PROTECTION:
|
133
|
-
if not
|
187
|
+
if not self.active_user.has_permission(USER_PERMS_PROTECTION[key]):
|
134
188
|
raise merrors.PermissionDeniedException()
|
135
|
-
elif not
|
189
|
+
elif not self.active_user.has_permission("manage_users"):
|
136
190
|
raise merrors.PermissionDeniedException()
|
137
191
|
if bool(value[key]):
|
138
|
-
self.add_permission(key)
|
192
|
+
self.add_permission(key, commit=False)
|
139
193
|
else:
|
140
|
-
self.remove_permission(key)
|
194
|
+
self.remove_permission(key, commit=False)
|
141
195
|
|
142
196
|
def has_module_perms(self, app_label):
|
143
197
|
"""Check if user has any permissions in a given app."""
|
@@ -145,7 +199,7 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
|
|
145
199
|
|
146
200
|
def has_permission(self, perm_key):
|
147
201
|
"""Check if user has a specific permission in JSON field."""
|
148
|
-
if isinstance(perm_key, list):
|
202
|
+
if isinstance(perm_key, (list, set)):
|
149
203
|
for pk in perm_key:
|
150
204
|
if self.has_permission(pk):
|
151
205
|
return True
|
@@ -154,25 +208,39 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
|
|
154
208
|
return True
|
155
209
|
return self.permissions.get(perm_key, False)
|
156
210
|
|
157
|
-
def add_permission(self, perm_key, value=True):
|
211
|
+
def add_permission(self, perm_key, value=True, commit=True):
|
158
212
|
"""Dynamically add a permission."""
|
213
|
+
changed = False
|
159
214
|
if isinstance(perm_key, (list, set)):
|
160
215
|
for pk in perm_key:
|
161
|
-
self.permissions
|
216
|
+
if self.permissions.get(pk) != value:
|
217
|
+
self.permissions[pk] = value
|
218
|
+
changed = True
|
162
219
|
else:
|
163
|
-
self.permissions
|
164
|
-
|
220
|
+
if self.permissions.get(perm_key) != value:
|
221
|
+
self.permissions[perm_key] = value
|
222
|
+
changed = True
|
223
|
+
if changed:
|
224
|
+
self.log(f"Added permission {perm_key}", "permission:added")
|
225
|
+
if commit and changed:
|
226
|
+
self.save()
|
165
227
|
|
166
|
-
def remove_permission(self, perm_key):
|
228
|
+
def remove_permission(self, perm_key, commit=True):
|
167
229
|
"""Remove a permission."""
|
230
|
+
changed = False
|
168
231
|
if isinstance(perm_key, (list, set)):
|
169
232
|
for pk in perm_key:
|
170
233
|
if pk in self.permissions:
|
171
234
|
del self.permissions[pk]
|
235
|
+
changed = True
|
172
236
|
else:
|
173
237
|
if perm_key in self.permissions:
|
174
238
|
del self.permissions[perm_key]
|
175
|
-
|
239
|
+
changed = True
|
240
|
+
if changed:
|
241
|
+
self.log(f"Removed permission {perm_key}", "permission:removed")
|
242
|
+
if commit and changed:
|
243
|
+
self.save()
|
176
244
|
|
177
245
|
def remove_all_permissions(self):
|
178
246
|
self.permissions = {}
|
@@ -182,18 +250,300 @@ class User(MojoSecrets, AbstractBaseUser, MojoModel):
|
|
182
250
|
self.set_password(value)
|
183
251
|
self.save()
|
184
252
|
|
185
|
-
def
|
253
|
+
def validate_email(self):
|
254
|
+
import re
|
255
|
+
if not self.email:
|
256
|
+
raise merrors.ValueException("Email is required")
|
257
|
+
if not re.match(r"[^@]+@[^@]+\.[^@]+", str(self.email)):
|
258
|
+
raise merrors.ValueException("Invalid email format")
|
259
|
+
return True
|
260
|
+
|
261
|
+
def validate_username(self):
|
186
262
|
if not self.username:
|
187
|
-
|
263
|
+
raise merrors.ValueException("Username is required")
|
264
|
+
if len(str(self.username)) <= 2:
|
265
|
+
raise merrors.ValueException("Username must be more than 2 characters")
|
266
|
+
# Check for special characters (only allow alphanumeric, underscore, dot, and @)
|
267
|
+
import re
|
268
|
+
if not re.match(r'^[a-zA-Z0-9_.@]+$', str(self.username)):
|
269
|
+
raise merrors.ValueException("Username can only contain letters, numbers, underscores, dots, and @")
|
270
|
+
# If username contains @, it must match the email field
|
271
|
+
if '@' in str(self.username) and str(self.username) != str(self.email):
|
272
|
+
raise merrors.ValueException("Username containing @ must match the email address")
|
273
|
+
return True
|
274
|
+
|
275
|
+
def set_new_password(self, new_password):
|
276
|
+
self.debug("SET NEW PASSWORD")
|
277
|
+
# Validate password strength
|
278
|
+
if len(new_password) < 8:
|
279
|
+
raise merrors.ValueException("Password must be at least 8 characters long")
|
280
|
+
|
281
|
+
strength_score = 0
|
282
|
+
|
283
|
+
# Length contributes to strength (longer is better)
|
284
|
+
if len(new_password) >= 12:
|
285
|
+
strength_score += 2
|
286
|
+
elif len(new_password) >= 10:
|
287
|
+
strength_score += 1
|
288
|
+
|
289
|
+
# Check for mixed case
|
290
|
+
has_upper = any(c.isupper() for c in new_password)
|
291
|
+
has_lower = any(c.islower() for c in new_password)
|
292
|
+
if has_upper and has_lower:
|
293
|
+
strength_score += 1
|
294
|
+
|
295
|
+
# Check for numbers
|
296
|
+
has_numbers = any(c.isdigit() for c in new_password)
|
297
|
+
if has_numbers:
|
298
|
+
strength_score += 1
|
299
|
+
|
300
|
+
# Check for special characters
|
301
|
+
import re
|
302
|
+
has_special = bool(re.search(r'[!@#$%^&*(),.?":{}|<>]', new_password))
|
303
|
+
if has_special:
|
304
|
+
strength_score += 1
|
305
|
+
|
306
|
+
# Require minimum strength score
|
307
|
+
if strength_score < 2:
|
308
|
+
raise merrors.ValueException("Password is too weak. Use a longer password or include a mix of uppercase, lowercase, numbers, and special characters")
|
309
|
+
|
310
|
+
self.set_password(new_password)
|
311
|
+
self._set_field_change("new_password", "*", "*********")
|
312
|
+
|
313
|
+
def can_change_password(self):
|
314
|
+
if self.pk == self.active_user.pk:
|
315
|
+
return True
|
316
|
+
if self.active_user.is_superuser:
|
317
|
+
return True
|
318
|
+
if self.active_user.has_permission(["manage_users"]):
|
319
|
+
return True
|
320
|
+
return False
|
321
|
+
|
322
|
+
def generate_username_from_email(self):
|
323
|
+
"""Generate a username from email, falling back to email if username exists."""
|
324
|
+
if not self.email:
|
325
|
+
raise merrors.ValueException("Email is required to generate username")
|
326
|
+
|
327
|
+
# Try using the part before @ as username
|
328
|
+
potential_username = self.email.split("@")[0].lower()
|
329
|
+
|
330
|
+
# Check if this username already exists
|
331
|
+
qset = User.objects.filter(username=potential_username)
|
332
|
+
if self.pk is not None:
|
333
|
+
qset = qset.exclude(pk=self.pk)
|
334
|
+
|
335
|
+
# If username doesn't exist, use it
|
336
|
+
if not qset.exists():
|
337
|
+
return potential_username
|
338
|
+
|
339
|
+
# Fall back to using the full email as username
|
340
|
+
return self.email.lower()
|
341
|
+
|
342
|
+
def generate_display_name(self):
|
343
|
+
"""Generate a display name from email, falling back to email if username exists."""
|
344
|
+
# Try using the part before @ as display name
|
345
|
+
# generate display name from usernames like "bob.smith", "bob_smith", "bob.smith@example.com"
|
346
|
+
# Extract the base part (before @ if email format)
|
347
|
+
base_username = self.username.split("@")[0] if "@" in self.username else self.username
|
348
|
+
# Replace underscores and dots with spaces, then title case
|
349
|
+
return base_username.replace("_", " ").replace(".", " ").title()
|
350
|
+
|
351
|
+
def on_rest_pre_save(self, changed_fields, created):
|
352
|
+
creds_changed = False
|
353
|
+
if "email" in changed_fields:
|
354
|
+
creds_changed = True
|
355
|
+
self.validate_email()
|
356
|
+
self.email = self.email.lower()
|
357
|
+
if not self.username:
|
358
|
+
self.username = self.generate_username_from_email()
|
359
|
+
elif "@" in self.username and self.username != self.email:
|
360
|
+
self.username = self.email
|
361
|
+
qset = User.objects.filter(email=self.email)
|
362
|
+
if self.pk is not None:
|
363
|
+
qset = qset.exclude(pk=self.pk)
|
364
|
+
if qset.exists():
|
365
|
+
raise merrors.ValueException("Email already exists")
|
366
|
+
if "username" in changed_fields:
|
367
|
+
creds_changed = True
|
368
|
+
self.validate_username()
|
369
|
+
self.username = self.username.lower()
|
370
|
+
qset = User.objects.filter(username=self.username)
|
371
|
+
if self.pk is not None:
|
372
|
+
qset = qset.exclude(pk=self.pk)
|
373
|
+
if qset.exists():
|
374
|
+
raise merrors.ValueException("Username already exists")
|
188
375
|
if not self.display_name:
|
189
|
-
self.display_name = self.
|
190
|
-
|
376
|
+
self.display_name = self.generate_display_name()
|
377
|
+
if self.pk is not None:
|
378
|
+
self._handle_new_user_pre_save(creds_changed, changed_fields)
|
379
|
+
|
380
|
+
def _handle_new_user_pre_save(self, creds_changed, changed_fields):
|
381
|
+
# only super user can change email or username
|
382
|
+
if creds_changed and not self.active_user.is_superuser:
|
383
|
+
raise merrors.PermissionDeniedException("You are not allowed to change email or username")
|
384
|
+
if "password" in changed_fields:
|
385
|
+
raise merrors.PermissionDeniedException("You are not allowed to change password")
|
386
|
+
if "new_password" in changed_fields:
|
387
|
+
if not self.can_change_password():
|
388
|
+
raise merrors.PermissionDeniedException("You are not allowed to change password")
|
389
|
+
self.debug("CHANGING PASSWORD")
|
390
|
+
self.log("****", kind="password:changed")
|
391
|
+
if "email" in changed_fields:
|
392
|
+
self.log(kind="email:changed", log=f"{changed_fields['email']} to {self.email}")
|
393
|
+
if "username" in changed_fields:
|
394
|
+
self.log(kind="username:changed", log=f"{changed_fields['username']} to {self.username}")
|
395
|
+
if "is_active" in changed_fields:
|
396
|
+
if not self.is_active:
|
397
|
+
metrics.record("user_deactivated", category="user", min_granularity="hours")
|
398
|
+
metrics.set_value("total_users", User.objects.filter(is_active=True).count(), account="global")
|
191
399
|
|
192
400
|
def check_edit_permission(self, perms, request):
|
193
401
|
if "owner" in perms and self.is_request_user():
|
194
402
|
return True
|
195
403
|
return request.user.has_permission(perms)
|
196
404
|
|
405
|
+
def on_action_send_invite(self, value):
|
406
|
+
self.send_invite()
|
407
|
+
|
408
|
+
def push_notification(self, title=None, body=None, data=None,
|
409
|
+
category="general", action_url=None,
|
410
|
+
devices=None, user_ids=None, delay=None):
|
411
|
+
from mojo.apps.account.services.push import send_direct_notification
|
412
|
+
send_direct_notification(
|
413
|
+
self, title=title, body=body, data=data, category=category,
|
414
|
+
action_url=action_url,
|
415
|
+
devices=devices, user_ids=user_ids, delay=delay)
|
416
|
+
|
417
|
+
def send_invite(self):
|
418
|
+
self.send_template_email(
|
419
|
+
template_name="invite",
|
420
|
+
context={"user": self.to_dict("basic")}
|
421
|
+
)
|
422
|
+
|
423
|
+
def send_email(
|
424
|
+
self,
|
425
|
+
subject=None,
|
426
|
+
body_text=None,
|
427
|
+
body_html=None,
|
428
|
+
cc=None,
|
429
|
+
bcc=None,
|
430
|
+
reply_to=None,
|
431
|
+
**kwargs
|
432
|
+
):
|
433
|
+
"""Send email to this user using mailbox determined by user's org domain or system default
|
434
|
+
|
435
|
+
Args:
|
436
|
+
subject: Email subject
|
437
|
+
body_text: Optional plain text body
|
438
|
+
body_html: Optional HTML body
|
439
|
+
cc, bcc, reply_to: Optional addressing
|
440
|
+
**kwargs: Additional arguments passed to mailbox.send_email()
|
441
|
+
|
442
|
+
Returns:
|
443
|
+
SentMessage instance
|
444
|
+
|
445
|
+
Raises:
|
446
|
+
ValueError: If no mailbox can be found
|
447
|
+
"""
|
448
|
+
from mojo.apps.aws.models import Mailbox
|
449
|
+
|
450
|
+
mailbox = None
|
451
|
+
|
452
|
+
# Try to get mailbox from org domain
|
453
|
+
if self.org and hasattr(self.org, 'metadata'):
|
454
|
+
domain = self.org.metadata.get("domain")
|
455
|
+
if domain:
|
456
|
+
# Try domain default first
|
457
|
+
mailbox = Mailbox.get_domain_default(domain)
|
458
|
+
if not mailbox:
|
459
|
+
# Try any mailbox from that domain
|
460
|
+
mailbox = Mailbox.objects.filter(
|
461
|
+
domain__name__iexact=domain,
|
462
|
+
allow_outbound=True
|
463
|
+
).first()
|
464
|
+
|
465
|
+
# Fall back to system default
|
466
|
+
if not mailbox:
|
467
|
+
mailbox = Mailbox.get_system_default()
|
468
|
+
|
469
|
+
if not mailbox:
|
470
|
+
raise ValueError("No mailbox available for sending email. Please configure a system default mailbox.")
|
471
|
+
|
472
|
+
return mailbox.send_email(
|
473
|
+
to=self.email,
|
474
|
+
subject=subject,
|
475
|
+
body_text=body_text,
|
476
|
+
body_html=body_html,
|
477
|
+
cc=cc,
|
478
|
+
bcc=bcc,
|
479
|
+
reply_to=reply_to,
|
480
|
+
**kwargs
|
481
|
+
)
|
482
|
+
|
483
|
+
def send_template_email(
|
484
|
+
self,
|
485
|
+
template_name,
|
486
|
+
context=None,
|
487
|
+
cc=None,
|
488
|
+
bcc=None,
|
489
|
+
reply_to=None,
|
490
|
+
**kwargs
|
491
|
+
):
|
492
|
+
"""Send template email to this user using mailbox determined by user's org domain or system default
|
493
|
+
|
494
|
+
Args:
|
495
|
+
template_name: Name of the EmailTemplate in database
|
496
|
+
context: Template context variables (user will be added automatically)
|
497
|
+
cc, bcc, reply_to: Optional addressing
|
498
|
+
**kwargs: Additional arguments passed to mailbox.send_template_email()
|
499
|
+
|
500
|
+
Returns:
|
501
|
+
SentMessage instance
|
502
|
+
|
503
|
+
Raises:
|
504
|
+
ValueError: If no mailbox can be found or template not found
|
505
|
+
"""
|
506
|
+
from mojo.apps.aws.models import Mailbox
|
507
|
+
|
508
|
+
mailbox = None
|
509
|
+
|
510
|
+
# Try to get mailbox from org domain
|
511
|
+
if self.org and hasattr(self.org, 'metadata'):
|
512
|
+
domain = self.org.metadata.get("domain")
|
513
|
+
if domain:
|
514
|
+
# Try domain default first
|
515
|
+
mailbox = Mailbox.get_domain_default(domain)
|
516
|
+
if not mailbox:
|
517
|
+
# Try any mailbox from that domain
|
518
|
+
mailbox = Mailbox.objects.filter(
|
519
|
+
domain__name__iexact=domain,
|
520
|
+
allow_outbound=True
|
521
|
+
).first()
|
522
|
+
|
523
|
+
# Fall back to system default
|
524
|
+
if not mailbox:
|
525
|
+
mailbox = Mailbox.get_system_default()
|
526
|
+
|
527
|
+
if not mailbox:
|
528
|
+
raise ValueError("No mailbox available for sending email. Please configure a system default mailbox.")
|
529
|
+
|
530
|
+
# Add user to context if not already present
|
531
|
+
if context is None:
|
532
|
+
context = {}
|
533
|
+
if 'user' not in context:
|
534
|
+
context['user'] = self.to_dict("basic")
|
535
|
+
|
536
|
+
return mailbox.send_template_email(
|
537
|
+
to=self.email,
|
538
|
+
template_name=template_name,
|
539
|
+
context=context,
|
540
|
+
cc=cc,
|
541
|
+
bcc=bcc,
|
542
|
+
reply_to=reply_to,
|
543
|
+
allow_unverified=True,
|
544
|
+
**kwargs
|
545
|
+
)
|
546
|
+
|
197
547
|
@classmethod
|
198
548
|
def validate_jwt(cls, token):
|
199
549
|
token_manager = JWToken()
|