django-nativemojo 0.1.15__py3-none-any.whl → 0.1.17__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.17.dist-info}/METADATA +3 -2
- django_nativemojo-0.1.17.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 +279 -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.17.dist-info}/LICENSE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
- {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.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
@@ -1,8 +1,10 @@
|
|
1
1
|
import os
|
2
|
+
import boto3
|
3
|
+
from botocore.exceptions import ClientError
|
2
4
|
from django.db import models
|
3
5
|
from mojo.models import MojoModel, MojoSecrets
|
4
6
|
from urllib.parse import urlparse
|
5
|
-
|
7
|
+
from mojo.helpers.settings import settings
|
6
8
|
|
7
9
|
class FileManager(MojoSecrets, MojoModel):
|
8
10
|
"""
|
@@ -13,6 +15,7 @@ class FileManager(MojoSecrets, MojoModel):
|
|
13
15
|
CAN_SAVE = CAN_CREATE = True
|
14
16
|
CAN_DELETE = True
|
15
17
|
DEFAULT_SORT = "-id"
|
18
|
+
POST_SAVE_ACTIONS = ["test_connection", "fix_cors", "clone", "check_cors"]
|
16
19
|
VIEW_PERMS = ["view_fileman", "manage_files"]
|
17
20
|
SEARCH_FIELDS = ["name", "backend_type", "description"]
|
18
21
|
SEARCH_TERMS = [
|
@@ -21,17 +24,19 @@ class FileManager(MojoSecrets, MojoModel):
|
|
21
24
|
|
22
25
|
GRAPHS = {
|
23
26
|
"default": {
|
27
|
+
"extra": ["aws_region", "aws_key", "aws_secret_masked", "allowed_origins"],
|
24
28
|
"fields": [
|
25
29
|
"created", "id", "name", "backend_type", "backend_url",
|
26
|
-
"
|
30
|
+
"is_active", "is_default"],
|
27
31
|
"graphs": {
|
28
32
|
"user": "basic",
|
29
33
|
"group": "basic"
|
30
34
|
}
|
31
35
|
},
|
32
36
|
"list": {
|
37
|
+
"extra": ["aws_region", "aws_key", "aws_secret_masked", "allowed_origins"],
|
33
38
|
"fields": ["created", "id", "name", "backend_type", "backend_url",
|
34
|
-
"
|
39
|
+
"is_active", "is_default"],
|
35
40
|
"graphs": {
|
36
41
|
"user": "basic",
|
37
42
|
"group": "basic"
|
@@ -198,6 +203,47 @@ class FileManager(MojoSecrets, MojoModel):
|
|
198
203
|
|
199
204
|
_backend = None
|
200
205
|
|
206
|
+
@property
|
207
|
+
def aws_key(self):
|
208
|
+
return self.get_secret('aws_key')
|
209
|
+
|
210
|
+
@property
|
211
|
+
def aws_secret(self):
|
212
|
+
return self.get_secret('aws_secret')
|
213
|
+
|
214
|
+
@property
|
215
|
+
def aws_secret_masked(self):
|
216
|
+
secret = self.get_secret('aws_secret', '')
|
217
|
+
if len(secret) > 4:
|
218
|
+
return '*' * (len(secret) - 4) + secret[-4:]
|
219
|
+
return secret
|
220
|
+
|
221
|
+
@property
|
222
|
+
def aws_region(self):
|
223
|
+
return self.get_secret('aws_region')
|
224
|
+
|
225
|
+
@property
|
226
|
+
def is_verified(self):
|
227
|
+
return self.status in ["verified", "ready"]
|
228
|
+
|
229
|
+
def set_aws_key(self, key):
|
230
|
+
self.set_secret('aws_key', key)
|
231
|
+
|
232
|
+
def set_aws_secret(self, secret):
|
233
|
+
self.set_secret('aws_secret', secret)
|
234
|
+
|
235
|
+
def set_aws_region(self, secret):
|
236
|
+
self.set_secret('aws_region', secret)
|
237
|
+
|
238
|
+
def set_allowed_origins(self, origins):
|
239
|
+
if isinstance(origins, str) and "," in origins:
|
240
|
+
origins = [origin.strip() for origin in origins.split(',')]
|
241
|
+
self.set_secret('allowed_origins', origins)
|
242
|
+
|
243
|
+
@property
|
244
|
+
def allowed_origins(self):
|
245
|
+
return self.get_secret('allowed_origins')
|
246
|
+
|
201
247
|
@property
|
202
248
|
def backend(self):
|
203
249
|
"""Get the backend instance"""
|
@@ -282,6 +328,13 @@ class FileManager(MojoSecrets, MojoModel):
|
|
282
328
|
self._update_default()
|
283
329
|
if not self.name:
|
284
330
|
self.name = self.generate_name()
|
331
|
+
if created:
|
332
|
+
if not self.aws_region:
|
333
|
+
self.set_aws_region(settings.get("AWS_REGION", "us-east-1"))
|
334
|
+
if not self.aws_key:
|
335
|
+
self.set_aws_key(settings.get("AWS_KEY", None))
|
336
|
+
if not self.aws_secret:
|
337
|
+
self.set_aws_secret(settings.get("AWS_SECRET", None))
|
285
338
|
if created or "is_default" in changed_fields:
|
286
339
|
self._update_default()
|
287
340
|
|
@@ -304,6 +357,219 @@ class FileManager(MojoSecrets, MojoModel):
|
|
304
357
|
return f"{self.group.name}'s {self.backend_type} FileManager"
|
305
358
|
return f"{self.backend_type} FileManager"
|
306
359
|
|
360
|
+
def on_action_test_connection(self, value):
|
361
|
+
try:
|
362
|
+
self.backend.test_connection()
|
363
|
+
return dict(status=True)
|
364
|
+
except Exception as e:
|
365
|
+
return dict(status=False, error=str(e))
|
366
|
+
|
367
|
+
def on_action_fix_cors(self, value):
|
368
|
+
try:
|
369
|
+
if not self.is_s3:
|
370
|
+
return dict(status=False, error="CORS management is only supported for S3 backends.")
|
371
|
+
# Validate connectivity first
|
372
|
+
self.backend.test_connection()
|
373
|
+
allowed_origins = self._resolve_allowed_origins_from_value_or_settings(value or {})
|
374
|
+
result = self.update_cors(allowed_origins)
|
375
|
+
return dict(status=True, result=result)
|
376
|
+
except Exception as e:
|
377
|
+
return dict(status=False, error=str(e))
|
378
|
+
|
379
|
+
def on_action_check_cors(self, value):
|
380
|
+
try:
|
381
|
+
if not self.is_s3:
|
382
|
+
return dict(status=False, error="CORS management is only supported for S3 backends.")
|
383
|
+
self.backend.test_connection()
|
384
|
+
# allowed_origins = self._resolve_allowed_origins_from_value_or_settings(value or {})
|
385
|
+
result = self.check_cors_config(allowed_origins=self.allowed_origins)
|
386
|
+
return dict(status=True, result=result)
|
387
|
+
except Exception as e:
|
388
|
+
return dict(status=False, error=str(e))
|
389
|
+
|
390
|
+
def on_action_clone(self, value):
|
391
|
+
secrets = self.secrets
|
392
|
+
new_manager = FileManager(user=self.user, group=self.group)
|
393
|
+
new_manager.name = f"Clone of {self.name}"
|
394
|
+
new_manager.backend_url = self.backend_url
|
395
|
+
new_manager.backend_type = self.backend_type
|
396
|
+
new_manager.set_secrets(secrets)
|
397
|
+
new_manager.save()
|
398
|
+
return dict(status=True, id=new_manager.id)
|
399
|
+
|
400
|
+
def fix_cors(self):
|
401
|
+
"""
|
402
|
+
Ensure bucket CORS allows direct uploads from configured origins.
|
403
|
+
This uses manager settings and does not require manual AWS console changes.
|
404
|
+
"""
|
405
|
+
if not self.is_s3:
|
406
|
+
return
|
407
|
+
allowed_origins = self._resolve_allowed_origins_from_value_or_settings({})
|
408
|
+
self.update_cors(allowed_origins)
|
409
|
+
|
410
|
+
# --- CORS helpers for S3 direct upload ---
|
411
|
+
def _s3_client(self):
|
412
|
+
if not self.is_s3:
|
413
|
+
raise ValueError("CORS management is only supported for S3 backends.")
|
414
|
+
session = boto3.Session(
|
415
|
+
aws_access_key_id=self.aws_key,
|
416
|
+
aws_secret_access_key=self.aws_secret,
|
417
|
+
region_name=self.aws_region or "us-east-1",
|
418
|
+
)
|
419
|
+
endpoint_url = self.get_setting("endpoint_url", None)
|
420
|
+
return session.client("s3", endpoint_url=endpoint_url)
|
421
|
+
|
422
|
+
def _resolve_allowed_origins_from_value_or_settings(self, value):
|
423
|
+
"""
|
424
|
+
Resolve a list of allowed origins from action value or global settings.
|
425
|
+
Accepts 'origins', 'allowed_origins', 'domains', or 'list_of_domains' keys.
|
426
|
+
Falls back to settings such as CORS_ALLOWED_ORIGINS, ALLOWED_ORIGINS, FRONTEND_ORIGIN/URL.
|
427
|
+
"""
|
428
|
+
origins = []
|
429
|
+
|
430
|
+
if isinstance(value, dict):
|
431
|
+
for key in ("origins", "allowed_origins", "domains", "list_of_domains"):
|
432
|
+
v = value.get(key)
|
433
|
+
if v:
|
434
|
+
if isinstance(v, str):
|
435
|
+
origins.extend([s.strip() for s in v.split(",") if s.strip()])
|
436
|
+
elif isinstance(v, (list, tuple)):
|
437
|
+
origins.extend([str(s).strip() for s in v if str(s).strip()])
|
438
|
+
break
|
439
|
+
|
440
|
+
for key in ("CORS_ALLOWED_ORIGINS", "ALLOWED_ORIGINS"):
|
441
|
+
v = settings.get(key)
|
442
|
+
if v:
|
443
|
+
if isinstance(v, str):
|
444
|
+
origins.extend([s.strip() for s in v.split(",") if s.strip()])
|
445
|
+
elif isinstance(v, (list, tuple)):
|
446
|
+
origins.extend([str(s).strip() for s in v if str(s).strip()])
|
447
|
+
|
448
|
+
for key in ("FRONTEND_ORIGIN", "FRONTEND_URL", "SITE_URL", "BASE_URL"):
|
449
|
+
v = settings.get(key)
|
450
|
+
if v:
|
451
|
+
origins.append(str(v).strip())
|
452
|
+
|
453
|
+
# Normalize: dedupe, drop trailing slash
|
454
|
+
cleaned = []
|
455
|
+
seen = set()
|
456
|
+
for o in origins:
|
457
|
+
if not o:
|
458
|
+
continue
|
459
|
+
if o.endswith("/"):
|
460
|
+
o = o[:-1]
|
461
|
+
if o not in seen:
|
462
|
+
seen.add(o)
|
463
|
+
cleaned.append(o)
|
464
|
+
|
465
|
+
if not cleaned:
|
466
|
+
raise ValueError("No allowed origins provided. Please pass at least one origin.")
|
467
|
+
return cleaned
|
468
|
+
|
469
|
+
def check_cors_config(self, allowed_origins=None, required_methods=None, required_headers=None):
|
470
|
+
"""
|
471
|
+
Check the current CORS configuration to ensure it supports direct uploads.
|
472
|
+
Note: S3 CORS is bucket-wide. Prefix-level restriction must be enforced by IAM/policy and presigned URLs.
|
473
|
+
"""
|
474
|
+
if not self.is_s3:
|
475
|
+
raise ValueError("CORS management is only supported for S3 backends.")
|
476
|
+
|
477
|
+
s3 = self._s3_client()
|
478
|
+
bucket = self.root_location
|
479
|
+
|
480
|
+
try:
|
481
|
+
resp = s3.get_bucket_cors(Bucket=bucket)
|
482
|
+
config = resp
|
483
|
+
except ClientError as e:
|
484
|
+
if e.response.get("Error", {}).get("Code") == "NoSuchCORSConfiguration":
|
485
|
+
return {"ok": False, "issues": ["No CORS configuration set on this bucket."], "config": None}
|
486
|
+
return {"ok": False, "issues": [str(e)], "config": None}
|
487
|
+
|
488
|
+
if allowed_origins is None:
|
489
|
+
allowed_origins = self._resolve_allowed_origins_from_value_or_settings({})
|
490
|
+
if not allowed_origins:
|
491
|
+
raise ValueError("No allowed origins provided. Please pass at least one origin.")
|
492
|
+
|
493
|
+
required_methods = [m.upper() for m in (required_methods or ["GET", "PUT", "POST", "HEAD"])]
|
494
|
+
required_headers = [h.lower() for h in (required_headers or ["content-type"])]
|
495
|
+
|
496
|
+
rules = config.get("CORSRules", [])
|
497
|
+
issues = []
|
498
|
+
|
499
|
+
def origin_covered(origin: str) -> bool:
|
500
|
+
for r in rules:
|
501
|
+
origins = r.get("AllowedOrigins", [])
|
502
|
+
if "*" in origins or origin in origins:
|
503
|
+
methods = [m.upper() for m in r.get("AllowedMethods", [])]
|
504
|
+
if not all(m in methods for m in required_methods):
|
505
|
+
continue
|
506
|
+
headers = [h.lower() for h in r.get("AllowedHeaders", [])]
|
507
|
+
if "*" in headers or all(h in headers for h in required_headers):
|
508
|
+
return True
|
509
|
+
return False
|
510
|
+
|
511
|
+
for origin in allowed_origins:
|
512
|
+
if not origin_covered(origin):
|
513
|
+
issues.append(f"Origin not covered for direct upload: {origin}")
|
514
|
+
|
515
|
+
return {"ok": len(issues) == 0, "issues": issues, "config": config}
|
516
|
+
|
517
|
+
def update_cors(self, allowed_origins, merge=True, allowed_methods=None, allowed_headers=None, expose_headers=None, max_age_seconds=3000):
|
518
|
+
"""
|
519
|
+
Update bucket CORS to support direct uploads from allowed_origins.
|
520
|
+
If merge=True, append our rule to any existing rules; otherwise replace entirely.
|
521
|
+
"""
|
522
|
+
if not self.is_s3:
|
523
|
+
raise ValueError("CORS management is only supported for S3 backends.")
|
524
|
+
|
525
|
+
s3 = self._s3_client()
|
526
|
+
bucket = self.root_location
|
527
|
+
if not allowed_origins:
|
528
|
+
raise ValueError("No allowed origins provided. Please pass at least one origin.")
|
529
|
+
|
530
|
+
if allowed_methods is None:
|
531
|
+
allowed_methods = ["POST", "HEAD"] if getattr(self.backend, "server_side_encryption", None) else ["PUT", "HEAD"]
|
532
|
+
allowed_methods = [m.upper() for m in allowed_methods]
|
533
|
+
allowed_headers = [h for h in (allowed_headers or ["*"])]
|
534
|
+
expose_headers = expose_headers or ["ETag", "x-amz-request-id", "x-amz-id-2", "x-amz-version-id"]
|
535
|
+
|
536
|
+
desired = {
|
537
|
+
"CORSRules": [
|
538
|
+
{
|
539
|
+
"AllowedOrigins": allowed_origins,
|
540
|
+
"AllowedMethods": allowed_methods,
|
541
|
+
"AllowedHeaders": allowed_headers,
|
542
|
+
"ExposeHeaders": expose_headers,
|
543
|
+
"MaxAgeSeconds": max_age_seconds,
|
544
|
+
}
|
545
|
+
]
|
546
|
+
}
|
547
|
+
|
548
|
+
# If already compliant, no change
|
549
|
+
verify_required_headers = [] if getattr(self.backend, "server_side_encryption", None) else ["content-type"]
|
550
|
+
check = self.check_cors_config(allowed_origins, required_methods=allowed_methods, required_headers=verify_required_headers)
|
551
|
+
if check["ok"]:
|
552
|
+
return {"changed": False, "message": "Existing CORS already supports direct uploads.", "current": check["config"]}
|
553
|
+
|
554
|
+
current = None
|
555
|
+
try:
|
556
|
+
current = s3.get_bucket_cors(Bucket=bucket)
|
557
|
+
except ClientError as e:
|
558
|
+
if e.response.get("Error", {}).get("Code") != "NoSuchCORSConfiguration":
|
559
|
+
raise
|
560
|
+
|
561
|
+
if merge and current:
|
562
|
+
merged = {"CORSRules": current.get("CORSRules", []) + desired["CORSRules"]}
|
563
|
+
s3.put_bucket_cors(Bucket=bucket, CORSConfiguration=merged)
|
564
|
+
applied = merged
|
565
|
+
else:
|
566
|
+
s3.put_bucket_cors(Bucket=bucket, CORSConfiguration=desired)
|
567
|
+
applied = desired
|
568
|
+
|
569
|
+
verify_required_headers = [] if getattr(self.backend, "server_side_encryption", None) else ["content-type"]
|
570
|
+
verify = self.check_cors_config(allowed_origins, required_methods=allowed_methods, required_headers=verify_required_headers)
|
571
|
+
return {"changed": True, "applied": applied, "verified": verify["ok"], "post_update_issues": verify["issues"]}
|
572
|
+
|
307
573
|
@classmethod
|
308
574
|
def get_from_request(cls, request):
|
309
575
|
"""Get the file manager from the request"""
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Generated by Django 4.2.21 on 2025-08-29 18:04
|
2
|
+
|
3
|
+
from django.db import migrations, models
|
4
|
+
|
5
|
+
|
6
|
+
class Migration(migrations.Migration):
|
7
|
+
|
8
|
+
dependencies = [
|
9
|
+
('incident', '0006_alter_incident_state'),
|
10
|
+
]
|
11
|
+
|
12
|
+
operations = [
|
13
|
+
migrations.AddField(
|
14
|
+
model_name='event',
|
15
|
+
name='uid',
|
16
|
+
field=models.IntegerField(db_index=True, default=None, null=True),
|
17
|
+
),
|
18
|
+
]
|
@@ -0,0 +1,55 @@
|
|
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
|
+
('fileman', '0011_alter_filerendition_original_file'),
|
13
|
+
('account', '0011_user_org_registereddevice_pushconfig_and_more'),
|
14
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
15
|
+
('incident', '0007_event_uid'),
|
16
|
+
]
|
17
|
+
|
18
|
+
operations = [
|
19
|
+
migrations.CreateModel(
|
20
|
+
name='Ticket',
|
21
|
+
fields=[
|
22
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
23
|
+
('created', models.DateTimeField(auto_now_add=True)),
|
24
|
+
('modified', models.DateTimeField(auto_now=True)),
|
25
|
+
('title', models.CharField(max_length=255)),
|
26
|
+
('description', models.TextField(blank=True, null=True)),
|
27
|
+
('status', models.CharField(db_index=True, default='open', max_length=50)),
|
28
|
+
('priority', models.IntegerField(db_index=True, default=1)),
|
29
|
+
('metadata', models.JSONField(blank=True, default=dict)),
|
30
|
+
('assignee', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL)),
|
31
|
+
('group', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='account.group')),
|
32
|
+
('incident', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='incident.incident')),
|
33
|
+
('user', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
34
|
+
],
|
35
|
+
options={
|
36
|
+
'ordering': ['-created'],
|
37
|
+
},
|
38
|
+
bases=(models.Model, mojo.models.rest.MojoModel),
|
39
|
+
),
|
40
|
+
migrations.CreateModel(
|
41
|
+
name='TicketNote',
|
42
|
+
fields=[
|
43
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
44
|
+
('created', models.DateTimeField(auto_now_add=True)),
|
45
|
+
('note', models.TextField(blank=True, null=True)),
|
46
|
+
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
|
47
|
+
('media', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='fileman.file')),
|
48
|
+
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='incident.ticket')),
|
49
|
+
],
|
50
|
+
options={
|
51
|
+
'ordering': ['-created'],
|
52
|
+
},
|
53
|
+
bases=(models.Model, mojo.models.rest.MojoModel),
|
54
|
+
),
|
55
|
+
]
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Generated by Django 4.2.23 on 2025-09-05 22:03
|
2
|
+
|
3
|
+
from django.db import migrations, models
|
4
|
+
|
5
|
+
|
6
|
+
class Migration(migrations.Migration):
|
7
|
+
|
8
|
+
dependencies = [
|
9
|
+
('incident', '0008_ticket_ticketnote'),
|
10
|
+
]
|
11
|
+
|
12
|
+
operations = [
|
13
|
+
migrations.AddField(
|
14
|
+
model_name='incident',
|
15
|
+
name='status',
|
16
|
+
field=models.CharField(db_index=True, default='open', max_length=50),
|
17
|
+
),
|
18
|
+
]
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Generated by Django 4.2.23 on 2025-09-05 22:22
|
2
|
+
|
3
|
+
from django.db import migrations, models
|
4
|
+
|
5
|
+
|
6
|
+
class Migration(migrations.Migration):
|
7
|
+
|
8
|
+
dependencies = [
|
9
|
+
('incident', '0009_incident_status'),
|
10
|
+
]
|
11
|
+
|
12
|
+
operations = [
|
13
|
+
migrations.AddField(
|
14
|
+
model_name='event',
|
15
|
+
name='country_code',
|
16
|
+
field=models.CharField(db_index=True, default=None, max_length=2, null=True),
|
17
|
+
),
|
18
|
+
]
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Generated by Django 4.2.23 on 2025-09-05 22:25
|
2
|
+
|
3
|
+
from django.db import migrations, models
|
4
|
+
|
5
|
+
|
6
|
+
class Migration(migrations.Migration):
|
7
|
+
|
8
|
+
dependencies = [
|
9
|
+
('incident', '0010_event_country_code'),
|
10
|
+
]
|
11
|
+
|
12
|
+
operations = [
|
13
|
+
migrations.AddField(
|
14
|
+
model_name='incident',
|
15
|
+
name='country_code',
|
16
|
+
field=models.CharField(db_index=True, default=None, max_length=2, null=True),
|
17
|
+
),
|
18
|
+
]
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# Generated by Django 4.2.23 on 2025-09-06 00:00
|
2
|
+
|
3
|
+
from django.db import migrations, models
|
4
|
+
|
5
|
+
|
6
|
+
class Migration(migrations.Migration):
|
7
|
+
|
8
|
+
dependencies = [
|
9
|
+
('incident', '0011_incident_country_code'),
|
10
|
+
]
|
11
|
+
|
12
|
+
operations = [
|
13
|
+
migrations.AlterField(
|
14
|
+
model_name='incident',
|
15
|
+
name='status',
|
16
|
+
field=models.CharField(db_index=True, default='new', max_length=50),
|
17
|
+
),
|
18
|
+
]
|
@@ -4,6 +4,7 @@ from mojo.models import MojoModel
|
|
4
4
|
from mojo.helpers import dates
|
5
5
|
from mojo.helpers.settings import settings
|
6
6
|
from mojo.apps import metrics
|
7
|
+
from mojo.apps.account.models import GeoLocatedIP
|
7
8
|
|
8
9
|
|
9
10
|
INCIDENT_LEVEL_THRESHOLD = settings.get('INCIDENT_LEVEL_THRESHOLD', 7)
|
@@ -22,6 +23,8 @@ class Event(models.Model, MojoModel):
|
|
22
23
|
category = models.CharField(max_length=124, db_index=True)
|
23
24
|
source_ip = models.CharField(max_length=16, null=True, default=None, db_index=True)
|
24
25
|
hostname = models.CharField(max_length=16, null=True, default=None, db_index=True)
|
26
|
+
uid = models.IntegerField(default=None, null=True, db_index=True)
|
27
|
+
country_code = models.CharField(max_length=2, default=None, null=True, db_index=True)
|
25
28
|
|
26
29
|
title = models.TextField(default=None, null=True)
|
27
30
|
details = models.TextField(default=None, null=True)
|
@@ -40,6 +43,16 @@ class Event(models.Model, MojoModel):
|
|
40
43
|
VIEW_PERMS = ["view_incidents"]
|
41
44
|
CREATE_PERMS = None
|
42
45
|
|
46
|
+
_geo_ip = None
|
47
|
+
@property
|
48
|
+
def geo_ip(self):
|
49
|
+
if self._geo_ip is None and self.source_ip:
|
50
|
+
try:
|
51
|
+
self._geo_ip = GeoLocatedIP.geolocate(self.source_ip, subdomain_only=True)
|
52
|
+
except Exception:
|
53
|
+
pass
|
54
|
+
return self._geo_ip
|
55
|
+
|
43
56
|
def sync_metadata(self):
|
44
57
|
# Gather all field values into the metadata
|
45
58
|
field_values = {
|
@@ -50,6 +63,17 @@ class Event(models.Model, MojoModel):
|
|
50
63
|
'details': self.details,
|
51
64
|
'model_name': self.model_name,
|
52
65
|
'model_id': self.model_id }
|
66
|
+
|
67
|
+
if not self.country_code and self.geo_ip:
|
68
|
+
self.country_code = self.geo_ip.country_code
|
69
|
+
field_values["country_code"] = self.geo_ip.country_code
|
70
|
+
field_values["country_name"] = self.geo_ip.country_name
|
71
|
+
field_values["city"] = self.geo_ip.city
|
72
|
+
field_values["region"] = self.geo_ip.region
|
73
|
+
field_values["latitude"] = self.geo_ip.latitude
|
74
|
+
field_values["longitude"] = self.geo_ip.longitude
|
75
|
+
field_values["timezone"] = self.geo_ip.timezone
|
76
|
+
|
53
77
|
# Update the metadata with these values
|
54
78
|
self.metadata.update(field_values)
|
55
79
|
|
@@ -69,11 +93,21 @@ class Event(models.Model, MojoModel):
|
|
69
93
|
if settings.INCIDENT_EVENT_METRICS:
|
70
94
|
metrics.record('incident_events', account="incident",
|
71
95
|
min_granularity=settings.get("INCIDENT_METRICS_MIN_GRANULARITY", "hours"))
|
96
|
+
if self.country_code:
|
97
|
+
metrics.record(f'incident_events:country:{self.country_code}',
|
98
|
+
account="incident",
|
99
|
+
category="incident_events_by_country",
|
100
|
+
min_granularity=settings.get("INCIDENT_METRICS_MIN_GRANULARITY", "hours"))
|
72
101
|
|
73
102
|
def record_incident_metrics(self):
|
74
103
|
if settings.INCIDENT_EVENT_METRICS:
|
75
104
|
metrics.record('incidents', account="incident",
|
76
105
|
min_granularity=settings.get("INCIDENT_METRICS_MIN_GRANULARITY", "hours"))
|
106
|
+
if self.country_code:
|
107
|
+
metrics.record(f'incident:country:{self.country_code}',
|
108
|
+
account="incident",
|
109
|
+
category="incidents_by_country",
|
110
|
+
min_granularity=settings.get("INCIDENT_METRICS_MIN_GRANULARITY", "hours"))
|
77
111
|
|
78
112
|
def get_or_create_incident(self, rule_set=None):
|
79
113
|
"""
|
@@ -94,6 +128,7 @@ class Event(models.Model, MojoModel):
|
|
94
128
|
priority=self.level,
|
95
129
|
state=0,
|
96
130
|
category=self.category,
|
131
|
+
country_code=self.country_code,
|
97
132
|
title=self.title,
|
98
133
|
details=self.details,
|
99
134
|
hostname=self.hostname,
|
@@ -10,7 +10,9 @@ class Incident(models.Model, MojoModel):
|
|
10
10
|
|
11
11
|
priority = models.IntegerField(default=0, db_index=True)
|
12
12
|
state = models.CharField(max_length=24, default=0, db_index=True)
|
13
|
+
status = models.CharField(max_length=50, default='new', db_index=True)
|
13
14
|
category = models.CharField(max_length=124, db_index=True)
|
15
|
+
country_code = models.CharField(max_length=2, default=None, null=True, db_index=True)
|
14
16
|
title = models.TextField(default=None, null=True)
|
15
17
|
details = models.TextField(default=None, null=True)
|
16
18
|
|
@@ -0,0 +1,62 @@
|
|
1
|
+
from django.db import models
|
2
|
+
from mojo.models import MojoModel
|
3
|
+
|
4
|
+
|
5
|
+
class Ticket(models.Model, MojoModel):
|
6
|
+
class Meta:
|
7
|
+
ordering = ['-created']
|
8
|
+
|
9
|
+
class RestMeta:
|
10
|
+
VIEW_PERMS = ['view_incidents']
|
11
|
+
SAVE_PERMS = ['manage_incidents']
|
12
|
+
GRAPHS = {
|
13
|
+
"default": {
|
14
|
+
"graphs": {
|
15
|
+
"assignee": "basic",
|
16
|
+
"incident": "basic",
|
17
|
+
"user": "basic",
|
18
|
+
"group": "basic"
|
19
|
+
}
|
20
|
+
},
|
21
|
+
}
|
22
|
+
|
23
|
+
created = models.DateTimeField(auto_now_add=True, editable=False)
|
24
|
+
modified = models.DateTimeField(auto_now=True)
|
25
|
+
|
26
|
+
user = models.ForeignKey("account.User", blank=True, null=True, default=None, related_name="+", on_delete=models.SET_NULL)
|
27
|
+
group = models.ForeignKey("account.Group", blank=True, null=True, default=None, related_name="+", on_delete=models.SET_NULL)
|
28
|
+
|
29
|
+
title = models.CharField(max_length=255)
|
30
|
+
description = models.TextField(blank=True, null=True)
|
31
|
+
|
32
|
+
status = models.CharField(max_length=50, default='open', db_index=True)
|
33
|
+
priority = models.IntegerField(default=1, db_index=True)
|
34
|
+
|
35
|
+
assignee = models.ForeignKey("account.User", blank=True, null=True, default=None, related_name="assigned_tickets", on_delete=models.SET_NULL)
|
36
|
+
incident = models.ForeignKey("incident.Incident", blank=True, null=True, default=None, related_name="tickets", on_delete=models.SET_NULL)
|
37
|
+
|
38
|
+
metadata = models.JSONField(default=dict, blank=True)
|
39
|
+
|
40
|
+
|
41
|
+
class TicketNote(models.Model, MojoModel):
|
42
|
+
class Meta:
|
43
|
+
ordering = ['-created']
|
44
|
+
|
45
|
+
class RestMeta:
|
46
|
+
VIEW_PERMS = ['view_incidents']
|
47
|
+
SAVE_PERMS = ['manage_incidents']
|
48
|
+
GRAPHS = {
|
49
|
+
"default": {
|
50
|
+
"graphs": {
|
51
|
+
"author": "basic",
|
52
|
+
"media": "basic"
|
53
|
+
}
|
54
|
+
},
|
55
|
+
}
|
56
|
+
|
57
|
+
parent = models.ForeignKey(Ticket, related_name="notes", on_delete=models.CASCADE)
|
58
|
+
created = models.DateTimeField(auto_now_add=True, editable=False)
|
59
|
+
|
60
|
+
author = models.ForeignKey("account.User", related_name="+", on_delete=models.CASCADE)
|
61
|
+
note = models.TextField(blank=True, null=True)
|
62
|
+
media = models.ForeignKey("fileman.File", related_name="+", null=True, blank=True, default=None, on_delete=models.SET_NULL)
|
mojo/apps/incident/reporter.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
|
2
2
|
|
3
3
|
# TODO make this async using our task queue
|
4
|
-
|
4
|
+
def report_event(details, title=None, category="api_error", level=1, request=None, **kwargs):
|
5
5
|
from .models import Event
|
6
6
|
event_data = _create_event_dict(details, title, category, level, request, **kwargs)
|
7
7
|
event = Event(**event_data)
|
@@ -19,6 +19,7 @@ def _create_event_dict(details, title=None, category="api_error", level=1, reque
|
|
19
19
|
"title": title,
|
20
20
|
"category": category,
|
21
21
|
"level": level,
|
22
|
+
"uid": kwargs.pop("uid", None),
|
22
23
|
"hostname": kwargs.pop("hostname", None),
|
23
24
|
"model_name": kwargs.pop("model_name", None),
|
24
25
|
"model_id": kwargs.pop("model_id", None),
|
@@ -38,7 +39,24 @@ def _create_event_dict(details, title=None, category="api_error", level=1, reque
|
|
38
39
|
"http_user_agent": request.META.get("HTTP_USER_AGENT", ""),
|
39
40
|
"http_host": request.META.get("HTTP_HOST", "")
|
40
41
|
})
|
41
|
-
|
42
|
-
|
42
|
+
if request.user.is_authenticated:
|
43
|
+
event_data["uid"] = request.user.id
|
44
|
+
event_metadata["user_name"] = request.user.display_name
|
45
|
+
event_metadata["user_email"] = request.user.email
|
46
|
+
|
47
|
+
processed_kwargs = {}
|
48
|
+
for k, v in kwargs.items():
|
49
|
+
if k not in event_data:
|
50
|
+
if is_json_serializable(v):
|
51
|
+
processed_kwargs[k] = v
|
52
|
+
elif hasattr(v, 'id'):
|
53
|
+
processed_kwargs[k] = v.id
|
54
|
+
else:
|
55
|
+
processed_kwargs[k] = str(v)
|
56
|
+
|
57
|
+
event_metadata.update(processed_kwargs)
|
43
58
|
event_data['metadata'] = event_metadata
|
44
59
|
return event_data
|
60
|
+
|
61
|
+
def is_json_serializable(value):
|
62
|
+
return isinstance(value, (str, int, float, bool, type(None), list, dict))
|