django-nativemojo 0.1.15__py3-none-any.whl → 0.1.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/METADATA +3 -1
  2. django_nativemojo-0.1.16.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 +281 -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.16.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.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
mojo/models/rest.py CHANGED
@@ -1,12 +1,12 @@
1
1
  # from django.http import JsonResponse
2
2
  from mojo.helpers.response import JsonResponse
3
- from mojo.serializers.simple import GraphSerializer
4
- from mojo.serializers.manager import get_serializer_manager
5
- from mojo.helpers import modules
3
+ from mojo.serializers import get_serializer_manager
6
4
  from mojo.helpers.settings import settings
5
+ from mojo import errors as me
7
6
  from django.core.exceptions import ObjectDoesNotExist
8
7
  from django.db import transaction, models as dm
9
8
  import objict
9
+ import datetime
10
10
  from mojo.helpers import dates, logit
11
11
 
12
12
 
@@ -14,6 +14,17 @@ logger = logit.get_logger("debug", "debug.log")
14
14
  ACTIVE_REQUEST = None
15
15
  LOGGING_CLASS = None
16
16
  MOJO_APP_STATUS_200_ON_ERROR = settings.MOJO_APP_STATUS_200_ON_ERROR
17
+ MOJO_REST_LIST_PERM_DENY = settings.get("MOJO_REST_LIST_PERM_DENY", False)
18
+
19
+ # use this when there is no ACTIVE_REQUEST
20
+ SYSTEM_REQUEST = objict.objict()
21
+ SYSTEM_REQUEST.user = objict.objict()
22
+ SYSTEM_REQUEST.user.id = 1
23
+ SYSTEM_REQUEST.user.username = "system"
24
+ SYSTEM_REQUEST.user.email = ""
25
+ SYSTEM_REQUEST.user.is_authenticated = True
26
+ SYSTEM_REQUEST.user.has_permission = lambda perm: True
27
+ SYSTEM_REQUEST.DATA = objict.objict()
17
28
 
18
29
  class MojoModel:
19
30
  """Base model class for REST operations with GraphSerializer integration."""
@@ -52,6 +63,17 @@ class MojoModel:
52
63
  return default
53
64
  return getattr(cls.RestMeta, name, default)
54
65
 
66
+ @classmethod
67
+ def get_rest_meta_graph(cls, graph_name):
68
+ graphs = cls.get_rest_meta_prop("GRAPHS", {})
69
+ if isinstance(graph_name, list):
70
+ for n in graph_name:
71
+ res = graphs.get(n, None)
72
+ if res is not None:
73
+ return res
74
+ return None
75
+ return graphs.get(graph_name, None)
76
+
55
77
  @classmethod
56
78
  def rest_error_response(cls, request, status=500, **kwargs):
57
79
  """
@@ -121,6 +143,16 @@ class MojoModel:
121
143
  except ObjectDoesNotExist:
122
144
  return cls.rest_error_response(None, 404, error=f"{cls.__name__} not found")
123
145
 
146
+ @classmethod
147
+ def get_instance_from_request(cls, request, field_name=None):
148
+ if field_name is None:
149
+ field_name = cls.__name__.lower()
150
+ if field_name not in request.DATA:
151
+ field_name = f"{field_name}_id"
152
+ if field_name not in request.DATA:
153
+ return None
154
+ return cls.objects.filter(pk=request.DATA.get(field_name)).last()
155
+
124
156
  @classmethod
125
157
  def rest_check_permission(cls, request, permission_keys, instance=None):
126
158
  """
@@ -153,6 +185,25 @@ class MojoModel:
153
185
  return False
154
186
 
155
187
  if instance is not None:
188
+ is_view = isinstance(permission_keys, list) and "VIEW_PERMS" in permission_keys
189
+ if not is_view and isinstance(permission_keys, str):
190
+ is_view = permission_keys == "VIEW_PERMS"
191
+ if is_view:
192
+ if hasattr(instance, "check_view_permission"):
193
+ allowed = instance.check_view_permission(perms, request)
194
+ if not allowed:
195
+ cls.class_report_incident(
196
+ details="Permission denied: view_permission_denied",
197
+ event_type="view_permission_denied",
198
+ request=request,
199
+ perms=perms,
200
+ permission_keys=permission_keys,
201
+ branch="instance.check_view_permission",
202
+ instance=repr(instance),
203
+ request_path=getattr(request, "path", None),
204
+ )
205
+ return allowed
206
+
156
207
  if hasattr(instance, "check_edit_permission"):
157
208
  allowed = instance.check_edit_permission(perms, request)
158
209
  if not allowed:
@@ -167,12 +218,17 @@ class MojoModel:
167
218
  request_path=getattr(request, "path", None),
168
219
  )
169
220
  return allowed
170
- if "owner" in perms and getattr(instance, "user", None) is not None:
171
- if instance.user.id == request.user.id:
221
+
222
+ if "owner" in perms:
223
+ owner_field = instance.get_rest_meta_prop("OWNER_FIELD", "user")
224
+ owner = getattr(instance, owner_field, None)
225
+ if owner is not None and owner.id == request.user.id:
172
226
  return True
227
+ if hasattr(instance, "group"):
228
+ request.group = getattr(instance, "group", None)
173
229
 
174
230
  if request.group and hasattr(cls, "group"):
175
- allowed = request.group.member_has_permission(request.user, perms)
231
+ allowed = request.group.user_has_permission(request.user, perms)
176
232
  if not allowed:
177
233
  cls.class_report_incident(
178
234
  details="Permission denied: group_member_permission_denied",
@@ -181,12 +237,13 @@ class MojoModel:
181
237
  perms=perms,
182
238
  permission_keys=permission_keys,
183
239
  group=getattr(request, "group", None),
184
- branch="group.member_has_permission",
240
+ branch="group.user_has_permission",
185
241
  instance=repr(instance) if instance else None,
186
242
  request_path=getattr(request, "path", None),
187
243
  )
188
244
  return allowed
189
-
245
+ if request.user is None or not request.user.is_authenticated:
246
+ return False
190
247
  allowed = request.user.has_permission(perms)
191
248
  if not allowed:
192
249
  cls.class_report_incident(
@@ -263,10 +320,41 @@ class MojoModel:
263
320
  Returns:
264
321
  JsonResponse representing the list of resources.
265
322
  """
266
- cls.debug("on_rest_handle_list")
323
+ # cls.debug("on_rest_handle_list")
267
324
  if cls.rest_check_permission(request, "VIEW_PERMS"):
268
325
  return cls.on_rest_list(request)
269
- return cls.rest_error_response(request, 403, error=f"GET permission denied: {cls.__name__}")
326
+ else:
327
+ perms = cls.get_rest_meta_prop("VIEW_PERMS", [])
328
+ if perms and "owner" in perms and request.user.is_authenticated:
329
+ owner_field = cls.get_rest_meta_prop("OWNER_FIELD", "user")
330
+ q = {owner_field: request.user}
331
+ return cls.on_rest_list(request, cls.objects.filter(**q))
332
+ if MOJO_REST_LIST_PERM_DENY:
333
+ return cls.rest_error_response(request, 403, error=f"GET permission denied: {cls.__name__}")
334
+ return cls.on_rest_list_response(request, cls.objects.none())
335
+
336
+ def update_from_dict(self, dict_data):
337
+ request = ACTIVE_REQUEST or SYSTEM_REQUEST
338
+ return self.on_rest_save(request, dict_data)
339
+
340
+ @classmethod
341
+ def create_from_dict(cls, dict_data, **kwargs):
342
+ request = kwargs.pop('request', ACTIVE_REQUEST or SYSTEM_REQUEST)
343
+ instance = cls(**kwargs)
344
+ instance.on_rest_save(request, dict_data)
345
+ instance.on_rest_created()
346
+ if cls.get_rest_meta_prop("LOG_CHANGES", False):
347
+ instance.log(kind="model:created", log=f"{request.user.username} created {instance.pk}")
348
+ return instance
349
+
350
+ @classmethod
351
+ def create_from_request(cls, request, **kwargs):
352
+ instance = cls(**kwargs)
353
+ instance.on_rest_save(request, request.DATA)
354
+ instance.on_rest_created()
355
+ if cls.get_rest_meta_prop("LOG_CHANGES", False):
356
+ instance.log(kind="model:created", log=f"{ACTIVE_REQUEST.user.username} created {instance.pk}")
357
+ return instance
270
358
 
271
359
  @classmethod
272
360
  def on_rest_handle_create(cls, request):
@@ -279,10 +367,8 @@ class MojoModel:
279
367
  Returns:
280
368
  JsonResponse representing the result of the create operation.
281
369
  """
282
- if cls.rest_check_permission(request, ["SAVE_PERMS", "VIEW_PERMS"]):
283
- instance = cls()
284
- instance.on_rest_save(request, request.DATA)
285
- instance.on_rest_created()
370
+ if cls.rest_check_permission(request, ["CREATE_PERMS", "SAVE_PERMS", "VIEW_PERMS"]):
371
+ instance = cls.create_from_request(request)
286
372
  return instance.on_rest_get(request)
287
373
  return cls.rest_error_response(request, 403, error=f"CREATE permission denied: {cls.__name__}")
288
374
 
@@ -314,31 +400,62 @@ class MojoModel:
314
400
  Returns:
315
401
  JsonResponse representing the paginated and serialized list of objects.
316
402
  """
317
- cls.debug("on_rest_list:start")
318
403
  if queryset is None:
319
404
  queryset = cls.objects.all()
320
- if request.group is not None and hasattr(cls, "group"):
321
- if "group" in request.DATA:
322
- del request.DATA["group"]
323
- queryset = queryset.filter(group=request.group)
405
+ # for better query perfomance we want raw request GET data
406
+ request.QUERY_PARAMS = request.GET.copy()
407
+ if request.group is not None:
408
+ GROUP_FIELD = cls.get_rest_meta_prop("GROUP_FIELD", None)
409
+ if GROUP_FIELD or hasattr(cls, "group"):
410
+ if "group" in request.QUERY_PARAMS:
411
+ del request.QUERY_PARAMS["group"]
412
+ if GROUP_FIELD:
413
+ q = {GROUP_FIELD: request.group}
414
+ else:
415
+ q = {"group": request.group}
416
+ queryset = queryset.filter(**q)
324
417
  queryset = cls.on_rest_list_filter(request, queryset)
325
418
  queryset = cls.on_rest_list_date_range_filter(request, queryset)
326
419
  queryset = cls.on_rest_list_sort(request, queryset)
327
- cls.debug("on_rest_list:end")
328
420
  return cls.on_rest_list_response(request, queryset)
329
421
 
330
422
  @classmethod
331
423
  def on_rest_list_response(cls, request, queryset):
332
424
  # Implement pagination
333
- page_size = request.DATA.get_typed("size", 10, int)
334
- page_start = request.DATA.get_typed("start", 0, int)
425
+ page_size = request.DATA.get_typed(["size", "limit"], 10, int)
426
+ page_start = request.DATA.get_typed(["start", "offset"], 0, int)
335
427
  page_end = page_start+page_size
336
428
  paged_queryset = queryset[page_start:page_end]
337
429
  graph = request.DATA.get("graph", "list")
430
+ format = request.DATA.get("download_format", "json")
431
+ count = queryset.count()
338
432
  # Use serializer manager for optimal performance
339
433
  manager = get_serializer_manager()
434
+ if format != "json":
435
+ format_key = format.split("_")[0]
436
+ serializer = manager.get_format_serializer(format_key)
437
+ formats = cls.get_rest_meta_prop("FORMATS")
438
+ if formats is not None and format in formats:
439
+ fields = formats[format]
440
+ else:
441
+ graph = cls.get_rest_meta_graph(["basic", "default"])
442
+ if not graph or not graph.get("fields"):
443
+ raise me.ValueException("No valid graph found")
444
+ fields = graph.get("fields")
445
+ logger.info(f"Serializing queryset with fields: {fields}")
446
+ return serializer.serialize_queryset(queryset, fields=fields, filename=request.DATA.get("filename", f"{cls.__name__}.csv"))
340
447
  serializer = manager.get_serializer(paged_queryset, graph=graph, many=True)
341
- return serializer.to_response(request, count=queryset.count(), start=page_start, size=page_size)
448
+ resp = serializer.to_response(request, count=count, start=page_start, size=page_size)
449
+ resp.log_context = {
450
+ "endpoint": "list",
451
+ "model": cls.__name__,
452
+ "page_size": page_size,
453
+ "page_start": page_start,
454
+ "page_end": page_end,
455
+ "graph": graph,
456
+ "count": count
457
+ }
458
+ return resp
342
459
 
343
460
  @classmethod
344
461
  def on_rest_list_date_range_filter(cls, request, queryset):
@@ -369,6 +486,22 @@ class MojoModel:
369
486
  queryset = queryset.filter(**{f"{dr_field}__lte": dr_end})
370
487
  return queryset
371
488
 
489
+ @classmethod
490
+ def normalize_rest_value(cls, request, field_name, value):
491
+ field = cls.get_model_field(field_name)
492
+ if field.get_internal_type() == "BooleanField":
493
+ if isinstance(value, str):
494
+ value = value.lower() in ("true", "1")
495
+ elif isinstance(value, int):
496
+ value = bool(value)
497
+ else:
498
+ value = bool(value)
499
+ elif value is not None:
500
+ if field.get_internal_type() in ["DateTimeField", "DateField"]:
501
+ if not isinstance(value, (datetime.datetime, datetime.date)):
502
+ value = dates.parse_datetime(value)
503
+ return value
504
+
372
505
  @classmethod
373
506
  def on_rest_list_filter(cls, request, queryset):
374
507
  """
@@ -381,16 +514,22 @@ class MojoModel:
381
514
  Returns:
382
515
  The filtered queryset.
383
516
  """
517
+ reserved_keys = ["start", "size", "download_format", "dr_start", "dr_end", "limit", "offset"]
384
518
  filters = {}
385
- for key, value in request.GET.items():
519
+ for key, value in request.QUERY_PARAMS.items():
386
520
  # Split key to check for foreign key relationships
521
+ if "." in key:
522
+ key = key.replace(".", "__")
387
523
  key_parts = key.split('__')
388
524
  field_name = key_parts[0]
525
+ if field_name in reserved_keys:
526
+ continue
389
527
  if hasattr(cls, field_name):
390
- filters[key] = value
528
+ filters[key] = cls.normalize_rest_value(request, field_name, value)
391
529
  elif field_name in cls.__rest_field_names__ and cls._meta.get_field(field_name).is_relation:
530
+ # TODO Normalize relation field values
392
531
  filters[key] = value
393
- # logger.info("filters", filters)
532
+ logger.info("filters", filters)
394
533
  queryset = cls.on_rest_list_search(request, queryset)
395
534
  return queryset.filter(**filters)
396
535
 
@@ -406,7 +545,7 @@ class MojoModel:
406
545
  Returns:
407
546
  The filtered queryset based on the search criteria.
408
547
  """
409
- search_query = request.GET.get('search', None)
548
+ search_query = request.DATA.get('search', None)
410
549
  if not search_query:
411
550
  return queryset
412
551
 
@@ -500,6 +639,19 @@ class MojoModel:
500
639
  # Perform any additional actions after object creation
501
640
  pass
502
641
 
642
+ def on_rest_response(self, request, graph="default"):
643
+ """
644
+ Handle the response after a REST request.
645
+
646
+ Args:
647
+ request: Django HTTP request object.
648
+ graph: String representing the graph to use for serialization.
649
+
650
+ Returns:
651
+ JsonResponse representing the object.
652
+ """
653
+ return self.on_rest_get(request, graph=graph)
654
+
503
655
  def on_rest_get(self, request, graph="default"):
504
656
  """
505
657
  Handle the retrieval of a single object.
@@ -510,7 +662,7 @@ class MojoModel:
510
662
  Returns:
511
663
  JsonResponse representing the object.
512
664
  """
513
- graph = request.GET.get("graph", graph)
665
+ graph = request.DATA.get("graph", graph)
514
666
  # Use serializer manager for optimal performance
515
667
  manager = get_serializer_manager()
516
668
  serializer = manager.get_serializer(self, graph=graph)
@@ -525,6 +677,15 @@ class MojoModel:
525
677
  def has_field_changed(self, key):
526
678
  return key in self.__changed_fields__
527
679
 
680
+ def has_changed(self):
681
+ return bool(self.__changed_fields__)
682
+
683
+ def get_changes(self, data):
684
+ changes = {}
685
+ for key, value in self.__changed_fields__.items():
686
+ changes[key] = f"{value} -> {data.get(key, None)}"
687
+ return changes
688
+
528
689
  def on_rest_save(self, request, data_dict):
529
690
  """
530
691
  Create a model instance from a dictionary.
@@ -539,47 +700,78 @@ class MojoModel:
539
700
  self.__changed_fields__ = objict.objict()
540
701
  # Get fields that should not be saved
541
702
  no_save_fields = self.get_rest_meta_prop("NO_SAVE_FIELDS", ["id", "pk", "created", "uuid"])
542
-
703
+ post_save_actions = self.get_rest_meta_prop("POST_SAVE_ACTIONS", ['action'])
704
+ post_save_data = {}
705
+ action_resp = None # an action may have a specific response
543
706
  # Iterate through data_dict keys instead of model fields
544
707
  for key, value in data_dict.items():
545
708
  # Skip fields that shouldn't be saved
546
709
  if key in no_save_fields:
547
710
  continue
548
-
549
- # First check for custom setter method
550
- set_field_method = getattr(self, f'set_{key}', None)
551
- if callable(set_field_method):
552
- old_value = getattr(self, key, None)
553
- set_field_method(value)
554
- new_value = getattr(self, key, None)
555
- self._set_field_change(key, old_value, new_value)
556
- continue
557
-
558
- # Check if this is a model field
559
- field = self.get_model_field(key)
560
- if field is None:
711
+ if key in post_save_actions:
712
+ post_save_data[key] = value
561
713
  continue
562
- if field.get_internal_type() == "ForeignKey":
563
- self.on_rest_save_related_field(field, value, request)
564
- elif field.get_internal_type() == "JSONField":
565
- self.on_rest_update_jsonfield(key, value)
566
- else:
567
- self._set_field_change(key, getattr(self, key), value)
568
- setattr(self, key, value)
714
+ self.on_rest_save_field(key, value, request)
569
715
 
570
716
  created = self.pk is None
571
717
  if created:
572
- if request.user.is_authenticated and self.get_model_field("user"):
573
- if getattr(self, "user", None) is None:
574
- self.user = request.user
718
+ owner_field = self.get_rest_meta_prop("CREATED_BY_OWNER_FIELD", "user")
719
+ if request.user.is_authenticated and self.get_model_field(owner_field):
720
+ setattr(self, owner_field, request.user)
575
721
  if request.group and self.get_model_field("group"):
576
722
  if getattr(self, "group", None) is None:
577
723
  self.group = request.group
724
+ else:
725
+ owner_field = self.get_rest_meta_prop("UPDATED_BY_OWNER_FIELD", "modified_by")
726
+ if request.user.is_authenticated and self.get_model_field(owner_field):
727
+ setattr(self, owner_field, request.user)
578
728
  self.on_rest_pre_save(self.__changed_fields__, created)
579
729
  if "files" in data_dict:
580
730
  self.on_rest_save_files(data_dict["files"])
581
731
  self.atomic_save()
582
732
  self.on_rest_saved(self.__changed_fields__, created)
733
+ for key, value in post_save_data.items():
734
+ # post save fields can only be called via on_action_
735
+ handler = getattr(self, f'on_action_{key}', None)
736
+ if callable(handler):
737
+ action_resp = handler(value)
738
+
739
+ if self.get_rest_meta_prop("LOG_CHANGES", False) and self.has_changed():
740
+ self.log(kind="model:changed", log=self.get_changes(data_dict))
741
+ return action_resp
742
+
743
+ def on_rest_save_field(self, key, value, request):
744
+ # First check for custom setter method
745
+ set_field_method = getattr(self, f'set_{key}', None)
746
+ if callable(set_field_method):
747
+ old_value = getattr(self, key, None)
748
+ set_field_method(value)
749
+ new_value = getattr(self, key, None)
750
+ self._set_field_change(key, old_value, new_value)
751
+ return
752
+
753
+ # Check if this is a model field
754
+ field = self.get_model_field(key)
755
+ if field is None:
756
+ return
757
+ if field.get_internal_type() == "ForeignKey":
758
+ self.on_rest_save_related_field(field, value, request)
759
+ elif field.get_internal_type() == "JSONField":
760
+ self.on_rest_update_jsonfield(key, value)
761
+ else:
762
+ if field.get_internal_type() == "BooleanField":
763
+ if isinstance(value, str):
764
+ value = value.lower() in ("true", "1")
765
+ elif isinstance(value, int):
766
+ value = bool(value)
767
+ else:
768
+ value = bool(value)
769
+ elif value is not None:
770
+ if field.get_internal_type() in ["DateTimeField", "DateField"]:
771
+ if not isinstance(value, (datetime.datetime, datetime.date)):
772
+ value = dates.parse_datetime(value)
773
+ self._set_field_change(key, getattr(self, key), value)
774
+ setattr(self, key, value)
583
775
 
584
776
  def on_rest_save_files(self, files):
585
777
  for name, file in files.items():
@@ -599,8 +791,10 @@ class MojoModel:
599
791
  setattr(self, name, instance)
600
792
 
601
793
  def on_rest_save_and_respond(self, request):
602
- self.on_rest_save(request, request.DATA)
603
- return self.on_rest_get(request)
794
+ resp = self.on_rest_save(request, request.DATA)
795
+ if resp is None:
796
+ return self.on_rest_get(request)
797
+ return JsonResponse(resp)
604
798
 
605
799
  def on_rest_save_related_field(self, field, field_value, request):
606
800
  if isinstance(field_value, dict):
@@ -619,8 +813,10 @@ class MojoModel:
619
813
  field.related_model.on_rest_related_save(self, field.name, field_value, related_instance)
620
814
  elif isinstance(field_value, int) or (isinstance(field_value, str)):
621
815
  # self.debug(f"Related Model: {field.related_model.__name__}, Field Value: {field_value}")
816
+ field_value = int(field_value)
622
817
  if not bool(field_value):
623
818
  # None, "", 0 will set it to None
819
+ logger.info(f"Setting field {field.name} to None")
624
820
  setattr(self, field.name, None)
625
821
  return
626
822
  field_value = int(field_value)
@@ -698,13 +894,27 @@ class MojoModel:
698
894
  Instance-level audit/event reporting. Automatically includes model+id.
699
895
  """
700
896
  context = dict(context)
701
- context.setdefault("model_name", self.__class__.__name__)
702
- if hasattr(self, 'id'):
897
+ context.setdefault("model_name", self.__class__.get_model_string())
898
+ if hasattr(self, 'id') and self.id is not None:
703
899
  context.setdefault("model_id", self.id)
704
900
  self.__class__.class_report_incident(
705
901
  details, event_type=event_type, level=level, request=request, **context
706
902
  )
707
903
 
904
+ @classmethod
905
+ def class_report_incident_for_user(cls, details, event_type="info", level=1, request=None, **context):
906
+ """
907
+ Class-level audit/event reporting for a specific user.
908
+ details: Human description.
909
+ event_type: Category/kind (e.g. "permission_denied", "security_alert").
910
+ level: Numeric severity.
911
+ request: Optional HTTP request or actor.
912
+ **context: Any additional context.
913
+ """
914
+ if ACTIVE_REQUEST and ACTIVE_REQUEST.user.is_authenticated:
915
+ return request.user.report_incident(details, event_type=event_type, level=level, request=request, **context)
916
+ return cls.class_report_incident(details, event_type=event_type, level=level, request=request, **context)
917
+
708
918
  @classmethod
709
919
  def class_report_incident(cls, details, event_type="info", level=1, request=None, **context):
710
920
  """
@@ -737,10 +947,14 @@ class MojoModel:
737
947
  def debug(cls, log, *args):
738
948
  return logger.info(log, *args)
739
949
 
950
+ @classmethod
951
+ def get_model_string(cls):
952
+ return f"{ cls._meta.app_label.lower()}.{cls.__name__}"
953
+
740
954
  @classmethod
741
955
  def class_logit(cls, request, log, kind="cls_log", model_id=0, level="info", **kwargs):
742
956
  from mojo.apps.logit.models import Log
743
- return Log.logit(request, log, kind, cls.__name__, model_id, level, **kwargs)
957
+ return Log.logit(request, log, kind, cls.get_model_string(), model_id, level, **kwargs)
744
958
 
745
959
  @classmethod
746
960
  def get_model_field(cls, field_name):
mojo/models/secrets.py CHANGED
@@ -66,3 +66,89 @@ class MojoSecrets(models.Model):
66
66
  super().save(*args, **kwargs)
67
67
  self.save_secrets()
68
68
  super().save()
69
+
70
+
71
+
72
+ class KSMSecrets(models.Model):
73
+ """Base model class for adding secrets to a model using AWS KMS (envelope encryption)."""
74
+ class Meta:
75
+ abstract = True
76
+
77
+ mojo_secrets = models.TextField(blank=True, null=True, default=None)
78
+ _exposed_secrets = None
79
+ _secrets_changed = False
80
+ _kms_cache = {}
81
+
82
+ def set_secrets(self, value):
83
+ self.debug("Setting secrets", repr(value))
84
+ if isinstance(value, str):
85
+ value = objict.from_json(value)
86
+ self._exposed_secrets = merge_dicts(self.secrets, value)
87
+ self._secrets_changed = True
88
+
89
+ def set_secret(self, key, value):
90
+ self.secrets[key] = value
91
+ self._secrets_changed = True
92
+
93
+ def get_secret(self, key, default=None):
94
+ return self.secrets.get(key, default)
95
+
96
+ def clear_secrets(self):
97
+ self.mojo_secrets = None
98
+ self._exposed_secrets = objict()
99
+ self._secrets_changed = True
100
+
101
+ @property
102
+ def secrets(self):
103
+ if self._exposed_secrets is not None:
104
+ return self._exposed_secrets
105
+ if self.mojo_secrets is None or self.pk is None:
106
+ self._exposed_secrets = objict()
107
+ return self._exposed_secrets
108
+ if self._exposed_secrets is None:
109
+ try:
110
+ data = self._get_kms().decrypt_dict_field(self._kms_context(), self.mojo_secrets)
111
+ # convert to objict mapping
112
+ self._exposed_secrets = objict.from_dict(data)
113
+ except Exception:
114
+ # On failure, expose empty to avoid leaking details
115
+ self._exposed_secrets = objict()
116
+ return self._exposed_secrets
117
+
118
+ def save_secrets(self):
119
+ if self._secrets_changed:
120
+ if self._exposed_secrets:
121
+ # objict behaves like a dict; KMSHelper accepts dict
122
+ blob = self._get_kms().encrypt_field(self._kms_context(), self._exposed_secrets)
123
+ self.mojo_secrets = blob
124
+ else:
125
+ self.mojo_secrets = None
126
+ self._secrets_changed = False
127
+
128
+ def save(self, *args, **kwargs):
129
+ if self.pk is not None:
130
+ self.save_secrets()
131
+ super().save(*args, **kwargs)
132
+ else:
133
+ super().save(*args, **kwargs)
134
+ self.save_secrets()
135
+ super().save()
136
+
137
+ @classmethod
138
+ def _get_kms(cls):
139
+ kms_key_id = settings.get("KMS_KEY_ID", None)
140
+ region = settings.get("AWS_REGION", settings.get("AWS_DEFAULT_REGION", "us-east-1"))
141
+ if not kms_key_id:
142
+ raise RuntimeError("KMS_KEY_ID must be configured to use KSMSecrets")
143
+ cache_key = (kms_key_id, region)
144
+ helper = cls._kms_cache.get(cache_key)
145
+ if helper is None:
146
+ from mojo.helpers.aws.kms import KMSHelper
147
+ helper = KMSHelper(kms_key_id=kms_key_id, region_name=region)
148
+ cls._kms_cache[cache_key] = helper
149
+ return helper
150
+
151
+ def _kms_context(self):
152
+ # Bind to app_label.Model.<pk>.mojo_secrets for contextual integrity
153
+ app_label = getattr(self._meta, "app_label", self.__class__.__module__.split(".")[0])
154
+ return f"{app_label}.{self.__class__.__name__}.{self.pk}.mojo_secrets"
@@ -24,16 +24,10 @@ Usage:
24
24
 
25
25
  # Core serializer classes
26
26
  from .simple import GraphSerializer
27
- from .optimized import OptimizedGraphSerializer
28
27
 
29
- # Advanced serializer (optional - may not be available)
30
- try:
31
- from .advanced import AdvancedGraphSerializer
32
- except ImportError:
33
- AdvancedGraphSerializer = None
34
-
35
- # Manager and convenience functions
36
- from .manager import (
28
+ # New optimized core system (default)
29
+ from .core import (
30
+ OptimizedGraphSerializer,
37
31
  SerializerManager,
38
32
  get_serializer_manager,
39
33
  register_serializer,
@@ -43,9 +37,17 @@ from .manager import (
43
37
  to_response,
44
38
  get_performance_stats,
45
39
  clear_serializer_caches,
46
- benchmark_serializers
40
+ benchmark_serializers,
41
+ HAS_UJSON,
42
+ UJSON_VERSION
47
43
  )
48
44
 
45
+ # Advanced serializer (optional - may not be available)
46
+ try:
47
+ from .advanced import AdvancedGraphSerializer
48
+ except ImportError:
49
+ AdvancedGraphSerializer = None
50
+
49
51
  # Version and metadata
50
52
  __version__ = "2.0.0"
51
53
  __author__ = "Django-MOJO Team"
@@ -74,6 +76,10 @@ __all__ = [
74
76
  'get_performance_stats',
75
77
  'clear_serializer_caches',
76
78
  'benchmark_serializers',
79
+
80
+ # Performance info
81
+ 'HAS_UJSON',
82
+ 'UJSON_VERSION',
77
83
  ]
78
84
 
79
85
  # Initialize default manager on import