django-bulk-hooks 0.1.95__tar.gz → 0.1.97__tar.gz

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,7 +1,8 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.1
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.95
3
+ Version: 0.1.97
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
+ Home-page: https://github.com/AugendLimited/django-bulk-hooks
5
6
  License: MIT
6
7
  Keywords: django,bulk,hooks
7
8
  Author: Konrad Beck
@@ -13,7 +14,6 @@ Classifier: Programming Language :: Python :: 3.11
13
14
  Classifier: Programming Language :: Python :: 3.12
14
15
  Classifier: Programming Language :: Python :: 3.13
15
16
  Requires-Dist: Django (>=4.0)
16
- Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
17
17
  Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -1,52 +1,60 @@
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
- IsBlank,
19
- safe_get_related_object,
20
- safe_get_related_attr,
21
- is_field_set,
22
- )
23
- from django_bulk_hooks.decorators import hook, select_related
24
- from django_bulk_hooks.handler import HookHandler
25
- from django_bulk_hooks.models import HookModelMixin
26
- from django_bulk_hooks.enums import Priority
27
-
28
- __all__ = [
29
- "HookHandler",
30
- "HookModelMixin",
31
- "BEFORE_CREATE",
32
- "AFTER_CREATE",
33
- "BEFORE_UPDATE",
34
- "AFTER_UPDATE",
35
- "BEFORE_DELETE",
36
- "AFTER_DELETE",
37
- "VALIDATE_CREATE",
38
- "VALIDATE_UPDATE",
39
- "VALIDATE_DELETE",
40
- "safe_get_related_object",
41
- "safe_get_related_attr",
42
- "is_field_set",
43
- "Priority",
44
- "hook",
45
- "select_related",
46
- "ChangesTo",
47
- "HasChanged",
48
- "IsEqual",
49
- "IsNotEqual",
50
- "WasEqual",
51
- "IsBlank",
52
- ]
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
+ WasEqual,
12
+ is_field_set,
13
+ safe_get_related_attr,
14
+ safe_get_related_object,
15
+ )
16
+ from django_bulk_hooks.constants import (
17
+ AFTER_CREATE,
18
+ AFTER_DELETE,
19
+ AFTER_UPDATE,
20
+ BEFORE_CREATE,
21
+ BEFORE_DELETE,
22
+ BEFORE_UPDATE,
23
+ VALIDATE_CREATE,
24
+ VALIDATE_DELETE,
25
+ VALIDATE_UPDATE,
26
+ )
27
+ from django_bulk_hooks.decorators import hook, select_related
28
+ from django_bulk_hooks.enums import Priority
29
+ from django_bulk_hooks.handler import HookHandler
30
+ from django_bulk_hooks.models import HookModelMixin
31
+
32
+ __all__ = [
33
+ "HookHandler",
34
+ "HookModelMixin",
35
+ "BEFORE_CREATE",
36
+ "AFTER_CREATE",
37
+ "BEFORE_UPDATE",
38
+ "AFTER_UPDATE",
39
+ "BEFORE_DELETE",
40
+ "AFTER_DELETE",
41
+ "VALIDATE_CREATE",
42
+ "VALIDATE_UPDATE",
43
+ "VALIDATE_DELETE",
44
+ "safe_get_related_object",
45
+ "safe_get_related_attr",
46
+ "is_field_set",
47
+ "Priority",
48
+ "hook",
49
+ "select_related",
50
+ "ChangesTo",
51
+ "HasChanged",
52
+ "IsEqual",
53
+ "IsNotEqual",
54
+ "WasEqual",
55
+ "IsBlank",
56
+ "IsGreaterThan",
57
+ "IsLessThan",
58
+ "IsGreaterThanOrEqual",
59
+ "IsLessThanOrEqual",
60
+ ]
@@ -1,236 +1,316 @@
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 safe_get_related_attr(instance, field_name, attr_name=None):
33
- """
34
- Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
35
-
36
- This is particularly useful in hooks where objects might not have their related
37
- fields populated yet (e.g., during bulk_create operations or on unsaved objects).
38
-
39
- Args:
40
- instance: The model instance
41
- field_name: The foreign key field name
42
- attr_name: Optional attribute name to access on the related object
43
-
44
- Returns:
45
- The related object, the attribute value, or None if not available
46
-
47
- Example:
48
- # Instead of: loan_transaction.status.name (which might fail)
49
- # Use: safe_get_related_attr(loan_transaction, 'status', 'name')
50
-
51
- status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
52
- if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
53
- # Process the transaction
54
- pass
55
- """
56
- # For unsaved objects, check the foreign key ID field first
57
- if instance.pk is None:
58
- fk_field_name = f"{field_name}_id"
59
- if hasattr(instance, fk_field_name):
60
- fk_value = getattr(instance, fk_field_name, None)
61
- if fk_value is None:
62
- return None
63
- # If we have an ID but the object isn't loaded, try to load it
64
- try:
65
- field = instance._meta.get_field(field_name)
66
- if hasattr(field, 'related_model'):
67
- related_obj = field.related_model.objects.get(id=fk_value)
68
- if attr_name is None:
69
- return related_obj
70
- return getattr(related_obj, attr_name, None)
71
- except (field.related_model.DoesNotExist, AttributeError):
72
- return None
73
-
74
- # For saved objects or when the above doesn't work, use the original method
75
- related_obj = safe_get_related_object(instance, field_name)
76
- if related_obj is None:
77
- return None
78
- if attr_name is None:
79
- return related_obj
80
- return getattr(related_obj, attr_name, None)
81
-
82
-
83
- def is_field_set(instance, field_name):
84
- """
85
- Check if a field has been set on a model instance.
86
-
87
- This is useful for checking if a field has been explicitly set,
88
- even if it's been set to None.
89
- """
90
- return hasattr(instance, field_name)
91
-
92
-
93
- class HookCondition:
94
- def check(self, instance, original_instance=None):
95
- raise NotImplementedError
96
-
97
- def __call__(self, instance, original_instance=None):
98
- return self.check(instance, original_instance)
99
-
100
- def __and__(self, other):
101
- return AndCondition(self, other)
102
-
103
- def __or__(self, other):
104
- return OrCondition(self, other)
105
-
106
- def __invert__(self):
107
- return NotCondition(self)
108
-
109
- def get_required_fields(self):
110
- """
111
- Returns a set of field names that this condition needs to evaluate.
112
- Override in subclasses to specify required fields.
113
- """
114
- return set()
115
-
116
-
117
- class IsBlank(HookCondition):
118
- """
119
- Condition that checks if a field is blank (None or empty string).
120
- """
121
- def __init__(self, field_name):
122
- self.field_name = field_name
123
-
124
- def check(self, instance, original_instance=None):
125
- value = getattr(instance, self.field_name, None)
126
- return value is None or value == ""
127
-
128
- def get_required_fields(self):
129
- return {self.field_name}
130
-
131
-
132
- class AndCondition(HookCondition):
133
- def __init__(self, *conditions):
134
- self.conditions = conditions
135
-
136
- def check(self, instance, original_instance=None):
137
- return all(c.check(instance, original_instance) for c in self.conditions)
138
-
139
- def get_required_fields(self):
140
- fields = set()
141
- for condition in self.conditions:
142
- fields.update(condition.get_required_fields())
143
- return fields
144
-
145
-
146
- class OrCondition(HookCondition):
147
- def __init__(self, *conditions):
148
- self.conditions = conditions
149
-
150
- def check(self, instance, original_instance=None):
151
- return any(c.check(instance, original_instance) for c in self.conditions)
152
-
153
- def get_required_fields(self):
154
- fields = set()
155
- for condition in self.conditions:
156
- fields.update(condition.get_required_fields())
157
- return fields
158
-
159
-
160
- class NotCondition(HookCondition):
161
- def __init__(self, condition):
162
- self.condition = condition
163
-
164
- def check(self, instance, original_instance=None):
165
- return not self.condition.check(instance, original_instance)
166
-
167
- def get_required_fields(self):
168
- return self.condition.get_required_fields()
169
-
170
-
171
- class IsEqual(HookCondition):
172
- def __init__(self, field_name, value):
173
- self.field_name = field_name
174
- self.value = value
175
-
176
- def check(self, instance, original_instance=None):
177
- return getattr(instance, self.field_name, None) == self.value
178
-
179
- def get_required_fields(self):
180
- return {self.field_name}
181
-
182
-
183
- class WasEqual(HookCondition):
184
- def __init__(self, field_name, value):
185
- self.field_name = field_name
186
- self.value = value
187
-
188
- def check(self, instance, original_instance=None):
189
- if original_instance is None:
190
- return False
191
- return getattr(original_instance, self.field_name, None) == self.value
192
-
193
- def get_required_fields(self):
194
- return {self.field_name}
195
-
196
-
197
- class HasChanged(HookCondition):
198
- def __init__(self, field_name):
199
- self.field_name = field_name
200
-
201
- def check(self, instance, original_instance=None):
202
- if original_instance is None:
203
- return True
204
- return getattr(instance, self.field_name, None) != getattr(original_instance, self.field_name, None)
205
-
206
- def get_required_fields(self):
207
- return {self.field_name}
208
-
209
-
210
- class ChangesTo(HookCondition):
211
- def __init__(self, field_name, value):
212
- self.field_name = field_name
213
- self.value = value
214
-
215
- def check(self, instance, original_instance=None):
216
- if original_instance is None:
217
- return getattr(instance, self.field_name, None) == self.value
218
- return (
219
- getattr(instance, self.field_name, None) == self.value
220
- and getattr(instance, self.field_name, None) != getattr(original_instance, self.field_name, None)
221
- )
222
-
223
- def get_required_fields(self):
224
- return {self.field_name}
225
-
226
-
227
- class IsNotEqual(HookCondition):
228
- def __init__(self, field_name, value):
229
- self.field_name = field_name
230
- self.value = value
231
-
232
- def check(self, instance, original_instance=None):
233
- return getattr(instance, self.field_name, None) != self.value
234
-
235
- def get_required_fields(self):
236
- return {self.field_name}
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 safe_get_related_attr(instance, field_name, attr_name=None):
33
+ """
34
+ Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
35
+
36
+ This is particularly useful in hooks where objects might not have their related
37
+ fields populated yet (e.g., during bulk_create operations or on unsaved objects).
38
+
39
+ Args:
40
+ instance: The model instance
41
+ field_name: The foreign key field name
42
+ attr_name: Optional attribute name to access on the related object
43
+
44
+ Returns:
45
+ The related object, the attribute value, or None if not available
46
+
47
+ Example:
48
+ # Instead of: loan_transaction.status.name (which might fail)
49
+ # Use: safe_get_related_attr(loan_transaction, 'status', 'name')
50
+
51
+ status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
52
+ if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
53
+ # Process the transaction
54
+ pass
55
+ """
56
+ # For unsaved objects, check the foreign key ID field first
57
+ if instance.pk is None:
58
+ fk_field_name = f"{field_name}_id"
59
+ if hasattr(instance, fk_field_name):
60
+ fk_value = getattr(instance, fk_field_name, None)
61
+ if fk_value is None:
62
+ return None
63
+ # If we have an ID but the object isn't loaded, try to load it
64
+ try:
65
+ field = instance._meta.get_field(field_name)
66
+ if hasattr(field, "related_model"):
67
+ related_obj = field.related_model.objects.get(id=fk_value)
68
+ if attr_name is None:
69
+ return related_obj
70
+ return getattr(related_obj, attr_name, None)
71
+ except (field.related_model.DoesNotExist, AttributeError):
72
+ return None
73
+
74
+ # For saved objects or when the above doesn't work, use the original method
75
+ related_obj = safe_get_related_object(instance, field_name)
76
+ if related_obj is None:
77
+ return None
78
+ if attr_name is None:
79
+ return related_obj
80
+ return getattr(related_obj, attr_name, None)
81
+
82
+
83
+ def is_field_set(instance, field_name):
84
+ """
85
+ Check if a field has been set on a model instance.
86
+
87
+ This is useful for checking if a field has been explicitly set,
88
+ even if it's been set to None.
89
+ """
90
+ return hasattr(instance, field_name)
91
+
92
+
93
+ class HookCondition:
94
+ def check(self, instance, original_instance=None):
95
+ raise NotImplementedError
96
+
97
+ def __call__(self, instance, original_instance=None):
98
+ return self.check(instance, original_instance)
99
+
100
+ def __and__(self, other):
101
+ return AndCondition(self, other)
102
+
103
+ def __or__(self, other):
104
+ return OrCondition(self, other)
105
+
106
+ def __invert__(self):
107
+ return NotCondition(self)
108
+
109
+ def get_required_fields(self):
110
+ """
111
+ Returns a set of field names that this condition needs to evaluate.
112
+ Override in subclasses to specify required fields.
113
+ """
114
+ return set()
115
+
116
+
117
+ class IsBlank(HookCondition):
118
+ """
119
+ Condition that checks if a field is blank (None or empty string).
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
164
+
165
+ def check(self, instance, original_instance=None):
166
+ return not self.condition.check(instance, original_instance)
167
+
168
+ def get_required_fields(self):
169
+ return self.condition.get_required_fields()
170
+
171
+
172
+ class IsEqual(HookCondition):
173
+ def __init__(self, field_name, value):
174
+ self.field_name = field_name
175
+ self.value = value
176
+
177
+ def check(self, instance, original_instance=None):
178
+ return getattr(instance, self.field_name, None) == self.value
179
+
180
+ def get_required_fields(self):
181
+ return {self.field_name}
182
+
183
+
184
+ class WasEqual(HookCondition):
185
+ def __init__(self, field_name, value):
186
+ self.field_name = field_name
187
+ self.value = value
188
+
189
+ def check(self, instance, original_instance=None):
190
+ if original_instance is None:
191
+ return False
192
+ return getattr(original_instance, self.field_name, None) == self.value
193
+
194
+ def get_required_fields(self):
195
+ return {self.field_name}
196
+
197
+
198
+ class HasChanged(HookCondition):
199
+ def __init__(self, field_name, has_changed=True):
200
+ """
201
+ Check if a field's value has changed or remained the same.
202
+
203
+ Args:
204
+ field_name: The field name to check
205
+ has_changed: If True (default), condition passes when field has changed.
206
+ If False, condition passes when field has remained the same.
207
+ This is useful for:
208
+ - Detecting stable/unchanged fields
209
+ - Validating field immutability
210
+ - Ensuring critical fields remain constant
211
+ - State machine validations
212
+ """
213
+ self.field_name = field_name
214
+ self.has_changed = has_changed
215
+
216
+ def check(self, instance, original_instance=None):
217
+ if original_instance is None:
218
+ # For new instances:
219
+ # - If we're checking for changes (has_changed=True), return True since it's a new record
220
+ # - If we're checking for stability (has_changed=False), return False since it's technically changed from nothing
221
+ return self.has_changed
222
+
223
+ current_value = getattr(instance, self.field_name, None)
224
+ original_value = getattr(original_instance, self.field_name, None)
225
+ return (current_value != original_value) == self.has_changed
226
+
227
+ def get_required_fields(self):
228
+ return {self.field_name}
229
+
230
+
231
+ class ChangesTo(HookCondition):
232
+ def __init__(self, field_name, value):
233
+ self.field_name = field_name
234
+ self.value = value
235
+
236
+ def check(self, instance, original_instance=None):
237
+ if original_instance is None:
238
+ return getattr(instance, self.field_name, None) == self.value
239
+ return getattr(instance, self.field_name, None) == self.value and getattr(
240
+ instance, self.field_name, None
241
+ ) != getattr(original_instance, self.field_name, None)
242
+
243
+ def get_required_fields(self):
244
+ return {self.field_name}
245
+
246
+
247
+ class IsNotEqual(HookCondition):
248
+ def __init__(self, field_name, value):
249
+ self.field_name = field_name
250
+ self.value = value
251
+
252
+ def check(self, instance, original_instance=None):
253
+ return getattr(instance, self.field_name, None) != self.value
254
+
255
+ def get_required_fields(self):
256
+ return {self.field_name}
257
+
258
+
259
+ class IsGreaterThan(HookCondition):
260
+ def __init__(self, field_name, value):
261
+ self.field_name = field_name
262
+ self.value = value
263
+
264
+ def check(self, instance, original_instance=None):
265
+ field_value = getattr(instance, self.field_name, None)
266
+ if field_value is None:
267
+ return False
268
+ return field_value > self.value
269
+
270
+ def get_required_fields(self):
271
+ return {self.field_name}
272
+
273
+
274
+ class IsLessThan(HookCondition):
275
+ def __init__(self, field_name, value):
276
+ self.field_name = field_name
277
+ self.value = value
278
+
279
+ def check(self, instance, original_instance=None):
280
+ field_value = getattr(instance, self.field_name, None)
281
+ if field_value is None:
282
+ return False
283
+ return field_value < self.value
284
+
285
+ def get_required_fields(self):
286
+ return {self.field_name}
287
+
288
+
289
+ class IsGreaterThanOrEqual(HookCondition):
290
+ def __init__(self, field_name, value):
291
+ self.field_name = field_name
292
+ self.value = value
293
+
294
+ def check(self, instance, original_instance=None):
295
+ field_value = getattr(instance, self.field_name, None)
296
+ if field_value is None:
297
+ return False
298
+ return field_value >= self.value
299
+
300
+ def get_required_fields(self):
301
+ return {self.field_name}
302
+
303
+
304
+ class IsLessThanOrEqual(HookCondition):
305
+ def __init__(self, field_name, value):
306
+ self.field_name = field_name
307
+ self.value = value
308
+
309
+ def check(self, instance, original_instance=None):
310
+ field_value = getattr(instance, self.field_name, None)
311
+ if field_value is None:
312
+ return False
313
+ return field_value <= self.value
314
+
315
+ def get_required_fields(self):
316
+ return {self.field_name}
@@ -1,82 +1,82 @@
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
- # Cache for hook handlers to avoid creating them repeatedly
12
- _handler_cache = {}
13
-
14
- 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
- hooks = get_hooks(model_cls, event)
18
-
19
- if not hooks:
20
- return
21
-
22
- # For BEFORE_* events, run model.clean() first for validation
23
- if event.startswith("before_"):
24
- for instance in new_instances:
25
- try:
26
- instance.clean()
27
- except ValidationError as e:
28
- logger.error("Validation failed for %s: %s", instance, e)
29
- 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
-
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
- for handler_cls, method_name, condition, priority in hooks:
46
- # Get or create handler instance from cache
47
- handler_key = (handler_cls, method_name)
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
- # Filter instances based on condition
56
- if condition:
57
- to_process_new = []
58
- to_process_old = []
59
-
60
- logger.debug(f"Checking condition {condition.__class__.__name__} for {len(new_instances)} instances")
61
- for new, original in zip(new_instances, original_instances, strict=True):
62
- logger.debug(f"Checking instance {new.__class__.__name__}(pk={new.pk})")
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
72
-
73
- # Only call if we have matching instances
74
- if to_process_new:
75
- logger.debug(f"Running hook for {len(to_process_new)} matching instances")
76
- func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
77
- else:
78
- logger.debug("No instances matched condition")
79
- else:
80
- # No condition, process all instances
81
- logger.debug("No condition, processing all instances")
82
- func(new_records=new_instances, old_records=original_instances if any(original_instances) 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
+ # Cache for hook handlers to avoid creating them repeatedly
12
+ _handler_cache = {}
13
+
14
+ 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
+ hooks = get_hooks(model_cls, event)
18
+
19
+ if not hooks:
20
+ return
21
+
22
+ # For BEFORE_* events, run model.clean() first for validation
23
+ if event.startswith("before_"):
24
+ for instance in new_instances:
25
+ try:
26
+ instance.clean()
27
+ except ValidationError as e:
28
+ logger.error("Validation failed for %s: %s", instance, e)
29
+ 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
+
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
+ for handler_cls, method_name, condition, priority in hooks:
46
+ # Get or create handler instance from cache
47
+ handler_key = (handler_cls, method_name)
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
+ # Filter instances based on condition
56
+ if condition:
57
+ to_process_new = []
58
+ to_process_old = []
59
+
60
+ logger.debug(f"Checking condition {condition.__class__.__name__} for {len(new_instances)} instances")
61
+ for new, original in zip(new_instances, original_instances, strict=True):
62
+ logger.debug(f"Checking instance {new.__class__.__name__}(pk={new.pk})")
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
72
+
73
+ # Only call if we have matching instances
74
+ if to_process_new:
75
+ logger.debug(f"Running hook for {len(to_process_new)} matching instances")
76
+ func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
77
+ else:
78
+ logger.debug("No instances matched condition")
79
+ else:
80
+ # No condition, process all instances
81
+ logger.debug("No condition, processing all instances")
82
+ func(new_records=new_instances, old_records=original_instances if any(original_instances) else None)
@@ -1,3 +1,4 @@
1
+ import inspect
1
2
  import logging
2
3
  import threading
3
4
  from collections import deque
@@ -144,11 +145,12 @@ class HookHandler(metaclass=HookMeta):
144
145
 
145
146
  # Inspect the method signature to determine parameter order
146
147
  import inspect
148
+
147
149
  sig = inspect.signature(method)
148
150
  params = list(sig.parameters.keys())
149
-
151
+
150
152
  # Remove 'self' from params if it exists
151
- if params and params[0] == 'self':
153
+ if params and params[0] == "self":
152
154
  params = params[1:]
153
155
 
154
156
  # Always call with keyword arguments to make order irrelevant
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.95"
3
+ version = "0.1.97"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"