django-bulk-hooks 0.1.96__tar.gz → 0.1.98__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.96
3
+ Version: 0.1.98
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
 
@@ -226,3 +226,166 @@ 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
+
@@ -206,4 +206,166 @@ class EfficientTransactionHandler:
206
206
  self._batch_process_failed(transactions)
207
207
  ```
208
208
 
209
- This approach ensures your hooks are robust and won't fail due to missing related objects, while also being efficient with database queries.
209
+ This approach ensures your hooks are robust and won't fail due to missing related objects, while also being efficient with database queries.
210
+
211
+ ## 🎯 Lambda Conditions and Anonymous Functions
212
+
213
+ `django-bulk-hooks` supports using anonymous functions (lambda functions) and custom callables as conditions, giving you maximum flexibility for complex filtering logic.
214
+
215
+ ### Using LambdaCondition
216
+
217
+ The `LambdaCondition` class allows you to use lambda functions or any callable as a condition:
218
+
219
+ ```python
220
+ from django_bulk_hooks import LambdaCondition
221
+
222
+ class ProductHandler:
223
+ # Simple lambda condition
224
+ @hook(Product, "after_create", condition=LambdaCondition(
225
+ lambda instance: instance.price > 100
226
+ ))
227
+ def handle_expensive_products(self, new_records, old_records):
228
+ """Handle products with price > 100"""
229
+ for product in new_records:
230
+ print(f"Expensive product: {product.name}")
231
+
232
+ # Lambda with multiple conditions
233
+ @hook(Product, "after_update", condition=LambdaCondition(
234
+ lambda instance: instance.price > 50 and instance.is_active and instance.stock_quantity > 0
235
+ ))
236
+ def handle_available_expensive_products(self, new_records, old_records):
237
+ """Handle active products with price > 50 and stock > 0"""
238
+ for product in new_records:
239
+ print(f"Available expensive product: {product.name}")
240
+
241
+ # Lambda comparing with original instance
242
+ @hook(Product, "after_update", condition=LambdaCondition(
243
+ lambda instance, original: original and instance.price > original.price * 1.5
244
+ ))
245
+ def handle_significant_price_increases(self, new_records, old_records):
246
+ """Handle products with >50% price increase"""
247
+ for new_product, old_product in zip(new_records, old_records):
248
+ if old_product:
249
+ increase = ((new_product.price - old_product.price) / old_product.price) * 100
250
+ print(f"Significant price increase: {new_product.name} +{increase:.1f}%")
251
+ ```
252
+
253
+ ### Combining Lambda Conditions with Built-in Conditions
254
+
255
+ You can combine lambda conditions with built-in conditions using the `&` (AND) and `|` (OR) operators:
256
+
257
+ ```python
258
+ from django_bulk_hooks.conditions import HasChanged, IsEqual
259
+
260
+ class AdvancedProductHandler:
261
+ # Combine lambda with built-in conditions
262
+ @hook(Product, "after_update", condition=(
263
+ HasChanged("price") &
264
+ LambdaCondition(lambda instance: instance.price > 100)
265
+ ))
266
+ def handle_expensive_price_changes(self, new_records, old_records):
267
+ """Handle when expensive products have price changes"""
268
+ for new_product, old_product in zip(new_records, old_records):
269
+ print(f"Expensive product price changed: {new_product.name}")
270
+
271
+ # Complex combined conditions
272
+ @hook(Order, "after_update", condition=(
273
+ LambdaCondition(lambda instance: instance.status == 'completed') &
274
+ LambdaCondition(lambda instance, original: original and instance.total_amount > original.total_amount)
275
+ ))
276
+ def handle_completed_orders_with_increased_amount(self, new_records, old_records):
277
+ """Handle completed orders that had amount increases"""
278
+ for new_order, old_order in zip(new_records, old_records):
279
+ if old_order:
280
+ increase = new_order.total_amount - old_order.total_amount
281
+ print(f"Completed order with amount increase: {new_order.customer_name} +${increase}")
282
+ ```
283
+
284
+ ### Custom Condition Classes
285
+
286
+ For reusable logic, you can create custom condition classes:
287
+
288
+ ```python
289
+ from django_bulk_hooks.conditions import HookCondition
290
+
291
+ class IsPremiumProduct(HookCondition):
292
+ def check(self, instance, original_instance=None):
293
+ return (
294
+ instance.price > 200 and
295
+ instance.rating >= 4.0 and
296
+ instance.is_active
297
+ )
298
+
299
+ def get_required_fields(self):
300
+ return {'price', 'rating', 'is_active'}
301
+
302
+ class ProductHandler:
303
+ @hook(Product, "after_create", condition=IsPremiumProduct())
304
+ def handle_premium_products(self, new_records, old_records):
305
+ """Handle premium products"""
306
+ for product in new_records:
307
+ print(f"Premium product: {product.name}")
308
+ ```
309
+
310
+ ### Lambda Conditions with Required Fields
311
+
312
+ For optimization, you can specify which fields your lambda condition depends on:
313
+
314
+ ```python
315
+ class OptimizedProductHandler:
316
+ @hook(Product, "after_update", condition=LambdaCondition(
317
+ lambda instance: instance.price > 100 and instance.category == 'electronics',
318
+ required_fields={'price', 'category'}
319
+ ))
320
+ def handle_expensive_electronics(self, new_records, old_records):
321
+ """Handle expensive electronics products"""
322
+ for product in new_records:
323
+ print(f"Expensive electronics: {product.name}")
324
+ ```
325
+
326
+ ### Best Practices for Lambda Conditions
327
+
328
+ 1. **Keep lambdas simple** - Complex logic should be moved to custom condition classes
329
+ 2. **Handle None values** - Always check for None before performing operations
330
+ 3. **Specify required fields** - This helps with query optimization
331
+ 4. **Use descriptive names** - Make your lambda conditions self-documenting
332
+ 5. **Test thoroughly** - Lambda conditions can be harder to debug than named functions
333
+
334
+ ```python
335
+ # ✅ GOOD: Simple, clear lambda
336
+ condition = LambdaCondition(lambda instance: instance.price > 100)
337
+
338
+ # ✅ GOOD: Handles None values
339
+ condition = LambdaCondition(
340
+ lambda instance: instance.price is not None and instance.price > 100
341
+ )
342
+
343
+ # ❌ AVOID: Complex logic in lambda
344
+ condition = LambdaCondition(
345
+ lambda instance: (
346
+ instance.price > 100 and
347
+ instance.category in ['electronics', 'computers'] and
348
+ instance.stock_quantity > 0 and
349
+ instance.rating >= 4.0 and
350
+ instance.is_active and
351
+ instance.created_at > datetime.now() - timedelta(days=30)
352
+ )
353
+ )
354
+
355
+ # ✅ BETTER: Use custom condition class for complex logic
356
+ class IsRecentExpensiveElectronics(HookCondition):
357
+ def check(self, instance, original_instance=None):
358
+ return (
359
+ instance.price > 100 and
360
+ instance.category in ['electronics', 'computers'] and
361
+ instance.stock_quantity > 0 and
362
+ instance.rating >= 4.0 and
363
+ instance.is_active and
364
+ instance.created_at > datetime.now() - timedelta(days=30)
365
+ )
366
+
367
+ def get_required_fields(self):
368
+ return {'price', 'category', 'stock_quantity', 'rating', 'is_active', 'created_at'}
369
+ ```
370
+
371
+ ## 🔧 Best Practices for Related Objects
@@ -1,52 +1,62 @@
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
+ LambdaCondition,
12
+ WasEqual,
13
+ is_field_set,
14
+ safe_get_related_attr,
15
+ safe_get_related_object,
16
+ )
17
+ from django_bulk_hooks.constants import (
18
+ AFTER_CREATE,
19
+ AFTER_DELETE,
20
+ AFTER_UPDATE,
21
+ BEFORE_CREATE,
22
+ BEFORE_DELETE,
23
+ BEFORE_UPDATE,
24
+ VALIDATE_CREATE,
25
+ VALIDATE_DELETE,
26
+ VALIDATE_UPDATE,
27
+ )
28
+ from django_bulk_hooks.decorators import hook, select_related
29
+ from django_bulk_hooks.enums import Priority
30
+ from django_bulk_hooks.handler import HookHandler
31
+ from django_bulk_hooks.models import HookModelMixin
32
+
33
+ __all__ = [
34
+ "HookHandler",
35
+ "HookModelMixin",
36
+ "BEFORE_CREATE",
37
+ "AFTER_CREATE",
38
+ "BEFORE_UPDATE",
39
+ "AFTER_UPDATE",
40
+ "BEFORE_DELETE",
41
+ "AFTER_DELETE",
42
+ "VALIDATE_CREATE",
43
+ "VALIDATE_UPDATE",
44
+ "VALIDATE_DELETE",
45
+ "safe_get_related_object",
46
+ "safe_get_related_attr",
47
+ "is_field_set",
48
+ "Priority",
49
+ "hook",
50
+ "select_related",
51
+ "ChangesTo",
52
+ "HasChanged",
53
+ "IsEqual",
54
+ "IsNotEqual",
55
+ "WasEqual",
56
+ "IsBlank",
57
+ "IsGreaterThan",
58
+ "IsLessThan",
59
+ "IsGreaterThanOrEqual",
60
+ "IsLessThanOrEqual",
61
+ "LambdaCondition",
62
+ ]
@@ -1,256 +1,358 @@
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, has_changed=True):
199
- """
200
- Check if a field's value has changed or remained the same.
201
-
202
- Args:
203
- field_name: The field name to check
204
- has_changed: If True (default), condition passes when field has changed.
205
- If False, condition passes when field has remained the same.
206
- This is useful for:
207
- - Detecting stable/unchanged fields
208
- - Validating field immutability
209
- - Ensuring critical fields remain constant
210
- - State machine validations
211
- """
212
- self.field_name = field_name
213
- self.has_changed = has_changed
214
-
215
- def check(self, instance, original_instance=None):
216
- if original_instance is None:
217
- # For new instances:
218
- # - If we're checking for changes (has_changed=True), return True since it's a new record
219
- # - If we're checking for stability (has_changed=False), return False since it's technically changed from nothing
220
- return self.has_changed
221
-
222
- current_value = getattr(instance, self.field_name, None)
223
- original_value = getattr(original_instance, self.field_name, None)
224
- return (current_value != original_value) == self.has_changed
225
-
226
- def get_required_fields(self):
227
- return {self.field_name}
228
-
229
-
230
- class ChangesTo(HookCondition):
231
- def __init__(self, field_name, value):
232
- self.field_name = field_name
233
- self.value = value
234
-
235
- def check(self, instance, original_instance=None):
236
- if original_instance is None:
237
- return getattr(instance, self.field_name, None) == self.value
238
- return (
239
- getattr(instance, self.field_name, None) == self.value
240
- and getattr(instance, self.field_name, None) != getattr(original_instance, self.field_name, None)
241
- )
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}
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}
317
+
318
+
319
+ class LambdaCondition(HookCondition):
320
+ """
321
+ A condition that uses a lambda function or any callable.
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)
329
+
330
+ # Lambda with original instance
331
+ condition = LambdaCondition(
332
+ lambda instance, original: instance.price > original.price
333
+ )
334
+
335
+ # Using in a hook
336
+ @hook(Product, "after_update", condition=LambdaCondition(
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
+ """
342
+
343
+ def __init__(self, func, required_fields=None):
344
+ """
345
+ Initialize with a callable function.
346
+
347
+ Args:
348
+ func: A callable that takes (instance, original_instance=None) and returns bool
349
+ required_fields: Optional set of field names this condition depends on
350
+ """
351
+ self.func = func
352
+ self._required_fields = required_fields or set()
353
+
354
+ def check(self, instance, original_instance=None):
355
+ return self.func(instance, original_instance)
356
+
357
+ def get_required_fields(self):
358
+ return self._required_fields
@@ -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.96"
3
+ version = "0.1.98"
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"