statezero 0.1.0b3__py3-none-any.whl → 0.1.0b5__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.
Potentially problematic release.
This version of statezero might be problematic. Click here for more details.
- statezero/adaptors/django/orm.py +224 -174
- statezero/adaptors/django/serializers.py +45 -26
- statezero/core/ast_parser.py +315 -175
- statezero/core/hook_checks.py +86 -0
- statezero/core/interfaces.py +193 -69
- statezero/core/process_request.py +1 -1
- {statezero-0.1.0b3.dist-info → statezero-0.1.0b5.dist-info}/METADATA +1 -1
- {statezero-0.1.0b3.dist-info → statezero-0.1.0b5.dist-info}/RECORD +11 -10
- {statezero-0.1.0b3.dist-info → statezero-0.1.0b5.dist-info}/WHEEL +0 -0
- {statezero-0.1.0b3.dist-info → statezero-0.1.0b5.dist-info}/licenses/license.md +0 -0
- {statezero-0.1.0b3.dist-info → statezero-0.1.0b5.dist-info}/top_level.txt +0 -0
statezero/adaptors/django/orm.py
CHANGED
|
@@ -13,11 +13,17 @@ from rest_framework import serializers
|
|
|
13
13
|
from statezero.adaptors.django.config import config, registry
|
|
14
14
|
from statezero.core.classes import FieldNode, ModelNode
|
|
15
15
|
from statezero.core.event_bus import EventBus
|
|
16
|
-
from statezero.core.exceptions import (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
)
|
|
21
27
|
from statezero.core.types import ActionType, RequestType
|
|
22
28
|
|
|
23
29
|
logger = logging.getLogger(__name__)
|
|
@@ -79,14 +85,14 @@ class QueryASTVisitor:
|
|
|
79
85
|
def _process_field_lookup(self, field: str, value: Any) -> Tuple[str, Any]:
|
|
80
86
|
"""
|
|
81
87
|
This used to contain logic, right now it just passes through the field and value.
|
|
82
|
-
|
|
88
|
+
|
|
83
89
|
Args:
|
|
84
90
|
field: The field lookup string (e.g., 'datetime_field__hour__gt')
|
|
85
91
|
value: The value to filter by
|
|
86
|
-
|
|
92
|
+
|
|
87
93
|
Returns:
|
|
88
94
|
A tuple of (lookup, value)
|
|
89
|
-
"""
|
|
95
|
+
"""
|
|
90
96
|
return field, value
|
|
91
97
|
|
|
92
98
|
def visit_filter(self, node: Dict[str, Any]) -> Q:
|
|
@@ -133,7 +139,7 @@ class QueryASTVisitor:
|
|
|
133
139
|
def visit_or(self, node: Dict[str, Any]) -> Q:
|
|
134
140
|
"""Process an OR node by combining all children with OR."""
|
|
135
141
|
return self._combine(node.get("children", []), lambda a, b: a | b)
|
|
136
|
-
|
|
142
|
+
|
|
137
143
|
def visit_search(self, node: Dict[str, Any]) -> Q:
|
|
138
144
|
"""
|
|
139
145
|
Process a search node.
|
|
@@ -142,6 +148,7 @@ class QueryASTVisitor:
|
|
|
142
148
|
"""
|
|
143
149
|
return Q()
|
|
144
150
|
|
|
151
|
+
|
|
145
152
|
# -------------------------------------------------------------------
|
|
146
153
|
# Django ORM Adapter (implements our generic engine/provider)
|
|
147
154
|
# -------------------------------------------------------------------
|
|
@@ -197,35 +204,30 @@ def check_bulk_permissions(
|
|
|
197
204
|
|
|
198
205
|
class DjangoORMAdapter(AbstractORMProvider):
|
|
199
206
|
def __init__(self) -> None:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def set_queryset(self, queryset: Any) -> None:
|
|
204
|
-
self.queryset = queryset
|
|
205
|
-
self.model = queryset.model
|
|
207
|
+
# No instance state - completely stateless
|
|
208
|
+
pass
|
|
206
209
|
|
|
207
210
|
# --- QueryEngine Methods ---
|
|
208
|
-
def filter_node(self, node: Dict[str, Any]) ->
|
|
209
|
-
"""Apply a filter node to the queryset."""
|
|
210
|
-
|
|
211
|
-
visitor = QueryASTVisitor(
|
|
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)
|
|
212
215
|
q_object = visitor.visit(node)
|
|
213
|
-
|
|
216
|
+
return queryset.filter(q_object)
|
|
214
217
|
|
|
215
|
-
def search_node(
|
|
218
|
+
def search_node(
|
|
219
|
+
self, queryset: QuerySet, search_query: str, search_fields: Set[str]
|
|
220
|
+
) -> QuerySet:
|
|
216
221
|
"""
|
|
217
|
-
|
|
218
|
-
Assumes that the queryset and model are already set.
|
|
222
|
+
Apply a full-text search to the queryset and return new queryset.
|
|
219
223
|
Uses the search_provider from the global configuration.
|
|
220
224
|
"""
|
|
221
|
-
|
|
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)
|
|
225
|
+
return config.search_provider.search(queryset, search_query, search_fields)
|
|
224
226
|
|
|
225
|
-
def exclude_node(self, node: Dict[str, Any]) ->
|
|
226
|
-
"""Apply an exclude node to the queryset."""
|
|
227
|
-
|
|
228
|
-
visitor = QueryASTVisitor(
|
|
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)
|
|
229
231
|
|
|
230
232
|
# Handle both direct exclude nodes and exclude nodes with a child filter
|
|
231
233
|
if "child" in node:
|
|
@@ -235,79 +237,91 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
235
237
|
# Otherwise, treat it as a standard filter node to be negated
|
|
236
238
|
q_object = visitor.visit(node)
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
return queryset.exclude(q_object)
|
|
239
241
|
|
|
240
|
-
def create(
|
|
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."""
|
|
242
251
|
# Use the provided serializer's save method
|
|
243
252
|
return serializer.save(
|
|
244
|
-
model=
|
|
253
|
+
model=model,
|
|
245
254
|
data=data,
|
|
246
255
|
instance=None,
|
|
247
256
|
partial=False,
|
|
248
257
|
request=req,
|
|
249
|
-
fields_map=fields_map
|
|
258
|
+
fields_map=fields_map,
|
|
250
259
|
)
|
|
251
260
|
|
|
252
261
|
def update_instance(
|
|
253
262
|
self,
|
|
263
|
+
model: Type[models.Model],
|
|
254
264
|
ast: Dict[str, Any],
|
|
255
265
|
req: RequestType,
|
|
256
266
|
permissions: List[Type[AbstractPermission]],
|
|
257
267
|
serializer,
|
|
258
|
-
fields_map
|
|
268
|
+
fields_map,
|
|
259
269
|
) -> models.Model:
|
|
270
|
+
"""Update a single model instance."""
|
|
260
271
|
data = ast.get("data", {})
|
|
261
272
|
filter_ast = ast.get("filter")
|
|
262
273
|
if not filter_ast:
|
|
263
274
|
raise ValueError("Filter is required for update_instance operation")
|
|
264
275
|
|
|
265
|
-
visitor = QueryASTVisitor(
|
|
276
|
+
visitor = QueryASTVisitor(model)
|
|
266
277
|
q_obj = visitor.visit(filter_ast)
|
|
267
|
-
instance =
|
|
278
|
+
instance = model.objects.get(q_obj)
|
|
268
279
|
|
|
269
280
|
# Check object-level permissions for update.
|
|
270
281
|
for perm_cls in permissions:
|
|
271
282
|
perm = perm_cls()
|
|
272
|
-
allowed = perm.allowed_object_actions(req, instance,
|
|
283
|
+
allowed = perm.allowed_object_actions(req, instance, model)
|
|
273
284
|
if ActionType.UPDATE not in allowed:
|
|
274
285
|
raise PermissionDenied(f"Update not permitted on {instance}")
|
|
275
286
|
|
|
276
287
|
# Use the provided serializer's save method for the update
|
|
277
288
|
return serializer.save(
|
|
278
|
-
model=
|
|
289
|
+
model=model,
|
|
279
290
|
data=data,
|
|
280
291
|
instance=instance,
|
|
281
292
|
partial=True,
|
|
282
293
|
request=req,
|
|
283
|
-
fields_map=fields_map
|
|
294
|
+
fields_map=fields_map,
|
|
284
295
|
)
|
|
285
296
|
|
|
286
297
|
def delete_instance(
|
|
287
298
|
self,
|
|
299
|
+
model: Type[models.Model],
|
|
288
300
|
ast: Dict[str, Any],
|
|
289
301
|
req: RequestType,
|
|
290
302
|
permissions: List[Type[AbstractPermission]],
|
|
291
303
|
) -> int:
|
|
304
|
+
"""Delete a single model instance."""
|
|
292
305
|
filter_ast = ast.get("filter")
|
|
293
306
|
if not filter_ast:
|
|
294
307
|
raise ValueError("Filter is required for delete_instance operation")
|
|
295
308
|
|
|
296
|
-
visitor = QueryASTVisitor(
|
|
309
|
+
visitor = QueryASTVisitor(model)
|
|
297
310
|
q_obj = visitor.visit(filter_ast)
|
|
298
|
-
instance =
|
|
311
|
+
instance = model.objects.get(q_obj)
|
|
299
312
|
|
|
300
313
|
# Check object-level permissions.
|
|
301
314
|
for perm_cls in permissions:
|
|
302
315
|
perm = perm_cls()
|
|
303
|
-
allowed = perm.allowed_object_actions(req, instance,
|
|
316
|
+
allowed = perm.allowed_object_actions(req, instance, model)
|
|
304
317
|
if ActionType.DELETE not in allowed:
|
|
305
318
|
raise PermissionDenied(f"Delete not permitted on {instance}")
|
|
306
319
|
|
|
307
320
|
instance.delete()
|
|
308
321
|
return 1
|
|
309
|
-
|
|
310
|
-
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def get_pk_list(queryset: QuerySet) -> List[Any]:
|
|
311
325
|
"""
|
|
312
326
|
Gets a list of primary key values from a QuerySet, handling different PK field names.
|
|
313
327
|
|
|
@@ -324,31 +338,30 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
324
338
|
|
|
325
339
|
def update(
|
|
326
340
|
self,
|
|
341
|
+
queryset: QuerySet,
|
|
327
342
|
node: Dict[str, Any],
|
|
328
343
|
req: RequestType,
|
|
329
344
|
permissions: List[Type[AbstractPermission]],
|
|
330
|
-
readable_fields: Set[str] = None
|
|
345
|
+
readable_fields: Set[str] = None,
|
|
331
346
|
) -> Tuple[int, List[Dict[str, Union[int, str]]]]:
|
|
332
347
|
"""
|
|
333
348
|
Update operations with support for F expressions.
|
|
334
349
|
Includes permission checks for fields referenced in F expressions.
|
|
335
350
|
"""
|
|
336
|
-
|
|
351
|
+
model = queryset.model
|
|
337
352
|
data: Dict[str, Any] = node.get("data", {})
|
|
338
353
|
filter_ast: Optional[Dict[str, Any]] = node.get("filter")
|
|
339
|
-
|
|
340
|
-
# Start with
|
|
341
|
-
qs: QuerySet =
|
|
354
|
+
|
|
355
|
+
# Start with the provided queryset which already has permission filtering
|
|
356
|
+
qs: QuerySet = queryset
|
|
342
357
|
if filter_ast:
|
|
343
|
-
visitor = QueryASTVisitor(
|
|
358
|
+
visitor = QueryASTVisitor(model)
|
|
344
359
|
q_obj = visitor.visit(filter_ast)
|
|
345
360
|
qs = qs.filter(q_obj)
|
|
346
361
|
|
|
347
362
|
# Check bulk update permissions
|
|
348
|
-
check_bulk_permissions(req, qs, ActionType.UPDATE, permissions,
|
|
363
|
+
check_bulk_permissions(req, qs, ActionType.UPDATE, permissions, model)
|
|
349
364
|
|
|
350
|
-
model = qs.model
|
|
351
|
-
|
|
352
365
|
# Get the fields to update (keys from data plus primary key)
|
|
353
366
|
update_fields = list(data.keys())
|
|
354
367
|
update_fields.append(model._meta.pk.name)
|
|
@@ -356,36 +369,40 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
356
369
|
# Process any F expressions in the update data
|
|
357
370
|
processed_data = {}
|
|
358
371
|
from statezero.adaptors.django.f_handler import FExpressionHandler
|
|
359
|
-
|
|
372
|
+
|
|
360
373
|
for key, value in data.items():
|
|
361
|
-
if isinstance(value, dict) and value.get(
|
|
374
|
+
if isinstance(value, dict) and value.get("__f_expr"):
|
|
362
375
|
# It's an F expression - check permissions and process it
|
|
363
376
|
try:
|
|
364
377
|
# Extract field names referenced in the F expression
|
|
365
|
-
referenced_fields = FExpressionHandler.extract_referenced_fields(
|
|
366
|
-
|
|
378
|
+
referenced_fields = FExpressionHandler.extract_referenced_fields(
|
|
379
|
+
value
|
|
380
|
+
)
|
|
381
|
+
|
|
367
382
|
# Check that user has READ permissions for all referenced fields
|
|
368
383
|
for field in referenced_fields:
|
|
369
384
|
if field not in readable_fields:
|
|
370
385
|
raise PermissionDenied(
|
|
371
386
|
f"No permission to read field '{field}' referenced in F expression"
|
|
372
387
|
)
|
|
373
|
-
|
|
388
|
+
|
|
374
389
|
# Process the F expression now that permissions are verified
|
|
375
390
|
processed_data[key] = FExpressionHandler.process_expression(value)
|
|
376
391
|
except ValueError as e:
|
|
377
392
|
logger.error(f"Error processing F expression for field {key}: {e}")
|
|
378
|
-
raise ValidationError(
|
|
393
|
+
raise ValidationError(
|
|
394
|
+
f"Invalid F expression for field {key}: {str(e)}"
|
|
395
|
+
)
|
|
379
396
|
else:
|
|
380
397
|
# Regular value, use as-is
|
|
381
398
|
processed_data[key] = value
|
|
382
|
-
|
|
399
|
+
|
|
383
400
|
# Execute the update with processed expressions
|
|
384
401
|
rows_updated = qs.update(**processed_data)
|
|
385
402
|
|
|
386
403
|
# After update, fetch the updated instances
|
|
387
404
|
updated_instances = list(qs.only(*update_fields))
|
|
388
|
-
|
|
405
|
+
|
|
389
406
|
# Triggers cache invalidation and broadcast to the frontend
|
|
390
407
|
config.event_bus.emit_bulk_event(ActionType.BULK_UPDATE, updated_instances)
|
|
391
408
|
|
|
@@ -393,50 +410,56 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
393
410
|
|
|
394
411
|
def delete(
|
|
395
412
|
self,
|
|
413
|
+
queryset: QuerySet,
|
|
396
414
|
node: Dict[str, Any],
|
|
397
415
|
req: RequestType,
|
|
398
416
|
permissions: List[Type[AbstractPermission]],
|
|
399
417
|
) -> Tuple[int, Tuple[int]]:
|
|
400
|
-
|
|
418
|
+
"""Delete multiple model instances."""
|
|
419
|
+
model = queryset.model
|
|
401
420
|
filter_ast: Optional[Dict[str, Any]] = node.get("filter")
|
|
402
|
-
# Start with
|
|
403
|
-
qs: QuerySet =
|
|
421
|
+
# Start with the provided queryset which already has permission filtering
|
|
422
|
+
qs: QuerySet = queryset
|
|
404
423
|
if filter_ast:
|
|
405
|
-
visitor = QueryASTVisitor(
|
|
424
|
+
visitor = QueryASTVisitor(model)
|
|
406
425
|
q_obj = visitor.visit(filter_ast)
|
|
407
426
|
qs = qs.filter(q_obj)
|
|
408
427
|
|
|
409
|
-
check_bulk_permissions(req, qs, ActionType.DELETE, permissions,
|
|
410
|
-
|
|
428
|
+
check_bulk_permissions(req, qs, ActionType.DELETE, permissions, model)
|
|
429
|
+
|
|
411
430
|
# TODO: this should be a values list, but we need to check the bulk event emitter code
|
|
412
|
-
model = qs.model
|
|
413
431
|
pk_field_name = model._meta.pk.name
|
|
414
432
|
instances = list(qs.only(pk_field_name))
|
|
415
|
-
|
|
433
|
+
|
|
416
434
|
deleted, _ = qs.delete()
|
|
417
435
|
|
|
418
436
|
# Triggers cache invalidation and broadcast to the frontend
|
|
419
437
|
config.event_bus.emit_bulk_event(ActionType.BULK_DELETE, instances)
|
|
420
438
|
|
|
421
439
|
# Dynamically create a Meta inner class
|
|
422
|
-
Meta = type(
|
|
423
|
-
"
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
440
|
+
Meta = type(
|
|
441
|
+
"Meta",
|
|
442
|
+
(),
|
|
443
|
+
{
|
|
444
|
+
"model": model,
|
|
445
|
+
"fields": [pk_field_name], # Only include the PK field
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
|
|
427
449
|
# Create the serializer class
|
|
428
450
|
serializer_class = type(
|
|
429
|
-
f"Dynamic{model.__name__}PkSerializer",
|
|
430
|
-
(serializers.ModelSerializer,),
|
|
431
|
-
{"Meta": Meta}
|
|
451
|
+
f"Dynamic{model.__name__}PkSerializer",
|
|
452
|
+
(serializers.ModelSerializer,),
|
|
453
|
+
{"Meta": Meta},
|
|
432
454
|
)
|
|
433
455
|
|
|
434
456
|
serializer = serializer_class(instances, many=True)
|
|
435
|
-
|
|
457
|
+
|
|
436
458
|
return deleted, serializer.data
|
|
437
459
|
|
|
438
460
|
def get(
|
|
439
461
|
self,
|
|
462
|
+
queryset: QuerySet,
|
|
440
463
|
node: Dict[str, Any],
|
|
441
464
|
req: RequestType,
|
|
442
465
|
permissions: List[Type[AbstractPermission]],
|
|
@@ -445,6 +468,7 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
445
468
|
Retrieve a single model instance with permission checks.
|
|
446
469
|
|
|
447
470
|
Args:
|
|
471
|
+
queryset: The base queryset to search in
|
|
448
472
|
node: The query AST node
|
|
449
473
|
req: The request object
|
|
450
474
|
permissions: List of permission classes to check
|
|
@@ -457,34 +481,32 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
457
481
|
PermissionDenied: If the user doesn't have permission to read the object
|
|
458
482
|
MultipleObjectsReturned: If multiple objects match the query
|
|
459
483
|
"""
|
|
460
|
-
|
|
484
|
+
model = queryset.model
|
|
461
485
|
filter_ast: Optional[Dict[str, Any]] = node.get("filter")
|
|
462
486
|
|
|
463
487
|
if filter_ast:
|
|
464
|
-
visitor = QueryASTVisitor(
|
|
488
|
+
visitor = QueryASTVisitor(model)
|
|
465
489
|
q_obj = visitor.visit(filter_ast)
|
|
466
490
|
try:
|
|
467
|
-
instance =
|
|
468
|
-
except
|
|
469
|
-
raise NotFound(f"No {
|
|
470
|
-
except
|
|
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:
|
|
471
495
|
raise MultipleObjectsReturned(
|
|
472
|
-
f"Multiple {
|
|
496
|
+
f"Multiple {model.__name__} instances match the given query."
|
|
473
497
|
)
|
|
474
498
|
else:
|
|
475
499
|
try:
|
|
476
|
-
instance =
|
|
477
|
-
except
|
|
478
|
-
raise NotFound(f"No {
|
|
479
|
-
except
|
|
500
|
+
instance = queryset.get()
|
|
501
|
+
except model.DoesNotExist:
|
|
502
|
+
raise NotFound(f"No {model.__name__} matches the given query.")
|
|
503
|
+
except model.MultipleObjectsReturned:
|
|
480
504
|
raise MultipleObjectsReturned(
|
|
481
|
-
f"Multiple {
|
|
505
|
+
f"Multiple {model.__name__} instances match the given query."
|
|
482
506
|
)
|
|
483
507
|
|
|
484
508
|
# Check object-level permissions for reading
|
|
485
|
-
check_object_permissions(
|
|
486
|
-
req, instance, ActionType.READ, permissions, self.model
|
|
487
|
-
)
|
|
509
|
+
check_object_permissions(req, instance, ActionType.READ, permissions, model)
|
|
488
510
|
|
|
489
511
|
return instance
|
|
490
512
|
|
|
@@ -503,15 +525,17 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
503
525
|
|
|
504
526
|
def get_or_create(
|
|
505
527
|
self,
|
|
528
|
+
queryset: QuerySet,
|
|
506
529
|
node: Dict[str, Any],
|
|
507
530
|
serializer,
|
|
508
531
|
req: RequestType,
|
|
509
532
|
permissions: List[Type[AbstractPermission]],
|
|
510
|
-
create_fields_map
|
|
533
|
+
create_fields_map,
|
|
511
534
|
) -> Tuple[models.Model, bool]:
|
|
512
535
|
"""
|
|
513
536
|
Get an existing object, or create it if it doesn't exist, with object-level permission checks.
|
|
514
537
|
"""
|
|
538
|
+
model = queryset.model
|
|
515
539
|
lookup = node.get("lookup", {})
|
|
516
540
|
defaults = node.get("defaults", {})
|
|
517
541
|
|
|
@@ -520,20 +544,18 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
520
544
|
|
|
521
545
|
# Check if an instance exists
|
|
522
546
|
try:
|
|
523
|
-
instance =
|
|
547
|
+
instance = queryset.get(**lookup)
|
|
524
548
|
created = False
|
|
525
549
|
|
|
526
550
|
# Check object-level permission to read the existing object
|
|
527
|
-
check_object_permissions(
|
|
528
|
-
|
|
529
|
-
)
|
|
530
|
-
except self.model.DoesNotExist:
|
|
551
|
+
check_object_permissions(req, instance, ActionType.READ, permissions, model)
|
|
552
|
+
except model.DoesNotExist:
|
|
531
553
|
# Object doesn't exist, we'll create it
|
|
532
554
|
instance = None
|
|
533
555
|
created = True
|
|
534
|
-
except
|
|
556
|
+
except model.MultipleObjectsReturned as e:
|
|
535
557
|
raise MultipleObjectsReturned(
|
|
536
|
-
f"Multiple {
|
|
558
|
+
f"Multiple {model.__name__} instances match the given lookup parameters"
|
|
537
559
|
)
|
|
538
560
|
|
|
539
561
|
# If the instance exists, we don't need to update it, just return it
|
|
@@ -542,28 +564,30 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
542
564
|
|
|
543
565
|
# Only create a new instance if it doesn't exist
|
|
544
566
|
instance = serializer.save(
|
|
545
|
-
model=
|
|
567
|
+
model=model,
|
|
546
568
|
data=merged_data,
|
|
547
569
|
instance=None, # No instance for creation
|
|
548
|
-
partial=False,
|
|
570
|
+
partial=False, # Not a partial update for creation
|
|
549
571
|
request=req,
|
|
550
|
-
fields_map=create_fields_map
|
|
572
|
+
fields_map=create_fields_map,
|
|
551
573
|
)
|
|
552
574
|
|
|
553
575
|
return instance, created
|
|
554
576
|
|
|
555
577
|
def update_or_create(
|
|
556
578
|
self,
|
|
579
|
+
queryset: QuerySet,
|
|
557
580
|
node: Dict[str, Any],
|
|
558
581
|
req: RequestType,
|
|
559
582
|
serializer,
|
|
560
583
|
permissions: List[Type[AbstractPermission]],
|
|
561
584
|
update_fields_map,
|
|
562
|
-
create_fields_map
|
|
585
|
+
create_fields_map,
|
|
563
586
|
) -> Tuple[models.Model, bool]:
|
|
564
587
|
"""
|
|
565
588
|
Update an existing object, or create it if it doesn't exist, with object-level permission checks.
|
|
566
589
|
"""
|
|
590
|
+
model = queryset.model
|
|
567
591
|
lookup = node.get("lookup", {})
|
|
568
592
|
defaults = node.get("defaults", {})
|
|
569
593
|
|
|
@@ -572,45 +596,51 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
572
596
|
|
|
573
597
|
# Determine if the instance exists
|
|
574
598
|
try:
|
|
575
|
-
instance =
|
|
599
|
+
instance = queryset.get(**lookup)
|
|
576
600
|
created = False
|
|
577
601
|
|
|
578
602
|
# Perform object-level permission check before update
|
|
579
603
|
check_object_permissions(
|
|
580
|
-
req, instance, ActionType.UPDATE, permissions,
|
|
604
|
+
req, instance, ActionType.UPDATE, permissions, model
|
|
581
605
|
)
|
|
582
|
-
except
|
|
606
|
+
except model.DoesNotExist:
|
|
583
607
|
# Object doesn't exist, we'll create it
|
|
584
608
|
instance = None
|
|
585
609
|
created = True
|
|
586
|
-
except
|
|
610
|
+
except model.MultipleObjectsReturned as e:
|
|
587
611
|
raise MultipleObjectsReturned(
|
|
588
|
-
f"Multiple {
|
|
612
|
+
f"Multiple {model.__name__} instances match the given lookup parameters"
|
|
589
613
|
)
|
|
590
|
-
|
|
614
|
+
|
|
591
615
|
fields_map_to_use = create_fields_map if created else update_fields_map
|
|
592
616
|
|
|
593
617
|
# Use the serializer's save method, which handles validation and saving
|
|
594
618
|
instance = serializer.save(
|
|
595
|
-
model=
|
|
619
|
+
model=model,
|
|
596
620
|
data=merged_data,
|
|
597
621
|
instance=instance,
|
|
598
622
|
request=req,
|
|
599
|
-
fields_map=fields_map_to_use
|
|
623
|
+
fields_map=fields_map_to_use,
|
|
600
624
|
)
|
|
601
625
|
|
|
602
626
|
return instance, created
|
|
603
627
|
|
|
604
|
-
def first(self) -> Optional[models.Model]:
|
|
605
|
-
|
|
628
|
+
def first(self, queryset: QuerySet) -> Optional[models.Model]:
|
|
629
|
+
"""Return the first record from the queryset."""
|
|
630
|
+
return queryset.first()
|
|
606
631
|
|
|
607
|
-
def last(self) -> Optional[models.Model]:
|
|
608
|
-
|
|
632
|
+
def last(self, queryset: QuerySet) -> Optional[models.Model]:
|
|
633
|
+
"""Return the last record from the queryset."""
|
|
634
|
+
return queryset.last()
|
|
609
635
|
|
|
610
|
-
def exists(self) -> bool:
|
|
611
|
-
|
|
636
|
+
def exists(self, queryset: QuerySet) -> bool:
|
|
637
|
+
"""Return True if the queryset has any results; otherwise False."""
|
|
638
|
+
return queryset.exists()
|
|
612
639
|
|
|
613
|
-
def aggregate(
|
|
640
|
+
def aggregate(
|
|
641
|
+
self, queryset: QuerySet, agg_list: List[Dict[str, Any]]
|
|
642
|
+
) -> Dict[str, Any]:
|
|
643
|
+
"""Perform aggregation operations on the queryset."""
|
|
614
644
|
agg_expressions = {}
|
|
615
645
|
for agg in agg_list:
|
|
616
646
|
func = agg.get("function")
|
|
@@ -628,40 +658,52 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
628
658
|
agg_expressions[alias] = Max(field)
|
|
629
659
|
else:
|
|
630
660
|
raise ValidationError(f"Unknown aggregate function: {func}")
|
|
631
|
-
result =
|
|
661
|
+
result = queryset.aggregate(**agg_expressions)
|
|
632
662
|
return {"data": result, "metadata": {"aggregated": True}}
|
|
633
663
|
|
|
634
|
-
def count(self, field: str) -> int:
|
|
635
|
-
|
|
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"]
|
|
636
667
|
return int(result) if result is not None else 0
|
|
637
668
|
|
|
638
|
-
def sum(self, field: str) -> Optional[Union[int, float]]:
|
|
639
|
-
|
|
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"]
|
|
640
672
|
|
|
641
|
-
def avg(self, field: str) -> Optional[float]:
|
|
642
|
-
|
|
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"]
|
|
643
676
|
return float(result) if result is not None else None
|
|
644
677
|
|
|
645
|
-
def min(self, field: str) -> Optional[Union[int, float, str]]:
|
|
646
|
-
|
|
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"]
|
|
647
681
|
|
|
648
|
-
def max(self, field: str) -> Optional[Union[int, float, str]]:
|
|
649
|
-
|
|
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"]
|
|
650
685
|
|
|
651
|
-
def order_by(self, order_list: List[str]) ->
|
|
652
|
-
|
|
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)
|
|
653
689
|
|
|
654
|
-
def select_related(self, related_fields: List[str]) ->
|
|
655
|
-
|
|
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)
|
|
656
693
|
|
|
657
|
-
def prefetch_related(
|
|
658
|
-
self
|
|
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)
|
|
659
699
|
|
|
660
|
-
def select_fields(self, fields: List[str]) ->
|
|
661
|
-
|
|
700
|
+
def select_fields(self, queryset: QuerySet, fields: List[str]) -> QuerySet:
|
|
701
|
+
"""Select only specific fields from the queryset."""
|
|
702
|
+
return queryset.values(*fields)
|
|
662
703
|
|
|
663
704
|
def fetch_list(
|
|
664
705
|
self,
|
|
706
|
+
queryset: QuerySet,
|
|
665
707
|
offset: Optional[int] = None,
|
|
666
708
|
limit: Optional[int] = None,
|
|
667
709
|
req: RequestType = None,
|
|
@@ -671,31 +713,34 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
671
713
|
Fetch a list of model instances with bulk permission checks.
|
|
672
714
|
|
|
673
715
|
Args:
|
|
716
|
+
queryset: The queryset to paginate
|
|
674
717
|
offset: The offset for pagination
|
|
675
718
|
limit: The limit for pagination
|
|
676
719
|
req: The request object
|
|
677
720
|
permissions: List of permission classes to check
|
|
678
721
|
|
|
679
722
|
Returns:
|
|
680
|
-
A
|
|
723
|
+
A sliced queryset after permission checks
|
|
681
724
|
"""
|
|
725
|
+
model = queryset.model
|
|
682
726
|
offset = offset or 0
|
|
683
727
|
|
|
684
728
|
# FIXED: Perform bulk permission checks BEFORE slicing
|
|
685
729
|
if req is not None and permissions:
|
|
686
730
|
# Use the existing bulk permission check function on the unsliced queryset
|
|
687
|
-
check_bulk_permissions(req,
|
|
731
|
+
check_bulk_permissions(req, queryset, ActionType.READ, permissions, model)
|
|
688
732
|
|
|
689
733
|
# THEN apply pagination/slicing
|
|
690
734
|
if limit is None:
|
|
691
|
-
qs =
|
|
735
|
+
qs = queryset[offset:]
|
|
692
736
|
else:
|
|
693
|
-
qs =
|
|
737
|
+
qs = queryset[offset : offset + limit]
|
|
694
738
|
|
|
695
739
|
return qs
|
|
696
740
|
|
|
697
|
-
def _build_conditions(self, conditions: dict) -> Q:
|
|
698
|
-
|
|
741
|
+
def _build_conditions(self, model: Type[models.Model], conditions: dict) -> Q:
|
|
742
|
+
"""Build Q conditions from a dictionary."""
|
|
743
|
+
visitor = QueryASTVisitor(model)
|
|
699
744
|
fake_ast = {"type": "filter", "conditions": conditions}
|
|
700
745
|
return visitor.visit(fake_ast)
|
|
701
746
|
|
|
@@ -708,12 +753,13 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
708
753
|
custom_querysets: Dict[str, Type[AbstractCustomQueryset]],
|
|
709
754
|
registered_permissions: List[Type[AbstractPermission]],
|
|
710
755
|
) -> Any:
|
|
756
|
+
"""Assemble and return the base QuerySet for the given model."""
|
|
711
757
|
custom_name = initial_ast.get("custom_queryset")
|
|
712
758
|
if custom_name and custom_name in custom_querysets:
|
|
713
759
|
custom_queryset_class = custom_querysets[custom_name]
|
|
714
760
|
return custom_queryset_class().get_queryset(req)
|
|
715
761
|
return model.objects.all()
|
|
716
|
-
|
|
762
|
+
|
|
717
763
|
def get_fields(self, model: models.Model) -> Set[str]:
|
|
718
764
|
"""
|
|
719
765
|
Return a set of the model fields.
|
|
@@ -723,7 +769,9 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
723
769
|
resolved_fields = model_config.fields
|
|
724
770
|
else:
|
|
725
771
|
resolved_fields = set((field.name for field in model._meta.get_fields()))
|
|
726
|
-
additional_fields = set(
|
|
772
|
+
additional_fields = set(
|
|
773
|
+
(field.name for field in model_config.additional_fields)
|
|
774
|
+
)
|
|
727
775
|
resolved_fields = resolved_fields.union(additional_fields)
|
|
728
776
|
return resolved_fields
|
|
729
777
|
|
|
@@ -732,37 +780,37 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
732
780
|
) -> nx.DiGraph:
|
|
733
781
|
"""
|
|
734
782
|
Build a directed graph of models and their fields, focusing on direct relationships.
|
|
735
|
-
|
|
783
|
+
|
|
736
784
|
Args:
|
|
737
785
|
model: The Django model to build the graph for
|
|
738
786
|
model_graph: An existing graph to add to (optional)
|
|
739
|
-
|
|
787
|
+
|
|
740
788
|
Returns:
|
|
741
789
|
nx.DiGraph: The model graph
|
|
742
790
|
"""
|
|
743
791
|
from django.db.models.fields.related import RelatedField, ForeignObjectRel
|
|
744
|
-
|
|
792
|
+
|
|
745
793
|
if model_graph is None:
|
|
746
794
|
model_graph = nx.DiGraph()
|
|
747
|
-
|
|
795
|
+
|
|
748
796
|
# Use the adapter's get_model_name method.
|
|
749
797
|
model_name = self.get_model_name(model)
|
|
750
|
-
|
|
798
|
+
|
|
751
799
|
# Add the model node if it doesn't exist.
|
|
752
800
|
if not model_graph.has_node(model_name):
|
|
753
801
|
model_graph.add_node(
|
|
754
802
|
model_name, data=ModelNode(model_name=model_name, model=model)
|
|
755
803
|
)
|
|
756
|
-
|
|
804
|
+
|
|
757
805
|
# Iterate over all fields in the model.
|
|
758
806
|
for field in model._meta.get_fields():
|
|
759
807
|
field_name = field.name
|
|
760
|
-
|
|
808
|
+
|
|
761
809
|
# Skip reverse relations for validation purposes
|
|
762
810
|
# These are relationships defined on other models pointing to this model
|
|
763
811
|
if isinstance(field, ForeignObjectRel):
|
|
764
812
|
continue
|
|
765
|
-
|
|
813
|
+
|
|
766
814
|
field_node = f"{model_name}::{field_name}"
|
|
767
815
|
field_node_data = FieldNode(
|
|
768
816
|
model_name=model_name,
|
|
@@ -776,18 +824,18 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
776
824
|
)
|
|
777
825
|
model_graph.add_node(field_node, data=field_node_data)
|
|
778
826
|
model_graph.add_edge(model_name, field_node)
|
|
779
|
-
|
|
827
|
+
|
|
780
828
|
if field.is_relation and field.related_model:
|
|
781
829
|
related_model = field.related_model
|
|
782
830
|
related_model_name = self.get_model_name(related_model)
|
|
783
831
|
if not model_graph.has_node(related_model_name):
|
|
784
832
|
self.build_model_graph(related_model, model_graph)
|
|
785
833
|
model_graph.add_edge(field_node, related_model_name)
|
|
786
|
-
|
|
834
|
+
|
|
787
835
|
# Add additional (computed) fields from the model's configuration.
|
|
788
836
|
try:
|
|
789
837
|
from statezero.adaptors.django.config import registry
|
|
790
|
-
|
|
838
|
+
|
|
791
839
|
config = registry.get_config(model)
|
|
792
840
|
for additional_field in config.additional_fields:
|
|
793
841
|
add_field_name = additional_field.name
|
|
@@ -812,14 +860,16 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
812
860
|
model_graph.add_edge(model_name, add_field_node)
|
|
813
861
|
except ValueError:
|
|
814
862
|
pass
|
|
815
|
-
|
|
863
|
+
|
|
816
864
|
return model_graph
|
|
817
865
|
|
|
818
866
|
def register_event_signals(self, event_bus: EventBus) -> None:
|
|
867
|
+
"""Register Django signals for model events."""
|
|
868
|
+
|
|
819
869
|
def pre_save_receiver(sender, instance, **kwargs):
|
|
820
870
|
if not instance.pk:
|
|
821
|
-
return
|
|
822
|
-
|
|
871
|
+
return # It can't be used for cache invalidation, cause there's no pk
|
|
872
|
+
|
|
823
873
|
action = ActionType.PRE_UPDATE
|
|
824
874
|
try:
|
|
825
875
|
event_bus.emit_event(action, instance)
|
|
@@ -836,7 +886,7 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
836
886
|
logger.exception(
|
|
837
887
|
"Error emitting event %s for instance %s: %s", action, instance, e
|
|
838
888
|
)
|
|
839
|
-
|
|
889
|
+
|
|
840
890
|
def pre_delete_receiver(sender, instance, **kwargs):
|
|
841
891
|
try:
|
|
842
892
|
# Use PRE_DELETE action type for cache invalidation before DB operation
|
|
@@ -858,28 +908,28 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
858
908
|
|
|
859
909
|
for model in registry._models_config.keys():
|
|
860
910
|
model_name = config.orm_provider.get_model_name(model)
|
|
861
|
-
|
|
911
|
+
|
|
862
912
|
# Register pre_save signals (new)
|
|
863
913
|
uid_pre_save = f"statezero:{model_name}:pre_save"
|
|
864
914
|
pre_save.disconnect(sender=model, dispatch_uid=uid_pre_save)
|
|
865
915
|
receiver(pre_save, sender=model, weak=False, dispatch_uid=uid_pre_save)(
|
|
866
916
|
pre_save_receiver
|
|
867
917
|
)
|
|
868
|
-
|
|
918
|
+
|
|
869
919
|
# Register post_save signals
|
|
870
920
|
uid_save = f"statezero:{model_name}:post_save"
|
|
871
921
|
post_save.disconnect(sender=model, dispatch_uid=uid_save)
|
|
872
922
|
receiver(post_save, sender=model, weak=False, dispatch_uid=uid_save)(
|
|
873
923
|
post_save_receiver
|
|
874
924
|
)
|
|
875
|
-
|
|
925
|
+
|
|
876
926
|
# Register pre_delete signals
|
|
877
927
|
uid_pre_delete = f"statezero:{model_name}:pre_delete"
|
|
878
928
|
pre_delete.disconnect(sender=model, dispatch_uid=uid_pre_delete)
|
|
879
929
|
receiver(pre_delete, sender=model, weak=False, dispatch_uid=uid_pre_delete)(
|
|
880
930
|
pre_delete_receiver
|
|
881
931
|
)
|
|
882
|
-
|
|
932
|
+
|
|
883
933
|
# Register post_delete signals
|
|
884
934
|
uid_delete = f"statezero:{model_name}:post_delete"
|
|
885
935
|
post_delete.disconnect(sender=model, dispatch_uid=uid_delete)
|
|
@@ -888,6 +938,7 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
888
938
|
)
|
|
889
939
|
|
|
890
940
|
def get_model_by_name(self, model_name: str) -> Type[models.Model]:
|
|
941
|
+
"""Retrieve the model class based on a given model name."""
|
|
891
942
|
try:
|
|
892
943
|
app_label, model_cls = model_name.split(".")
|
|
893
944
|
model = apps.get_model(app_label, model_cls)
|
|
@@ -899,9 +950,8 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
899
950
|
f"Model name '{model_name}' must be in the format 'app_label.ModelName'"
|
|
900
951
|
)
|
|
901
952
|
|
|
902
|
-
def get_model_name(
|
|
903
|
-
|
|
904
|
-
) -> str: # type:ignore
|
|
953
|
+
def get_model_name(self, model: Union[models.Model, Type[models.Model]]) -> str:
|
|
954
|
+
"""Retrieve the model name for the given model class or instance."""
|
|
905
955
|
if not isinstance(model, type):
|
|
906
956
|
model = model.__class__
|
|
907
957
|
if hasattr(model, "_meta"):
|
|
@@ -909,7 +959,7 @@ class DjangoORMAdapter(AbstractORMProvider):
|
|
|
909
959
|
raise ValueError(
|
|
910
960
|
f"Cannot determine model name from {model} of type {type(model)}: _meta attribute is missing from the model."
|
|
911
961
|
)
|
|
912
|
-
|
|
962
|
+
|
|
913
963
|
def get_user(self, request):
|
|
914
|
-
"""
|
|
915
|
-
return request.user
|
|
964
|
+
"""Return the user from the request."""
|
|
965
|
+
return request.user
|