django-bulk-hooks 0.1.80__py3-none-any.whl → 0.1.82__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.

@@ -1,47 +1,50 @@
1
- from django_bulk_hooks.constants import (
2
- AFTER_CREATE,
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
- )
19
- from django_bulk_hooks.decorators import hook, select_related
20
- from django_bulk_hooks.engine import safe_get_related_object, safe_get_related_attr
21
- from django_bulk_hooks.handler import HookHandler
22
- from django_bulk_hooks.models import HookModelMixin
23
- from django_bulk_hooks.enums import Priority
24
-
25
- __all__ = [
26
- "HookHandler",
27
- "HookModelMixin",
28
- "BEFORE_CREATE",
29
- "AFTER_CREATE",
30
- "BEFORE_UPDATE",
31
- "AFTER_UPDATE",
32
- "BEFORE_DELETE",
33
- "AFTER_DELETE",
34
- "VALIDATE_CREATE",
35
- "VALIDATE_UPDATE",
36
- "VALIDATE_DELETE",
37
- "safe_get_related_object",
38
- "safe_get_related_attr",
39
- "Priority",
40
- "hook",
41
- "select_related",
42
- "ChangesTo",
43
- "HasChanged",
44
- "IsEqual",
45
- "IsNotEqual",
46
- "WasEqual",
47
- ]
1
+ from django_bulk_hooks.constants import (
2
+ AFTER_CREATE,
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
26
+
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
+ ]
@@ -1,233 +1,351 @@
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 resolve_dotted_attr(instance, dotted_path):
33
- """
34
- Recursively resolve a dotted attribute path, e.g., "type.category".
35
- This function is designed to work with pre-loaded foreign keys to avoid queries.
36
- """
37
- if instance is None:
38
- return None
39
-
40
- current = instance
41
- for attr in dotted_path.split("."):
42
- if current is None:
43
- return None
44
-
45
- # Check if this is a foreign key that might trigger a query
46
- if hasattr(current, '_meta') and hasattr(current._meta, 'get_field'):
47
- try:
48
- field = current._meta.get_field(attr)
49
- if field.is_relation and not field.many_to_many and not field.one_to_many:
50
- # For foreign keys, use safe access to prevent RelatedObjectDoesNotExist
51
- current = safe_get_related_object(current, attr)
52
- else:
53
- current = getattr(current, attr, None)
54
- except Exception:
55
- # If field lookup fails, fall back to regular attribute access
56
- current = getattr(current, attr, None)
57
- else:
58
- # Not a model instance, use regular attribute access
59
- current = getattr(current, attr, None)
60
-
61
- return current
62
-
63
-
64
- class HookCondition:
65
- def check(self, instance, original_instance=None):
66
- raise NotImplementedError
67
-
68
- def __call__(self, instance, original_instance=None):
69
- return self.check(instance, original_instance)
70
-
71
- def __and__(self, other):
72
- return AndCondition(self, other)
73
-
74
- def __or__(self, other):
75
- return OrCondition(self, other)
76
-
77
- def __invert__(self):
78
- return NotCondition(self)
79
-
80
-
81
- class IsNotEqual(HookCondition):
82
- def __init__(self, field, value, only_on_change=False):
83
- self.field = field
84
- self.value = value
85
- self.only_on_change = only_on_change
86
-
87
- def check(self, instance, original_instance=None):
88
- current = resolve_dotted_attr(instance, self.field)
89
- if self.only_on_change:
90
- if original_instance is None:
91
- return False
92
- previous = resolve_dotted_attr(original_instance, self.field)
93
- return previous == self.value and current != self.value
94
- else:
95
- return current != self.value
96
-
97
-
98
- class IsEqual(HookCondition):
99
- def __init__(self, field, value, only_on_change=False):
100
- self.field = field
101
- self.value = value
102
- self.only_on_change = only_on_change
103
-
104
- def check(self, instance, original_instance=None):
105
- current = resolve_dotted_attr(instance, self.field)
106
- if self.only_on_change:
107
- if original_instance is None:
108
- return False
109
- previous = resolve_dotted_attr(original_instance, self.field)
110
- return previous != self.value and current == self.value
111
- else:
112
- return current == self.value
113
-
114
-
115
- class HasChanged(HookCondition):
116
- def __init__(self, field, has_changed=True):
117
- self.field = field
118
- self.has_changed = has_changed
119
-
120
- def check(self, instance, original_instance=None):
121
- if not original_instance:
122
- return False
123
- current = resolve_dotted_attr(instance, self.field)
124
- previous = resolve_dotted_attr(original_instance, self.field)
125
- return (current != previous) == self.has_changed
126
-
127
-
128
- class WasEqual(HookCondition):
129
- def __init__(self, field, value, only_on_change=False):
130
- """
131
- Check if a field's original value was `value`.
132
- If only_on_change is True, only return True when the field has changed away from that value.
133
- """
134
- self.field = field
135
- self.value = value
136
- self.only_on_change = only_on_change
137
-
138
- def check(self, instance, original_instance=None):
139
- if original_instance is None:
140
- return False
141
- previous = resolve_dotted_attr(original_instance, self.field)
142
- if self.only_on_change:
143
- current = resolve_dotted_attr(instance, self.field)
144
- return previous == self.value and current != self.value
145
- else:
146
- return previous == self.value
147
-
148
-
149
- class ChangesTo(HookCondition):
150
- def __init__(self, field, value):
151
- """
152
- Check if a field's value has changed to `value`.
153
- Only returns True when original value != value and current value == value.
154
- """
155
- self.field = field
156
- self.value = value
157
-
158
- def check(self, instance, original_instance=None):
159
- if original_instance is None:
160
- return False
161
- previous = resolve_dotted_attr(original_instance, self.field)
162
- current = resolve_dotted_attr(instance, self.field)
163
- return previous != self.value and current == self.value
164
-
165
-
166
- class IsGreaterThan(HookCondition):
167
- def __init__(self, field, value):
168
- self.field = field
169
- self.value = value
170
-
171
- def check(self, instance, original_instance=None):
172
- current = resolve_dotted_attr(instance, self.field)
173
- return current is not None and current > self.value
174
-
175
-
176
- class IsGreaterThanOrEqual(HookCondition):
177
- def __init__(self, field, value):
178
- self.field = field
179
- self.value = value
180
-
181
- def check(self, instance, original_instance=None):
182
- current = resolve_dotted_attr(instance, self.field)
183
- return current is not None and current >= self.value
184
-
185
-
186
- class IsLessThan(HookCondition):
187
- def __init__(self, field, value):
188
- self.field = field
189
- self.value = value
190
-
191
- def check(self, instance, original_instance=None):
192
- current = resolve_dotted_attr(instance, self.field)
193
- return current is not None and current < self.value
194
-
195
-
196
- class IsLessThanOrEqual(HookCondition):
197
- def __init__(self, field, value):
198
- self.field = field
199
- self.value = value
200
-
201
- def check(self, instance, original_instance=None):
202
- current = resolve_dotted_attr(instance, self.field)
203
- return current is not None and current <= self.value
204
-
205
-
206
- class AndCondition(HookCondition):
207
- def __init__(self, cond1, cond2):
208
- self.cond1 = cond1
209
- self.cond2 = cond2
210
-
211
- def check(self, instance, original_instance=None):
212
- return self.cond1.check(instance, original_instance) and self.cond2.check(
213
- instance, original_instance
214
- )
215
-
216
-
217
- class OrCondition(HookCondition):
218
- def __init__(self, cond1, cond2):
219
- self.cond1 = cond1
220
- self.cond2 = cond2
221
-
222
- def check(self, instance, original_instance=None):
223
- return self.cond1.check(instance, original_instance) or self.cond2.check(
224
- instance, original_instance
225
- )
226
-
227
-
228
- class NotCondition(HookCondition):
229
- def __init__(self, cond):
230
- self.cond = cond
231
-
232
- def check(self, instance, original_instance=None):
233
- return not self.cond.check(instance, original_instance)
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
+ 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.
154
+ """
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
180
+
181
+
182
+ class HookCondition:
183
+ def check(self, instance, original_instance=None):
184
+ raise NotImplementedError
185
+
186
+ def __call__(self, instance, original_instance=None):
187
+ return self.check(instance, original_instance)
188
+
189
+ def __and__(self, other):
190
+ return AndCondition(self, other)
191
+
192
+ def __or__(self, other):
193
+ return OrCondition(self, other)
194
+
195
+ def __invert__(self):
196
+ return NotCondition(self)
197
+
198
+
199
+ class IsNotEqual(HookCondition):
200
+ def __init__(self, field, value, only_on_change=False):
201
+ self.field = field
202
+ self.value = value
203
+ self.only_on_change = only_on_change
204
+
205
+ def check(self, instance, original_instance=None):
206
+ current = resolve_dotted_attr(instance, self.field)
207
+ if self.only_on_change:
208
+ if original_instance is None:
209
+ return False
210
+ previous = resolve_dotted_attr(original_instance, self.field)
211
+ return previous == self.value and current != self.value
212
+ else:
213
+ return current != self.value
214
+
215
+
216
+ class IsEqual(HookCondition):
217
+ def __init__(self, field, value, only_on_change=False):
218
+ self.field = field
219
+ self.value = value
220
+ self.only_on_change = only_on_change
221
+
222
+ def check(self, instance, original_instance=None):
223
+ current = resolve_dotted_attr(instance, self.field)
224
+ if self.only_on_change:
225
+ if original_instance is None:
226
+ return False
227
+ previous = resolve_dotted_attr(original_instance, self.field)
228
+ return previous != self.value and current == self.value
229
+ else:
230
+ return current == self.value
231
+
232
+
233
+ class HasChanged(HookCondition):
234
+ def __init__(self, field, has_changed=True):
235
+ self.field = field
236
+ self.has_changed = has_changed
237
+
238
+ def check(self, instance, original_instance=None):
239
+ if not original_instance:
240
+ return False
241
+ current = resolve_dotted_attr(instance, self.field)
242
+ previous = resolve_dotted_attr(original_instance, self.field)
243
+ return (current != previous) == self.has_changed
244
+
245
+
246
+ class WasEqual(HookCondition):
247
+ def __init__(self, field, value, only_on_change=False):
248
+ """
249
+ Check if a field's original value was `value`.
250
+ If only_on_change is True, only return True when the field has changed away from that value.
251
+ """
252
+ self.field = field
253
+ self.value = value
254
+ self.only_on_change = only_on_change
255
+
256
+ def check(self, instance, original_instance=None):
257
+ if original_instance is None:
258
+ return False
259
+ previous = resolve_dotted_attr(original_instance, self.field)
260
+ if self.only_on_change:
261
+ current = resolve_dotted_attr(instance, self.field)
262
+ return previous == self.value and current != self.value
263
+ else:
264
+ return previous == self.value
265
+
266
+
267
+ class ChangesTo(HookCondition):
268
+ def __init__(self, field, value):
269
+ """
270
+ Check if a field's value has changed to `value`.
271
+ Only returns True when original value != value and current value == value.
272
+ """
273
+ self.field = field
274
+ self.value = value
275
+
276
+ def check(self, instance, original_instance=None):
277
+ if original_instance is None:
278
+ return False
279
+ previous = resolve_dotted_attr(original_instance, self.field)
280
+ current = resolve_dotted_attr(instance, self.field)
281
+ return previous != self.value and current == self.value
282
+
283
+
284
+ class IsGreaterThan(HookCondition):
285
+ def __init__(self, field, value):
286
+ self.field = field
287
+ self.value = value
288
+
289
+ def check(self, instance, original_instance=None):
290
+ current = resolve_dotted_attr(instance, self.field)
291
+ return current is not None and current > self.value
292
+
293
+
294
+ class IsGreaterThanOrEqual(HookCondition):
295
+ def __init__(self, field, value):
296
+ self.field = field
297
+ self.value = value
298
+
299
+ def check(self, instance, original_instance=None):
300
+ current = resolve_dotted_attr(instance, self.field)
301
+ return current is not None and current >= self.value
302
+
303
+
304
+ class IsLessThan(HookCondition):
305
+ def __init__(self, field, value):
306
+ self.field = field
307
+ self.value = value
308
+
309
+ def check(self, instance, original_instance=None):
310
+ current = resolve_dotted_attr(instance, self.field)
311
+ return current is not None and current < self.value
312
+
313
+
314
+ class IsLessThanOrEqual(HookCondition):
315
+ def __init__(self, field, value):
316
+ self.field = field
317
+ self.value = value
318
+
319
+ def check(self, instance, original_instance=None):
320
+ current = resolve_dotted_attr(instance, self.field)
321
+ return current is not None and current <= self.value
322
+
323
+
324
+ class AndCondition(HookCondition):
325
+ def __init__(self, cond1, cond2):
326
+ self.cond1 = cond1
327
+ self.cond2 = cond2
328
+
329
+ def check(self, instance, original_instance=None):
330
+ return self.cond1.check(instance, original_instance) and self.cond2.check(
331
+ instance, original_instance
332
+ )
333
+
334
+
335
+ class OrCondition(HookCondition):
336
+ def __init__(self, cond1, cond2):
337
+ self.cond1 = cond1
338
+ self.cond2 = cond2
339
+
340
+ def check(self, instance, original_instance=None):
341
+ return self.cond1.check(instance, original_instance) or self.cond2.check(
342
+ instance, original_instance
343
+ )
344
+
345
+
346
+ class NotCondition(HookCondition):
347
+ def __init__(self, cond):
348
+ self.cond = cond
349
+
350
+ def check(self, instance, original_instance=None):
351
+ return not self.cond.check(instance, original_instance)
@@ -1,66 +1,53 @@
1
- import logging
2
-
3
- from django.core.exceptions import ValidationError
4
- from django.db import models
5
- from django_bulk_hooks.registry import get_hooks
6
- from django_bulk_hooks.conditions import safe_get_related_object
7
-
8
- logger = logging.getLogger(__name__)
9
-
10
-
11
- def safe_get_related_attr(instance, field_name, attr_name=None):
12
- """
13
- Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
14
-
15
- Args:
16
- instance: The model instance
17
- field_name: The foreign key field name
18
- attr_name: Optional attribute name to access on the related object
19
-
20
- Returns:
21
- The related object, the attribute value, or None if not available
22
- """
23
- related_obj = safe_get_related_object(instance, field_name)
24
- if related_obj is None:
25
- return None
26
-
27
- if attr_name is None:
28
- return related_obj
29
-
30
- return getattr(related_obj, attr_name, None)
31
-
32
-
33
- def run(model_cls, event, new_instances, original_instances=None, ctx=None):
34
- hooks = get_hooks(model_cls, event)
35
-
36
- if not hooks:
37
- return
38
-
39
- # For BEFORE_* events, run model.clean() first for validation
40
- if event.startswith("before_"):
41
- for instance in new_instances:
42
- try:
43
- instance.clean()
44
- except ValidationError as e:
45
- logger.error("Validation failed for %s: %s", instance, e)
46
- raise
47
-
48
- for handler_cls, method_name, condition, priority in hooks:
49
- handler_instance = handler_cls()
50
- func = getattr(handler_instance, method_name)
51
-
52
- to_process_new = []
53
- to_process_old = []
54
-
55
- for new, original in zip(
56
- new_instances,
57
- original_instances or [None] * len(new_instances),
58
- strict=True,
59
- ):
60
- if not condition or condition.check(new, original):
61
- to_process_new.append(new)
62
- to_process_old.append(original)
63
-
64
- if to_process_new:
65
- # Call the function with keyword arguments
66
- func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
1
+ import logging
2
+
3
+ from django.core.exceptions import ValidationError
4
+ from django.db import models
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
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
12
+ hooks = get_hooks(model_cls, event)
13
+
14
+ if not hooks:
15
+ return
16
+
17
+ # For BEFORE_* events, run model.clean() first for validation
18
+ if event.startswith("before_"):
19
+ for instance in new_instances:
20
+ try:
21
+ instance.clean()
22
+ except ValidationError as e:
23
+ logger.error("Validation failed for %s: %s", instance, e)
24
+ raise
25
+ except Exception as e:
26
+ # Handle RelatedObjectDoesNotExist and other exceptions that might occur
27
+ # when accessing foreign key fields on unsaved objects
28
+ if "RelatedObjectDoesNotExist" in str(type(e).__name__):
29
+ logger.debug("Skipping validation for unsaved object with unset foreign keys: %s", e)
30
+ continue
31
+ else:
32
+ logger.error("Unexpected error during validation for %s: %s", instance, e)
33
+ raise
34
+
35
+ for handler_cls, method_name, condition, priority in hooks:
36
+ handler_instance = handler_cls()
37
+ func = getattr(handler_instance, method_name)
38
+
39
+ to_process_new = []
40
+ to_process_old = []
41
+
42
+ for new, original in zip(
43
+ new_instances,
44
+ original_instances or [None] * len(new_instances),
45
+ strict=True,
46
+ ):
47
+ if not condition or condition.check(new, original):
48
+ to_process_new.append(new)
49
+ to_process_old.append(original)
50
+
51
+ if to_process_new:
52
+ # Call the function with keyword arguments
53
+ func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
@@ -28,8 +28,15 @@ class HookModelMixin(models.Model):
28
28
  This ensures that when Django calls clean() (like in admin forms),
29
29
  it triggers the VALIDATE_* hooks for validation only.
30
30
  """
31
+ # Call Django's clean first
31
32
  super().clean()
32
33
 
34
+ # Skip hook validation during admin form validation
35
+ # This prevents RelatedObjectDoesNotExist errors when Django hasn't
36
+ # fully set up the object's relationships yet
37
+ if hasattr(self, '_state') and getattr(self._state, 'validating', False):
38
+ return
39
+
33
40
  # Determine if this is a create or update operation
34
41
  is_create = self.pk is None
35
42
 
@@ -0,0 +1,228 @@
1
+ Metadata-Version: 2.3
2
+ Name: django-bulk-hooks
3
+ Version: 0.1.82
4
+ Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
+ License: MIT
6
+ Keywords: django,bulk,hooks
7
+ Author: Konrad Beck
8
+ Author-email: konrad.beck@merchantcapital.co.za
9
+ Requires-Python: >=3.11,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: Django (>=4.0)
16
+ Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
17
+ Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
+ Description-Content-Type: text/markdown
19
+
20
+
21
+ # django-bulk-hooks
22
+
23
+ ⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
24
+
25
+ `django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
26
+
27
+ ## ✨ Features
28
+
29
+ - Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
30
+ - BEFORE/AFTER hooks for create, update, delete
31
+ - Hook-aware manager that wraps Django's `bulk_` operations
32
+ - **NEW**: `HookModelMixin` for individual model lifecycle events
33
+ - Hook chaining, hook deduplication, and atomicity
34
+ - Class-based hook handlers with DI support
35
+ - Support for both bulk and individual model operations
36
+ - **NEW**: Safe handling of related objects to prevent `RelatedObjectDoesNotExist` errors
37
+
38
+ ## 🚀 Quickstart
39
+
40
+ ```bash
41
+ pip install django-bulk-hooks
42
+ ```
43
+
44
+ ### Define Your Model
45
+
46
+ ```python
47
+ from django.db import models
48
+ from django_bulk_hooks.models import HookModelMixin
49
+
50
+ class Account(HookModelMixin):
51
+ balance = models.DecimalField(max_digits=10, decimal_places=2)
52
+ # The HookModelMixin automatically provides BulkHookManager
53
+ ```
54
+
55
+ ### Create a Hook Handler
56
+
57
+ ```python
58
+ from django_bulk_hooks import hook, AFTER_UPDATE, Hook
59
+ from django_bulk_hooks.conditions import WhenFieldHasChanged
60
+ from .models import Account
61
+
62
+ class AccountHooks(HookHandler):
63
+ @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
64
+ def log_balance_change(self, new_records, old_records):
65
+ print("Accounts updated:", [a.pk for a in new_records])
66
+
67
+ @hook(BEFORE_CREATE, model=Account)
68
+ def before_create(self, new_records, old_records):
69
+ for account in new_records:
70
+ if account.balance < 0:
71
+ raise ValueError("Account cannot have negative balance")
72
+
73
+ @hook(AFTER_DELETE, model=Account)
74
+ def after_delete(self, new_records, old_records):
75
+ print("Accounts deleted:", [a.pk for a in old_records])
76
+ ```
77
+
78
+ ### Advanced Hook Usage
79
+
80
+ ```python
81
+ class AdvancedAccountHooks(HookHandler):
82
+ @hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
83
+ def validate_balance_change(self, new_records, old_records):
84
+ for new_account, old_account in zip(new_records, old_records):
85
+ if new_account.balance < 0 and old_account.balance >= 0:
86
+ raise ValueError("Cannot set negative balance")
87
+
88
+ @hook(AFTER_CREATE, model=Account)
89
+ def send_welcome_email(self, new_records, old_records):
90
+ for account in new_records:
91
+ # Send welcome email logic here
92
+ pass
93
+ ```
94
+
95
+ ## 🔒 Safely Handling Related Objects
96
+
97
+ One of the most common issues when working with hooks is the `RelatedObjectDoesNotExist` exception. This occurs when you try to access a related object that doesn't exist or hasn't been saved yet.
98
+
99
+ ### The Problem
100
+
101
+ ```python
102
+ # ❌ DANGEROUS: This can raise RelatedObjectDoesNotExist
103
+ @hook(AFTER_CREATE, model=Transaction)
104
+ def process_transaction(self, new_records, old_records):
105
+ for transaction in new_records:
106
+ # This will fail if transaction.status is None or doesn't exist
107
+ if transaction.status.name == "COMPLETE":
108
+ # Process the transaction
109
+ pass
110
+ ```
111
+
112
+ ### The Solution
113
+
114
+ Use the `safe_get_related_attr` utility function to safely access related object attributes:
115
+
116
+ ```python
117
+ from django_bulk_hooks.conditions import safe_get_related_attr
118
+
119
+ # ✅ SAFE: Use safe_get_related_attr to handle None values
120
+ @hook(AFTER_CREATE, model=Transaction)
121
+ def process_transaction(self, new_records, old_records):
122
+ for transaction in new_records:
123
+ # Safely get the status name, returns None if status doesn't exist
124
+ status_name = safe_get_related_attr(transaction, 'status', 'name')
125
+
126
+ if status_name == "COMPLETE":
127
+ # Process the transaction
128
+ pass
129
+ elif status_name is None:
130
+ # Handle case where status is not set
131
+ print(f"Transaction {transaction.id} has no status")
132
+ ```
133
+
134
+ ### Complete Example
135
+
136
+ ```python
137
+ from django.db import models
138
+ from django_bulk_hooks import hook
139
+ from django_bulk_hooks.conditions import safe_get_related_attr
140
+
141
+ class Status(models.Model):
142
+ name = models.CharField(max_length=50)
143
+
144
+ class Transaction(HookModelMixin, models.Model):
145
+ amount = models.DecimalField(max_digits=10, decimal_places=2)
146
+ status = models.ForeignKey(Status, on_delete=models.CASCADE, null=True, blank=True)
147
+ category = models.ForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True)
148
+
149
+ class TransactionHandler:
150
+ @hook(Transaction, "before_create")
151
+ def set_default_status(self, new_records, old_records=None):
152
+ """Set default status for new transactions."""
153
+ default_status = Status.objects.filter(name="PENDING").first()
154
+ for transaction in new_records:
155
+ if transaction.status is None:
156
+ transaction.status = default_status
157
+
158
+ @hook(Transaction, "after_create")
159
+ def process_transactions(self, new_records, old_records=None):
160
+ """Process transactions based on their status."""
161
+ for transaction in new_records:
162
+ # ✅ SAFE: Get status name safely
163
+ status_name = safe_get_related_attr(transaction, 'status', 'name')
164
+
165
+ if status_name == "COMPLETE":
166
+ self._process_complete_transaction(transaction)
167
+ elif status_name == "FAILED":
168
+ self._process_failed_transaction(transaction)
169
+ elif status_name is None:
170
+ print(f"Transaction {transaction.id} has no status")
171
+
172
+ # ✅ SAFE: Check for related object existence
173
+ category = safe_get_related_attr(transaction, 'category')
174
+ if category:
175
+ print(f"Transaction {transaction.id} belongs to category: {category.name}")
176
+
177
+ def _process_complete_transaction(self, transaction):
178
+ # Process complete transaction logic
179
+ pass
180
+
181
+ def _process_failed_transaction(self, transaction):
182
+ # Process failed transaction logic
183
+ pass
184
+ ```
185
+
186
+ ### Best Practices for Related Objects
187
+
188
+ 1. **Always use `safe_get_related_attr`** when accessing related object attributes in hooks
189
+ 2. **Set default values in `BEFORE_CREATE` hooks** to ensure related objects exist
190
+ 3. **Handle None cases explicitly** to avoid unexpected behavior
191
+ 4. **Use bulk operations efficiently** by fetching related objects once and reusing them
192
+
193
+ ```python
194
+ class EfficientTransactionHandler:
195
+ @hook(Transaction, "before_create")
196
+ def prepare_transactions(self, new_records, old_records=None):
197
+ """Efficiently prepare transactions for bulk creation."""
198
+ # Get default objects once to avoid multiple queries
199
+ default_status = Status.objects.filter(name="PENDING").first()
200
+ default_category = Category.objects.filter(name="GENERAL").first()
201
+
202
+ for transaction in new_records:
203
+ if transaction.status is None:
204
+ transaction.status = default_status
205
+ if transaction.category is None:
206
+ transaction.category = default_category
207
+
208
+ @hook(Transaction, "after_create")
209
+ def post_creation_processing(self, new_records, old_records=None):
210
+ """Process transactions after creation."""
211
+ # Group by status for efficient processing
212
+ transactions_by_status = {}
213
+
214
+ for transaction in new_records:
215
+ status_name = safe_get_related_attr(transaction, 'status', 'name')
216
+ if status_name not in transactions_by_status:
217
+ transactions_by_status[status_name] = []
218
+ transactions_by_status[status_name].append(transaction)
219
+
220
+ # Process each group
221
+ for status_name, transactions in transactions_by_status.items():
222
+ if status_name == "COMPLETE":
223
+ self._batch_process_complete(transactions)
224
+ elif status_name == "FAILED":
225
+ self._batch_process_failed(transactions)
226
+ ```
227
+
228
+ This approach ensures your hooks are robust and won't fail due to missing related objects, while also being efficient with database queries.
@@ -1,16 +1,16 @@
1
- django_bulk_hooks/__init__.py,sha256=iY8RkL2-qPV2y15FaEVgO7ZYrbYceZjsCtYCU073S2U,1119
2
- django_bulk_hooks/conditions.py,sha256=o31qTnSfEkAKMVnFPF0JdkuvMFWb92Lc-o-WQ2CoWAk,8107
1
+ django_bulk_hooks/__init__.py,sha256=2PcJ6xz7t7Du0nmLO_5732G6u_oZTygogG0fKESRHHk,1082
2
+ django_bulk_hooks/conditions.py,sha256=cif5R4C_N2HosuYiB03STc2rHb800EJV0Nby-ZPac1s,12460
3
3
  django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
4
4
  django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
5
5
  django_bulk_hooks/decorators.py,sha256=zstmb27dKcOHu3Atg7cauewCTzPvUmq03mzVKJRi56o,7230
6
- django_bulk_hooks/engine.py,sha256=ru6oxoXEj4eC8JM30hzLj-jcD-_Mcg-SaPqexxR87c0,2183
6
+ django_bulk_hooks/engine.py,sha256=b1AO8qyl2VMt1CHyefn1Gt1_ARaV80yBZ3mGXsJ9_eA,2021
7
7
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
8
  django_bulk_hooks/handler.py,sha256=Qpg_zT6SsQiTlhduvzXxPdG6uynjyR2fBjj-R6HZiXI,4861
9
9
  django_bulk_hooks/manager.py,sha256=-V128ACxPAz82ua4jQRFUkjAKtKW4MN5ppz0bHcv5s4,7138
10
- django_bulk_hooks/models.py,sha256=7RG7GrOdHXFjGVPV4FPRZVNMIHHW-hMCi6hn9LH_hVI,3331
10
+ django_bulk_hooks/models.py,sha256=S4CLMcZeOv0XWCmmC7ieOExL73WmfWUKXPYD3N9wrVc,3666
11
11
  django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
12
12
  django_bulk_hooks/registry.py,sha256=Vh78exKYcdZhM27120kQm-iXGOjd_kf9ZUYBZ8eQ2V0,683
13
- django_bulk_hooks-0.1.80.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
14
- django_bulk_hooks-0.1.80.dist-info/METADATA,sha256=A1dYixWjfYcpuaUIrf_a_tLzbvtGLkbeuAOweiJqFN8,3388
15
- django_bulk_hooks-0.1.80.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
16
- django_bulk_hooks-0.1.80.dist-info/RECORD,,
13
+ django_bulk_hooks-0.1.82.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
14
+ django_bulk_hooks-0.1.82.dist-info/METADATA,sha256=gYgpti0VC1zlAh2NlINn-lyAPPCXkB0K1XzIq4WgsxM,9051
15
+ django_bulk_hooks-0.1.82.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
16
+ django_bulk_hooks-0.1.82.dist-info/RECORD,,
@@ -1,92 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: django-bulk-hooks
3
- Version: 0.1.80
4
- Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
- License: MIT
6
- Keywords: django,bulk,hooks
7
- Author: Konrad Beck
8
- Author-email: konrad.beck@merchantcapital.co.za
9
- Requires-Python: >=3.11,<4.0
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Classifier: Programming Language :: Python :: 3.13
15
- Requires-Dist: Django (>=4.0)
16
- Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
17
- Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
- Description-Content-Type: text/markdown
19
-
20
-
21
- # django-bulk-hooks
22
-
23
- ⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
24
-
25
- `django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
26
-
27
- ## ✨ Features
28
-
29
- - Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
30
- - BEFORE/AFTER hooks for create, update, delete
31
- - Hook-aware manager that wraps Django's `bulk_` operations
32
- - **NEW**: `HookModelMixin` for individual model lifecycle events
33
- - Hook chaining, hook deduplication, and atomicity
34
- - Class-based hook handlers with DI support
35
- - Support for both bulk and individual model operations
36
-
37
- ## 🚀 Quickstart
38
-
39
- ```bash
40
- pip install django-bulk-hooks
41
- ```
42
-
43
- ### Define Your Model
44
-
45
- ```python
46
- from django.db import models
47
- from django_bulk_hooks.models import HookModelMixin
48
-
49
- class Account(HookModelMixin):
50
- balance = models.DecimalField(max_digits=10, decimal_places=2)
51
- # The HookModelMixin automatically provides BulkHookManager
52
- ```
53
-
54
- ### Create a Hook Handler
55
-
56
- ```python
57
- from django_bulk_hooks import hook, AFTER_UPDATE, Hook
58
- from django_bulk_hooks.conditions import WhenFieldHasChanged
59
- from .models import Account
60
-
61
- class AccountHooks(HookHandler):
62
- @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
63
- def log_balance_change(self, new_records, old_records):
64
- print("Accounts updated:", [a.pk for a in new_records])
65
-
66
- @hook(BEFORE_CREATE, model=Account)
67
- def before_create(self, new_records, old_records):
68
- for account in new_records:
69
- if account.balance < 0:
70
- raise ValueError("Account cannot have negative balance")
71
-
72
- @hook(AFTER_DELETE, model=Account)
73
- def after_delete(self, new_records, old_records):
74
- print("Accounts deleted:", [a.pk for a in old_records])
75
- ```
76
-
77
- ### Advanced Hook Usage
78
-
79
- ```python
80
- class AdvancedAccountHooks(HookHandler):
81
- @hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
82
- def validate_balance_change(self, new_records, old_records):
83
- for new_account, old_account in zip(new_records, old_records):
84
- if new_account.balance < 0 and old_account.balance >= 0:
85
- raise ValueError("Cannot set negative balance")
86
-
87
- @hook(AFTER_CREATE, model=Account)
88
- def send_welcome_email(self, new_records, old_records):
89
- for account in new_records:
90
- # Send welcome email logic here
91
- pass
92
- ```