django-bulk-hooks 0.1.102__py3-none-any.whl → 0.1.104__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 django-bulk-hooks might be problematic. Click here for more details.
- django_bulk_hooks/__init__.py +3 -49
- django_bulk_hooks/conditions.py +66 -261
- django_bulk_hooks/decorators.py +3 -48
- django_bulk_hooks/engine.py +9 -37
- django_bulk_hooks/handler.py +1 -1
- django_bulk_hooks/manager.py +151 -162
- django_bulk_hooks/models.py +35 -66
- django_bulk_hooks/priority.py +16 -0
- django_bulk_hooks/queryset.py +3 -2
- django_bulk_hooks/registry.py +3 -2
- django_bulk_hooks-0.1.104.dist-info/METADATA +217 -0
- django_bulk_hooks-0.1.104.dist-info/RECORD +17 -0
- {django_bulk_hooks-0.1.102.dist-info → django_bulk_hooks-0.1.104.dist-info}/WHEEL +1 -1
- django_bulk_hooks-0.1.102.dist-info/METADATA +0 -228
- django_bulk_hooks-0.1.102.dist-info/RECORD +0 -16
- {django_bulk_hooks-0.1.102.dist-info → django_bulk_hooks-0.1.104.dist-info}/LICENSE +0 -0
django_bulk_hooks/__init__.py
CHANGED
|
@@ -1,50 +1,4 @@
|
|
|
1
|
-
from django_bulk_hooks.
|
|
2
|
-
|
|
3
|
-
AFTER_DELETE,
|
|
4
|
-
AFTER_UPDATE,
|
|
5
|
-
BEFORE_CREATE,
|
|
6
|
-
BEFORE_DELETE,
|
|
7
|
-
BEFORE_UPDATE,
|
|
8
|
-
VALIDATE_CREATE,
|
|
9
|
-
VALIDATE_DELETE,
|
|
10
|
-
VALIDATE_UPDATE,
|
|
11
|
-
)
|
|
12
|
-
from django_bulk_hooks.conditions import (
|
|
13
|
-
ChangesTo,
|
|
14
|
-
HasChanged,
|
|
15
|
-
IsEqual,
|
|
16
|
-
IsNotEqual,
|
|
17
|
-
WasEqual,
|
|
18
|
-
safe_get_related_object,
|
|
19
|
-
safe_get_related_attr,
|
|
20
|
-
is_field_set,
|
|
21
|
-
)
|
|
22
|
-
from django_bulk_hooks.decorators import hook, select_related
|
|
23
|
-
from django_bulk_hooks.handler import HookHandler
|
|
24
|
-
from django_bulk_hooks.models import HookModelMixin
|
|
25
|
-
from django_bulk_hooks.enums import Priority
|
|
1
|
+
from django_bulk_hooks.handler import Hook
|
|
2
|
+
from django_bulk_hooks.manager import BulkHookManager
|
|
26
3
|
|
|
27
|
-
__all__ = [
|
|
28
|
-
"HookHandler",
|
|
29
|
-
"HookModelMixin",
|
|
30
|
-
"BEFORE_CREATE",
|
|
31
|
-
"AFTER_CREATE",
|
|
32
|
-
"BEFORE_UPDATE",
|
|
33
|
-
"AFTER_UPDATE",
|
|
34
|
-
"BEFORE_DELETE",
|
|
35
|
-
"AFTER_DELETE",
|
|
36
|
-
"VALIDATE_CREATE",
|
|
37
|
-
"VALIDATE_UPDATE",
|
|
38
|
-
"VALIDATE_DELETE",
|
|
39
|
-
"safe_get_related_object",
|
|
40
|
-
"safe_get_related_attr",
|
|
41
|
-
"is_field_set",
|
|
42
|
-
"Priority",
|
|
43
|
-
"hook",
|
|
44
|
-
"select_related",
|
|
45
|
-
"ChangesTo",
|
|
46
|
-
"HasChanged",
|
|
47
|
-
"IsEqual",
|
|
48
|
-
"IsNotEqual",
|
|
49
|
-
"WasEqual",
|
|
50
|
-
]
|
|
4
|
+
__all__ = ["BulkHookManager", "Hook"]
|
django_bulk_hooks/conditions.py
CHANGED
|
@@ -1,182 +1,12 @@
|
|
|
1
|
-
from django.db import models
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def safe_get_related_object(instance, field_name):
|
|
5
|
-
"""
|
|
6
|
-
Safely get a related object without raising RelatedObjectDoesNotExist.
|
|
7
|
-
Returns None if the foreign key field is None or the related object doesn't exist.
|
|
8
|
-
"""
|
|
9
|
-
if not hasattr(instance, field_name):
|
|
10
|
-
return None
|
|
11
|
-
|
|
12
|
-
# Get the foreign key field
|
|
13
|
-
try:
|
|
14
|
-
field = instance._meta.get_field(field_name)
|
|
15
|
-
if not field.is_relation or field.many_to_many or field.one_to_many:
|
|
16
|
-
return getattr(instance, field_name, None)
|
|
17
|
-
except models.FieldDoesNotExist:
|
|
18
|
-
return getattr(instance, field_name, None)
|
|
19
|
-
|
|
20
|
-
# Check if the foreign key field is None
|
|
21
|
-
fk_field_name = f"{field_name}_id"
|
|
22
|
-
if hasattr(instance, fk_field_name) and getattr(instance, fk_field_name) is None:
|
|
23
|
-
return None
|
|
24
|
-
|
|
25
|
-
# Try to get the related object, but catch RelatedObjectDoesNotExist
|
|
26
|
-
try:
|
|
27
|
-
return getattr(instance, field_name)
|
|
28
|
-
except field.related_model.RelatedObjectDoesNotExist:
|
|
29
|
-
return None
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def is_field_set(instance, field_name):
|
|
33
|
-
"""
|
|
34
|
-
Check if a foreign key field is set without raising RelatedObjectDoesNotExist.
|
|
35
|
-
|
|
36
|
-
Args:
|
|
37
|
-
instance: The model instance
|
|
38
|
-
field_name: The foreign key field name
|
|
39
|
-
|
|
40
|
-
Returns:
|
|
41
|
-
True if the field is set, False otherwise
|
|
42
|
-
"""
|
|
43
|
-
# Check the foreign key ID field first
|
|
44
|
-
fk_field_name = f"{field_name}_id"
|
|
45
|
-
if hasattr(instance, fk_field_name):
|
|
46
|
-
fk_value = getattr(instance, fk_field_name, None)
|
|
47
|
-
return fk_value is not None
|
|
48
|
-
|
|
49
|
-
# Fallback to checking the field directly
|
|
50
|
-
try:
|
|
51
|
-
return getattr(instance, field_name) is not None
|
|
52
|
-
except Exception:
|
|
53
|
-
return False
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def safe_get_related_attr(instance, field_name, attr_name=None):
|
|
57
|
-
"""
|
|
58
|
-
Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
|
|
59
|
-
|
|
60
|
-
This is particularly useful in hooks where objects might not have their related
|
|
61
|
-
fields populated yet (e.g., during bulk_create operations or on unsaved objects).
|
|
62
|
-
|
|
63
|
-
Args:
|
|
64
|
-
instance: The model instance
|
|
65
|
-
field_name: The foreign key field name
|
|
66
|
-
attr_name: Optional attribute name to access on the related object
|
|
67
|
-
|
|
68
|
-
Returns:
|
|
69
|
-
The related object, the attribute value, or None if not available
|
|
70
|
-
|
|
71
|
-
Example:
|
|
72
|
-
# Instead of: loan_transaction.status.name (which might fail)
|
|
73
|
-
# Use: safe_get_related_attr(loan_transaction, 'status', 'name')
|
|
74
|
-
|
|
75
|
-
status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
|
|
76
|
-
if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
|
|
77
|
-
# Process the transaction
|
|
78
|
-
pass
|
|
79
|
-
"""
|
|
80
|
-
# For unsaved objects, check the foreign key ID field first
|
|
81
|
-
if instance.pk is None:
|
|
82
|
-
fk_field_name = f"{field_name}_id"
|
|
83
|
-
if hasattr(instance, fk_field_name):
|
|
84
|
-
fk_value = getattr(instance, fk_field_name, None)
|
|
85
|
-
if fk_value is None:
|
|
86
|
-
return None
|
|
87
|
-
# If we have an ID but the object isn't loaded, try to load it
|
|
88
|
-
try:
|
|
89
|
-
field = instance._meta.get_field(field_name)
|
|
90
|
-
if hasattr(field, 'related_model'):
|
|
91
|
-
related_obj = field.related_model.objects.get(id=fk_value)
|
|
92
|
-
if attr_name is None:
|
|
93
|
-
return related_obj
|
|
94
|
-
return getattr(related_obj, attr_name, None)
|
|
95
|
-
except (field.related_model.DoesNotExist, AttributeError):
|
|
96
|
-
return None
|
|
97
|
-
|
|
98
|
-
# For saved objects or when the above doesn't work, use the original method
|
|
99
|
-
related_obj = safe_get_related_object(instance, field_name)
|
|
100
|
-
if related_obj is None:
|
|
101
|
-
return None
|
|
102
|
-
|
|
103
|
-
if attr_name is None:
|
|
104
|
-
return related_obj
|
|
105
|
-
|
|
106
|
-
return getattr(related_obj, attr_name, None)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def safe_get_related_attr_with_fallback(instance, field_name, attr_name=None, fallback_value=None):
|
|
110
|
-
"""
|
|
111
|
-
Enhanced version of safe_get_related_attr that provides fallback handling.
|
|
112
|
-
|
|
113
|
-
This function is especially useful for bulk operations where related objects
|
|
114
|
-
might not be fully loaded or might not exist yet.
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
instance: The model instance
|
|
118
|
-
field_name: The foreign key field name
|
|
119
|
-
attr_name: Optional attribute name to access on the related object
|
|
120
|
-
fallback_value: Value to return if the related object or attribute doesn't exist
|
|
121
|
-
|
|
122
|
-
Returns:
|
|
123
|
-
The related object, the attribute value, or fallback_value if not available
|
|
124
|
-
"""
|
|
125
|
-
# First try the standard safe access
|
|
126
|
-
result = safe_get_related_attr(instance, field_name, attr_name)
|
|
127
|
-
if result is not None:
|
|
128
|
-
return result
|
|
129
|
-
|
|
130
|
-
# If that fails, try to get the foreign key ID and fetch the object directly
|
|
131
|
-
fk_field_name = f"{field_name}_id"
|
|
132
|
-
if hasattr(instance, fk_field_name):
|
|
133
|
-
fk_id = getattr(instance, fk_field_name)
|
|
134
|
-
if fk_id is not None:
|
|
135
|
-
try:
|
|
136
|
-
# Get the field to determine the related model
|
|
137
|
-
field = instance._meta.get_field(field_name)
|
|
138
|
-
if field.is_relation and not field.many_to_many and not field.one_to_many:
|
|
139
|
-
# Try to fetch the related object directly
|
|
140
|
-
related_obj = field.related_model.objects.get(pk=fk_id)
|
|
141
|
-
if attr_name is None:
|
|
142
|
-
return related_obj
|
|
143
|
-
return getattr(related_obj, attr_name, fallback_value)
|
|
144
|
-
except (field.related_model.DoesNotExist, AttributeError):
|
|
145
|
-
pass
|
|
146
|
-
|
|
147
|
-
return fallback_value
|
|
148
|
-
|
|
149
|
-
|
|
150
1
|
def resolve_dotted_attr(instance, dotted_path):
|
|
151
2
|
"""
|
|
152
3
|
Recursively resolve a dotted attribute path, e.g., "type.category".
|
|
153
|
-
This function is designed to work with pre-loaded foreign keys to avoid queries.
|
|
154
4
|
"""
|
|
155
|
-
if instance is None:
|
|
156
|
-
return None
|
|
157
|
-
|
|
158
|
-
current = instance
|
|
159
5
|
for attr in dotted_path.split("."):
|
|
160
|
-
if
|
|
6
|
+
if instance is None:
|
|
161
7
|
return None
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if hasattr(current, '_meta') and hasattr(current._meta, 'get_field'):
|
|
165
|
-
try:
|
|
166
|
-
field = current._meta.get_field(attr)
|
|
167
|
-
if field.is_relation and not field.many_to_many and not field.one_to_many:
|
|
168
|
-
# For foreign keys, use safe access to prevent RelatedObjectDoesNotExist
|
|
169
|
-
current = safe_get_related_object(current, attr)
|
|
170
|
-
else:
|
|
171
|
-
current = getattr(current, attr, None)
|
|
172
|
-
except Exception:
|
|
173
|
-
# If field lookup fails, fall back to regular attribute access
|
|
174
|
-
current = getattr(current, attr, None)
|
|
175
|
-
else:
|
|
176
|
-
# Not a model instance, use regular attribute access
|
|
177
|
-
current = getattr(current, attr, None)
|
|
178
|
-
|
|
179
|
-
return current
|
|
8
|
+
instance = getattr(instance, attr, None)
|
|
9
|
+
return instance
|
|
180
10
|
|
|
181
11
|
|
|
182
12
|
class HookCondition:
|
|
@@ -195,104 +25,90 @@ class HookCondition:
|
|
|
195
25
|
def __invert__(self):
|
|
196
26
|
return NotCondition(self)
|
|
197
27
|
|
|
198
|
-
def get_required_fields(self):
|
|
199
|
-
"""
|
|
200
|
-
Returns a set of field names that this condition needs to evaluate.
|
|
201
|
-
Override in subclasses to specify required fields.
|
|
202
|
-
"""
|
|
203
|
-
return set()
|
|
204
|
-
|
|
205
28
|
|
|
206
|
-
class
|
|
207
|
-
def __init__(self, field, value):
|
|
29
|
+
class IsNotEqual(HookCondition):
|
|
30
|
+
def __init__(self, field, value, only_on_change=False):
|
|
208
31
|
self.field = field
|
|
209
32
|
self.value = value
|
|
33
|
+
self.only_on_change = only_on_change
|
|
210
34
|
|
|
211
35
|
def check(self, instance, original_instance=None):
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
36
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
37
|
+
if self.only_on_change:
|
|
38
|
+
if original_instance is None:
|
|
39
|
+
return False
|
|
40
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
41
|
+
return previous == self.value and current != self.value
|
|
42
|
+
else:
|
|
43
|
+
return current != self.value
|
|
217
44
|
|
|
218
45
|
|
|
219
|
-
class
|
|
220
|
-
def __init__(self, field, value):
|
|
46
|
+
class IsEqual(HookCondition):
|
|
47
|
+
def __init__(self, field, value, only_on_change=False):
|
|
221
48
|
self.field = field
|
|
222
49
|
self.value = value
|
|
50
|
+
self.only_on_change = only_on_change
|
|
223
51
|
|
|
224
52
|
def check(self, instance, original_instance=None):
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
53
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
54
|
+
if self.only_on_change:
|
|
55
|
+
if original_instance is None:
|
|
56
|
+
return False
|
|
57
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
58
|
+
return previous != self.value and current == self.value
|
|
59
|
+
else:
|
|
60
|
+
return current == self.value
|
|
230
61
|
|
|
231
62
|
|
|
232
|
-
class
|
|
233
|
-
def __init__(self, field,
|
|
63
|
+
class HasChanged(HookCondition):
|
|
64
|
+
def __init__(self, field, has_changed=True):
|
|
234
65
|
self.field = field
|
|
235
|
-
self.
|
|
66
|
+
self.has_changed = has_changed
|
|
236
67
|
|
|
237
68
|
def check(self, instance, original_instance=None):
|
|
238
|
-
if original_instance
|
|
69
|
+
if not original_instance:
|
|
239
70
|
return False
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def get_required_fields(self):
|
|
244
|
-
return {self.field.split('.')[0]}
|
|
71
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
72
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
73
|
+
return (current != previous) == self.has_changed
|
|
245
74
|
|
|
246
75
|
|
|
247
|
-
class
|
|
248
|
-
def __init__(self, field,
|
|
76
|
+
class WasEqual(HookCondition):
|
|
77
|
+
def __init__(self, field, value, only_on_change=False):
|
|
249
78
|
"""
|
|
250
|
-
Check if a field's value
|
|
251
|
-
|
|
252
|
-
Args:
|
|
253
|
-
field: The field name to check
|
|
254
|
-
has_changed: If True (default), condition passes when field has changed.
|
|
255
|
-
If False, condition passes when field has remained the same.
|
|
256
|
-
This is useful for:
|
|
257
|
-
- Detecting stable/unchanged fields
|
|
258
|
-
- Validating field immutability
|
|
259
|
-
- Ensuring critical fields remain constant
|
|
260
|
-
- State machine validations
|
|
79
|
+
Check if a field's original value was `value`.
|
|
80
|
+
If only_on_change is True, only return True when the field has changed away from that value.
|
|
261
81
|
"""
|
|
262
82
|
self.field = field
|
|
263
|
-
self.
|
|
83
|
+
self.value = value
|
|
84
|
+
self.only_on_change = only_on_change
|
|
264
85
|
|
|
265
86
|
def check(self, instance, original_instance=None):
|
|
266
87
|
if original_instance is None:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return (current_value != original_value) == self.has_changed
|
|
275
|
-
|
|
276
|
-
def get_required_fields(self):
|
|
277
|
-
return {self.field.split('.')[0]}
|
|
88
|
+
return False
|
|
89
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
90
|
+
if self.only_on_change:
|
|
91
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
92
|
+
return previous == self.value and current != self.value
|
|
93
|
+
else:
|
|
94
|
+
return previous == self.value
|
|
278
95
|
|
|
279
96
|
|
|
280
97
|
class ChangesTo(HookCondition):
|
|
281
98
|
def __init__(self, field, value):
|
|
99
|
+
"""
|
|
100
|
+
Check if a field's value has changed to `value`.
|
|
101
|
+
Only returns True when original value != value and current value == value.
|
|
102
|
+
"""
|
|
282
103
|
self.field = field
|
|
283
104
|
self.value = value
|
|
284
105
|
|
|
285
106
|
def check(self, instance, original_instance=None):
|
|
286
107
|
if original_instance is None:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
original_value = resolve_dotted_attr(original_instance, self.field)
|
|
292
|
-
return current_value == self.value and current_value != original_value
|
|
293
|
-
|
|
294
|
-
def get_required_fields(self):
|
|
295
|
-
return {self.field.split('.')[0]}
|
|
108
|
+
return False
|
|
109
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
110
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
111
|
+
return previous != self.value and current == self.value
|
|
296
112
|
|
|
297
113
|
|
|
298
114
|
class IsGreaterThan(HookCondition):
|
|
@@ -336,41 +152,30 @@ class IsLessThanOrEqual(HookCondition):
|
|
|
336
152
|
|
|
337
153
|
|
|
338
154
|
class AndCondition(HookCondition):
|
|
339
|
-
def __init__(self,
|
|
340
|
-
self.
|
|
341
|
-
self.
|
|
155
|
+
def __init__(self, cond1, cond2):
|
|
156
|
+
self.cond1 = cond1
|
|
157
|
+
self.cond2 = cond2
|
|
342
158
|
|
|
343
159
|
def check(self, instance, original_instance=None):
|
|
344
|
-
return (
|
|
345
|
-
|
|
346
|
-
and self.condition2.check(instance, original_instance)
|
|
160
|
+
return self.cond1.check(instance, original_instance) and self.cond2.check(
|
|
161
|
+
instance, original_instance
|
|
347
162
|
)
|
|
348
163
|
|
|
349
|
-
def get_required_fields(self):
|
|
350
|
-
return self.condition1.get_required_fields() | self.condition2.get_required_fields()
|
|
351
|
-
|
|
352
164
|
|
|
353
165
|
class OrCondition(HookCondition):
|
|
354
|
-
def __init__(self,
|
|
355
|
-
self.
|
|
356
|
-
self.
|
|
166
|
+
def __init__(self, cond1, cond2):
|
|
167
|
+
self.cond1 = cond1
|
|
168
|
+
self.cond2 = cond2
|
|
357
169
|
|
|
358
170
|
def check(self, instance, original_instance=None):
|
|
359
|
-
return (
|
|
360
|
-
|
|
361
|
-
or self.condition2.check(instance, original_instance)
|
|
171
|
+
return self.cond1.check(instance, original_instance) or self.cond2.check(
|
|
172
|
+
instance, original_instance
|
|
362
173
|
)
|
|
363
174
|
|
|
364
|
-
def get_required_fields(self):
|
|
365
|
-
return self.condition1.get_required_fields() | self.condition2.get_required_fields()
|
|
366
|
-
|
|
367
175
|
|
|
368
176
|
class NotCondition(HookCondition):
|
|
369
|
-
def __init__(self,
|
|
370
|
-
self.
|
|
177
|
+
def __init__(self, cond):
|
|
178
|
+
self.cond = cond
|
|
371
179
|
|
|
372
180
|
def check(self, instance, original_instance=None):
|
|
373
|
-
return not self.
|
|
374
|
-
|
|
375
|
-
def get_required_fields(self):
|
|
376
|
-
return self.condition.get_required_fields()
|
|
181
|
+
return not self.cond.check(instance, original_instance)
|
django_bulk_hooks/decorators.py
CHANGED
|
@@ -4,7 +4,6 @@ from functools import wraps
|
|
|
4
4
|
from django.core.exceptions import FieldDoesNotExist
|
|
5
5
|
from django_bulk_hooks.enums import DEFAULT_PRIORITY
|
|
6
6
|
from django_bulk_hooks.registry import register_hook
|
|
7
|
-
from django_bulk_hooks.engine import safe_get_related_object
|
|
8
7
|
|
|
9
8
|
|
|
10
9
|
def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
|
|
@@ -25,15 +24,10 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
|
|
|
25
24
|
def select_related(*related_fields):
|
|
26
25
|
"""
|
|
27
26
|
Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
|
|
28
|
-
|
|
29
|
-
This decorator provides bulk loading for performance when you explicitly need it.
|
|
30
|
-
If you don't use this decorator, the framework will automatically detect and load
|
|
31
|
-
foreign keys only when conditions need them, preserving standard Django behavior.
|
|
32
27
|
|
|
33
28
|
- Works with instance methods (resolves `self`)
|
|
34
29
|
- Avoids replacing model instances
|
|
35
30
|
- Populates Django's relation cache to avoid extra queries
|
|
36
|
-
- Provides bulk loading for performance optimization
|
|
37
31
|
"""
|
|
38
32
|
|
|
39
33
|
def decorator(func):
|
|
@@ -46,14 +40,14 @@ def select_related(*related_fields):
|
|
|
46
40
|
|
|
47
41
|
if "new_records" not in bound.arguments:
|
|
48
42
|
raise TypeError(
|
|
49
|
-
"@
|
|
43
|
+
"@preload_related requires a 'new_records' argument in the decorated function"
|
|
50
44
|
)
|
|
51
45
|
|
|
52
46
|
new_records = bound.arguments["new_records"]
|
|
53
47
|
|
|
54
48
|
if not isinstance(new_records, list):
|
|
55
49
|
raise TypeError(
|
|
56
|
-
f"@
|
|
50
|
+
f"@preload_related expects a list of model instances, got {type(new_records)}"
|
|
57
51
|
)
|
|
58
52
|
|
|
59
53
|
if not new_records:
|
|
@@ -62,29 +56,19 @@ def select_related(*related_fields):
|
|
|
62
56
|
# Determine which instances actually need preloading
|
|
63
57
|
model_cls = new_records[0].__class__
|
|
64
58
|
ids_to_fetch = []
|
|
65
|
-
instances_without_pk = []
|
|
66
|
-
|
|
67
59
|
for obj in new_records:
|
|
68
60
|
if obj.pk is None:
|
|
69
|
-
# For objects without PKs (BEFORE_CREATE), check if foreign key fields are already set
|
|
70
|
-
instances_without_pk.append(obj)
|
|
71
61
|
continue
|
|
72
|
-
|
|
73
62
|
# if any related field is not already cached on the instance,
|
|
74
63
|
# mark it for fetching
|
|
75
64
|
if any(field not in obj._state.fields_cache for field in related_fields):
|
|
76
65
|
ids_to_fetch.append(obj.pk)
|
|
77
66
|
|
|
78
|
-
# Load foreign keys for objects with PKs
|
|
79
67
|
fetched = {}
|
|
80
68
|
if ids_to_fetch:
|
|
81
69
|
fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids_to_fetch)
|
|
82
70
|
|
|
83
|
-
# Apply loaded foreign keys to objects with PKs
|
|
84
71
|
for obj in new_records:
|
|
85
|
-
if obj.pk is None:
|
|
86
|
-
continue
|
|
87
|
-
|
|
88
72
|
preloaded = fetched.get(obj.pk)
|
|
89
73
|
if not preloaded:
|
|
90
74
|
continue
|
|
@@ -94,7 +78,7 @@ def select_related(*related_fields):
|
|
|
94
78
|
continue
|
|
95
79
|
if "." in field:
|
|
96
80
|
raise ValueError(
|
|
97
|
-
f"@
|
|
81
|
+
f"@preload_related does not support nested fields like '{field}'"
|
|
98
82
|
)
|
|
99
83
|
|
|
100
84
|
try:
|
|
@@ -112,35 +96,6 @@ def select_related(*related_fields):
|
|
|
112
96
|
obj._state.fields_cache[field] = rel_obj
|
|
113
97
|
except AttributeError:
|
|
114
98
|
pass
|
|
115
|
-
|
|
116
|
-
# For objects without PKs, ensure foreign key fields are properly set in the cache
|
|
117
|
-
# This prevents RelatedObjectDoesNotExist when accessing foreign keys
|
|
118
|
-
for obj in instances_without_pk:
|
|
119
|
-
for field in related_fields:
|
|
120
|
-
if "." in field:
|
|
121
|
-
raise ValueError(
|
|
122
|
-
f"@select_related does not support nested fields like '{field}'"
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
try:
|
|
126
|
-
f = model_cls._meta.get_field(field)
|
|
127
|
-
if not (
|
|
128
|
-
f.is_relation and not f.many_to_many and not f.one_to_many
|
|
129
|
-
):
|
|
130
|
-
continue
|
|
131
|
-
except FieldDoesNotExist:
|
|
132
|
-
continue
|
|
133
|
-
|
|
134
|
-
# Check if the foreign key field is set
|
|
135
|
-
fk_field_name = f"{field}_id"
|
|
136
|
-
if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
|
|
137
|
-
# The foreign key ID is set, so we can try to get the related object safely
|
|
138
|
-
rel_obj = safe_get_related_object(obj, field)
|
|
139
|
-
if rel_obj is not None:
|
|
140
|
-
# Ensure it's cached to prevent future queries
|
|
141
|
-
if not hasattr(obj._state, 'fields_cache'):
|
|
142
|
-
obj._state.fields_cache = {}
|
|
143
|
-
obj._state.fields_cache[field] = rel_obj
|
|
144
99
|
|
|
145
100
|
return func(*bound.args, **bound.kwargs)
|
|
146
101
|
|
django_bulk_hooks/engine.py
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
3
|
from django.core.exceptions import ValidationError
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
from django_bulk_hooks.registry import get_hooks
|
|
6
|
-
from django_bulk_hooks.conditions import safe_get_related_object, safe_get_related_attr
|
|
7
6
|
|
|
8
7
|
logger = logging.getLogger(__name__)
|
|
9
8
|
|
|
10
9
|
|
|
11
|
-
# Cache for hook handlers to avoid creating them repeatedly
|
|
12
|
-
_handler_cache = {}
|
|
13
|
-
|
|
14
10
|
def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
15
|
-
# Get hooks from cache or fetch them
|
|
16
|
-
cache_key = (model_cls, event)
|
|
17
11
|
hooks = get_hooks(model_cls, event)
|
|
18
12
|
|
|
19
13
|
if not hooks:
|
|
@@ -27,42 +21,20 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
|
27
21
|
except ValidationError as e:
|
|
28
22
|
logger.error("Validation failed for %s: %s", instance, e)
|
|
29
23
|
raise
|
|
30
|
-
except Exception as e:
|
|
31
|
-
# Handle RelatedObjectDoesNotExist and other exceptions that might occur
|
|
32
|
-
# when accessing foreign key fields on unsaved objects
|
|
33
|
-
if "RelatedObjectDoesNotExist" in str(type(e).__name__):
|
|
34
|
-
logger.debug("Skipping validation for unsaved object with unset foreign keys: %s", e)
|
|
35
|
-
continue
|
|
36
|
-
else:
|
|
37
|
-
logger.error("Unexpected error during validation for %s: %s", instance, e)
|
|
38
|
-
raise
|
|
39
24
|
|
|
40
|
-
# Pre-create None list for originals if needed
|
|
41
|
-
if original_instances is None:
|
|
42
|
-
original_instances = [None] * len(new_instances)
|
|
43
|
-
|
|
44
|
-
# Process all hooks
|
|
45
25
|
for handler_cls, method_name, condition, priority in hooks:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if handler_key not in _handler_cache:
|
|
49
|
-
handler_instance = handler_cls()
|
|
50
|
-
func = getattr(handler_instance, method_name)
|
|
51
|
-
_handler_cache[handler_key] = (handler_instance, func)
|
|
52
|
-
else:
|
|
53
|
-
handler_instance, func = _handler_cache[handler_key]
|
|
54
|
-
|
|
55
|
-
# If no condition, process all instances at once
|
|
56
|
-
if not condition:
|
|
57
|
-
func(new_records=new_instances, old_records=original_instances if any(original_instances) else None)
|
|
58
|
-
continue
|
|
26
|
+
handler_instance = handler_cls()
|
|
27
|
+
func = getattr(handler_instance, method_name)
|
|
59
28
|
|
|
60
|
-
# For conditional hooks, filter instances first
|
|
61
29
|
to_process_new = []
|
|
62
30
|
to_process_old = []
|
|
63
31
|
|
|
64
|
-
for new, original in zip(
|
|
65
|
-
|
|
32
|
+
for new, original in zip(
|
|
33
|
+
new_instances,
|
|
34
|
+
original_instances or [None] * len(new_instances),
|
|
35
|
+
strict=True,
|
|
36
|
+
):
|
|
37
|
+
if not condition or condition.check(new, original):
|
|
66
38
|
to_process_new.append(new)
|
|
67
39
|
to_process_old.append(original)
|
|
68
40
|
|