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
@@ -1,7 +1,11 @@
|
|
1
1
|
from django.db import models
|
2
2
|
from mojo.models import MojoModel, MojoSecrets
|
3
|
-
from mojo.helpers import dates
|
3
|
+
from mojo.helpers import dates, logit
|
4
|
+
from mojo.apps import metrics
|
5
|
+
from mojo.helpers.settings import settings
|
4
6
|
|
7
|
+
GROUP_LAST_ACTIVITY_FREQ = settings.get("GROUP_LAST_ACTIVITY_FREQ", 300)
|
8
|
+
METRICS_TIMEZONE = settings.get("METRICS_TIMEZONE", "America/Los_Angeles")
|
5
9
|
|
6
10
|
|
7
11
|
class Group(MojoSecrets, MojoModel):
|
@@ -10,6 +14,7 @@ class Group(MojoSecrets, MojoModel):
|
|
10
14
|
"""
|
11
15
|
created = models.DateTimeField(auto_now_add=True, editable=False)
|
12
16
|
modified = models.DateTimeField(auto_now=True, db_index=True)
|
17
|
+
last_activity = models.DateTimeField(default=None, null=True, db_index=True)
|
13
18
|
|
14
19
|
name = models.CharField(max_length=200)
|
15
20
|
uuid = models.CharField(max_length=200, null=True, default=None, db_index=True)
|
@@ -22,7 +27,11 @@ class Group(MojoSecrets, MojoModel):
|
|
22
27
|
# JSON-based metadata field
|
23
28
|
metadata = models.JSONField(default=dict, blank=True)
|
24
29
|
|
30
|
+
avatar = models.ForeignKey('fileman.File', on_delete=models.SET_NULL,
|
31
|
+
null=True, blank=True, related_name='+')
|
32
|
+
|
25
33
|
class RestMeta:
|
34
|
+
LOG_CHANGES = True
|
26
35
|
SEARCH_FIELDS = ["name"]
|
27
36
|
VIEW_PERMS = ["view_groups", "manage_groups"]
|
28
37
|
SAVE_PERMS = ["manage_groups"]
|
@@ -36,9 +45,13 @@ class Group(MojoSecrets, MojoModel):
|
|
36
45
|
'name',
|
37
46
|
'created',
|
38
47
|
'modified',
|
48
|
+
'last_activity',
|
39
49
|
'is_active',
|
40
50
|
'kind',
|
41
|
-
]
|
51
|
+
],
|
52
|
+
"graphs": {
|
53
|
+
"avatar": "basic"
|
54
|
+
}
|
42
55
|
},
|
43
56
|
"default": {
|
44
57
|
"fields": [
|
@@ -46,15 +59,33 @@ class Group(MojoSecrets, MojoModel):
|
|
46
59
|
'name',
|
47
60
|
'created',
|
48
61
|
'modified',
|
62
|
+
'last_activity',
|
49
63
|
'is_active',
|
50
64
|
'kind',
|
51
65
|
'parent',
|
52
66
|
'metadata'
|
53
|
-
]
|
67
|
+
],
|
68
|
+
"graphs": {
|
69
|
+
"avatar": "basic",
|
70
|
+
"parent": "basic"
|
71
|
+
}
|
54
72
|
},
|
55
|
-
|
56
|
-
|
57
|
-
|
73
|
+
|
74
|
+
}
|
75
|
+
FORMATS = {
|
76
|
+
"csv": [
|
77
|
+
"id",
|
78
|
+
"uuid",
|
79
|
+
"name",
|
80
|
+
"created",
|
81
|
+
"modified",
|
82
|
+
"last_activity",
|
83
|
+
"is_active",
|
84
|
+
"kind",
|
85
|
+
"parent.id",
|
86
|
+
"parent.name",
|
87
|
+
("metadata.timezone", "timezone")
|
88
|
+
]
|
58
89
|
}
|
59
90
|
|
60
91
|
@property
|
@@ -68,31 +99,304 @@ class Group(MojoSecrets, MojoModel):
|
|
68
99
|
return dates.get_local_time(self.timezone, dt_utc)
|
69
100
|
|
70
101
|
def __str__(self):
|
71
|
-
return self.name
|
102
|
+
return str(self.name)
|
72
103
|
|
73
|
-
def
|
74
|
-
from mojo.account.models.member import GroupMember
|
75
|
-
return GroupMember.objects.filter(user=user).last()
|
76
|
-
|
77
|
-
def member_has_permission(self, user, perms, check_user=True):
|
104
|
+
def user_has_permission(self, user, perms, check_user=True):
|
78
105
|
if check_user and user.has_permission(perms):
|
79
106
|
return True
|
80
|
-
ms = self.
|
107
|
+
ms = self.get_member_for_user(user)
|
81
108
|
if ms is not None:
|
82
109
|
return ms.has_permission(perms)
|
83
110
|
return False
|
84
111
|
|
112
|
+
def touch(self):
|
113
|
+
# can't subtract offset-naive and offset-aware datetimes
|
114
|
+
if self.last_activity and not dates.is_today(self.last_activity, METRICS_TIMEZONE):
|
115
|
+
metrics.record("group_activity_day", category="group", min_granularity="days")
|
116
|
+
if self.last_activity is None or dates.has_time_elsapsed(self.last_activity, seconds=GROUP_LAST_ACTIVITY_FREQ):
|
117
|
+
self.last_activity = dates.utcnow()
|
118
|
+
self.atomic_save()
|
119
|
+
|
85
120
|
def get_metadata(self):
|
86
121
|
# converts our local metadata into an objict
|
87
122
|
self.metadata = self.jsonfield_as_objict("metadata")
|
88
123
|
return self.metadata
|
89
124
|
|
125
|
+
def add_member(self, user):
|
126
|
+
member, created = self.members.get_or_create(user=user)
|
127
|
+
return member
|
128
|
+
|
90
129
|
def get_member_for_user(self, user):
|
91
130
|
return self.members.filter(user=user).last()
|
92
131
|
|
132
|
+
def get_children(self, is_active=True, kind=None):
|
133
|
+
"""
|
134
|
+
Returns a QuerySet of all direct and indirect children of this group.
|
135
|
+
"""
|
136
|
+
child_ids = self._get_all_child_ids()
|
137
|
+
queryset = Group.objects.filter(id__in=child_ids)
|
138
|
+
|
139
|
+
if is_active is not None:
|
140
|
+
queryset = queryset.filter(is_active=is_active)
|
141
|
+
if kind:
|
142
|
+
queryset = queryset.filter(kind=kind)
|
143
|
+
|
144
|
+
return queryset
|
145
|
+
|
146
|
+
def _get_all_child_ids(self, collected_ids=None):
|
147
|
+
"""
|
148
|
+
Recursively collects the IDs of all children.
|
149
|
+
"""
|
150
|
+
if collected_ids is None:
|
151
|
+
collected_ids = set()
|
152
|
+
|
153
|
+
# Note: self.groups is the related_name from the parent ForeignKey
|
154
|
+
children = self.groups.all()
|
155
|
+
for child in children:
|
156
|
+
if child.id not in collected_ids:
|
157
|
+
collected_ids.add(child.id)
|
158
|
+
child._get_all_child_ids(collected_ids)
|
159
|
+
return list(collected_ids)
|
160
|
+
|
161
|
+
def get_parents(self, is_active=True, kind=None):
|
162
|
+
"""
|
163
|
+
Returns a QuerySet of all parents (ancestors) of this group.
|
164
|
+
"""
|
165
|
+
parent_ids = []
|
166
|
+
current = self.parent
|
167
|
+
while current:
|
168
|
+
parent_ids.append(current.id)
|
169
|
+
current = current.parent
|
170
|
+
|
171
|
+
queryset = Group.objects.filter(id__in=parent_ids)
|
172
|
+
|
173
|
+
if is_active is not None:
|
174
|
+
queryset = queryset.filter(is_active=is_active)
|
175
|
+
if kind:
|
176
|
+
queryset = queryset.filter(kind=kind)
|
177
|
+
|
178
|
+
return queryset
|
179
|
+
|
180
|
+
@property
|
181
|
+
def top_most_parent(self):
|
182
|
+
"""
|
183
|
+
Finds the top-most parent (root ancestor) of this group.
|
184
|
+
Returns self if the group has no parent.
|
185
|
+
"""
|
186
|
+
current = self
|
187
|
+
while current.parent:
|
188
|
+
current = current.parent
|
189
|
+
return current
|
190
|
+
|
191
|
+
def is_child_of(self, parent_group):
|
192
|
+
"""
|
193
|
+
Checks if this group is a descendant of the given parent_group.
|
194
|
+
"""
|
195
|
+
current = self.parent
|
196
|
+
while current:
|
197
|
+
if current.id == parent_group.id:
|
198
|
+
return True
|
199
|
+
current = current.parent
|
200
|
+
return False
|
201
|
+
|
202
|
+
def is_parent_of(self, child_group):
|
203
|
+
"""
|
204
|
+
Checks if this group is an ancestor of the given child_group.
|
205
|
+
"""
|
206
|
+
return child_group.is_child_of(self)
|
207
|
+
|
208
|
+
def invite(self, email, context=None):
|
209
|
+
"""
|
210
|
+
Invites a user to join the group.
|
211
|
+
"""
|
212
|
+
from mojo.apps.account.models import User
|
213
|
+
user = User.objects.filter(email=email).last()
|
214
|
+
ms = None
|
215
|
+
if context is None:
|
216
|
+
context = {}
|
217
|
+
context['group'] = self
|
218
|
+
if user:
|
219
|
+
ms = self.add_member(user)
|
220
|
+
elif not user:
|
221
|
+
user = User(is_active=True)
|
222
|
+
user.on_rest_pre_save(dict(email=None), True)
|
223
|
+
user.save()
|
224
|
+
ms = self.add_member(user)
|
225
|
+
try:
|
226
|
+
user.send_template_email('group_invite', context)
|
227
|
+
except Exception as e:
|
228
|
+
logit.error(f"Error sending email: {e}")
|
229
|
+
return ms
|
230
|
+
|
231
|
+
def push_notification(self, title=None, body=None, data=None, **kwargs):
|
232
|
+
from mojo.apps.account.services.push import send_direct_notification
|
233
|
+
for member in self.members.filter(is_active=True):
|
234
|
+
send_direct_notification(member.user, title=title, body=body, data=data, **kwargs)
|
235
|
+
|
236
|
+
def send_email(
|
237
|
+
self,
|
238
|
+
to,
|
239
|
+
subject=None,
|
240
|
+
body_text=None,
|
241
|
+
body_html=None,
|
242
|
+
cc=None,
|
243
|
+
bcc=None,
|
244
|
+
reply_to=None,
|
245
|
+
**kwargs
|
246
|
+
):
|
247
|
+
"""Send email using mailbox determined by group's domain or system default
|
248
|
+
|
249
|
+
Args:
|
250
|
+
to: One or more recipient addresses
|
251
|
+
subject: Email subject
|
252
|
+
body_text: Optional plain text body
|
253
|
+
body_html: Optional HTML body
|
254
|
+
cc, bcc, reply_to: Optional addressing
|
255
|
+
**kwargs: Additional arguments passed to mailbox.send_email()
|
256
|
+
|
257
|
+
Returns:
|
258
|
+
SentMessage instance
|
259
|
+
|
260
|
+
Raises:
|
261
|
+
ValueError: If no mailbox can be found
|
262
|
+
"""
|
263
|
+
from mojo.apps.aws.models import Mailbox
|
264
|
+
|
265
|
+
mailbox = None
|
266
|
+
domain = None
|
267
|
+
|
268
|
+
# Try to get domain from this group's metadata
|
269
|
+
if self.metadata:
|
270
|
+
domain = self.metadata.get("domain")
|
271
|
+
|
272
|
+
# If no domain, check top_most_parent's metadata
|
273
|
+
if not domain and self.top_most_parent != self:
|
274
|
+
parent_metadata = self.top_most_parent.metadata
|
275
|
+
if parent_metadata:
|
276
|
+
domain = parent_metadata.get("domain")
|
277
|
+
|
278
|
+
# Try to get mailbox from domain
|
279
|
+
if domain:
|
280
|
+
# Try domain default first
|
281
|
+
mailbox = Mailbox.get_domain_default(domain)
|
282
|
+
if not mailbox:
|
283
|
+
# Try any mailbox from that domain
|
284
|
+
mailbox = Mailbox.objects.filter(
|
285
|
+
domain__name__iexact=domain,
|
286
|
+
allow_outbound=True
|
287
|
+
).first()
|
288
|
+
|
289
|
+
# Fall back to system default
|
290
|
+
if not mailbox:
|
291
|
+
mailbox = Mailbox.get_system_default()
|
292
|
+
|
293
|
+
if not mailbox:
|
294
|
+
raise ValueError("No mailbox available for sending email. Please configure a system default mailbox.")
|
295
|
+
|
296
|
+
return mailbox.send_email(
|
297
|
+
to=to,
|
298
|
+
subject=subject,
|
299
|
+
body_text=body_text,
|
300
|
+
body_html=body_html,
|
301
|
+
cc=cc,
|
302
|
+
bcc=bcc,
|
303
|
+
reply_to=reply_to,
|
304
|
+
**kwargs
|
305
|
+
)
|
306
|
+
|
307
|
+
def send_template_email(
|
308
|
+
self,
|
309
|
+
to,
|
310
|
+
template_name,
|
311
|
+
context=None,
|
312
|
+
cc=None,
|
313
|
+
bcc=None,
|
314
|
+
reply_to=None,
|
315
|
+
**kwargs
|
316
|
+
):
|
317
|
+
"""Send template email using mailbox determined by group's domain or system default
|
318
|
+
|
319
|
+
Args:
|
320
|
+
to: One or more recipient addresses
|
321
|
+
template_name: Name of the EmailTemplate in database
|
322
|
+
context: Template context variables (group will be added automatically)
|
323
|
+
cc, bcc, reply_to: Optional addressing
|
324
|
+
**kwargs: Additional arguments passed to mailbox.send_template_email()
|
325
|
+
|
326
|
+
Returns:
|
327
|
+
SentMessage instance
|
328
|
+
|
329
|
+
Raises:
|
330
|
+
ValueError: If no mailbox can be found or template not found
|
331
|
+
"""
|
332
|
+
from mojo.apps.aws.models import Mailbox
|
333
|
+
|
334
|
+
mailbox = None
|
335
|
+
domain = None
|
336
|
+
|
337
|
+
# Try to get domain from this group's metadata
|
338
|
+
if self.metadata:
|
339
|
+
domain = self.metadata.get("domain")
|
340
|
+
|
341
|
+
# If no domain, check top_most_parent's metadata
|
342
|
+
if not domain and self.top_most_parent != self:
|
343
|
+
parent_metadata = self.top_most_parent.metadata
|
344
|
+
if parent_metadata:
|
345
|
+
domain = parent_metadata.get("domain")
|
346
|
+
|
347
|
+
# Try to get mailbox from domain
|
348
|
+
if domain:
|
349
|
+
# Try domain default first
|
350
|
+
mailbox = Mailbox.get_domain_default(domain)
|
351
|
+
if not mailbox:
|
352
|
+
# Try any mailbox from that domain
|
353
|
+
mailbox = Mailbox.objects.filter(
|
354
|
+
domain__name__iexact=domain,
|
355
|
+
allow_outbound=True
|
356
|
+
).first()
|
357
|
+
|
358
|
+
# Fall back to system default
|
359
|
+
if not mailbox:
|
360
|
+
mailbox = Mailbox.get_system_default()
|
361
|
+
|
362
|
+
if not mailbox:
|
363
|
+
raise ValueError("No mailbox available for sending email. Please configure a system default mailbox.")
|
364
|
+
|
365
|
+
# Add group to context if not already present
|
366
|
+
if context is None:
|
367
|
+
context = {}
|
368
|
+
if 'group' not in context:
|
369
|
+
context['group'] = self
|
370
|
+
|
371
|
+
return mailbox.send_template_email(
|
372
|
+
to=to,
|
373
|
+
template_name=template_name,
|
374
|
+
context=context,
|
375
|
+
cc=cc,
|
376
|
+
bcc=bcc,
|
377
|
+
reply_to=reply_to,
|
378
|
+
**kwargs
|
379
|
+
)
|
380
|
+
|
381
|
+
def check_view_permission(self, perms, request):
|
382
|
+
# check if the user is a member of the group
|
383
|
+
if request.user.has_permission(perms):
|
384
|
+
return True
|
385
|
+
ms = self.get_member_for_user(request.user)
|
386
|
+
if ms is None:
|
387
|
+
return False
|
388
|
+
if ms.has_permission(["view_group", "manage_group"]):
|
389
|
+
return True
|
390
|
+
# we still allow the user to view the group if they are a member
|
391
|
+
# but we limit the fields they can see
|
392
|
+
request.DATA.set("graph", "basic")
|
393
|
+
return True
|
394
|
+
|
93
395
|
@classmethod
|
94
396
|
def on_rest_handle_list(cls, request):
|
95
397
|
if cls.rest_check_permission(request, "VIEW_PERMS"):
|
96
398
|
return cls.on_rest_list(request)
|
97
|
-
|
98
|
-
|
399
|
+
if getattr(request.user, 'members') is not None:
|
400
|
+
group_ids = request.user.members.values_list('group__id', flat=True)
|
401
|
+
return cls.on_rest_list(request, cls.objects.filter(id__in=group_ids))
|
402
|
+
return cls.on_rest_list(request, cls.objects.none())
|
@@ -27,6 +27,7 @@ class GroupMember(models.Model, MojoModel):
|
|
27
27
|
class RestMeta:
|
28
28
|
VIEW_PERMS = ["view_groups", "manage_groups"]
|
29
29
|
SAVE_PERMS = ["manage_groups"]
|
30
|
+
CREATED_BY_OWNER_FIELD = 'created_by' # we do this to protect user
|
30
31
|
LIST_DEFAULT_FILTERS = {
|
31
32
|
"is_active": True
|
32
33
|
}
|
@@ -34,7 +35,6 @@ class GroupMember(models.Model, MojoModel):
|
|
34
35
|
"default": {
|
35
36
|
"fields": [
|
36
37
|
'id',
|
37
|
-
'name',
|
38
38
|
'created',
|
39
39
|
'modified',
|
40
40
|
'is_active',
|
@@ -42,7 +42,7 @@ class GroupMember(models.Model, MojoModel):
|
|
42
42
|
'metadata'
|
43
43
|
],
|
44
44
|
"graphs": {
|
45
|
-
"user": "
|
45
|
+
"user": "default",
|
46
46
|
"group": "basic"
|
47
47
|
}
|
48
48
|
}
|
@@ -51,6 +51,18 @@ class GroupMember(models.Model, MojoModel):
|
|
51
51
|
def __str__(self):
|
52
52
|
return f"{self.user.username}@{self.group.name}"
|
53
53
|
|
54
|
+
@property
|
55
|
+
def username(self):
|
56
|
+
return self.user.username
|
57
|
+
|
58
|
+
@property
|
59
|
+
def display_name(self):
|
60
|
+
return self.user.display_name
|
61
|
+
|
62
|
+
@property
|
63
|
+
def email(self):
|
64
|
+
return self.user.email
|
65
|
+
|
54
66
|
def can_change_permission(self, perm, value, request):
|
55
67
|
if request.user.has_permission(["manage_groups", "manage_users"]):
|
56
68
|
return True
|
@@ -61,11 +73,11 @@ class GroupMember(models.Model, MojoModel):
|
|
61
73
|
return req_member.has_permission(["manage_group", "manage_members"])
|
62
74
|
return False
|
63
75
|
|
64
|
-
def set_permissions(self, value
|
76
|
+
def set_permissions(self, value):
|
65
77
|
if not isinstance(value, dict):
|
66
78
|
return
|
67
79
|
for perm, perm_value in value.items():
|
68
|
-
if not self.can_change_permission(perm, perm_value,
|
80
|
+
if not self.can_change_permission(perm, perm_value, self.active_request):
|
69
81
|
raise merrors.PermissionDeniedException()
|
70
82
|
if bool(perm_value):
|
71
83
|
self.add_permission(perm)
|
@@ -73,12 +85,24 @@ class GroupMember(models.Model, MojoModel):
|
|
73
85
|
self.remove_permission(perm)
|
74
86
|
|
75
87
|
def has_permission(self, perm_key):
|
76
|
-
"""
|
88
|
+
"""
|
89
|
+
Check if user has a specific permission—supports system-level permissions via 'sys.' prefix.
|
90
|
+
If perm_key starts with 'sys.', only the user-level permission is checked.
|
91
|
+
Otherwise, checks group-member-level permission as before.
|
92
|
+
"""
|
93
|
+
# Support lists for "OR" logic
|
77
94
|
if isinstance(perm_key, list):
|
78
95
|
for pk in perm_key:
|
79
96
|
if self.has_permission(pk):
|
80
97
|
return True
|
81
98
|
return False
|
99
|
+
|
100
|
+
# System-level: only check user permission
|
101
|
+
SYS_PREFIX = "sys."
|
102
|
+
if isinstance(perm_key, str) and perm_key.startswith(SYS_PREFIX):
|
103
|
+
bare_perm = perm_key[len(SYS_PREFIX):]
|
104
|
+
return self.user.has_permission(bare_perm)
|
105
|
+
|
82
106
|
if perm_key == "all":
|
83
107
|
return True
|
84
108
|
return self.permissions.get(perm_key, False)
|
@@ -0,0 +1,112 @@
|
|
1
|
+
from django.db import models
|
2
|
+
from mojo.models import MojoModel, MojoSecrets
|
3
|
+
|
4
|
+
|
5
|
+
class PushConfig(MojoSecrets, MojoModel):
|
6
|
+
"""
|
7
|
+
Push notification configuration. Can be system-wide (group=None) or org-specific.
|
8
|
+
Sensitive credentials are encrypted via MojoSecrets.
|
9
|
+
|
10
|
+
FCM is the primary service supporting both iOS and Android devices.
|
11
|
+
APNS is available for iOS-specific requirements but rarely needed.
|
12
|
+
Test mode allows fake notifications for development and testing.
|
13
|
+
"""
|
14
|
+
created = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
|
15
|
+
modified = models.DateTimeField(auto_now=True, db_index=True)
|
16
|
+
|
17
|
+
group = models.OneToOneField("account.Group", on_delete=models.CASCADE,
|
18
|
+
related_name="push_config", null=True, blank=True,
|
19
|
+
help_text="Organization for this config. Null = system default")
|
20
|
+
|
21
|
+
name = models.CharField(max_length=100, help_text="Configuration name")
|
22
|
+
is_active = models.BooleanField(default=True, db_index=True)
|
23
|
+
|
24
|
+
# Test/Development Mode
|
25
|
+
test_mode = models.BooleanField(default=False, db_index=True,
|
26
|
+
help_text="Enable test mode - fake notifications for development")
|
27
|
+
|
28
|
+
# FCM Configuration (Primary - supports both iOS and Android)
|
29
|
+
# APNS Configuration (iOS-specific, rarely needed - use FCM instead)
|
30
|
+
apns_enabled = models.BooleanField(default=False,
|
31
|
+
help_text="APNS for iOS-specific needs. FCM is preferred.")
|
32
|
+
apns_key_id = models.CharField(max_length=100, blank=True)
|
33
|
+
apns_team_id = models.CharField(max_length=100, blank=True)
|
34
|
+
apns_bundle_id = models.CharField(max_length=255, blank=True)
|
35
|
+
apns_use_sandbox = models.BooleanField(default=False)
|
36
|
+
|
37
|
+
# FCM Configuration (Primary - supports both iOS and Android)
|
38
|
+
fcm_enabled = models.BooleanField(default=True,
|
39
|
+
help_text="FCM handles both iOS and Android notifications")
|
40
|
+
fcm_sender_id = models.CharField(max_length=100, blank=True)
|
41
|
+
|
42
|
+
# General Settings
|
43
|
+
default_sound = models.CharField(max_length=50, default="default")
|
44
|
+
default_badge_count = models.IntegerField(default=1)
|
45
|
+
|
46
|
+
class Meta:
|
47
|
+
ordering = ['group__name', 'name']
|
48
|
+
|
49
|
+
class RestMeta:
|
50
|
+
VIEW_PERMS = ["manage_push_config", "manage_groups"]
|
51
|
+
SAVE_PERMS = ["manage_push_config", "manage_groups"]
|
52
|
+
SEARCH_FIELDS = ["name"]
|
53
|
+
LIST_DEFAULT_FILTERS = {"is_active": True}
|
54
|
+
GRAPHS = {
|
55
|
+
"basic": {
|
56
|
+
"fields": ["id", "name", "fcm_enabled", "apns_enabled", "test_mode", "default_sound", "is_active"]
|
57
|
+
},
|
58
|
+
"default": {
|
59
|
+
"exclude": ["mojo_secrets"], # Never expose encrypted secrets
|
60
|
+
"graphs": {
|
61
|
+
"group": "basic"
|
62
|
+
}
|
63
|
+
},
|
64
|
+
"full": {
|
65
|
+
"exclude": ["mojo_secrets"], # Never expose encrypted secrets
|
66
|
+
"graphs": {
|
67
|
+
"group": "default"
|
68
|
+
}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
|
72
|
+
def __str__(self):
|
73
|
+
org = self.group.name if self.group else "System Default"
|
74
|
+
return f"{self.name} ({org})"
|
75
|
+
|
76
|
+
@classmethod
|
77
|
+
def get_for_user(cls, user):
|
78
|
+
"""
|
79
|
+
Get push config for user. Priority: user's org config -> system default
|
80
|
+
"""
|
81
|
+
if user.org:
|
82
|
+
config = cls.objects.filter(group=user.org, is_active=True).first()
|
83
|
+
if config:
|
84
|
+
return config
|
85
|
+
|
86
|
+
# Fallback to system default
|
87
|
+
return cls.objects.filter(group__isnull=True, is_active=True).first()
|
88
|
+
|
89
|
+
def set_apns_key_file(self, key_content):
|
90
|
+
"""Set APNS private key file content (will be encrypted)."""
|
91
|
+
self.set_secret('apns_key_file', key_content)
|
92
|
+
|
93
|
+
def get_apns_key_file(self):
|
94
|
+
"""Get decrypted APNS private key file content."""
|
95
|
+
return self.get_secret('apns_key_file', '')
|
96
|
+
|
97
|
+
def set_fcm_server_key(self, server_key):
|
98
|
+
"""Set FCM server key (will be encrypted)."""
|
99
|
+
self.set_secret('fcm_server_key', server_key)
|
100
|
+
|
101
|
+
def get_fcm_server_key(self):
|
102
|
+
"""Get decrypted FCM server key."""
|
103
|
+
return self.get_secret('fcm_server_key', '')
|
104
|
+
|
105
|
+
# Backwards compatibility aliases
|
106
|
+
def get_decrypted_apns_key(self):
|
107
|
+
"""Get decrypted APNS key file content."""
|
108
|
+
return self.get_apns_key_file()
|
109
|
+
|
110
|
+
def get_decrypted_fcm_key(self):
|
111
|
+
"""Get decrypted FCM server key."""
|
112
|
+
return self.get_fcm_server_key()
|
@@ -0,0 +1,93 @@
|
|
1
|
+
from django.db import models
|
2
|
+
from mojo.models import MojoModel
|
3
|
+
|
4
|
+
|
5
|
+
class NotificationDelivery(models.Model, MojoModel):
|
6
|
+
"""
|
7
|
+
Track all push notification delivery attempts and results.
|
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
|
+
user = models.ForeignKey("account.User", on_delete=models.CASCADE,
|
13
|
+
related_name="notification_deliveries")
|
14
|
+
device = models.ForeignKey("account.RegisteredDevice", on_delete=models.CASCADE,
|
15
|
+
related_name="notification_deliveries")
|
16
|
+
template = models.ForeignKey("account.NotificationTemplate", on_delete=models.SET_NULL,
|
17
|
+
null=True, blank=True, related_name="deliveries")
|
18
|
+
|
19
|
+
title = models.CharField(max_length=200, blank=True, null=True)
|
20
|
+
body = models.TextField(blank=True, null=True)
|
21
|
+
category = models.CharField(max_length=50, db_index=True)
|
22
|
+
action_url = models.URLField(blank=True, null=True)
|
23
|
+
data_payload = models.JSONField(default=dict, blank=True,
|
24
|
+
help_text="Custom data payload sent with notification")
|
25
|
+
|
26
|
+
# Delivery tracking
|
27
|
+
status = models.CharField(max_length=20, choices=[
|
28
|
+
('pending', 'Pending'),
|
29
|
+
('sent', 'Sent'),
|
30
|
+
('delivered', 'Delivered'),
|
31
|
+
('failed', 'Failed')
|
32
|
+
], default='pending', db_index=True)
|
33
|
+
|
34
|
+
sent_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
35
|
+
delivered_at = models.DateTimeField(null=True, blank=True)
|
36
|
+
error_message = models.TextField(blank=True, null=True)
|
37
|
+
|
38
|
+
# Push service specific data
|
39
|
+
platform_data = models.JSONField(default=dict, blank=True,
|
40
|
+
help_text="Platform-specific response data")
|
41
|
+
|
42
|
+
class Meta:
|
43
|
+
ordering = ['-created']
|
44
|
+
|
45
|
+
class RestMeta:
|
46
|
+
VIEW_PERMS = ["view_notifications", "manage_notifications", "owner", "manage_users"]
|
47
|
+
SAVE_PERMS = ["manage_notifications"]
|
48
|
+
SEARCH_FIELDS = ["title", "category"]
|
49
|
+
LIST_DEFAULT_FILTERS = {"status": "sent"}
|
50
|
+
GRAPHS = {
|
51
|
+
"basic": {
|
52
|
+
"fields": ["id", "title", "category", "status", "sent_at", "created"]
|
53
|
+
},
|
54
|
+
"default": {
|
55
|
+
"fields": ["id", "title", "body", "category", "action_url", "data_payload", "status",
|
56
|
+
"sent_at", "delivered_at", "error_message", "created"],
|
57
|
+
"graphs": {
|
58
|
+
"user": "basic",
|
59
|
+
"device": "basic"
|
60
|
+
}
|
61
|
+
},
|
62
|
+
"full": {
|
63
|
+
"graphs": {
|
64
|
+
"user": "default",
|
65
|
+
"device": "default",
|
66
|
+
"template": "basic"
|
67
|
+
}
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
def __str__(self):
|
72
|
+
display_title = self.title or f"[{self.category} data]"
|
73
|
+
return f"{display_title} -> {self.device} ({self.status})"
|
74
|
+
|
75
|
+
def mark_sent(self):
|
76
|
+
"""Mark notification as sent with timestamp."""
|
77
|
+
from mojo.helpers import dates
|
78
|
+
self.status = 'sent'
|
79
|
+
self.sent_at = dates.utcnow()
|
80
|
+
self.save(update_fields=['status', 'sent_at'])
|
81
|
+
|
82
|
+
def mark_delivered(self):
|
83
|
+
"""Mark notification as delivered with timestamp."""
|
84
|
+
from mojo.helpers import dates
|
85
|
+
self.status = 'delivered'
|
86
|
+
self.delivered_at = dates.utcnow()
|
87
|
+
self.save(update_fields=['status', 'delivered_at'])
|
88
|
+
|
89
|
+
def mark_failed(self, error_message):
|
90
|
+
"""Mark notification as failed with error message."""
|
91
|
+
self.status = 'failed'
|
92
|
+
self.error_message = error_message
|
93
|
+
self.save(update_fields=['status', 'error_message'])
|