statezero 0.1.0b1__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 (45) hide show
  1. statezero/__init__.py +0 -0
  2. statezero/adaptors/__init__.py +0 -0
  3. statezero/adaptors/django/__init__.py +0 -0
  4. statezero/adaptors/django/apps.py +97 -0
  5. statezero/adaptors/django/config.py +99 -0
  6. statezero/adaptors/django/context_manager.py +12 -0
  7. statezero/adaptors/django/event_emitters.py +78 -0
  8. statezero/adaptors/django/exception_handler.py +98 -0
  9. statezero/adaptors/django/extensions/__init__.py +0 -0
  10. statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
  11. statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +141 -0
  12. statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +75 -0
  13. statezero/adaptors/django/f_handler.py +312 -0
  14. statezero/adaptors/django/helpers.py +153 -0
  15. statezero/adaptors/django/middleware.py +10 -0
  16. statezero/adaptors/django/migrations/0001_initial.py +33 -0
  17. statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +16 -0
  18. statezero/adaptors/django/migrations/__init__.py +0 -0
  19. statezero/adaptors/django/orm.py +915 -0
  20. statezero/adaptors/django/permissions.py +252 -0
  21. statezero/adaptors/django/query_optimizer.py +772 -0
  22. statezero/adaptors/django/schemas.py +324 -0
  23. statezero/adaptors/django/search_providers/__init__.py +0 -0
  24. statezero/adaptors/django/search_providers/basic_search.py +24 -0
  25. statezero/adaptors/django/search_providers/postgres_search.py +51 -0
  26. statezero/adaptors/django/serializers.py +554 -0
  27. statezero/adaptors/django/urls.py +14 -0
  28. statezero/adaptors/django/views.py +336 -0
  29. statezero/core/__init__.py +34 -0
  30. statezero/core/ast_parser.py +821 -0
  31. statezero/core/ast_validator.py +266 -0
  32. statezero/core/classes.py +167 -0
  33. statezero/core/config.py +263 -0
  34. statezero/core/context_storage.py +4 -0
  35. statezero/core/event_bus.py +175 -0
  36. statezero/core/event_emitters.py +60 -0
  37. statezero/core/exceptions.py +106 -0
  38. statezero/core/interfaces.py +492 -0
  39. statezero/core/process_request.py +184 -0
  40. statezero/core/types.py +63 -0
  41. statezero-0.1.0b1.dist-info/METADATA +252 -0
  42. statezero-0.1.0b1.dist-info/RECORD +45 -0
  43. statezero-0.1.0b1.dist-info/WHEEL +5 -0
  44. statezero-0.1.0b1.dist-info/licenses/license.md +117 -0
  45. statezero-0.1.0b1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,915 @@
1
+ import logging
2
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union
3
+
4
+ import networkx as nx
5
+ from django.apps import apps
6
+ from django.db import models
7
+ from django.db.models import Avg, Count, Max, Min, Q, Sum, QuerySet
8
+ from django.db.models.signals import post_delete, post_save, pre_delete, pre_save
9
+ from django.dispatch import receiver
10
+ from rest_framework import serializers
11
+
12
+
13
+ from statezero.adaptors.django.config import config, registry
14
+ from statezero.core.classes import FieldNode, ModelNode
15
+ from statezero.core.event_bus import EventBus
16
+ from statezero.core.exceptions import (MultipleObjectsReturned,
17
+ NotFound, PermissionDenied,
18
+ ValidationError)
19
+ from statezero.core.interfaces import (AbstractCustomQueryset,
20
+ AbstractORMProvider, AbstractPermission)
21
+ from statezero.core.types import ActionType, RequestType
22
+
23
+ logger = logging.getLogger(__name__)
24
+ logger.setLevel(logging.DEBUG)
25
+
26
+
27
+ # -------------------------------------------------------------------
28
+ # AST Visitor for Django (builds Django Q objects)
29
+ # -------------------------------------------------------------------
30
+ class QueryASTVisitor:
31
+ SUPPORTED_OPERATORS: Set[str] = {
32
+ "contains",
33
+ "icontains",
34
+ "startswith",
35
+ "istartswith",
36
+ "endswith",
37
+ "iendswith",
38
+ "lt",
39
+ "gt",
40
+ "lte",
41
+ "gte",
42
+ "in",
43
+ "eq",
44
+ "exact",
45
+ "isnull",
46
+ }
47
+
48
+ def __init__(self, model: Type[models.Model]) -> None:
49
+ self.model = model
50
+
51
+ def visit(self, node: Dict[str, Any]) -> Q:
52
+ """Process an AST node and return a Django Q object."""
53
+ if not node:
54
+ return Q()
55
+
56
+ node_type: str = node.get("type")
57
+ if not node_type:
58
+ # Handle implicit filter nodes (raw dict conditions)
59
+ if isinstance(node, dict) and "conditions" in node:
60
+ return self.visit_filter(node)
61
+ return Q()
62
+
63
+ method = getattr(self, f"visit_{node_type}", None)
64
+ if not method:
65
+ raise ValidationError(f"Unsupported AST node type: {node_type}")
66
+ return method(node)
67
+
68
+ def _combine(
69
+ self, children: List[Dict[str, Any]], combine_func: Callable[[Q, Q], Q]
70
+ ) -> Q:
71
+ """Combine multiple Q objects using the provided function (AND/OR)."""
72
+ if not children:
73
+ return Q() # Return an identity filter when no children exist.
74
+ q = self.visit(children[0])
75
+ for child in children[1:]:
76
+ q = combine_func(q, self.visit(child))
77
+ return q
78
+
79
+ def _process_field_lookup(self, field: str, value: Any) -> Tuple[str, Any]:
80
+ """
81
+ This used to contain logic, right now it just passes through the field and value.
82
+
83
+ Args:
84
+ field: The field lookup string (e.g., 'datetime_field__hour__gt')
85
+ value: The value to filter by
86
+
87
+ Returns:
88
+ A tuple of (lookup, value)
89
+ """
90
+ return field, value
91
+
92
+ def visit_filter(self, node: Dict[str, Any]) -> Q:
93
+ """Process a filter node, handling both conditions and Q objects."""
94
+ q = Q()
95
+
96
+ # Process direct conditions
97
+ conditions: Dict[str, Any] = node.get("conditions", {})
98
+ for field, value in conditions.items():
99
+ lookup, processed_value = self._process_field_lookup(field, value)
100
+ q &= Q(**{lookup: processed_value})
101
+
102
+ # Handle Q list format for OR conditions
103
+ q_objects = node.get("Q", [])
104
+ if q_objects:
105
+ q_combined = None
106
+ for q_condition in q_objects:
107
+ q_part = Q()
108
+ for field, value in q_condition.items():
109
+ lookup, processed_value = self._process_field_lookup(field, value)
110
+ q_part &= Q(**{lookup: processed_value})
111
+ if q_combined is None:
112
+ q_combined = q_part
113
+ else:
114
+ q_combined |= q_part
115
+ if q_combined:
116
+ q &= q_combined
117
+ return q
118
+
119
+ def visit_exclude(self, node: Dict[str, Any]) -> Q:
120
+ """Process an exclude node by negating the inner filter."""
121
+ # Handle child nodes if present
122
+ if "child" in node and node["child"]:
123
+ inner_q = self.visit(node["child"])
124
+ return ~inner_q
125
+
126
+ # Otherwise, treat it as a filter but negate the result
127
+ return ~self.visit_filter(node)
128
+
129
+ def visit_and(self, node: Dict[str, Any]) -> Q:
130
+ """Process an AND node by combining all children with AND."""
131
+ return self._combine(node.get("children", []), lambda a, b: a & b)
132
+
133
+ def visit_or(self, node: Dict[str, Any]) -> Q:
134
+ """Process an OR node by combining all children with OR."""
135
+ return self._combine(node.get("children", []), lambda a, b: a | b)
136
+
137
+ def visit_search(self, node: Dict[str, Any]) -> Q:
138
+ """
139
+ Process a search node.
140
+ Since search is applied as a query modifier in the AST parser and via the ORM adapter,
141
+ simply return an empty Q object.
142
+ """
143
+ return Q()
144
+
145
+ # -------------------------------------------------------------------
146
+ # Django ORM Adapter (implements our generic engine/provider)
147
+ # -------------------------------------------------------------------
148
+ def check_object_permissions(
149
+ req: Any,
150
+ instance: Any,
151
+ action: ActionType,
152
+ permissions: List[Type[AbstractPermission]],
153
+ model: Type,
154
+ ) -> None:
155
+ """
156
+ Check if the given action is allowed on the instance using each permission class.
157
+ Raises PermissionDenied if none of the permissions grant access.
158
+ """
159
+ allowed_obj_actions = set()
160
+ for perm_cls in permissions:
161
+ perm = perm_cls()
162
+ allowed_obj_actions |= perm.allowed_object_actions(req, instance, model)
163
+ if action not in allowed_obj_actions:
164
+ raise PermissionDenied(
165
+ f"Object-level permission denied: Missing {action.value} on object {instance}"
166
+ )
167
+
168
+
169
+ def check_bulk_permissions(
170
+ req: Any,
171
+ items: models.QuerySet,
172
+ action: ActionType,
173
+ permissions: List[Type[AbstractPermission]],
174
+ model: Type,
175
+ ) -> None:
176
+ """
177
+ If the queryset contains one or fewer items, perform individual permission checks.
178
+ Otherwise, loop over permission classes and call bulk_operation_allowed.
179
+ If none allow the bulk operation, raise PermissionDenied.
180
+ """
181
+ if items.count() <= 1:
182
+ for instance in items:
183
+ check_object_permissions(req, instance, action, permissions, model)
184
+ else:
185
+ allowed = False
186
+ for perm_cls in permissions:
187
+ perm = perm_cls()
188
+ # Assume bulk_operation_allowed is defined on all permission classes.
189
+ if perm.bulk_operation_allowed(req, items, action, model):
190
+ allowed = True
191
+ break
192
+ if not allowed:
193
+ raise PermissionDenied(
194
+ f"Bulk {action.value} operation not permitted on queryset"
195
+ )
196
+
197
+
198
+ class DjangoORMAdapter(AbstractORMProvider):
199
+ def __init__(self) -> None:
200
+ self.queryset: Optional[Any] = None # Django QuerySet
201
+ self.model: Optional[Type[models.Model]] = None
202
+
203
+ def set_queryset(self, queryset: Any) -> None:
204
+ self.queryset = queryset
205
+ self.model = queryset.model
206
+
207
+ # --- QueryEngine Methods ---
208
+ def filter_node(self, node: Dict[str, Any]) -> None:
209
+ """Apply a filter node to the queryset."""
210
+ assert self.model is not None, "Model must be set before filtering."
211
+ visitor = QueryASTVisitor(self.model)
212
+ q_object = visitor.visit(node)
213
+ self.queryset = self.queryset.filter(q_object)
214
+
215
+ def search_node(self, search_query: str, search_fields: Set[str]) -> None:
216
+ """
217
+ Update the current queryset by applying a full-text search.
218
+ Assumes that the queryset and model are already set.
219
+ Uses the search_provider from the global configuration.
220
+ """
221
+ # Ensure that a model is set (queryset should already be there as well).
222
+ assert self.model is not None, "Model must be set before applying search."
223
+ self.queryset = config.search_provider.search(self.queryset, search_query, search_fields)
224
+
225
+ def exclude_node(self, node: Dict[str, Any]) -> None:
226
+ """Apply an exclude node to the queryset."""
227
+ assert self.model is not None, "Model must be set before filtering."
228
+ visitor = QueryASTVisitor(self.model)
229
+
230
+ # Handle both direct exclude nodes and exclude nodes with a child filter
231
+ if "child" in node:
232
+ # If there's a child node, visit it and exclude the result
233
+ q_object = visitor.visit(node["child"])
234
+ else:
235
+ # Otherwise, treat it as a standard filter node to be negated
236
+ q_object = visitor.visit(node)
237
+
238
+ self.queryset = self.queryset.exclude(q_object)
239
+
240
+ def create(self, data: Dict[str, Any], serializer, req, fields_map) -> models.Model:
241
+ assert self.model is not None, "Model must be set before creating."
242
+ # Use the provided serializer's save method
243
+ return serializer.save(
244
+ model=self.model,
245
+ data=data,
246
+ instance=None,
247
+ partial=False,
248
+ request=req,
249
+ fields_map=fields_map
250
+ )
251
+
252
+ def update_instance(
253
+ self,
254
+ ast: Dict[str, Any],
255
+ req: RequestType,
256
+ permissions: List[Type[AbstractPermission]],
257
+ serializer,
258
+ fields_map
259
+ ) -> models.Model:
260
+ data = ast.get("data", {})
261
+ filter_ast = ast.get("filter")
262
+ if not filter_ast:
263
+ raise ValueError("Filter is required for update_instance operation")
264
+
265
+ visitor = QueryASTVisitor(self.model)
266
+ q_obj = visitor.visit(filter_ast)
267
+ instance = self.model.objects.get(q_obj)
268
+
269
+ # Check object-level permissions for update.
270
+ for perm_cls in permissions:
271
+ perm = perm_cls()
272
+ allowed = perm.allowed_object_actions(req, instance, self.model)
273
+ if ActionType.UPDATE not in allowed:
274
+ raise PermissionDenied(f"Update not permitted on {instance}")
275
+
276
+ # Use the provided serializer's save method for the update
277
+ return serializer.save(
278
+ model=self.model,
279
+ data=data,
280
+ instance=instance,
281
+ partial=True,
282
+ request=req,
283
+ fields_map=fields_map
284
+ )
285
+
286
+ def delete_instance(
287
+ self,
288
+ ast: Dict[str, Any],
289
+ req: RequestType,
290
+ permissions: List[Type[AbstractPermission]],
291
+ ) -> int:
292
+ filter_ast = ast.get("filter")
293
+ if not filter_ast:
294
+ raise ValueError("Filter is required for delete_instance operation")
295
+
296
+ visitor = QueryASTVisitor(self.model)
297
+ q_obj = visitor.visit(filter_ast)
298
+ instance = self.model.objects.get(q_obj)
299
+
300
+ # Check object-level permissions.
301
+ for perm_cls in permissions:
302
+ perm = perm_cls()
303
+ allowed = perm.allowed_object_actions(req, instance, self.model)
304
+ if ActionType.DELETE not in allowed:
305
+ raise PermissionDenied(f"Delete not permitted on {instance}")
306
+
307
+ instance.delete()
308
+ return 1
309
+
310
+ def get_pk_list(queryset: QuerySet):
311
+ """
312
+ Gets a list of primary key values from a QuerySet, handling different PK field names.
313
+
314
+ Args:
315
+ queryset: The Django QuerySet.
316
+
317
+ Returns:
318
+ A list of primary key values.
319
+ """
320
+ model = queryset.model
321
+ pk_field_name = model._meta.pk.name # Dynamically get the PK field name
322
+ pk_list = queryset.values_list(pk_field_name, flat=True)
323
+ return list(pk_list)
324
+
325
+ def update(
326
+ self,
327
+ node: Dict[str, Any],
328
+ req: RequestType,
329
+ permissions: List[Type[AbstractPermission]],
330
+ readable_fields: Set[str] = None
331
+ ) -> Tuple[int, List[Dict[str, Union[int, str]]]]:
332
+ """
333
+ Update operations with support for F expressions.
334
+ Includes permission checks for fields referenced in F expressions.
335
+ """
336
+ assert self.model is not None, "Model must be set before updating."
337
+ data: Dict[str, Any] = node.get("data", {})
338
+ filter_ast: Optional[Dict[str, Any]] = node.get("filter")
339
+
340
+ # Start with self.queryset which already has permission filtering
341
+ qs: QuerySet = self.queryset
342
+ if filter_ast:
343
+ visitor = QueryASTVisitor(self.model)
344
+ q_obj = visitor.visit(filter_ast)
345
+ qs = qs.filter(q_obj)
346
+
347
+ # Check bulk update permissions
348
+ check_bulk_permissions(req, qs, ActionType.UPDATE, permissions, self.model)
349
+
350
+ model = qs.model
351
+
352
+ # Get the fields to update (keys from data plus primary key)
353
+ update_fields = list(data.keys())
354
+ update_fields.append(model._meta.pk.name)
355
+
356
+ # Process any F expressions in the update data
357
+ processed_data = {}
358
+ from statezero.adaptors.django.f_handler import FExpressionHandler
359
+
360
+ for key, value in data.items():
361
+ if isinstance(value, dict) and value.get('__f_expr'):
362
+ # It's an F expression - check permissions and process it
363
+ try:
364
+ # Extract field names referenced in the F expression
365
+ referenced_fields = FExpressionHandler.extract_referenced_fields(value)
366
+
367
+ # Check that user has READ permissions for all referenced fields
368
+ for field in referenced_fields:
369
+ if field not in readable_fields:
370
+ raise PermissionDenied(
371
+ f"No permission to read field '{field}' referenced in F expression"
372
+ )
373
+
374
+ # Process the F expression now that permissions are verified
375
+ processed_data[key] = FExpressionHandler.process_expression(value)
376
+ except ValueError as e:
377
+ logger.error(f"Error processing F expression for field {key}: {e}")
378
+ raise ValidationError(f"Invalid F expression for field {key}: {str(e)}")
379
+ else:
380
+ # Regular value, use as-is
381
+ processed_data[key] = value
382
+
383
+ # Execute the update with processed expressions
384
+ rows_updated = qs.update(**processed_data)
385
+
386
+ # After update, fetch the updated instances
387
+ updated_instances = list(qs.only(*update_fields))
388
+
389
+ # Triggers cache invalidation and broadcast to the frontend
390
+ config.event_bus.emit_bulk_event(ActionType.BULK_UPDATE, updated_instances)
391
+
392
+ return rows_updated, updated_instances
393
+
394
+ def delete(
395
+ self,
396
+ node: Dict[str, Any],
397
+ req: RequestType,
398
+ permissions: List[Type[AbstractPermission]],
399
+ ) -> Tuple[int, Tuple[int]]:
400
+ assert self.model is not None, "Model must be set before deleting."
401
+ filter_ast: Optional[Dict[str, Any]] = node.get("filter")
402
+ # Start with self.queryset which already has permission filtering
403
+ qs: QuerySet = self.queryset
404
+ if filter_ast:
405
+ visitor = QueryASTVisitor(self.model)
406
+ q_obj = visitor.visit(filter_ast)
407
+ qs = qs.filter(q_obj)
408
+
409
+ check_bulk_permissions(req, qs, ActionType.DELETE, permissions, self.model)
410
+
411
+ # TODO: this should be a values list, but we need to check the bulk event emitter code
412
+ model = qs.model
413
+ pk_field_name = model._meta.pk.name
414
+ instances = list(qs.only(pk_field_name))
415
+
416
+ deleted, _ = qs.delete()
417
+
418
+ # Triggers cache invalidation and broadcast to the frontend
419
+ config.event_bus.emit_bulk_event(ActionType.BULK_DELETE, instances)
420
+
421
+ # Dynamically create a Meta inner class
422
+ Meta = type("Meta", (), {
423
+ "model": model,
424
+ "fields": [pk_field_name], # Only include the PK field
425
+ })
426
+
427
+ # Create the serializer class
428
+ serializer_class = type(
429
+ f"Dynamic{model.__name__}PkSerializer",
430
+ (serializers.ModelSerializer,),
431
+ {"Meta": Meta}
432
+ )
433
+
434
+ serializer = serializer_class(instances, many=True)
435
+
436
+ return deleted, serializer.data
437
+
438
+ def get(
439
+ self,
440
+ node: Dict[str, Any],
441
+ req: RequestType,
442
+ permissions: List[Type[AbstractPermission]],
443
+ ) -> models.Model:
444
+ """
445
+ Retrieve a single model instance with permission checks.
446
+
447
+ Args:
448
+ node: The query AST node
449
+ req: The request object
450
+ permissions: List of permission classes to check
451
+
452
+ Returns:
453
+ A single model instance
454
+
455
+ Raises:
456
+ NotFound: If no object matches the query
457
+ PermissionDenied: If the user doesn't have permission to read the object
458
+ MultipleObjectsReturned: If multiple objects match the query
459
+ """
460
+ assert self.model is not None, "Model must be set before retrieving."
461
+ filter_ast: Optional[Dict[str, Any]] = node.get("filter")
462
+
463
+ if filter_ast:
464
+ visitor = QueryASTVisitor(self.model)
465
+ q_obj = visitor.visit(filter_ast)
466
+ try:
467
+ instance = self.queryset.filter(q_obj).get()
468
+ except self.model.DoesNotExist:
469
+ raise NotFound(f"No {self.model.__name__} matches the given query.")
470
+ except self.model.MultipleObjectsReturned:
471
+ raise MultipleObjectsReturned(
472
+ f"Multiple {self.model.__name__} instances match the given query."
473
+ )
474
+ else:
475
+ try:
476
+ instance = self.queryset.get()
477
+ except self.model.DoesNotExist:
478
+ raise NotFound(f"No {self.model.__name__} matches the given query.")
479
+ except self.model.MultipleObjectsReturned:
480
+ raise MultipleObjectsReturned(
481
+ f"Multiple {self.model.__name__} instances match the given query."
482
+ )
483
+
484
+ # Check object-level permissions for reading
485
+ check_object_permissions(
486
+ req, instance, ActionType.READ, permissions, self.model
487
+ )
488
+
489
+ return instance
490
+
491
+ def _normalize_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]:
492
+ """
493
+ For each key in data, if the value is a model instance, replace it with its primary key.
494
+ """
495
+ normalized = {}
496
+ for key, value in data.items():
497
+ # Check for model instance by looking for the _meta attribute.
498
+ if hasattr(value, "_meta"):
499
+ normalized[key] = value.pk
500
+ else:
501
+ normalized[key] = value
502
+ return normalized
503
+
504
+ def get_or_create(
505
+ self,
506
+ node: Dict[str, Any],
507
+ serializer,
508
+ req: RequestType,
509
+ permissions: List[Type[AbstractPermission]],
510
+ create_fields_map
511
+ ) -> Tuple[models.Model, bool]:
512
+ """
513
+ Get an existing object, or create it if it doesn't exist, with object-level permission checks.
514
+ """
515
+ lookup = node.get("lookup", {})
516
+ defaults = node.get("defaults", {})
517
+
518
+ # Merge lookup and defaults and normalize foreign key values
519
+ merged_data = self._normalize_foreign_keys({**lookup, **defaults})
520
+
521
+ # Check if an instance exists
522
+ try:
523
+ instance = self.queryset.get(**lookup)
524
+ created = False
525
+
526
+ # Check object-level permission to read the existing object
527
+ check_object_permissions(
528
+ req, instance, ActionType.READ, permissions, self.model
529
+ )
530
+ except self.model.DoesNotExist:
531
+ # Object doesn't exist, we'll create it
532
+ instance = None
533
+ created = True
534
+ except self.model.MultipleObjectsReturned as e:
535
+ raise MultipleObjectsReturned(
536
+ f"Multiple {self.model.__name__} instances match the given lookup parameters"
537
+ )
538
+
539
+ # If the instance exists, we don't need to update it, just return it
540
+ if not created:
541
+ return instance, created
542
+
543
+ # Only create a new instance if it doesn't exist
544
+ instance = serializer.save(
545
+ model=self.model,
546
+ data=merged_data,
547
+ instance=None, # No instance for creation
548
+ partial=False, # Not a partial update for creation
549
+ request=req,
550
+ fields_map=create_fields_map
551
+ )
552
+
553
+ return instance, created
554
+
555
+ def update_or_create(
556
+ self,
557
+ node: Dict[str, Any],
558
+ req: RequestType,
559
+ serializer,
560
+ permissions: List[Type[AbstractPermission]],
561
+ update_fields_map,
562
+ create_fields_map
563
+ ) -> Tuple[models.Model, bool]:
564
+ """
565
+ Update an existing object, or create it if it doesn't exist, with object-level permission checks.
566
+ """
567
+ lookup = node.get("lookup", {})
568
+ defaults = node.get("defaults", {})
569
+
570
+ # Merge lookup and defaults and normalize foreign key values
571
+ merged_data = self._normalize_foreign_keys({**lookup, **defaults})
572
+
573
+ # Determine if the instance exists
574
+ try:
575
+ instance = self.queryset.get(**lookup)
576
+ created = False
577
+
578
+ # Perform object-level permission check before update
579
+ check_object_permissions(
580
+ req, instance, ActionType.UPDATE, permissions, self.model
581
+ )
582
+ except self.model.DoesNotExist:
583
+ # Object doesn't exist, we'll create it
584
+ instance = None
585
+ created = True
586
+ except self.model.MultipleObjectsReturned as e:
587
+ raise MultipleObjectsReturned(
588
+ f"Multiple {self.model.__name__} instances match the given lookup parameters"
589
+ )
590
+
591
+ fields_map_to_use = create_fields_map if created else update_fields_map
592
+
593
+ # Use the serializer's save method, which handles validation and saving
594
+ instance = serializer.save(
595
+ model=self.model,
596
+ data=merged_data,
597
+ instance=instance,
598
+ request=req,
599
+ fields_map=fields_map_to_use
600
+ )
601
+
602
+ return instance, created
603
+
604
+ def first(self) -> Optional[models.Model]:
605
+ return self.queryset.first() if self.queryset is not None else None
606
+
607
+ def last(self) -> Optional[models.Model]:
608
+ return self.queryset.last() if self.queryset is not None else None
609
+
610
+ def exists(self) -> bool:
611
+ return self.queryset.exists() if self.queryset is not None else False
612
+
613
+ def aggregate(self, agg_list: List[Dict[str, Any]]) -> Dict[str, Any]:
614
+ agg_expressions = {}
615
+ for agg in agg_list:
616
+ func = agg.get("function")
617
+ field = agg.get("field")
618
+ alias = agg.get("alias")
619
+ if func == "count":
620
+ agg_expressions[alias] = Count(field)
621
+ elif func == "sum":
622
+ agg_expressions[alias] = Sum(field)
623
+ elif func == "avg":
624
+ agg_expressions[alias] = Avg(field)
625
+ elif func == "min":
626
+ agg_expressions[alias] = Min(field)
627
+ elif func == "max":
628
+ agg_expressions[alias] = Max(field)
629
+ else:
630
+ raise ValidationError(f"Unknown aggregate function: {func}")
631
+ result = self.queryset.aggregate(**agg_expressions)
632
+ return {"data": result, "metadata": {"aggregated": True}}
633
+
634
+ def count(self, field: str) -> int:
635
+ result = self.queryset.aggregate(result=Count(field))["result"]
636
+ return int(result) if result is not None else 0
637
+
638
+ def sum(self, field: str) -> Optional[Union[int, float]]:
639
+ return self.queryset.aggregate(result=Sum(field))["result"]
640
+
641
+ def avg(self, field: str) -> Optional[float]:
642
+ result = self.queryset.aggregate(result=Avg(field))["result"]
643
+ return float(result) if result is not None else None
644
+
645
+ def min(self, field: str) -> Optional[Union[int, float, str]]:
646
+ return self.queryset.aggregate(result=Min(field))["result"]
647
+
648
+ def max(self, field: str) -> Optional[Union[int, float, str]]:
649
+ return self.queryset.aggregate(result=Max(field))["result"]
650
+
651
+ def order_by(self, order_list: List[str]) -> None:
652
+ self.queryset = self.queryset.order_by(*order_list)
653
+
654
+ def select_related(self, related_fields: List[str]) -> None:
655
+ self.queryset = self.queryset.select_related(*related_fields)
656
+
657
+ def prefetch_related(self, related_fields: List[str]) -> None:
658
+ self.queryset = self.queryset.prefetch_related(*related_fields)
659
+
660
+ def select_fields(self, fields: List[str]) -> None:
661
+ self.queryset = self.queryset.values(*fields)
662
+
663
+ def fetch_list(
664
+ self,
665
+ offset: Optional[int] = None,
666
+ limit: Optional[int] = None,
667
+ req: RequestType = None,
668
+ permissions: List[Type[AbstractPermission]] = None,
669
+ ) -> QuerySet:
670
+ """
671
+ Fetch a list of model instances with bulk permission checks.
672
+
673
+ Args:
674
+ offset: The offset for pagination
675
+ limit: The limit for pagination
676
+ req: The request object
677
+ permissions: List of permission classes to check
678
+
679
+ Returns:
680
+ A list of model instances after permission checks
681
+ """
682
+ offset = offset or 0
683
+
684
+ # First, get the paginated queryset
685
+ if limit is None:
686
+ qs = self.queryset[offset:]
687
+ else:
688
+ qs = self.queryset[offset : offset + limit]
689
+
690
+ # If permissions are provided, perform bulk permission checks
691
+ if req is not None and permissions:
692
+ # Use the existing bulk permission check function
693
+ check_bulk_permissions(req, qs, ActionType.READ, permissions, self.model)
694
+
695
+ return qs
696
+
697
+ def _build_conditions(self, conditions: dict) -> Q:
698
+ visitor = QueryASTVisitor(self.model)
699
+ fake_ast = {"type": "filter", "conditions": conditions}
700
+ return visitor.visit(fake_ast)
701
+
702
+ # --- AbstractORMProvider Methods ---
703
+ def get_queryset(
704
+ self,
705
+ req: RequestType,
706
+ model: Type,
707
+ initial_ast: Dict[str, Any],
708
+ custom_querysets: Dict[str, Type[AbstractCustomQueryset]],
709
+ registered_permissions: List[Type[AbstractPermission]],
710
+ ) -> Any:
711
+ custom_name = initial_ast.get("custom_queryset")
712
+ if custom_name and custom_name in custom_querysets:
713
+ custom_queryset_class = custom_querysets[custom_name]
714
+ return custom_queryset_class().get_queryset(req)
715
+ return model.objects.all()
716
+
717
+ def get_fields(self, model: models.Model) -> Set[str]:
718
+ """
719
+ Return a set of the model fields.
720
+ """
721
+ model_config = registry.get_config(model)
722
+ if model_config.fields and "__all__" != model_config.fields:
723
+ resolved_fields = model_config.fields
724
+ else:
725
+ resolved_fields = set((field.name for field in model._meta.get_fields()))
726
+ additional_fields = set((field.name for field in model_config.additional_fields))
727
+ resolved_fields = resolved_fields.union(additional_fields)
728
+ return resolved_fields
729
+
730
+ def build_model_graph(
731
+ self, model: Type[models.Model], model_graph: nx.DiGraph = None
732
+ ) -> nx.DiGraph:
733
+ """
734
+ Build a directed graph of models and their fields, focusing on direct relationships.
735
+
736
+ Args:
737
+ model: The Django model to build the graph for
738
+ model_graph: An existing graph to add to (optional)
739
+
740
+ Returns:
741
+ nx.DiGraph: The model graph
742
+ """
743
+ from django.db.models.fields.related import RelatedField, ForeignObjectRel
744
+
745
+ if model_graph is None:
746
+ model_graph = nx.DiGraph()
747
+
748
+ # Use the adapter's get_model_name method.
749
+ model_name = self.get_model_name(model)
750
+
751
+ # Add the model node if it doesn't exist.
752
+ if not model_graph.has_node(model_name):
753
+ model_graph.add_node(
754
+ model_name, data=ModelNode(model_name=model_name, model=model)
755
+ )
756
+
757
+ # Iterate over all fields in the model.
758
+ for field in model._meta.get_fields():
759
+ field_name = field.name
760
+
761
+ # Skip reverse relations for validation purposes
762
+ # These are relationships defined on other models pointing to this model
763
+ if isinstance(field, ForeignObjectRel):
764
+ continue
765
+
766
+ field_node = f"{model_name}::{field_name}"
767
+ field_node_data = FieldNode(
768
+ model_name=model_name,
769
+ field_name=field_name,
770
+ is_relation=field.is_relation,
771
+ related_model=(
772
+ self.get_model_name(field.related_model)
773
+ if field.is_relation and field.related_model
774
+ else None
775
+ ),
776
+ )
777
+ model_graph.add_node(field_node, data=field_node_data)
778
+ model_graph.add_edge(model_name, field_node)
779
+
780
+ if field.is_relation and field.related_model:
781
+ related_model = field.related_model
782
+ related_model_name = self.get_model_name(related_model)
783
+ if not model_graph.has_node(related_model_name):
784
+ self.build_model_graph(related_model, model_graph)
785
+ model_graph.add_edge(field_node, related_model_name)
786
+
787
+ # Add additional (computed) fields from the model's configuration.
788
+ try:
789
+ from statezero.adaptors.django.config import registry
790
+
791
+ config = registry.get_config(model)
792
+ for additional_field in config.additional_fields:
793
+ add_field_name = additional_field.name
794
+ add_field_node = f"{model_name}::{add_field_name}"
795
+ is_rel = False
796
+ related_model_name = None
797
+ if isinstance(
798
+ additional_field.field,
799
+ (models.ForeignKey, models.OneToOneField, models.ManyToManyField),
800
+ ):
801
+ is_rel = True
802
+ related = getattr(additional_field.field, "related_model", None)
803
+ if related:
804
+ related_model_name = self.get_model_name(related)
805
+ add_field_node_data = FieldNode(
806
+ model_name=model_name,
807
+ field_name=add_field_name,
808
+ is_relation=is_rel,
809
+ related_model=related_model_name,
810
+ )
811
+ model_graph.add_node(add_field_node, data=add_field_node_data)
812
+ model_graph.add_edge(model_name, add_field_node)
813
+ except ValueError:
814
+ pass
815
+
816
+ return model_graph
817
+
818
+ def register_event_signals(self, event_bus: EventBus) -> None:
819
+ def pre_save_receiver(sender, instance, **kwargs):
820
+ if not instance.pk:
821
+ return # It can't be used for cache invalidation, cause there's no pk
822
+
823
+ action = ActionType.PRE_UPDATE
824
+ try:
825
+ event_bus.emit_event(action, instance)
826
+ except Exception as e:
827
+ logger.exception(
828
+ "Error emitting event %s for instance %s: %s", action, instance, e
829
+ )
830
+
831
+ def post_save_receiver(sender, instance, created, **kwargs):
832
+ action = ActionType.CREATE if created else ActionType.UPDATE
833
+ try:
834
+ event_bus.emit_event(action, instance)
835
+ except Exception as e:
836
+ logger.exception(
837
+ "Error emitting event %s for instance %s: %s", action, instance, e
838
+ )
839
+
840
+ def pre_delete_receiver(sender, instance, **kwargs):
841
+ try:
842
+ # Use PRE_DELETE action type for cache invalidation before DB operation
843
+ event_bus.emit_event(ActionType.PRE_DELETE, instance)
844
+ except Exception as e:
845
+ logger.exception(
846
+ "Error emitting PRE_DELETE event for instance %s: %s", instance, e
847
+ )
848
+
849
+ def post_delete_receiver(sender, instance, **kwargs):
850
+ try:
851
+ event_bus.emit_event(ActionType.DELETE, instance)
852
+ except Exception as e:
853
+ logger.exception(
854
+ "Error emitting DELETE event for instance %s: %s", instance, e
855
+ )
856
+
857
+ from statezero.adaptors.django.config import config, registry
858
+
859
+ for model in registry._models_config.keys():
860
+ model_name = config.orm_provider.get_model_name(model)
861
+
862
+ # Register pre_save signals (new)
863
+ uid_pre_save = f"statezero:{model_name}:pre_save"
864
+ pre_save.disconnect(sender=model, dispatch_uid=uid_pre_save)
865
+ receiver(pre_save, sender=model, weak=False, dispatch_uid=uid_pre_save)(
866
+ pre_save_receiver
867
+ )
868
+
869
+ # Register post_save signals
870
+ uid_save = f"statezero:{model_name}:post_save"
871
+ post_save.disconnect(sender=model, dispatch_uid=uid_save)
872
+ receiver(post_save, sender=model, weak=False, dispatch_uid=uid_save)(
873
+ post_save_receiver
874
+ )
875
+
876
+ # Register pre_delete signals
877
+ uid_pre_delete = f"statezero:{model_name}:pre_delete"
878
+ pre_delete.disconnect(sender=model, dispatch_uid=uid_pre_delete)
879
+ receiver(pre_delete, sender=model, weak=False, dispatch_uid=uid_pre_delete)(
880
+ pre_delete_receiver
881
+ )
882
+
883
+ # Register post_delete signals
884
+ uid_delete = f"statezero:{model_name}:post_delete"
885
+ post_delete.disconnect(sender=model, dispatch_uid=uid_delete)
886
+ receiver(post_delete, sender=model, weak=False, dispatch_uid=uid_delete)(
887
+ post_delete_receiver
888
+ )
889
+
890
+ def get_model_by_name(self, model_name: str) -> Type[models.Model]:
891
+ try:
892
+ app_label, model_cls = model_name.split(".")
893
+ model = apps.get_model(app_label, model_cls)
894
+ if model is None:
895
+ raise NotFound(f"Unknown model: {model_name}")
896
+ return model
897
+ except ValueError:
898
+ raise NotFound(
899
+ f"Model name '{model_name}' must be in the format 'app_label.ModelName'"
900
+ )
901
+
902
+ def get_model_name(
903
+ self, model: Union[models.Model, Type[models.Model]]
904
+ ) -> str: # type:ignore
905
+ if not isinstance(model, type):
906
+ model = model.__class__
907
+ if hasattr(model, "_meta"):
908
+ return f"{model._meta.app_label}.{model._meta.model_name}"
909
+ raise ValueError(
910
+ f"Cannot determine model name from {model} of type {type(model)}: _meta attribute is missing from the model."
911
+ )
912
+
913
+ def get_user(self, request):
914
+ """ Return the user """
915
+ return request.user