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.
- statezero/__init__.py +0 -0
- statezero/adaptors/__init__.py +0 -0
- statezero/adaptors/django/__init__.py +0 -0
- statezero/adaptors/django/actions.py +248 -0
- statezero/adaptors/django/apps.py +137 -0
- statezero/adaptors/django/config.py +99 -0
- statezero/adaptors/django/context_manager.py +12 -0
- statezero/adaptors/django/event_emitters.py +78 -0
- statezero/adaptors/django/exception_handler.py +98 -0
- statezero/adaptors/django/extensions/__init__.py +0 -0
- statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
- statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +141 -0
- statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +83 -0
- statezero/adaptors/django/f_handler.py +312 -0
- statezero/adaptors/django/helpers.py +153 -0
- statezero/adaptors/django/middleware.py +10 -0
- statezero/adaptors/django/migrations/0001_initial.py +33 -0
- statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +16 -0
- statezero/adaptors/django/migrations/__init__.py +0 -0
- statezero/adaptors/django/orm.py +1073 -0
- statezero/adaptors/django/permissions.py +252 -0
- statezero/adaptors/django/query_optimizer.py +810 -0
- statezero/adaptors/django/schemas.py +360 -0
- statezero/adaptors/django/search_providers/__init__.py +0 -0
- statezero/adaptors/django/search_providers/basic_search.py +24 -0
- statezero/adaptors/django/search_providers/postgres_search.py +51 -0
- statezero/adaptors/django/serializers.py +588 -0
- statezero/adaptors/django/urls.py +18 -0
- statezero/adaptors/django/views.py +588 -0
- statezero/core/__init__.py +34 -0
- statezero/core/actions.py +92 -0
- statezero/core/ast_parser.py +961 -0
- statezero/core/ast_validator.py +266 -0
- statezero/core/classes.py +224 -0
- statezero/core/config.py +267 -0
- statezero/core/context_storage.py +4 -0
- statezero/core/event_bus.py +175 -0
- statezero/core/event_emitters.py +60 -0
- statezero/core/exceptions.py +106 -0
- statezero/core/hook_checks.py +86 -0
- statezero/core/interfaces.py +677 -0
- statezero/core/process_request.py +184 -0
- statezero/core/types.py +29 -0
- statezero-0.1.0b20.dist-info/METADATA +242 -0
- statezero-0.1.0b20.dist-info/RECORD +48 -0
- statezero-0.1.0b20.dist-info/WHEEL +5 -0
- statezero-0.1.0b20.dist-info/licenses/license.md +117 -0
- 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
|