django-bulk-hooks 0.1.98__tar.gz → 0.1.101__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.

Files changed (19) hide show
  1. django_bulk_hooks-0.1.101/PKG-INFO +295 -0
  2. django_bulk_hooks-0.1.101/README.md +275 -0
  3. django_bulk_hooks-0.1.101/django_bulk_hooks/decorators.py +76 -0
  4. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/engine.py +59 -3
  5. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/handler.py +16 -3
  6. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/models.py +31 -15
  7. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/registry.py +3 -3
  8. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/pyproject.toml +1 -1
  9. django_bulk_hooks-0.1.98/PKG-INFO +0 -391
  10. django_bulk_hooks-0.1.98/README.md +0 -371
  11. django_bulk_hooks-0.1.98/django_bulk_hooks/decorators.py +0 -181
  12. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/LICENSE +0 -0
  13. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/__init__.py +0 -0
  14. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/conditions.py +0 -0
  15. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/constants.py +0 -0
  16. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/context.py +0 -0
  17. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/enums.py +0 -0
  18. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/manager.py +0 -0
  19. {django_bulk_hooks-0.1.98 → django_bulk_hooks-0.1.101}/django_bulk_hooks/queryset.py +0 -0
@@ -0,0 +1,295 @@
1
+ Metadata-Version: 2.1
2
+ Name: django-bulk-hooks
3
+ Version: 0.1.101
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
6
+ License: MIT
7
+ Keywords: django,bulk,hooks
8
+ Author: Konrad Beck
9
+ Author-email: konrad.beck@merchantcapital.co.za
10
+ Requires-Python: >=3.11,<4.0
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: Django (>=4.0)
17
+ Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
+ Description-Content-Type: text/markdown
19
+
20
+
21
+ # django-bulk-hooks
22
+
23
+ ⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
24
+
25
+ `django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
26
+
27
+ ## ✨ Features
28
+
29
+ - Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
30
+ - BEFORE/AFTER hooks for create, update, delete
31
+ - Hook-aware manager that wraps Django's `bulk_` operations
32
+ - **NEW**: `HookModelMixin` for individual model lifecycle events
33
+ - Hook chaining, hook deduplication, and atomicity
34
+ - Class-based hook handlers with DI support
35
+ - Support for both bulk and individual model operations
36
+ - **NEW**: Safe handling of related objects to prevent `RelatedObjectDoesNotExist` errors
37
+ - **NEW**: `@select_related` decorator to prevent queries in loops
38
+
39
+ ## 🚀 Quickstart
40
+
41
+ ```bash
42
+ pip install django-bulk-hooks
43
+ ```
44
+
45
+ ### Define Your Model
46
+
47
+ ```python
48
+ from django.db import models
49
+ from django_bulk_hooks.models import HookModelMixin
50
+
51
+ class Account(HookModelMixin):
52
+ balance = models.DecimalField(max_digits=10, decimal_places=2)
53
+ # The HookModelMixin automatically provides BulkHookManager
54
+ ```
55
+
56
+ ### Create a Hook Handler
57
+
58
+ ```python
59
+ from django_bulk_hooks import hook, AFTER_UPDATE, select_related
60
+ from django_bulk_hooks.conditions import WhenFieldHasChanged
61
+ from .models import Account
62
+
63
+ class AccountHandler:
64
+ @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
65
+ @select_related("user") # Preload user to prevent queries in loops
66
+ def notify_balance_change(self, new_records, old_records):
67
+ for account in new_records:
68
+ # This won't cause a query since user is preloaded
69
+ user_email = account.user.email
70
+ self.send_notification(user_email, account.balance)
71
+ ```
72
+
73
+ ## 🔧 Using `@select_related` to Prevent Queries in Loops
74
+
75
+ The `@select_related` decorator is essential when your hook logic needs to access related objects. Without it, you might end up with N+1 query problems.
76
+
77
+ ### ❌ Without `@select_related` (causes queries in loops)
78
+
79
+ ```python
80
+ @hook(AFTER_CREATE, model=LoanAccount)
81
+ def process_accounts(self, new_records, old_records):
82
+ for account in new_records:
83
+ # ❌ This causes a query for each account!
84
+ status_name = account.status.name
85
+ if status_name == "ACTIVE":
86
+ self.activate_account(account)
87
+ ```
88
+
89
+ ### ✅ With `@select_related` (bulk loads related objects)
90
+
91
+ ```python
92
+ @hook(AFTER_CREATE, model=LoanAccount)
93
+ @select_related("status") # Bulk load status objects
94
+ def process_accounts(self, new_records, old_records):
95
+ for account in new_records:
96
+ # ✅ No query here - status is preloaded
97
+ status_name = account.status.name
98
+ if status_name == "ACTIVE":
99
+ self.activate_account(account)
100
+ ```
101
+
102
+ ### Multiple Related Fields
103
+
104
+ ```python
105
+ @hook(AFTER_UPDATE, model=Transaction)
106
+ @select_related("account", "category", "status")
107
+ def process_transactions(self, new_records, old_records):
108
+ for transaction in new_records:
109
+ # All related objects are preloaded - no queries in loops
110
+ account_name = transaction.account.name
111
+ category_type = transaction.category.type
112
+ status_name = transaction.status.name
113
+
114
+ if status_name == "COMPLETE":
115
+ self.process_complete_transaction(transaction)
116
+ ```
117
+
118
+ ### Your Original Example (Fixed)
119
+
120
+ ```python
121
+ @hook(BEFORE_CREATE, model=LoanAccount, condition=IsEqual("status.name", value=Status.ACTIVE.value))
122
+ @hook(
123
+ BEFORE_UPDATE,
124
+ model=LoanAccount,
125
+ condition=HasChanged("status", has_changed=True) & IsEqual("status.name", value=Status.ACTIVE.value),
126
+ priority=Priority.HIGH,
127
+ )
128
+ @select_related("status") # This ensures status is preloaded
129
+ def _set_activated_date(self, old_records: list[LoanAccount], new_records: list[LoanAccount], **kwargs) -> None:
130
+ logger.info(f"Setting activated date for {new_records}")
131
+ # No queries in loops - status objects are preloaded
132
+ self._loan_account_service.set_activated_date(new_records)
133
+ ```
134
+
135
+ ## 🛡️ Safe Handling of Related Objects
136
+
137
+ Use the `safe_get_related_attr` utility function to safely access related object attributes:
138
+
139
+ ```python
140
+ from django_bulk_hooks.conditions import safe_get_related_attr
141
+
142
+ # ✅ SAFE: Use safe_get_related_attr to handle None values
143
+ @hook(AFTER_CREATE, model=Transaction)
144
+ def process_transaction(self, new_records, old_records):
145
+ for transaction in new_records:
146
+ # Safely get the status name, returns None if status doesn't exist
147
+ status_name = safe_get_related_attr(transaction, 'status', 'name')
148
+
149
+ if status_name == "COMPLETE":
150
+ # Process the transaction
151
+ pass
152
+ elif status_name is None:
153
+ # Handle case where status is not set
154
+ print(f"Transaction {transaction.id} has no status")
155
+ ```
156
+
157
+ ### Complete Example
158
+
159
+ ```python
160
+ from django.db import models
161
+ from django_bulk_hooks import hook, select_related
162
+ from django_bulk_hooks.conditions import safe_get_related_attr
163
+
164
+ class Status(models.Model):
165
+ name = models.CharField(max_length=50)
166
+
167
+ class Transaction(HookModelMixin, models.Model):
168
+ amount = models.DecimalField(max_digits=10, decimal_places=2)
169
+ status = models.ForeignKey(Status, on_delete=models.CASCADE, null=True, blank=True)
170
+ category = models.ForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True)
171
+
172
+ class TransactionHandler:
173
+ @hook(Transaction, "before_create")
174
+ def set_default_status(self, new_records, old_records=None):
175
+ """Set default status for new transactions."""
176
+ default_status = Status.objects.filter(name="PENDING").first()
177
+ for transaction in new_records:
178
+ if transaction.status is None:
179
+ transaction.status = default_status
180
+
181
+ @hook(Transaction, "after_create")
182
+ @select_related("status", "category") # Preload related objects
183
+ def process_transactions(self, new_records, old_records=None):
184
+ """Process transactions based on their status."""
185
+ for transaction in new_records:
186
+ # ✅ SAFE: Get status name safely (no queries in loops)
187
+ status_name = safe_get_related_attr(transaction, 'status', 'name')
188
+
189
+ if status_name == "COMPLETE":
190
+ self._process_complete_transaction(transaction)
191
+ elif status_name == "FAILED":
192
+ self._process_failed_transaction(transaction)
193
+ elif status_name is None:
194
+ print(f"Transaction {transaction.id} has no status")
195
+
196
+ # ✅ SAFE: Check for related object existence (no queries in loops)
197
+ category = safe_get_related_attr(transaction, 'category')
198
+ if category:
199
+ print(f"Transaction {transaction.id} belongs to category: {category.name}")
200
+
201
+ def _process_complete_transaction(self, transaction):
202
+ # Process complete transaction logic
203
+ pass
204
+
205
+ def _process_failed_transaction(self, transaction):
206
+ # Process failed transaction logic
207
+ pass
208
+ ```
209
+
210
+ ### Best Practices for Related Objects
211
+
212
+ 1. **Always use `@select_related`** when accessing related object attributes in hooks
213
+ 2. **Use `safe_get_related_attr`** for safe access to related object attributes
214
+ 3. **Set default values in `BEFORE_CREATE` hooks** to ensure related objects exist
215
+ 4. **Handle None cases explicitly** to avoid unexpected behavior
216
+ 5. **Use bulk operations efficiently** by fetching related objects once and reusing them
217
+
218
+ ## 🔍 Performance Tips
219
+
220
+ ### Monitor Query Count
221
+
222
+ ```python
223
+ from django.db import connection, reset_queries
224
+
225
+ # Before your bulk operation
226
+ reset_queries()
227
+
228
+ # Your bulk operation
229
+ accounts = Account.objects.bulk_create(account_list)
230
+
231
+ # After your bulk operation
232
+ print(f"Total queries: {len(connection.queries)}")
233
+ ```
234
+
235
+ ### Use `@select_related` Strategically
236
+
237
+ ```python
238
+ # Only select_related fields you actually use
239
+ @select_related("status") # Good - only what you need
240
+ @select_related("status", "category", "user", "account") # Only if you use all of them
241
+ ```
242
+
243
+ ### Avoid Nested Loops with Related Objects
244
+
245
+ ```python
246
+ # ❌ Bad - nested loops with related objects
247
+ @hook(AFTER_CREATE, model=Order)
248
+ def process_orders(self, new_records, old_records):
249
+ for order in new_records:
250
+ for item in order.items.all(): # This causes queries!
251
+ process_item(item)
252
+
253
+ # ✅ Good - use prefetch_related for many-to-many/one-to-many
254
+ @hook(AFTER_CREATE, model=Order)
255
+ @select_related("customer")
256
+ def process_orders(self, new_records, old_records):
257
+ # Prefetch items for all orders at once
258
+ from django.db.models import Prefetch
259
+ orders_with_items = Order.objects.prefetch_related(
260
+ Prefetch('items', queryset=Item.objects.select_related('product'))
261
+ ).filter(id__in=[order.id for order in new_records])
262
+
263
+ for order in orders_with_items:
264
+ for item in order.items.all(): # No queries here
265
+ process_item(item)
266
+ ```
267
+
268
+ ## 📚 API Reference
269
+
270
+ ### Decorators
271
+
272
+ - `@hook(event, model, condition=None, priority=DEFAULT_PRIORITY)` - Register a hook
273
+ - `@select_related(*fields)` - Preload related fields to prevent queries in loops
274
+
275
+ ### Conditions
276
+
277
+ - `IsEqual(field, value)` - Check if field equals value
278
+ - `HasChanged(field, has_changed=True)` - Check if field has changed
279
+ - `safe_get_related_attr(instance, field, attr=None)` - Safely get related object attributes
280
+
281
+ ### Events
282
+
283
+ - `BEFORE_CREATE`, `AFTER_CREATE`
284
+ - `BEFORE_UPDATE`, `AFTER_UPDATE`
285
+ - `BEFORE_DELETE`, `AFTER_DELETE`
286
+ - `VALIDATE_CREATE`, `VALIDATE_UPDATE`, `VALIDATE_DELETE`
287
+
288
+ ## 🤝 Contributing
289
+
290
+ Contributions are welcome! Please feel free to submit a Pull Request.
291
+
292
+ ## 📄 License
293
+
294
+ This project is licensed under the MIT License - see the LICENSE file for details.
295
+
@@ -0,0 +1,275 @@
1
+
2
+ # django-bulk-hooks
3
+
4
+ ⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
5
+
6
+ `django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
7
+
8
+ ## ✨ Features
9
+
10
+ - Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
11
+ - BEFORE/AFTER hooks for create, update, delete
12
+ - Hook-aware manager that wraps Django's `bulk_` operations
13
+ - **NEW**: `HookModelMixin` for individual model lifecycle events
14
+ - Hook chaining, hook deduplication, and atomicity
15
+ - Class-based hook handlers with DI support
16
+ - Support for both bulk and individual model operations
17
+ - **NEW**: Safe handling of related objects to prevent `RelatedObjectDoesNotExist` errors
18
+ - **NEW**: `@select_related` decorator to prevent queries in loops
19
+
20
+ ## 🚀 Quickstart
21
+
22
+ ```bash
23
+ pip install django-bulk-hooks
24
+ ```
25
+
26
+ ### Define Your Model
27
+
28
+ ```python
29
+ from django.db import models
30
+ from django_bulk_hooks.models import HookModelMixin
31
+
32
+ class Account(HookModelMixin):
33
+ balance = models.DecimalField(max_digits=10, decimal_places=2)
34
+ # The HookModelMixin automatically provides BulkHookManager
35
+ ```
36
+
37
+ ### Create a Hook Handler
38
+
39
+ ```python
40
+ from django_bulk_hooks import hook, AFTER_UPDATE, select_related
41
+ from django_bulk_hooks.conditions import WhenFieldHasChanged
42
+ from .models import Account
43
+
44
+ class AccountHandler:
45
+ @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
46
+ @select_related("user") # Preload user to prevent queries in loops
47
+ def notify_balance_change(self, new_records, old_records):
48
+ for account in new_records:
49
+ # This won't cause a query since user is preloaded
50
+ user_email = account.user.email
51
+ self.send_notification(user_email, account.balance)
52
+ ```
53
+
54
+ ## 🔧 Using `@select_related` to Prevent Queries in Loops
55
+
56
+ The `@select_related` decorator is essential when your hook logic needs to access related objects. Without it, you might end up with N+1 query problems.
57
+
58
+ ### ❌ Without `@select_related` (causes queries in loops)
59
+
60
+ ```python
61
+ @hook(AFTER_CREATE, model=LoanAccount)
62
+ def process_accounts(self, new_records, old_records):
63
+ for account in new_records:
64
+ # ❌ This causes a query for each account!
65
+ status_name = account.status.name
66
+ if status_name == "ACTIVE":
67
+ self.activate_account(account)
68
+ ```
69
+
70
+ ### ✅ With `@select_related` (bulk loads related objects)
71
+
72
+ ```python
73
+ @hook(AFTER_CREATE, model=LoanAccount)
74
+ @select_related("status") # Bulk load status objects
75
+ def process_accounts(self, new_records, old_records):
76
+ for account in new_records:
77
+ # ✅ No query here - status is preloaded
78
+ status_name = account.status.name
79
+ if status_name == "ACTIVE":
80
+ self.activate_account(account)
81
+ ```
82
+
83
+ ### Multiple Related Fields
84
+
85
+ ```python
86
+ @hook(AFTER_UPDATE, model=Transaction)
87
+ @select_related("account", "category", "status")
88
+ def process_transactions(self, new_records, old_records):
89
+ for transaction in new_records:
90
+ # All related objects are preloaded - no queries in loops
91
+ account_name = transaction.account.name
92
+ category_type = transaction.category.type
93
+ status_name = transaction.status.name
94
+
95
+ if status_name == "COMPLETE":
96
+ self.process_complete_transaction(transaction)
97
+ ```
98
+
99
+ ### Your Original Example (Fixed)
100
+
101
+ ```python
102
+ @hook(BEFORE_CREATE, model=LoanAccount, condition=IsEqual("status.name", value=Status.ACTIVE.value))
103
+ @hook(
104
+ BEFORE_UPDATE,
105
+ model=LoanAccount,
106
+ condition=HasChanged("status", has_changed=True) & IsEqual("status.name", value=Status.ACTIVE.value),
107
+ priority=Priority.HIGH,
108
+ )
109
+ @select_related("status") # This ensures status is preloaded
110
+ def _set_activated_date(self, old_records: list[LoanAccount], new_records: list[LoanAccount], **kwargs) -> None:
111
+ logger.info(f"Setting activated date for {new_records}")
112
+ # No queries in loops - status objects are preloaded
113
+ self._loan_account_service.set_activated_date(new_records)
114
+ ```
115
+
116
+ ## 🛡️ Safe Handling of Related Objects
117
+
118
+ Use the `safe_get_related_attr` utility function to safely access related object attributes:
119
+
120
+ ```python
121
+ from django_bulk_hooks.conditions import safe_get_related_attr
122
+
123
+ # ✅ SAFE: Use safe_get_related_attr to handle None values
124
+ @hook(AFTER_CREATE, model=Transaction)
125
+ def process_transaction(self, new_records, old_records):
126
+ for transaction in new_records:
127
+ # Safely get the status name, returns None if status doesn't exist
128
+ status_name = safe_get_related_attr(transaction, 'status', 'name')
129
+
130
+ if status_name == "COMPLETE":
131
+ # Process the transaction
132
+ pass
133
+ elif status_name is None:
134
+ # Handle case where status is not set
135
+ print(f"Transaction {transaction.id} has no status")
136
+ ```
137
+
138
+ ### Complete Example
139
+
140
+ ```python
141
+ from django.db import models
142
+ from django_bulk_hooks import hook, select_related
143
+ from django_bulk_hooks.conditions import safe_get_related_attr
144
+
145
+ class Status(models.Model):
146
+ name = models.CharField(max_length=50)
147
+
148
+ class Transaction(HookModelMixin, models.Model):
149
+ amount = models.DecimalField(max_digits=10, decimal_places=2)
150
+ status = models.ForeignKey(Status, on_delete=models.CASCADE, null=True, blank=True)
151
+ category = models.ForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True)
152
+
153
+ class TransactionHandler:
154
+ @hook(Transaction, "before_create")
155
+ def set_default_status(self, new_records, old_records=None):
156
+ """Set default status for new transactions."""
157
+ default_status = Status.objects.filter(name="PENDING").first()
158
+ for transaction in new_records:
159
+ if transaction.status is None:
160
+ transaction.status = default_status
161
+
162
+ @hook(Transaction, "after_create")
163
+ @select_related("status", "category") # Preload related objects
164
+ def process_transactions(self, new_records, old_records=None):
165
+ """Process transactions based on their status."""
166
+ for transaction in new_records:
167
+ # ✅ SAFE: Get status name safely (no queries in loops)
168
+ status_name = safe_get_related_attr(transaction, 'status', 'name')
169
+
170
+ if status_name == "COMPLETE":
171
+ self._process_complete_transaction(transaction)
172
+ elif status_name == "FAILED":
173
+ self._process_failed_transaction(transaction)
174
+ elif status_name is None:
175
+ print(f"Transaction {transaction.id} has no status")
176
+
177
+ # ✅ SAFE: Check for related object existence (no queries in loops)
178
+ category = safe_get_related_attr(transaction, 'category')
179
+ if category:
180
+ print(f"Transaction {transaction.id} belongs to category: {category.name}")
181
+
182
+ def _process_complete_transaction(self, transaction):
183
+ # Process complete transaction logic
184
+ pass
185
+
186
+ def _process_failed_transaction(self, transaction):
187
+ # Process failed transaction logic
188
+ pass
189
+ ```
190
+
191
+ ### Best Practices for Related Objects
192
+
193
+ 1. **Always use `@select_related`** when accessing related object attributes in hooks
194
+ 2. **Use `safe_get_related_attr`** for safe access to related object attributes
195
+ 3. **Set default values in `BEFORE_CREATE` hooks** to ensure related objects exist
196
+ 4. **Handle None cases explicitly** to avoid unexpected behavior
197
+ 5. **Use bulk operations efficiently** by fetching related objects once and reusing them
198
+
199
+ ## 🔍 Performance Tips
200
+
201
+ ### Monitor Query Count
202
+
203
+ ```python
204
+ from django.db import connection, reset_queries
205
+
206
+ # Before your bulk operation
207
+ reset_queries()
208
+
209
+ # Your bulk operation
210
+ accounts = Account.objects.bulk_create(account_list)
211
+
212
+ # After your bulk operation
213
+ print(f"Total queries: {len(connection.queries)}")
214
+ ```
215
+
216
+ ### Use `@select_related` Strategically
217
+
218
+ ```python
219
+ # Only select_related fields you actually use
220
+ @select_related("status") # Good - only what you need
221
+ @select_related("status", "category", "user", "account") # Only if you use all of them
222
+ ```
223
+
224
+ ### Avoid Nested Loops with Related Objects
225
+
226
+ ```python
227
+ # ❌ Bad - nested loops with related objects
228
+ @hook(AFTER_CREATE, model=Order)
229
+ def process_orders(self, new_records, old_records):
230
+ for order in new_records:
231
+ for item in order.items.all(): # This causes queries!
232
+ process_item(item)
233
+
234
+ # ✅ Good - use prefetch_related for many-to-many/one-to-many
235
+ @hook(AFTER_CREATE, model=Order)
236
+ @select_related("customer")
237
+ def process_orders(self, new_records, old_records):
238
+ # Prefetch items for all orders at once
239
+ from django.db.models import Prefetch
240
+ orders_with_items = Order.objects.prefetch_related(
241
+ Prefetch('items', queryset=Item.objects.select_related('product'))
242
+ ).filter(id__in=[order.id for order in new_records])
243
+
244
+ for order in orders_with_items:
245
+ for item in order.items.all(): # No queries here
246
+ process_item(item)
247
+ ```
248
+
249
+ ## 📚 API Reference
250
+
251
+ ### Decorators
252
+
253
+ - `@hook(event, model, condition=None, priority=DEFAULT_PRIORITY)` - Register a hook
254
+ - `@select_related(*fields)` - Preload related fields to prevent queries in loops
255
+
256
+ ### Conditions
257
+
258
+ - `IsEqual(field, value)` - Check if field equals value
259
+ - `HasChanged(field, has_changed=True)` - Check if field has changed
260
+ - `safe_get_related_attr(instance, field, attr=None)` - Safely get related object attributes
261
+
262
+ ### Events
263
+
264
+ - `BEFORE_CREATE`, `AFTER_CREATE`
265
+ - `BEFORE_UPDATE`, `AFTER_UPDATE`
266
+ - `BEFORE_DELETE`, `AFTER_DELETE`
267
+ - `VALIDATE_CREATE`, `VALIDATE_UPDATE`, `VALIDATE_DELETE`
268
+
269
+ ## 🤝 Contributing
270
+
271
+ Contributions are welcome! Please feel free to submit a Pull Request.
272
+
273
+ ## 📄 License
274
+
275
+ This project is licensed under the MIT License - see the LICENSE file for details.
@@ -0,0 +1,76 @@
1
+ import inspect
2
+ from functools import wraps
3
+
4
+ from django.core.exceptions import FieldDoesNotExist
5
+ from django_bulk_hooks.enums import DEFAULT_PRIORITY
6
+ from django_bulk_hooks.registry import register_hook
7
+ from django_bulk_hooks.engine import safe_get_related_object
8
+
9
+
10
+ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
11
+ """
12
+ Decorator to annotate a method with multiple hooks hook registrations.
13
+ If no priority is provided, uses Priority.NORMAL (50).
14
+ """
15
+
16
+ def decorator(fn):
17
+ if not hasattr(fn, "hooks_hooks"):
18
+ fn.hooks_hooks = []
19
+ fn.hooks_hooks.append((model, event, condition, priority))
20
+ return fn
21
+
22
+ return decorator
23
+
24
+
25
+ def select_related(*related_fields):
26
+ """
27
+ Decorator that marks a hook method to preload related fields.
28
+
29
+ This decorator works in conjunction with the hook system to ensure that
30
+ related fields are bulk-loaded before the hook logic runs, preventing
31
+ queries in loops.
32
+
33
+ - Works with instance methods (resolves `self`)
34
+ - Avoids replacing model instances
35
+ - Populates Django's relation cache to avoid extra queries
36
+ - Provides bulk loading for performance optimization
37
+ """
38
+
39
+ def decorator(func):
40
+ # Store the related fields on the function for later access
41
+ func._select_related_fields = related_fields
42
+ return func
43
+
44
+ return decorator
45
+
46
+
47
+ def bulk_hook(model_cls, event, when=None, priority=None):
48
+ """
49
+ Decorator to register a bulk hook for a model.
50
+
51
+ Args:
52
+ model_cls: The model class to hook into
53
+ event: The event to hook into (e.g., BEFORE_UPDATE, AFTER_UPDATE)
54
+ when: Optional condition for when the hook should run
55
+ priority: Optional priority for hook execution order
56
+ """
57
+ def decorator(func):
58
+ # Create a simple handler class for the function
59
+ class FunctionHandler:
60
+ def __init__(self):
61
+ self.func = func
62
+
63
+ def handle(self, new_instances, original_instances):
64
+ return self.func(new_instances, original_instances)
65
+
66
+ # Register the hook using the registry
67
+ register_hook(
68
+ model=model_cls,
69
+ event=event,
70
+ handler_cls=FunctionHandler,
71
+ method_name='handle',
72
+ condition=when,
73
+ priority=priority or DEFAULT_PRIORITY,
74
+ )
75
+ return func
76
+ return decorator