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.
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/PKG-INFO +166 -3
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/README.md +163 -1
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/__init__.py +62 -52
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/conditions.py +358 -256
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/engine.py +82 -82
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/handler.py +4 -2
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/pyproject.toml +1 -1
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/LICENSE +0 -0
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/models.py +0 -0
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/queryset.py +0 -0
- {django_bulk_hooks-0.1.96 → django_bulk_hooks-0.1.98}/django_bulk_hooks/registry.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
2
|
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
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,
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
self.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
self.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
-
|
|
209
|
-
-
|
|
210
|
-
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
self.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
#
|
|
219
|
-
# - If we're checking for
|
|
220
|
-
return
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
self.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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] ==
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|