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