django-bulk-hooks 0.1.100__py3-none-any.whl → 0.1.102__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.
- django_bulk_hooks/__init__.py +11 -23
- django_bulk_hooks/conditions.py +185 -167
- django_bulk_hooks/engine.py +15 -26
- django_bulk_hooks/handler.py +1 -13
- django_bulk_hooks/manager.py +69 -31
- django_bulk_hooks/models.py +14 -33
- {django_bulk_hooks-0.1.100.dist-info → django_bulk_hooks-0.1.102.dist-info}/METADATA +1 -164
- django_bulk_hooks-0.1.102.dist-info/RECORD +16 -0
- django_bulk_hooks-0.1.100.dist-info/RECORD +0 -16
- {django_bulk_hooks-0.1.100.dist-info → django_bulk_hooks-0.1.102.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.100.dist-info → django_bulk_hooks-0.1.102.dist-info}/WHEEL +0 -0
django_bulk_hooks/__init__.py
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
from django_bulk_hooks.conditions import (
|
|
2
|
-
ChangesTo,
|
|
3
|
-
HasChanged,
|
|
4
|
-
IsBlank,
|
|
5
|
-
IsEqual,
|
|
6
|
-
IsGreaterThan,
|
|
7
|
-
IsGreaterThanOrEqual,
|
|
8
|
-
IsLessThan,
|
|
9
|
-
IsLessThanOrEqual,
|
|
10
|
-
IsNotEqual,
|
|
11
|
-
LambdaCondition,
|
|
12
|
-
WasEqual,
|
|
13
|
-
is_field_set,
|
|
14
|
-
safe_get_related_attr,
|
|
15
|
-
safe_get_related_object,
|
|
16
|
-
)
|
|
17
1
|
from django_bulk_hooks.constants import (
|
|
18
2
|
AFTER_CREATE,
|
|
19
3
|
AFTER_DELETE,
|
|
@@ -25,10 +9,20 @@ from django_bulk_hooks.constants import (
|
|
|
25
9
|
VALIDATE_DELETE,
|
|
26
10
|
VALIDATE_UPDATE,
|
|
27
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
|
+
)
|
|
28
22
|
from django_bulk_hooks.decorators import hook, select_related
|
|
29
|
-
from django_bulk_hooks.enums import Priority
|
|
30
23
|
from django_bulk_hooks.handler import HookHandler
|
|
31
24
|
from django_bulk_hooks.models import HookModelMixin
|
|
25
|
+
from django_bulk_hooks.enums import Priority
|
|
32
26
|
|
|
33
27
|
__all__ = [
|
|
34
28
|
"HookHandler",
|
|
@@ -53,10 +47,4 @@ __all__ = [
|
|
|
53
47
|
"IsEqual",
|
|
54
48
|
"IsNotEqual",
|
|
55
49
|
"WasEqual",
|
|
56
|
-
"IsBlank",
|
|
57
|
-
"IsGreaterThan",
|
|
58
|
-
"IsLessThan",
|
|
59
|
-
"IsGreaterThanOrEqual",
|
|
60
|
-
"IsLessThanOrEqual",
|
|
61
|
-
"LambdaCondition",
|
|
62
50
|
]
|
django_bulk_hooks/conditions.py
CHANGED
|
@@ -8,7 +8,7 @@ def safe_get_related_object(instance, field_name):
|
|
|
8
8
|
"""
|
|
9
9
|
if not hasattr(instance, field_name):
|
|
10
10
|
return None
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
# Get the foreign key field
|
|
13
13
|
try:
|
|
14
14
|
field = instance._meta.get_field(field_name)
|
|
@@ -16,12 +16,12 @@ def safe_get_related_object(instance, field_name):
|
|
|
16
16
|
return getattr(instance, field_name, None)
|
|
17
17
|
except models.FieldDoesNotExist:
|
|
18
18
|
return getattr(instance, field_name, None)
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
# Check if the foreign key field is None
|
|
21
21
|
fk_field_name = f"{field_name}_id"
|
|
22
22
|
if hasattr(instance, fk_field_name) and getattr(instance, fk_field_name) is None:
|
|
23
23
|
return None
|
|
24
|
-
|
|
24
|
+
|
|
25
25
|
# Try to get the related object, but catch RelatedObjectDoesNotExist
|
|
26
26
|
try:
|
|
27
27
|
return getattr(instance, field_name)
|
|
@@ -29,25 +29,49 @@ def safe_get_related_object(instance, field_name):
|
|
|
29
29
|
return None
|
|
30
30
|
|
|
31
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
|
+
|
|
32
56
|
def safe_get_related_attr(instance, field_name, attr_name=None):
|
|
33
57
|
"""
|
|
34
58
|
Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
|
|
35
|
-
|
|
59
|
+
|
|
36
60
|
This is particularly useful in hooks where objects might not have their related
|
|
37
61
|
fields populated yet (e.g., during bulk_create operations or on unsaved objects).
|
|
38
|
-
|
|
62
|
+
|
|
39
63
|
Args:
|
|
40
64
|
instance: The model instance
|
|
41
65
|
field_name: The foreign key field name
|
|
42
66
|
attr_name: Optional attribute name to access on the related object
|
|
43
|
-
|
|
67
|
+
|
|
44
68
|
Returns:
|
|
45
69
|
The related object, the attribute value, or None if not available
|
|
46
|
-
|
|
70
|
+
|
|
47
71
|
Example:
|
|
48
72
|
# Instead of: loan_transaction.status.name (which might fail)
|
|
49
73
|
# Use: safe_get_related_attr(loan_transaction, 'status', 'name')
|
|
50
|
-
|
|
74
|
+
|
|
51
75
|
status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
|
|
52
76
|
if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
|
|
53
77
|
# Process the transaction
|
|
@@ -63,31 +87,96 @@ def safe_get_related_attr(instance, field_name, attr_name=None):
|
|
|
63
87
|
# If we have an ID but the object isn't loaded, try to load it
|
|
64
88
|
try:
|
|
65
89
|
field = instance._meta.get_field(field_name)
|
|
66
|
-
if hasattr(field,
|
|
90
|
+
if hasattr(field, 'related_model'):
|
|
67
91
|
related_obj = field.related_model.objects.get(id=fk_value)
|
|
68
92
|
if attr_name is None:
|
|
69
93
|
return related_obj
|
|
70
94
|
return getattr(related_obj, attr_name, None)
|
|
71
95
|
except (field.related_model.DoesNotExist, AttributeError):
|
|
72
96
|
return None
|
|
73
|
-
|
|
97
|
+
|
|
74
98
|
# For saved objects or when the above doesn't work, use the original method
|
|
75
99
|
related_obj = safe_get_related_object(instance, field_name)
|
|
76
100
|
if related_obj is None:
|
|
77
101
|
return None
|
|
102
|
+
|
|
78
103
|
if attr_name is None:
|
|
79
104
|
return related_obj
|
|
105
|
+
|
|
80
106
|
return getattr(related_obj, attr_name, None)
|
|
81
107
|
|
|
82
108
|
|
|
83
|
-
def
|
|
109
|
+
def safe_get_related_attr_with_fallback(instance, field_name, attr_name=None, fallback_value=None):
|
|
84
110
|
"""
|
|
85
|
-
|
|
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
|
+
|
|
86
149
|
|
|
87
|
-
|
|
88
|
-
|
|
150
|
+
def resolve_dotted_attr(instance, dotted_path):
|
|
151
|
+
"""
|
|
152
|
+
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.
|
|
89
154
|
"""
|
|
90
|
-
|
|
155
|
+
if instance is None:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
current = instance
|
|
159
|
+
for attr in dotted_path.split("."):
|
|
160
|
+
if current is None:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
# Check if this is a foreign key that might trigger a query
|
|
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
|
|
91
180
|
|
|
92
181
|
|
|
93
182
|
class HookCondition:
|
|
@@ -114,94 +203,54 @@ class HookCondition:
|
|
|
114
203
|
return set()
|
|
115
204
|
|
|
116
205
|
|
|
117
|
-
class
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def __init__(self, field_name):
|
|
123
|
-
self.field_name = field_name
|
|
124
|
-
|
|
125
|
-
def check(self, instance, original_instance=None):
|
|
126
|
-
value = getattr(instance, self.field_name, None)
|
|
127
|
-
return value is None or value == ""
|
|
128
|
-
|
|
129
|
-
def get_required_fields(self):
|
|
130
|
-
return {self.field_name}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
class AndCondition(HookCondition):
|
|
134
|
-
def __init__(self, *conditions):
|
|
135
|
-
self.conditions = conditions
|
|
136
|
-
|
|
137
|
-
def check(self, instance, original_instance=None):
|
|
138
|
-
return all(c.check(instance, original_instance) for c in self.conditions)
|
|
139
|
-
|
|
140
|
-
def get_required_fields(self):
|
|
141
|
-
fields = set()
|
|
142
|
-
for condition in self.conditions:
|
|
143
|
-
fields.update(condition.get_required_fields())
|
|
144
|
-
return fields
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
class OrCondition(HookCondition):
|
|
148
|
-
def __init__(self, *conditions):
|
|
149
|
-
self.conditions = conditions
|
|
150
|
-
|
|
151
|
-
def check(self, instance, original_instance=None):
|
|
152
|
-
return any(c.check(instance, original_instance) for c in self.conditions)
|
|
153
|
-
|
|
154
|
-
def get_required_fields(self):
|
|
155
|
-
fields = set()
|
|
156
|
-
for condition in self.conditions:
|
|
157
|
-
fields.update(condition.get_required_fields())
|
|
158
|
-
return fields
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
class NotCondition(HookCondition):
|
|
162
|
-
def __init__(self, condition):
|
|
163
|
-
self.condition = condition
|
|
206
|
+
class IsEqual(HookCondition):
|
|
207
|
+
def __init__(self, field, value):
|
|
208
|
+
self.field = field
|
|
209
|
+
self.value = value
|
|
164
210
|
|
|
165
211
|
def check(self, instance, original_instance=None):
|
|
166
|
-
|
|
212
|
+
current_value = resolve_dotted_attr(instance, self.field)
|
|
213
|
+
return current_value == self.value
|
|
167
214
|
|
|
168
215
|
def get_required_fields(self):
|
|
169
|
-
return self.
|
|
216
|
+
return {self.field.split('.')[0]}
|
|
170
217
|
|
|
171
218
|
|
|
172
|
-
class
|
|
173
|
-
def __init__(self,
|
|
174
|
-
self.
|
|
219
|
+
class IsNotEqual(HookCondition):
|
|
220
|
+
def __init__(self, field, value):
|
|
221
|
+
self.field = field
|
|
175
222
|
self.value = value
|
|
176
223
|
|
|
177
224
|
def check(self, instance, original_instance=None):
|
|
178
|
-
|
|
225
|
+
current_value = resolve_dotted_attr(instance, self.field)
|
|
226
|
+
return current_value != self.value
|
|
179
227
|
|
|
180
228
|
def get_required_fields(self):
|
|
181
|
-
return {self.
|
|
229
|
+
return {self.field.split('.')[0]}
|
|
182
230
|
|
|
183
231
|
|
|
184
232
|
class WasEqual(HookCondition):
|
|
185
|
-
def __init__(self,
|
|
186
|
-
self.
|
|
233
|
+
def __init__(self, field, value):
|
|
234
|
+
self.field = field
|
|
187
235
|
self.value = value
|
|
188
236
|
|
|
189
237
|
def check(self, instance, original_instance=None):
|
|
190
238
|
if original_instance is None:
|
|
191
239
|
return False
|
|
192
|
-
|
|
240
|
+
original_value = resolve_dotted_attr(original_instance, self.field)
|
|
241
|
+
return original_value == self.value
|
|
193
242
|
|
|
194
243
|
def get_required_fields(self):
|
|
195
|
-
return {self.
|
|
244
|
+
return {self.field.split('.')[0]}
|
|
196
245
|
|
|
197
246
|
|
|
198
247
|
class HasChanged(HookCondition):
|
|
199
|
-
def __init__(self,
|
|
248
|
+
def __init__(self, field, has_changed=True):
|
|
200
249
|
"""
|
|
201
250
|
Check if a field's value has changed or remained the same.
|
|
202
|
-
|
|
251
|
+
|
|
203
252
|
Args:
|
|
204
|
-
|
|
253
|
+
field: The field name to check
|
|
205
254
|
has_changed: If True (default), condition passes when field has changed.
|
|
206
255
|
If False, condition passes when field has remained the same.
|
|
207
256
|
This is useful for:
|
|
@@ -210,149 +259,118 @@ class HasChanged(HookCondition):
|
|
|
210
259
|
- Ensuring critical fields remain constant
|
|
211
260
|
- State machine validations
|
|
212
261
|
"""
|
|
213
|
-
self.
|
|
262
|
+
self.field = field
|
|
214
263
|
self.has_changed = has_changed
|
|
215
264
|
|
|
216
265
|
def check(self, instance, original_instance=None):
|
|
217
266
|
if original_instance is None:
|
|
218
267
|
# For new instances:
|
|
219
|
-
# - If we're checking for changes (has_changed=True), return
|
|
220
|
-
# - If we're checking for stability (has_changed=False), return
|
|
221
|
-
return self.has_changed
|
|
222
|
-
|
|
223
|
-
current_value =
|
|
224
|
-
original_value =
|
|
268
|
+
# - If we're checking for changes (has_changed=True), return False since there's no change yet
|
|
269
|
+
# - If we're checking for stability (has_changed=False), return True since it's technically unchanged
|
|
270
|
+
return not self.has_changed
|
|
271
|
+
|
|
272
|
+
current_value = resolve_dotted_attr(instance, self.field)
|
|
273
|
+
original_value = resolve_dotted_attr(original_instance, self.field)
|
|
225
274
|
return (current_value != original_value) == self.has_changed
|
|
226
275
|
|
|
227
276
|
def get_required_fields(self):
|
|
228
|
-
return {self.
|
|
277
|
+
return {self.field.split('.')[0]}
|
|
229
278
|
|
|
230
279
|
|
|
231
280
|
class ChangesTo(HookCondition):
|
|
232
|
-
def __init__(self,
|
|
233
|
-
self.
|
|
281
|
+
def __init__(self, field, value):
|
|
282
|
+
self.field = field
|
|
234
283
|
self.value = value
|
|
235
284
|
|
|
236
285
|
def check(self, instance, original_instance=None):
|
|
237
286
|
if original_instance is None:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
287
|
+
current_value = resolve_dotted_attr(instance, self.field)
|
|
288
|
+
return current_value == self.value
|
|
289
|
+
|
|
290
|
+
current_value = resolve_dotted_attr(instance, self.field)
|
|
291
|
+
original_value = resolve_dotted_attr(original_instance, self.field)
|
|
292
|
+
return current_value == self.value and current_value != original_value
|
|
242
293
|
|
|
243
294
|
def get_required_fields(self):
|
|
244
|
-
return {self.
|
|
295
|
+
return {self.field.split('.')[0]}
|
|
245
296
|
|
|
246
297
|
|
|
247
|
-
class
|
|
248
|
-
def __init__(self,
|
|
249
|
-
self.
|
|
298
|
+
class IsGreaterThan(HookCondition):
|
|
299
|
+
def __init__(self, field, value):
|
|
300
|
+
self.field = field
|
|
250
301
|
self.value = value
|
|
251
302
|
|
|
252
303
|
def check(self, instance, original_instance=None):
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def get_required_fields(self):
|
|
256
|
-
return {self.field_name}
|
|
304
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
305
|
+
return current is not None and current > self.value
|
|
257
306
|
|
|
258
307
|
|
|
259
|
-
class
|
|
260
|
-
def __init__(self,
|
|
261
|
-
self.
|
|
308
|
+
class IsGreaterThanOrEqual(HookCondition):
|
|
309
|
+
def __init__(self, field, value):
|
|
310
|
+
self.field = field
|
|
262
311
|
self.value = value
|
|
263
312
|
|
|
264
313
|
def check(self, instance, original_instance=None):
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return False
|
|
268
|
-
return field_value > self.value
|
|
269
|
-
|
|
270
|
-
def get_required_fields(self):
|
|
271
|
-
return {self.field_name}
|
|
314
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
315
|
+
return current is not None and current >= self.value
|
|
272
316
|
|
|
273
317
|
|
|
274
318
|
class IsLessThan(HookCondition):
|
|
275
|
-
def __init__(self,
|
|
276
|
-
self.
|
|
319
|
+
def __init__(self, field, value):
|
|
320
|
+
self.field = field
|
|
277
321
|
self.value = value
|
|
278
322
|
|
|
279
323
|
def check(self, instance, original_instance=None):
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
return False
|
|
283
|
-
return field_value < self.value
|
|
284
|
-
|
|
285
|
-
def get_required_fields(self):
|
|
286
|
-
return {self.field_name}
|
|
324
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
325
|
+
return current is not None and current < self.value
|
|
287
326
|
|
|
288
327
|
|
|
289
|
-
class
|
|
290
|
-
def __init__(self,
|
|
291
|
-
self.
|
|
328
|
+
class IsLessThanOrEqual(HookCondition):
|
|
329
|
+
def __init__(self, field, value):
|
|
330
|
+
self.field = field
|
|
292
331
|
self.value = value
|
|
293
332
|
|
|
294
333
|
def check(self, instance, original_instance=None):
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return False
|
|
298
|
-
return field_value >= self.value
|
|
334
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
335
|
+
return current is not None and current <= self.value
|
|
299
336
|
|
|
300
|
-
def get_required_fields(self):
|
|
301
|
-
return {self.field_name}
|
|
302
337
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
self.
|
|
307
|
-
self.value = value
|
|
338
|
+
class AndCondition(HookCondition):
|
|
339
|
+
def __init__(self, condition1, condition2):
|
|
340
|
+
self.condition1 = condition1
|
|
341
|
+
self.condition2 = condition2
|
|
308
342
|
|
|
309
343
|
def check(self, instance, original_instance=None):
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
344
|
+
return (
|
|
345
|
+
self.condition1.check(instance, original_instance)
|
|
346
|
+
and self.condition2.check(instance, original_instance)
|
|
347
|
+
)
|
|
314
348
|
|
|
315
349
|
def get_required_fields(self):
|
|
316
|
-
return
|
|
350
|
+
return self.condition1.get_required_fields() | self.condition2.get_required_fields()
|
|
317
351
|
|
|
318
352
|
|
|
319
|
-
class
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
This makes it easy to create custom conditions inline without defining
|
|
324
|
-
a full class.
|
|
325
|
-
|
|
326
|
-
Example:
|
|
327
|
-
# Simple lambda condition
|
|
328
|
-
condition = LambdaCondition(lambda instance: instance.price > 100)
|
|
353
|
+
class OrCondition(HookCondition):
|
|
354
|
+
def __init__(self, condition1, condition2):
|
|
355
|
+
self.condition1 = condition1
|
|
356
|
+
self.condition2 = condition2
|
|
329
357
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
358
|
+
def check(self, instance, original_instance=None):
|
|
359
|
+
return (
|
|
360
|
+
self.condition1.check(instance, original_instance)
|
|
361
|
+
or self.condition2.check(instance, original_instance)
|
|
333
362
|
)
|
|
334
363
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
lambda instance: instance.price > 100 and instance.is_active
|
|
338
|
-
))
|
|
339
|
-
def handle_expensive_active_products(self, new_records, old_records):
|
|
340
|
-
pass
|
|
341
|
-
"""
|
|
364
|
+
def get_required_fields(self):
|
|
365
|
+
return self.condition1.get_required_fields() | self.condition2.get_required_fields()
|
|
342
366
|
|
|
343
|
-
def __init__(self, func, required_fields=None):
|
|
344
|
-
"""
|
|
345
|
-
Initialize with a callable function.
|
|
346
367
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
"""
|
|
351
|
-
self.func = func
|
|
352
|
-
self._required_fields = required_fields or set()
|
|
368
|
+
class NotCondition(HookCondition):
|
|
369
|
+
def __init__(self, condition):
|
|
370
|
+
self.condition = condition
|
|
353
371
|
|
|
354
372
|
def check(self, instance, original_instance=None):
|
|
355
|
-
return self.
|
|
373
|
+
return not self.condition.check(instance, original_instance)
|
|
356
374
|
|
|
357
375
|
def get_required_fields(self):
|
|
358
|
-
return self.
|
|
376
|
+
return self.condition.get_required_fields()
|
django_bulk_hooks/engine.py
CHANGED
|
@@ -52,31 +52,20 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
|
52
52
|
else:
|
|
53
53
|
handler_instance, func = _handler_cache[handler_key]
|
|
54
54
|
|
|
55
|
-
#
|
|
56
|
-
if condition:
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
try:
|
|
64
|
-
matches = condition.check(new, original)
|
|
65
|
-
logger.debug(f"Condition check result: {matches}")
|
|
66
|
-
if matches:
|
|
67
|
-
to_process_new.append(new)
|
|
68
|
-
to_process_old.append(original)
|
|
69
|
-
except Exception as e:
|
|
70
|
-
logger.error(f"Error checking condition: {e}")
|
|
71
|
-
raise
|
|
60
|
+
# For conditional hooks, filter instances first
|
|
61
|
+
to_process_new = []
|
|
62
|
+
to_process_old = []
|
|
72
63
|
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
logger.debug("No condition, processing all instances")
|
|
82
|
-
func(new_records=new_instances, old_records=original_instances if any(original_instances) else None)
|
|
64
|
+
for new, original in zip(new_instances, original_instances, strict=True):
|
|
65
|
+
if condition.check(new, original):
|
|
66
|
+
to_process_new.append(new)
|
|
67
|
+
to_process_old.append(original)
|
|
68
|
+
|
|
69
|
+
if to_process_new:
|
|
70
|
+
# Call the function with keyword arguments
|
|
71
|
+
func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
|
django_bulk_hooks/handler.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import inspect
|
|
2
1
|
import logging
|
|
3
2
|
import threading
|
|
4
3
|
from collections import deque
|
|
@@ -143,21 +142,10 @@ class HookHandler(metaclass=HookMeta):
|
|
|
143
142
|
handler = handler_cls()
|
|
144
143
|
method = getattr(handler, method_name)
|
|
145
144
|
|
|
146
|
-
# Inspect the method signature to determine parameter order
|
|
147
|
-
import inspect
|
|
148
|
-
|
|
149
|
-
sig = inspect.signature(method)
|
|
150
|
-
params = list(sig.parameters.keys())
|
|
151
|
-
|
|
152
|
-
# Remove 'self' from params if it exists
|
|
153
|
-
if params and params[0] == "self":
|
|
154
|
-
params = params[1:]
|
|
155
|
-
|
|
156
|
-
# Always call with keyword arguments to make order irrelevant
|
|
157
145
|
try:
|
|
158
146
|
method(
|
|
159
|
-
old_records=old_local,
|
|
160
147
|
new_records=new_local,
|
|
148
|
+
old_records=old_local,
|
|
161
149
|
**kwargs,
|
|
162
150
|
)
|
|
163
151
|
except Exception:
|
django_bulk_hooks/manager.py
CHANGED
|
@@ -20,7 +20,7 @@ class BulkHookManager(models.Manager):
|
|
|
20
20
|
# Default chunk sizes - can be overridden per model
|
|
21
21
|
DEFAULT_CHUNK_SIZE = 200
|
|
22
22
|
DEFAULT_RELATED_CHUNK_SIZE = 500 # Higher for related object fetching
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
def __init__(self):
|
|
25
25
|
super().__init__()
|
|
26
26
|
self._chunk_size = self.DEFAULT_CHUNK_SIZE
|
|
@@ -28,11 +28,16 @@ class BulkHookManager(models.Manager):
|
|
|
28
28
|
self._prefetch_related_fields = set()
|
|
29
29
|
self._select_related_fields = set()
|
|
30
30
|
|
|
31
|
-
def configure(
|
|
32
|
-
|
|
31
|
+
def configure(
|
|
32
|
+
self,
|
|
33
|
+
chunk_size=None,
|
|
34
|
+
related_chunk_size=None,
|
|
35
|
+
select_related=None,
|
|
36
|
+
prefetch_related=None,
|
|
37
|
+
):
|
|
33
38
|
"""
|
|
34
39
|
Configure bulk operation parameters for this manager.
|
|
35
|
-
|
|
40
|
+
|
|
36
41
|
Args:
|
|
37
42
|
chunk_size: Number of objects to process in each bulk operation chunk
|
|
38
43
|
related_chunk_size: Number of objects to fetch in each related object query
|
|
@@ -53,24 +58,24 @@ class BulkHookManager(models.Manager):
|
|
|
53
58
|
Optimized loading of original instances with smart batching and field selection.
|
|
54
59
|
"""
|
|
55
60
|
queryset = self.model.objects.filter(pk__in=pks)
|
|
56
|
-
|
|
57
|
-
# Only select specific fields if provided
|
|
58
|
-
if fields_to_fetch:
|
|
59
|
-
queryset = queryset.only(
|
|
60
|
-
|
|
61
|
+
|
|
62
|
+
# Only select specific fields if provided and not empty
|
|
63
|
+
if fields_to_fetch and len(fields_to_fetch) > 0:
|
|
64
|
+
queryset = queryset.only("pk", *fields_to_fetch)
|
|
65
|
+
|
|
61
66
|
# Apply configured related field optimizations
|
|
62
67
|
if self._select_related_fields:
|
|
63
68
|
queryset = queryset.select_related(*self._select_related_fields)
|
|
64
69
|
if self._prefetch_related_fields:
|
|
65
70
|
queryset = queryset.prefetch_related(*self._prefetch_related_fields)
|
|
66
|
-
|
|
71
|
+
|
|
67
72
|
# Batch load in chunks to avoid memory issues
|
|
68
73
|
all_originals = []
|
|
69
74
|
for i in range(0, len(pks), self._related_chunk_size):
|
|
70
|
-
chunk_pks = pks[i:i + self._related_chunk_size]
|
|
75
|
+
chunk_pks = pks[i : i + self._related_chunk_size]
|
|
71
76
|
chunk_originals = list(queryset.filter(pk__in=chunk_pks))
|
|
72
77
|
all_originals.extend(chunk_originals)
|
|
73
|
-
|
|
78
|
+
|
|
74
79
|
return all_originals
|
|
75
80
|
|
|
76
81
|
def _get_fields_to_fetch(self, objs, fields):
|
|
@@ -79,20 +84,51 @@ class BulkHookManager(models.Manager):
|
|
|
79
84
|
and what's needed for hooks.
|
|
80
85
|
"""
|
|
81
86
|
fields_to_fetch = set(fields)
|
|
82
|
-
|
|
87
|
+
|
|
83
88
|
# Add fields needed by registered hooks
|
|
84
89
|
from django_bulk_hooks.registry import get_hooks
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
|
|
91
|
+
hooks = get_hooks(self.model, "before_update") + get_hooks(
|
|
92
|
+
self.model, "after_update"
|
|
93
|
+
)
|
|
94
|
+
|
|
87
95
|
for handler_cls, method_name, condition, _ in hooks:
|
|
88
96
|
if condition:
|
|
89
97
|
# If there's a condition, we need all fields it might access
|
|
90
98
|
fields_to_fetch.update(condition.get_required_fields())
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
|
|
100
|
+
# Filter out fields that don't exist on the model
|
|
101
|
+
valid_fields = set()
|
|
102
|
+
invalid_fields = set()
|
|
103
|
+
for field_name in fields_to_fetch:
|
|
104
|
+
try:
|
|
105
|
+
self.model._meta.get_field(field_name)
|
|
106
|
+
valid_fields.add(field_name)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
# Field doesn't exist, skip it
|
|
109
|
+
invalid_fields.add(field_name)
|
|
110
|
+
import logging
|
|
111
|
+
|
|
112
|
+
logger = logging.getLogger(__name__)
|
|
113
|
+
logger.debug(
|
|
114
|
+
f"Field '{field_name}' requested by hook condition but doesn't exist on {self.model.__name__}: {e}"
|
|
115
|
+
)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if invalid_fields:
|
|
119
|
+
import logging
|
|
120
|
+
logger = logging.getLogger(__name__)
|
|
121
|
+
logger.warning(
|
|
122
|
+
f"Invalid fields requested for {self.model.__name__}: {invalid_fields}. "
|
|
123
|
+
f"These fields were ignored to prevent errors."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return valid_fields
|
|
93
127
|
|
|
94
128
|
@transaction.atomic
|
|
95
|
-
def bulk_update(
|
|
129
|
+
def bulk_update(
|
|
130
|
+
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
131
|
+
):
|
|
96
132
|
if not objs:
|
|
97
133
|
return []
|
|
98
134
|
|
|
@@ -106,14 +142,14 @@ class BulkHookManager(models.Manager):
|
|
|
106
142
|
if not bypass_hooks:
|
|
107
143
|
# Determine which fields we need to fetch
|
|
108
144
|
fields_to_fetch = self._get_fields_to_fetch(objs, fields)
|
|
109
|
-
|
|
145
|
+
|
|
110
146
|
# Load originals efficiently
|
|
111
147
|
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
112
148
|
originals = self._load_originals_optimized(pks, fields_to_fetch)
|
|
113
|
-
|
|
149
|
+
|
|
114
150
|
# Create a mapping for quick lookup
|
|
115
151
|
original_map = {obj.pk: obj for obj in originals}
|
|
116
|
-
|
|
152
|
+
|
|
117
153
|
# Align originals with new instances
|
|
118
154
|
aligned_originals = [original_map.get(obj.pk) for obj in objs]
|
|
119
155
|
|
|
@@ -135,7 +171,7 @@ class BulkHookManager(models.Manager):
|
|
|
135
171
|
|
|
136
172
|
# Process in chunks
|
|
137
173
|
for i in range(0, len(objs), self._chunk_size):
|
|
138
|
-
chunk = objs[i:i + self._chunk_size]
|
|
174
|
+
chunk = objs[i : i + self._chunk_size]
|
|
139
175
|
super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
|
|
140
176
|
|
|
141
177
|
if not bypass_hooks:
|
|
@@ -205,30 +241,32 @@ class BulkHookManager(models.Manager):
|
|
|
205
241
|
# Process validation in chunks to avoid memory issues
|
|
206
242
|
if not bypass_validation:
|
|
207
243
|
for i in range(0, len(objs), self._chunk_size):
|
|
208
|
-
chunk = objs[i:i + self._chunk_size]
|
|
244
|
+
chunk = objs[i : i + self._chunk_size]
|
|
209
245
|
engine.run(model_cls, VALIDATE_CREATE, chunk, ctx=ctx)
|
|
210
246
|
|
|
211
247
|
# Process before_create hooks in chunks
|
|
212
248
|
for i in range(0, len(objs), self._chunk_size):
|
|
213
|
-
chunk = objs[i:i + self._chunk_size]
|
|
249
|
+
chunk = objs[i : i + self._chunk_size]
|
|
214
250
|
engine.run(model_cls, BEFORE_CREATE, chunk, ctx=ctx)
|
|
215
251
|
|
|
216
252
|
# Perform bulk create in chunks
|
|
217
253
|
for i in range(0, len(objs), self._chunk_size):
|
|
218
|
-
chunk = objs[i:i + self._chunk_size]
|
|
254
|
+
chunk = objs[i : i + self._chunk_size]
|
|
219
255
|
created_chunk = super(models.Manager, self).bulk_create(chunk, **kwargs)
|
|
220
256
|
result.extend(created_chunk)
|
|
221
257
|
|
|
222
258
|
if not bypass_hooks:
|
|
223
259
|
# Process after_create hooks in chunks
|
|
224
260
|
for i in range(0, len(result), self._chunk_size):
|
|
225
|
-
chunk = result[i:i + self._chunk_size]
|
|
261
|
+
chunk = result[i : i + self._chunk_size]
|
|
226
262
|
engine.run(model_cls, AFTER_CREATE, chunk, ctx=ctx)
|
|
227
263
|
|
|
228
264
|
return result
|
|
229
265
|
|
|
230
266
|
@transaction.atomic
|
|
231
|
-
def bulk_delete(
|
|
267
|
+
def bulk_delete(
|
|
268
|
+
self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
|
|
269
|
+
):
|
|
232
270
|
if not objs:
|
|
233
271
|
return []
|
|
234
272
|
|
|
@@ -245,8 +283,8 @@ class BulkHookManager(models.Manager):
|
|
|
245
283
|
if not bypass_hooks:
|
|
246
284
|
# Process hooks in chunks
|
|
247
285
|
for i in range(0, len(objs), chunk_size):
|
|
248
|
-
chunk = objs[i:i + chunk_size]
|
|
249
|
-
|
|
286
|
+
chunk = objs[i : i + chunk_size]
|
|
287
|
+
|
|
250
288
|
if not bypass_validation:
|
|
251
289
|
engine.run(model_cls, VALIDATE_DELETE, chunk, ctx=ctx)
|
|
252
290
|
engine.run(model_cls, BEFORE_DELETE, chunk, ctx=ctx)
|
|
@@ -254,13 +292,13 @@ class BulkHookManager(models.Manager):
|
|
|
254
292
|
# Collect PKs and delete in chunks
|
|
255
293
|
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
256
294
|
for i in range(0, len(pks), chunk_size):
|
|
257
|
-
chunk_pks = pks[i:i + chunk_size]
|
|
295
|
+
chunk_pks = pks[i : i + chunk_size]
|
|
258
296
|
model_cls._base_manager.filter(pk__in=chunk_pks).delete()
|
|
259
297
|
|
|
260
298
|
if not bypass_hooks:
|
|
261
299
|
# Process after_delete hooks in chunks
|
|
262
300
|
for i in range(0, len(objs), chunk_size):
|
|
263
|
-
chunk = objs[i:i + chunk_size]
|
|
301
|
+
chunk = objs[i : i + chunk_size]
|
|
264
302
|
engine.run(model_cls, AFTER_DELETE, chunk, ctx=ctx)
|
|
265
303
|
|
|
266
304
|
return objs
|
django_bulk_hooks/models.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import contextlib
|
|
2
|
-
from functools import wraps
|
|
3
|
-
|
|
4
1
|
from django.db import models, transaction
|
|
5
|
-
from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
|
|
6
2
|
|
|
7
3
|
from django_bulk_hooks.constants import (
|
|
8
4
|
AFTER_CREATE,
|
|
@@ -18,6 +14,9 @@ from django_bulk_hooks.constants import (
|
|
|
18
14
|
from django_bulk_hooks.context import HookContext
|
|
19
15
|
from django_bulk_hooks.engine import run
|
|
20
16
|
from django_bulk_hooks.manager import BulkHookManager
|
|
17
|
+
from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
|
|
18
|
+
from functools import wraps
|
|
19
|
+
import contextlib
|
|
21
20
|
|
|
22
21
|
|
|
23
22
|
@contextlib.contextmanager
|
|
@@ -27,7 +26,7 @@ def patch_foreign_key_behavior():
|
|
|
27
26
|
RelatedObjectDoesNotExist when accessing an unset foreign key field.
|
|
28
27
|
"""
|
|
29
28
|
original_get = ForwardManyToOneDescriptor.__get__
|
|
30
|
-
|
|
29
|
+
|
|
31
30
|
@wraps(original_get)
|
|
32
31
|
def safe_get(self, instance, cls=None):
|
|
33
32
|
if instance is None:
|
|
@@ -36,7 +35,7 @@ def patch_foreign_key_behavior():
|
|
|
36
35
|
return original_get(self, instance, cls)
|
|
37
36
|
except self.RelatedObjectDoesNotExist:
|
|
38
37
|
return None
|
|
39
|
-
|
|
38
|
+
|
|
40
39
|
# Patch the descriptor
|
|
41
40
|
ForwardManyToOneDescriptor.__get__ = safe_get
|
|
42
41
|
try:
|
|
@@ -64,7 +63,7 @@ class HookModelMixin(models.Model):
|
|
|
64
63
|
# Skip hook validation during admin form validation
|
|
65
64
|
# This prevents RelatedObjectDoesNotExist errors when Django hasn't
|
|
66
65
|
# fully set up the object's relationships yet
|
|
67
|
-
if hasattr(self,
|
|
66
|
+
if hasattr(self, '_state') and getattr(self._state, 'validating', False):
|
|
68
67
|
return
|
|
69
68
|
|
|
70
69
|
# Determine if this is a create or update operation
|
|
@@ -81,9 +80,7 @@ class HookModelMixin(models.Model):
|
|
|
81
80
|
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
82
81
|
ctx = HookContext(self.__class__)
|
|
83
82
|
with patch_foreign_key_behavior():
|
|
84
|
-
run(
|
|
85
|
-
self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
|
|
86
|
-
)
|
|
83
|
+
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
87
84
|
except self.__class__.DoesNotExist:
|
|
88
85
|
# If the old instance doesn't exist, treat as create
|
|
89
86
|
ctx = HookContext(self.__class__)
|
|
@@ -94,40 +91,24 @@ class HookModelMixin(models.Model):
|
|
|
94
91
|
is_create = self.pk is None
|
|
95
92
|
ctx = HookContext(self.__class__)
|
|
96
93
|
|
|
97
|
-
#
|
|
94
|
+
# Use a single context manager for all hooks
|
|
98
95
|
with patch_foreign_key_behavior():
|
|
99
96
|
if is_create:
|
|
100
97
|
# For create operations
|
|
101
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
102
98
|
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
103
|
-
|
|
104
|
-
# For update operations
|
|
105
|
-
try:
|
|
106
|
-
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
107
|
-
run(
|
|
108
|
-
self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
|
|
109
|
-
)
|
|
110
|
-
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
111
|
-
except self.__class__.DoesNotExist:
|
|
112
|
-
# If the old instance doesn't exist, treat as create
|
|
113
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
114
|
-
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
115
|
-
|
|
116
|
-
# Now let Django save with any modifications from BEFORE hooks
|
|
117
|
-
super().save(*args, **kwargs)
|
|
118
|
-
|
|
119
|
-
# Then run AFTER hooks
|
|
120
|
-
with patch_foreign_key_behavior():
|
|
121
|
-
if is_create:
|
|
122
|
-
# For create operations
|
|
99
|
+
super().save(*args, **kwargs)
|
|
123
100
|
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
124
101
|
else:
|
|
125
102
|
# For update operations
|
|
126
103
|
try:
|
|
127
104
|
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
105
|
+
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
106
|
+
super().save(*args, **kwargs)
|
|
128
107
|
run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
|
|
129
108
|
except self.__class__.DoesNotExist:
|
|
130
109
|
# If the old instance doesn't exist, treat as create
|
|
110
|
+
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
111
|
+
super().save(*args, **kwargs)
|
|
131
112
|
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
132
113
|
|
|
133
114
|
return self
|
|
@@ -141,5 +122,5 @@ class HookModelMixin(models.Model):
|
|
|
141
122
|
run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
|
|
142
123
|
result = super().delete(*args, **kwargs)
|
|
143
124
|
run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
|
|
144
|
-
|
|
125
|
+
|
|
145
126
|
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.102
|
|
4
4
|
Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
|
|
5
5
|
Home-page: https://github.com/AugendLimited/django-bulk-hooks
|
|
6
6
|
License: MIT
|
|
@@ -226,166 +226,3 @@ class EfficientTransactionHandler:
|
|
|
226
226
|
```
|
|
227
227
|
|
|
228
228
|
This approach ensures your hooks are robust and won't fail due to missing related objects, while also being efficient with database queries.
|
|
229
|
-
|
|
230
|
-
## 🎯 Lambda Conditions and Anonymous Functions
|
|
231
|
-
|
|
232
|
-
`django-bulk-hooks` supports using anonymous functions (lambda functions) and custom callables as conditions, giving you maximum flexibility for complex filtering logic.
|
|
233
|
-
|
|
234
|
-
### Using LambdaCondition
|
|
235
|
-
|
|
236
|
-
The `LambdaCondition` class allows you to use lambda functions or any callable as a condition:
|
|
237
|
-
|
|
238
|
-
```python
|
|
239
|
-
from django_bulk_hooks import LambdaCondition
|
|
240
|
-
|
|
241
|
-
class ProductHandler:
|
|
242
|
-
# Simple lambda condition
|
|
243
|
-
@hook(Product, "after_create", condition=LambdaCondition(
|
|
244
|
-
lambda instance: instance.price > 100
|
|
245
|
-
))
|
|
246
|
-
def handle_expensive_products(self, new_records, old_records):
|
|
247
|
-
"""Handle products with price > 100"""
|
|
248
|
-
for product in new_records:
|
|
249
|
-
print(f"Expensive product: {product.name}")
|
|
250
|
-
|
|
251
|
-
# Lambda with multiple conditions
|
|
252
|
-
@hook(Product, "after_update", condition=LambdaCondition(
|
|
253
|
-
lambda instance: instance.price > 50 and instance.is_active and instance.stock_quantity > 0
|
|
254
|
-
))
|
|
255
|
-
def handle_available_expensive_products(self, new_records, old_records):
|
|
256
|
-
"""Handle active products with price > 50 and stock > 0"""
|
|
257
|
-
for product in new_records:
|
|
258
|
-
print(f"Available expensive product: {product.name}")
|
|
259
|
-
|
|
260
|
-
# Lambda comparing with original instance
|
|
261
|
-
@hook(Product, "after_update", condition=LambdaCondition(
|
|
262
|
-
lambda instance, original: original and instance.price > original.price * 1.5
|
|
263
|
-
))
|
|
264
|
-
def handle_significant_price_increases(self, new_records, old_records):
|
|
265
|
-
"""Handle products with >50% price increase"""
|
|
266
|
-
for new_product, old_product in zip(new_records, old_records):
|
|
267
|
-
if old_product:
|
|
268
|
-
increase = ((new_product.price - old_product.price) / old_product.price) * 100
|
|
269
|
-
print(f"Significant price increase: {new_product.name} +{increase:.1f}%")
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
### Combining Lambda Conditions with Built-in Conditions
|
|
273
|
-
|
|
274
|
-
You can combine lambda conditions with built-in conditions using the `&` (AND) and `|` (OR) operators:
|
|
275
|
-
|
|
276
|
-
```python
|
|
277
|
-
from django_bulk_hooks.conditions import HasChanged, IsEqual
|
|
278
|
-
|
|
279
|
-
class AdvancedProductHandler:
|
|
280
|
-
# Combine lambda with built-in conditions
|
|
281
|
-
@hook(Product, "after_update", condition=(
|
|
282
|
-
HasChanged("price") &
|
|
283
|
-
LambdaCondition(lambda instance: instance.price > 100)
|
|
284
|
-
))
|
|
285
|
-
def handle_expensive_price_changes(self, new_records, old_records):
|
|
286
|
-
"""Handle when expensive products have price changes"""
|
|
287
|
-
for new_product, old_product in zip(new_records, old_records):
|
|
288
|
-
print(f"Expensive product price changed: {new_product.name}")
|
|
289
|
-
|
|
290
|
-
# Complex combined conditions
|
|
291
|
-
@hook(Order, "after_update", condition=(
|
|
292
|
-
LambdaCondition(lambda instance: instance.status == 'completed') &
|
|
293
|
-
LambdaCondition(lambda instance, original: original and instance.total_amount > original.total_amount)
|
|
294
|
-
))
|
|
295
|
-
def handle_completed_orders_with_increased_amount(self, new_records, old_records):
|
|
296
|
-
"""Handle completed orders that had amount increases"""
|
|
297
|
-
for new_order, old_order in zip(new_records, old_records):
|
|
298
|
-
if old_order:
|
|
299
|
-
increase = new_order.total_amount - old_order.total_amount
|
|
300
|
-
print(f"Completed order with amount increase: {new_order.customer_name} +${increase}")
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
### Custom Condition Classes
|
|
304
|
-
|
|
305
|
-
For reusable logic, you can create custom condition classes:
|
|
306
|
-
|
|
307
|
-
```python
|
|
308
|
-
from django_bulk_hooks.conditions import HookCondition
|
|
309
|
-
|
|
310
|
-
class IsPremiumProduct(HookCondition):
|
|
311
|
-
def check(self, instance, original_instance=None):
|
|
312
|
-
return (
|
|
313
|
-
instance.price > 200 and
|
|
314
|
-
instance.rating >= 4.0 and
|
|
315
|
-
instance.is_active
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
def get_required_fields(self):
|
|
319
|
-
return {'price', 'rating', 'is_active'}
|
|
320
|
-
|
|
321
|
-
class ProductHandler:
|
|
322
|
-
@hook(Product, "after_create", condition=IsPremiumProduct())
|
|
323
|
-
def handle_premium_products(self, new_records, old_records):
|
|
324
|
-
"""Handle premium products"""
|
|
325
|
-
for product in new_records:
|
|
326
|
-
print(f"Premium product: {product.name}")
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
### Lambda Conditions with Required Fields
|
|
330
|
-
|
|
331
|
-
For optimization, you can specify which fields your lambda condition depends on:
|
|
332
|
-
|
|
333
|
-
```python
|
|
334
|
-
class OptimizedProductHandler:
|
|
335
|
-
@hook(Product, "after_update", condition=LambdaCondition(
|
|
336
|
-
lambda instance: instance.price > 100 and instance.category == 'electronics',
|
|
337
|
-
required_fields={'price', 'category'}
|
|
338
|
-
))
|
|
339
|
-
def handle_expensive_electronics(self, new_records, old_records):
|
|
340
|
-
"""Handle expensive electronics products"""
|
|
341
|
-
for product in new_records:
|
|
342
|
-
print(f"Expensive electronics: {product.name}")
|
|
343
|
-
```
|
|
344
|
-
|
|
345
|
-
### Best Practices for Lambda Conditions
|
|
346
|
-
|
|
347
|
-
1. **Keep lambdas simple** - Complex logic should be moved to custom condition classes
|
|
348
|
-
2. **Handle None values** - Always check for None before performing operations
|
|
349
|
-
3. **Specify required fields** - This helps with query optimization
|
|
350
|
-
4. **Use descriptive names** - Make your lambda conditions self-documenting
|
|
351
|
-
5. **Test thoroughly** - Lambda conditions can be harder to debug than named functions
|
|
352
|
-
|
|
353
|
-
```python
|
|
354
|
-
# ✅ GOOD: Simple, clear lambda
|
|
355
|
-
condition = LambdaCondition(lambda instance: instance.price > 100)
|
|
356
|
-
|
|
357
|
-
# ✅ GOOD: Handles None values
|
|
358
|
-
condition = LambdaCondition(
|
|
359
|
-
lambda instance: instance.price is not None and instance.price > 100
|
|
360
|
-
)
|
|
361
|
-
|
|
362
|
-
# ❌ AVOID: Complex logic in lambda
|
|
363
|
-
condition = LambdaCondition(
|
|
364
|
-
lambda instance: (
|
|
365
|
-
instance.price > 100 and
|
|
366
|
-
instance.category in ['electronics', 'computers'] and
|
|
367
|
-
instance.stock_quantity > 0 and
|
|
368
|
-
instance.rating >= 4.0 and
|
|
369
|
-
instance.is_active and
|
|
370
|
-
instance.created_at > datetime.now() - timedelta(days=30)
|
|
371
|
-
)
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
# ✅ BETTER: Use custom condition class for complex logic
|
|
375
|
-
class IsRecentExpensiveElectronics(HookCondition):
|
|
376
|
-
def check(self, instance, original_instance=None):
|
|
377
|
-
return (
|
|
378
|
-
instance.price > 100 and
|
|
379
|
-
instance.category in ['electronics', 'computers'] and
|
|
380
|
-
instance.stock_quantity > 0 and
|
|
381
|
-
instance.rating >= 4.0 and
|
|
382
|
-
instance.is_active and
|
|
383
|
-
instance.created_at > datetime.now() - timedelta(days=30)
|
|
384
|
-
)
|
|
385
|
-
|
|
386
|
-
def get_required_fields(self):
|
|
387
|
-
return {'price', 'category', 'stock_quantity', 'rating', 'is_active', 'created_at'}
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
## 🔧 Best Practices for Related Objects
|
|
391
|
-
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
django_bulk_hooks/__init__.py,sha256=b5LIO5oWX9ZVITZddma_E_Hosx8Zy9B3_v3z8HmSykg,1132
|
|
2
|
+
django_bulk_hooks/conditions.py,sha256=iCdIrpVciGsmyKgIEjcC0nl_2-mAxay4Tss-ZaenSuY,13735
|
|
3
|
+
django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
|
|
4
|
+
django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
|
|
5
|
+
django_bulk_hooks/decorators.py,sha256=zstmb27dKcOHu3Atg7cauewCTzPvUmq03mzVKJRi56o,7230
|
|
6
|
+
django_bulk_hooks/engine.py,sha256=kWxAggInO8GhmQfSzJrGATAPgnuG7580llpO9NoxHA8,2897
|
|
7
|
+
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
8
|
+
django_bulk_hooks/handler.py,sha256=Qpg_zT6SsQiTlhduvzXxPdG6uynjyR2fBjj-R6HZiXI,4861
|
|
9
|
+
django_bulk_hooks/manager.py,sha256=vyIc7ktNbjXCJrqP7SO7lsamBYFrZSCIQBmXSnpK874,12481
|
|
10
|
+
django_bulk_hooks/models.py,sha256=9KvWkmrR0wbTHN6r7-FrSSO9ViS83NvG7iXLBw_iDZs,4793
|
|
11
|
+
django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
|
|
12
|
+
django_bulk_hooks/registry.py,sha256=Vh78exKYcdZhM27120kQm-iXGOjd_kf9ZUYBZ8eQ2V0,683
|
|
13
|
+
django_bulk_hooks-0.1.102.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
14
|
+
django_bulk_hooks-0.1.102.dist-info/METADATA,sha256=xpdCGrDTHoLswDQAXL67JvvvUkyEyTkyQfdbRGDiuz8,9040
|
|
15
|
+
django_bulk_hooks-0.1.102.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
16
|
+
django_bulk_hooks-0.1.102.dist-info/RECORD,,
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
django_bulk_hooks/__init__.py,sha256=EAWve4HjrrIuPbl8uc1s1ISDM3RPDtwCvTOPRwFpX8w,1392
|
|
2
|
-
django_bulk_hooks/conditions.py,sha256=wDtY90Kv3xjWx8HEA4aAjva8fDDaYegJhn0Eu6G0F60,12150
|
|
3
|
-
django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
|
|
4
|
-
django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
|
|
5
|
-
django_bulk_hooks/decorators.py,sha256=zstmb27dKcOHu3Atg7cauewCTzPvUmq03mzVKJRi56o,7230
|
|
6
|
-
django_bulk_hooks/engine.py,sha256=qSBvBel3pBWVE92ZzgKjMn2ceQpA7FMws2uMXes1MkA,3626
|
|
7
|
-
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
8
|
-
django_bulk_hooks/handler.py,sha256=tdDolHAJ_Nd7-RT4s9HRyLtM1UWGjRjP1Y_U6Af32Gg,5325
|
|
9
|
-
django_bulk_hooks/manager.py,sha256=DcVosEA4RS79KSYgw3Z14_a9Sd8CfxNNc5F3eSb8xc0,11459
|
|
10
|
-
django_bulk_hooks/models.py,sha256=U5nCxingZS2sznDjgW8fWo93SisA03WKcGpxxApqhuM,5519
|
|
11
|
-
django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
|
|
12
|
-
django_bulk_hooks/registry.py,sha256=Vh78exKYcdZhM27120kQm-iXGOjd_kf9ZUYBZ8eQ2V0,683
|
|
13
|
-
django_bulk_hooks-0.1.100.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
14
|
-
django_bulk_hooks-0.1.100.dist-info/METADATA,sha256=AH1pPY4iPjCTvBY5Vyj4dWYJeU5CELEkVt0uRdiW2bg,15415
|
|
15
|
-
django_bulk_hooks-0.1.100.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
16
|
-
django_bulk_hooks-0.1.100.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|