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.
Files changed (221) hide show
  1. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/METADATA +3 -2
  2. django_nativemojo-0.1.17.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/commands/serializer_admin.py +121 -1
  5. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  6. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  7. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  8. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  9. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  10. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  11. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  12. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  13. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  14. mojo/apps/account/models/__init__.py +2 -0
  15. mojo/apps/account/models/device.py +279 -0
  16. mojo/apps/account/models/group.py +294 -8
  17. mojo/apps/account/models/member.py +14 -1
  18. mojo/apps/account/models/push/__init__.py +4 -0
  19. mojo/apps/account/models/push/config.py +112 -0
  20. mojo/apps/account/models/push/delivery.py +93 -0
  21. mojo/apps/account/models/push/device.py +66 -0
  22. mojo/apps/account/models/push/template.py +99 -0
  23. mojo/apps/account/models/user.py +190 -17
  24. mojo/apps/account/rest/__init__.py +2 -0
  25. mojo/apps/account/rest/device.py +39 -0
  26. mojo/apps/account/rest/group.py +8 -0
  27. mojo/apps/account/rest/push.py +187 -0
  28. mojo/apps/account/rest/user.py +95 -5
  29. mojo/apps/account/services/__init__.py +1 -0
  30. mojo/apps/account/services/push.py +363 -0
  31. mojo/apps/aws/migrations/0001_initial.py +206 -0
  32. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  33. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  34. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  35. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  36. mojo/apps/aws/models/__init__.py +19 -0
  37. mojo/apps/aws/models/email_attachment.py +99 -0
  38. mojo/apps/aws/models/email_domain.py +218 -0
  39. mojo/apps/aws/models/email_template.py +132 -0
  40. mojo/apps/aws/models/incoming_email.py +197 -0
  41. mojo/apps/aws/models/mailbox.py +288 -0
  42. mojo/apps/aws/models/sent_message.py +175 -0
  43. mojo/apps/aws/rest/__init__.py +6 -0
  44. mojo/apps/aws/rest/email.py +33 -0
  45. mojo/apps/aws/rest/email_ops.py +183 -0
  46. mojo/apps/aws/rest/messages.py +32 -0
  47. mojo/apps/aws/rest/send.py +101 -0
  48. mojo/apps/aws/rest/sns.py +403 -0
  49. mojo/apps/aws/rest/templates.py +19 -0
  50. mojo/apps/aws/services/__init__.py +32 -0
  51. mojo/apps/aws/services/email.py +390 -0
  52. mojo/apps/aws/services/email_ops.py +548 -0
  53. mojo/apps/docit/__init__.py +6 -0
  54. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  55. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  56. mojo/apps/docit/migrations/0001_initial.py +113 -0
  57. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  58. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  59. mojo/apps/docit/models/__init__.py +17 -0
  60. mojo/apps/docit/models/asset.py +231 -0
  61. mojo/apps/docit/models/book.py +227 -0
  62. mojo/apps/docit/models/page.py +319 -0
  63. mojo/apps/docit/models/page_revision.py +203 -0
  64. mojo/apps/docit/rest/__init__.py +10 -0
  65. mojo/apps/docit/rest/asset.py +17 -0
  66. mojo/apps/docit/rest/book.py +22 -0
  67. mojo/apps/docit/rest/page.py +22 -0
  68. mojo/apps/docit/rest/page_revision.py +17 -0
  69. mojo/apps/docit/services/__init__.py +11 -0
  70. mojo/apps/docit/services/docit.py +315 -0
  71. mojo/apps/docit/services/markdown.py +44 -0
  72. mojo/apps/fileman/backends/s3.py +209 -0
  73. mojo/apps/fileman/models/file.py +45 -9
  74. mojo/apps/fileman/models/manager.py +269 -3
  75. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  76. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  77. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  78. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  79. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  80. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  81. mojo/apps/incident/models/__init__.py +1 -0
  82. mojo/apps/incident/models/event.py +35 -0
  83. mojo/apps/incident/models/incident.py +2 -0
  84. mojo/apps/incident/models/ticket.py +62 -0
  85. mojo/apps/incident/reporter.py +21 -3
  86. mojo/apps/incident/rest/__init__.py +1 -0
  87. mojo/apps/incident/rest/ticket.py +43 -0
  88. mojo/apps/jobs/__init__.py +489 -0
  89. mojo/apps/jobs/adapters.py +24 -0
  90. mojo/apps/jobs/cli.py +616 -0
  91. mojo/apps/jobs/daemon.py +370 -0
  92. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  93. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  94. mojo/apps/jobs/handlers/__init__.py +5 -0
  95. mojo/apps/jobs/handlers/webhook.py +317 -0
  96. mojo/apps/jobs/job_engine.py +734 -0
  97. mojo/apps/jobs/keys.py +203 -0
  98. mojo/apps/jobs/local_queue.py +363 -0
  99. mojo/apps/jobs/management/__init__.py +3 -0
  100. mojo/apps/jobs/management/commands/__init__.py +3 -0
  101. mojo/apps/jobs/manager.py +1327 -0
  102. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  103. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  104. mojo/apps/jobs/models/__init__.py +6 -0
  105. mojo/apps/jobs/models/job.py +441 -0
  106. mojo/apps/jobs/rest/__init__.py +2 -0
  107. mojo/apps/jobs/rest/control.py +466 -0
  108. mojo/apps/jobs/rest/jobs.py +421 -0
  109. mojo/apps/jobs/scheduler.py +571 -0
  110. mojo/apps/jobs/services/__init__.py +6 -0
  111. mojo/apps/jobs/services/job_actions.py +465 -0
  112. mojo/apps/jobs/settings.py +209 -0
  113. mojo/apps/logit/models/log.py +3 -0
  114. mojo/apps/metrics/__init__.py +8 -1
  115. mojo/apps/metrics/redis_metrics.py +198 -0
  116. mojo/apps/metrics/rest/__init__.py +3 -0
  117. mojo/apps/metrics/rest/categories.py +266 -0
  118. mojo/apps/metrics/rest/helpers.py +48 -0
  119. mojo/apps/metrics/rest/permissions.py +99 -0
  120. mojo/apps/metrics/rest/values.py +277 -0
  121. mojo/apps/metrics/utils.py +17 -0
  122. mojo/decorators/http.py +40 -1
  123. mojo/helpers/aws/__init__.py +11 -7
  124. mojo/helpers/aws/inbound_email.py +309 -0
  125. mojo/helpers/aws/kms.py +413 -0
  126. mojo/helpers/aws/ses_domain.py +959 -0
  127. mojo/helpers/crypto/__init__.py +1 -1
  128. mojo/helpers/crypto/utils.py +15 -0
  129. mojo/helpers/location/__init__.py +2 -0
  130. mojo/helpers/location/countries.py +262 -0
  131. mojo/helpers/location/geolocation.py +196 -0
  132. mojo/helpers/logit.py +37 -0
  133. mojo/helpers/redis/__init__.py +2 -0
  134. mojo/helpers/redis/adapter.py +606 -0
  135. mojo/helpers/redis/client.py +48 -0
  136. mojo/helpers/redis/pool.py +225 -0
  137. mojo/helpers/request.py +8 -0
  138. mojo/helpers/response.py +8 -0
  139. mojo/middleware/auth.py +1 -1
  140. mojo/middleware/cors.py +40 -0
  141. mojo/middleware/logging.py +131 -12
  142. mojo/middleware/mojo.py +5 -0
  143. mojo/models/rest.py +271 -57
  144. mojo/models/secrets.py +86 -0
  145. mojo/serializers/__init__.py +16 -10
  146. mojo/serializers/core/__init__.py +90 -0
  147. mojo/serializers/core/cache/__init__.py +121 -0
  148. mojo/serializers/core/cache/backends.py +518 -0
  149. mojo/serializers/core/cache/base.py +102 -0
  150. mojo/serializers/core/cache/disabled.py +181 -0
  151. mojo/serializers/core/cache/memory.py +287 -0
  152. mojo/serializers/core/cache/redis.py +533 -0
  153. mojo/serializers/core/cache/utils.py +454 -0
  154. mojo/serializers/{manager.py → core/manager.py} +53 -4
  155. mojo/serializers/core/serializer.py +475 -0
  156. mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
  157. mojo/serializers/suggested_improvements.md +388 -0
  158. testit/client.py +1 -1
  159. testit/helpers.py +14 -0
  160. testit/runner.py +23 -6
  161. django_nativemojo-0.1.15.dist-info/RECORD +0 -234
  162. mojo/apps/notify/README.md +0 -91
  163. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  164. mojo/apps/notify/admin.py +0 -52
  165. mojo/apps/notify/handlers/example_handlers.py +0 -516
  166. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  167. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  168. mojo/apps/notify/handlers/ses/message.py +0 -86
  169. mojo/apps/notify/management/commands/__init__.py +0 -1
  170. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  171. mojo/apps/notify/mod +0 -0
  172. mojo/apps/notify/models/__init__.py +0 -12
  173. mojo/apps/notify/models/account.py +0 -128
  174. mojo/apps/notify/models/attachment.py +0 -24
  175. mojo/apps/notify/models/bounce.py +0 -68
  176. mojo/apps/notify/models/complaint.py +0 -40
  177. mojo/apps/notify/models/inbox.py +0 -113
  178. mojo/apps/notify/models/inbox_message.py +0 -173
  179. mojo/apps/notify/models/outbox.py +0 -129
  180. mojo/apps/notify/models/outbox_message.py +0 -288
  181. mojo/apps/notify/models/template.py +0 -30
  182. mojo/apps/notify/providers/aws.py +0 -73
  183. mojo/apps/notify/rest/ses.py +0 -0
  184. mojo/apps/notify/utils/__init__.py +0 -2
  185. mojo/apps/notify/utils/notifications.py +0 -404
  186. mojo/apps/notify/utils/parsing.py +0 -202
  187. mojo/apps/notify/utils/render.py +0 -144
  188. mojo/apps/tasks/README.md +0 -118
  189. mojo/apps/tasks/__init__.py +0 -44
  190. mojo/apps/tasks/manager.py +0 -644
  191. mojo/apps/tasks/rest/__init__.py +0 -2
  192. mojo/apps/tasks/rest/hooks.py +0 -0
  193. mojo/apps/tasks/rest/tasks.py +0 -76
  194. mojo/apps/tasks/runner.py +0 -439
  195. mojo/apps/tasks/task.py +0 -99
  196. mojo/apps/tasks/tq_handlers.py +0 -132
  197. mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
  198. mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
  199. mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
  200. mojo/helpers/redis.py +0 -10
  201. mojo/models/meta.py +0 -262
  202. mojo/serializers/advanced/README.md +0 -363
  203. mojo/serializers/advanced/__init__.py +0 -247
  204. mojo/serializers/advanced/formats/__init__.py +0 -28
  205. mojo/serializers/advanced/formats/excel.py +0 -516
  206. mojo/serializers/advanced/formats/json.py +0 -239
  207. mojo/serializers/advanced/formats/response.py +0 -485
  208. mojo/serializers/advanced/serializer.py +0 -568
  209. mojo/serializers/optimized.py +0 -618
  210. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.17.dist-info}/WHEEL +0 -0
  213. /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
  214. /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
  215. /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
  216. /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
  217. /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
  218. /mojo/{serializers → rest}/openapi.py +0 -0
  219. /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
  220. /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
  221. /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
- "settings", "is_active", "is_default"],
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
- "settings", "is_active", "is_default"],
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
+ ]
@@ -2,3 +2,4 @@ from .event import Event
2
2
  from .rule import RuleSet, Rule
3
3
  from .incident import Incident
4
4
  from .history import IncidentHistory
5
+ from .ticket import Ticket, TicketNote
@@ -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)
@@ -1,7 +1,7 @@
1
1
 
2
2
 
3
3
  # TODO make this async using our task queue
4
- async def report_event(details, title=None, category="api_error", level=1, request=None, **kwargs):
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
- event_metadata.update({k: v for k, v in kwargs.items() if k not in event_data})
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))