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