django-bulk-hooks 0.1.97__tar.gz → 0.1.100__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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.97
3
+ Version: 0.1.100
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  Home-page: https://github.com/AugendLimited/django-bulk-hooks
6
6
  License: MIT
@@ -226,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
@@ -8,6 +8,7 @@ from django_bulk_hooks.conditions import (
8
8
  IsLessThan,
9
9
  IsLessThanOrEqual,
10
10
  IsNotEqual,
11
+ LambdaCondition,
11
12
  WasEqual,
12
13
  is_field_set,
13
14
  safe_get_related_attr,
@@ -57,4 +58,5 @@ __all__ = [
57
58
  "IsLessThan",
58
59
  "IsGreaterThanOrEqual",
59
60
  "IsLessThanOrEqual",
61
+ "LambdaCondition",
60
62
  ]
@@ -314,3 +314,45 @@ class IsLessThanOrEqual(HookCondition):
314
314
 
315
315
  def get_required_fields(self):
316
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,4 +1,8 @@
1
+ import contextlib
2
+ from functools import wraps
3
+
1
4
  from django.db import models, transaction
5
+ from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
2
6
 
3
7
  from django_bulk_hooks.constants import (
4
8
  AFTER_CREATE,
@@ -14,9 +18,6 @@ from django_bulk_hooks.constants import (
14
18
  from django_bulk_hooks.context import HookContext
15
19
  from django_bulk_hooks.engine import run
16
20
  from django_bulk_hooks.manager import BulkHookManager
17
- from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
18
- from functools import wraps
19
- import contextlib
20
21
 
21
22
 
22
23
  @contextlib.contextmanager
@@ -26,7 +27,7 @@ def patch_foreign_key_behavior():
26
27
  RelatedObjectDoesNotExist when accessing an unset foreign key field.
27
28
  """
28
29
  original_get = ForwardManyToOneDescriptor.__get__
29
-
30
+
30
31
  @wraps(original_get)
31
32
  def safe_get(self, instance, cls=None):
32
33
  if instance is None:
@@ -35,7 +36,7 @@ def patch_foreign_key_behavior():
35
36
  return original_get(self, instance, cls)
36
37
  except self.RelatedObjectDoesNotExist:
37
38
  return None
38
-
39
+
39
40
  # Patch the descriptor
40
41
  ForwardManyToOneDescriptor.__get__ = safe_get
41
42
  try:
@@ -63,7 +64,7 @@ class HookModelMixin(models.Model):
63
64
  # Skip hook validation during admin form validation
64
65
  # This prevents RelatedObjectDoesNotExist errors when Django hasn't
65
66
  # fully set up the object's relationships yet
66
- if hasattr(self, '_state') and getattr(self._state, 'validating', False):
67
+ if hasattr(self, "_state") and getattr(self._state, "validating", False):
67
68
  return
68
69
 
69
70
  # Determine if this is a create or update operation
@@ -80,7 +81,9 @@ class HookModelMixin(models.Model):
80
81
  old_instance = self.__class__.objects.get(pk=self.pk)
81
82
  ctx = HookContext(self.__class__)
82
83
  with patch_foreign_key_behavior():
83
- run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
84
+ run(
85
+ self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
86
+ )
84
87
  except self.__class__.DoesNotExist:
85
88
  # If the old instance doesn't exist, treat as create
86
89
  ctx = HookContext(self.__class__)
@@ -91,27 +94,40 @@ class HookModelMixin(models.Model):
91
94
  is_create = self.pk is None
92
95
  ctx = HookContext(self.__class__)
93
96
 
94
- # Let Django save first to handle form validation
95
- super().save(*args, **kwargs)
96
-
97
- # Then run our hooks with the validated data
97
+ # Run BEFORE hooks before saving to allow field modifications
98
98
  with patch_foreign_key_behavior():
99
99
  if is_create:
100
100
  # For create operations
101
101
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
102
102
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
103
- run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
104
103
  else:
105
104
  # For update operations
106
105
  try:
107
106
  old_instance = self.__class__.objects.get(pk=self.pk)
108
- run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
107
+ run(
108
+ self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
109
+ )
109
110
  run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
110
- run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
111
111
  except self.__class__.DoesNotExist:
112
112
  # If the old instance doesn't exist, treat as create
113
113
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
114
114
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
115
+
116
+ # Now let Django save with any modifications from BEFORE hooks
117
+ super().save(*args, **kwargs)
118
+
119
+ # Then run AFTER hooks
120
+ with patch_foreign_key_behavior():
121
+ if is_create:
122
+ # For create operations
123
+ run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
124
+ else:
125
+ # For update operations
126
+ try:
127
+ old_instance = self.__class__.objects.get(pk=self.pk)
128
+ run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
129
+ except self.__class__.DoesNotExist:
130
+ # If the old instance doesn't exist, treat as create
115
131
  run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
116
132
 
117
133
  return self
@@ -125,5 +141,5 @@ class HookModelMixin(models.Model):
125
141
  run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
126
142
  result = super().delete(*args, **kwargs)
127
143
  run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
128
-
144
+
129
145
  return result
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.97"
3
+ version = "0.1.100"
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"