django-bulk-hooks 0.1.83__py3-none-any.whl → 0.2.100__py3-none-any.whl
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/__init__.py +53 -50
- django_bulk_hooks/changeset.py +214 -0
- django_bulk_hooks/conditions.py +230 -351
- django_bulk_hooks/constants.py +4 -0
- django_bulk_hooks/context.py +49 -9
- django_bulk_hooks/decorators.py +219 -96
- django_bulk_hooks/dispatcher.py +588 -0
- django_bulk_hooks/factory.py +541 -0
- django_bulk_hooks/handler.py +106 -167
- django_bulk_hooks/helpers.py +258 -0
- django_bulk_hooks/manager.py +134 -208
- django_bulk_hooks/models.py +89 -101
- django_bulk_hooks/operations/__init__.py +18 -0
- django_bulk_hooks/operations/analyzer.py +466 -0
- django_bulk_hooks/operations/bulk_executor.py +757 -0
- django_bulk_hooks/operations/coordinator.py +928 -0
- django_bulk_hooks/operations/field_utils.py +341 -0
- django_bulk_hooks/operations/mti_handler.py +696 -0
- django_bulk_hooks/operations/mti_plans.py +103 -0
- django_bulk_hooks/operations/record_classifier.py +196 -0
- django_bulk_hooks/queryset.py +233 -43
- django_bulk_hooks/registry.py +276 -25
- django_bulk_hooks-0.2.100.dist-info/METADATA +320 -0
- django_bulk_hooks-0.2.100.dist-info/RECORD +27 -0
- django_bulk_hooks/engine.py +0 -53
- django_bulk_hooks-0.1.83.dist-info/METADATA +0 -228
- django_bulk_hooks-0.1.83.dist-info/RECORD +0 -16
- {django_bulk_hooks-0.1.83.dist-info → django_bulk_hooks-0.2.100.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.83.dist-info → django_bulk_hooks-0.2.100.dist-info}/WHEEL +0 -0
django_bulk_hooks/registry.py
CHANGED
|
@@ -1,25 +1,276 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
1
|
+
"""
|
|
2
|
+
Central registry for hook handlers.
|
|
3
|
+
|
|
4
|
+
Provides thread-safe registration and lookup of hooks with
|
|
5
|
+
deterministic priority ordering.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
from django_bulk_hooks.enums import Priority
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Type alias for hook info tuple
|
|
17
|
+
HookInfo = tuple[type, str, Callable | None, int]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HookRegistry:
|
|
21
|
+
"""
|
|
22
|
+
Central registry for all hook handlers.
|
|
23
|
+
|
|
24
|
+
Manages registration, lookup, and lifecycle of hooks with
|
|
25
|
+
thread-safe operations and deterministic ordering by priority.
|
|
26
|
+
|
|
27
|
+
This is a singleton - use get_registry() to access the instance.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
"""Initialize an empty registry with thread-safe storage."""
|
|
32
|
+
self._hooks: dict[tuple[type, str], list[HookInfo]] = {}
|
|
33
|
+
self._lock = threading.RLock()
|
|
34
|
+
|
|
35
|
+
def register(
|
|
36
|
+
self,
|
|
37
|
+
model: type,
|
|
38
|
+
event: str,
|
|
39
|
+
handler_cls: type,
|
|
40
|
+
method_name: str,
|
|
41
|
+
condition: Callable | None,
|
|
42
|
+
priority: int | Priority,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Register a hook handler for a model and event.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
model: Django model class
|
|
49
|
+
event: Event name (e.g., 'after_update', 'before_create')
|
|
50
|
+
handler_cls: Hook handler class
|
|
51
|
+
method_name: Name of the method to call on handler
|
|
52
|
+
condition: Optional condition to filter records
|
|
53
|
+
priority: Execution priority (lower values execute first)
|
|
54
|
+
"""
|
|
55
|
+
with self._lock:
|
|
56
|
+
key = (model, event)
|
|
57
|
+
hooks = self._hooks.setdefault(key, [])
|
|
58
|
+
|
|
59
|
+
# Check for duplicates before adding
|
|
60
|
+
hook_info = (handler_cls, method_name, condition, priority)
|
|
61
|
+
if hook_info not in hooks:
|
|
62
|
+
hooks.append(hook_info)
|
|
63
|
+
# Sort by priority (lower values first)
|
|
64
|
+
hooks.sort(key=lambda x: x[3])
|
|
65
|
+
else:
|
|
66
|
+
pass # Hook already registered
|
|
67
|
+
|
|
68
|
+
def get_hooks(self, model: type, event: str) -> list[HookInfo]:
|
|
69
|
+
"""
|
|
70
|
+
Get all hooks for a model and event.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
model: Django model class
|
|
74
|
+
event: Event name
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of hook info tuples (handler_cls, method_name, condition, priority)
|
|
78
|
+
sorted by priority (lower values first)
|
|
79
|
+
"""
|
|
80
|
+
with self._lock:
|
|
81
|
+
key = (model, event)
|
|
82
|
+
hooks = self._hooks.get(key, [])
|
|
83
|
+
logger.debug(f"Retrieved {len(hooks)} hooks for {model.__name__}.{event}")
|
|
84
|
+
return hooks
|
|
85
|
+
|
|
86
|
+
def unregister(
|
|
87
|
+
self,
|
|
88
|
+
model: type,
|
|
89
|
+
event: str,
|
|
90
|
+
handler_cls: type,
|
|
91
|
+
method_name: str,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Unregister a specific hook handler.
|
|
95
|
+
|
|
96
|
+
Used when child classes override parent hook methods.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
model: Django model class
|
|
100
|
+
event: Event name
|
|
101
|
+
handler_cls: Hook handler class to remove
|
|
102
|
+
method_name: Method name to remove
|
|
103
|
+
"""
|
|
104
|
+
with self._lock:
|
|
105
|
+
key = (model, event)
|
|
106
|
+
if key not in self._hooks:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
hooks = self._hooks[key]
|
|
110
|
+
# Filter out the specific hook
|
|
111
|
+
self._hooks[key] = [
|
|
112
|
+
(h_cls, m_name, cond, pri) for h_cls, m_name, cond, pri in hooks if not (h_cls == handler_cls and m_name == method_name)
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
# Clean up empty hook lists
|
|
116
|
+
if not self._hooks[key]:
|
|
117
|
+
del self._hooks[key]
|
|
118
|
+
|
|
119
|
+
def clear(self) -> None:
|
|
120
|
+
"""
|
|
121
|
+
Clear all registered hooks.
|
|
122
|
+
|
|
123
|
+
Useful for testing to ensure clean state between tests.
|
|
124
|
+
"""
|
|
125
|
+
with self._lock:
|
|
126
|
+
self._hooks.clear()
|
|
127
|
+
|
|
128
|
+
# Also clear HookMeta state to ensure complete reset
|
|
129
|
+
from django_bulk_hooks.handler import HookMeta
|
|
130
|
+
|
|
131
|
+
HookMeta._registered.clear()
|
|
132
|
+
HookMeta._class_hook_map.clear()
|
|
133
|
+
|
|
134
|
+
def list_all(self) -> dict[tuple[type, str], list[HookInfo]]:
|
|
135
|
+
"""
|
|
136
|
+
Get all registered hooks for debugging.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Dictionary mapping (model, event) tuples to lists of hook info
|
|
140
|
+
"""
|
|
141
|
+
with self._lock:
|
|
142
|
+
return dict(self._hooks)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def hooks(self) -> dict[tuple[type, str], list[HookInfo]]:
|
|
146
|
+
"""
|
|
147
|
+
Expose internal hooks dictionary for testing purposes.
|
|
148
|
+
|
|
149
|
+
This property provides direct access to the internal hooks storage
|
|
150
|
+
to allow tests to clear the registry state between test runs.
|
|
151
|
+
"""
|
|
152
|
+
return self._hooks
|
|
153
|
+
|
|
154
|
+
def count_hooks(
|
|
155
|
+
self,
|
|
156
|
+
model: type | None = None,
|
|
157
|
+
event: str | None = None,
|
|
158
|
+
) -> int:
|
|
159
|
+
"""
|
|
160
|
+
Count registered hooks, optionally filtered by model and/or event.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
model: Optional model class to filter by
|
|
164
|
+
event: Optional event name to filter by
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Number of matching hooks
|
|
168
|
+
"""
|
|
169
|
+
with self._lock:
|
|
170
|
+
if model is None and event is None:
|
|
171
|
+
# Count all hooks
|
|
172
|
+
return sum(len(hooks) for hooks in self._hooks.values())
|
|
173
|
+
if model is not None and event is not None:
|
|
174
|
+
# Count hooks for specific model and event
|
|
175
|
+
return len(self._hooks.get((model, event), []))
|
|
176
|
+
if model is not None:
|
|
177
|
+
# Count all hooks for a model
|
|
178
|
+
return sum(len(hooks) for (m, _), hooks in self._hooks.items() if m == model)
|
|
179
|
+
# event is not None
|
|
180
|
+
# Count all hooks for an event
|
|
181
|
+
return sum(len(hooks) for (_, e), hooks in self._hooks.items() if e == event)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Global singleton registry
|
|
185
|
+
_registry: HookRegistry | None = None
|
|
186
|
+
_registry_lock = threading.Lock()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_registry() -> HookRegistry:
|
|
190
|
+
"""
|
|
191
|
+
Get the global hook registry instance.
|
|
192
|
+
|
|
193
|
+
Creates the registry on first access (singleton pattern).
|
|
194
|
+
Thread-safe initialization.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
HookRegistry singleton instance
|
|
198
|
+
"""
|
|
199
|
+
global _registry
|
|
200
|
+
|
|
201
|
+
if _registry is None:
|
|
202
|
+
with _registry_lock:
|
|
203
|
+
# Double-checked locking
|
|
204
|
+
if _registry is None:
|
|
205
|
+
_registry = HookRegistry()
|
|
206
|
+
|
|
207
|
+
return _registry
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Backward-compatible module-level functions
|
|
211
|
+
def register_hook(
|
|
212
|
+
model: type,
|
|
213
|
+
event: str,
|
|
214
|
+
handler_cls: type,
|
|
215
|
+
method_name: str,
|
|
216
|
+
condition: Callable | None,
|
|
217
|
+
priority: int | Priority,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Register a hook handler (backward-compatible function).
|
|
221
|
+
|
|
222
|
+
Delegates to the global registry instance.
|
|
223
|
+
"""
|
|
224
|
+
registry = get_registry()
|
|
225
|
+
registry.register(model, event, handler_cls, method_name, condition, priority)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_hooks(model: type, event: str) -> list[HookInfo]:
|
|
229
|
+
"""
|
|
230
|
+
Get hooks for a model and event (backward-compatible function).
|
|
231
|
+
|
|
232
|
+
Delegates to the global registry instance.
|
|
233
|
+
"""
|
|
234
|
+
registry = get_registry()
|
|
235
|
+
return registry.get_hooks(model, event)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def unregister_hook(
|
|
239
|
+
model: type,
|
|
240
|
+
event: str,
|
|
241
|
+
handler_cls: type,
|
|
242
|
+
method_name: str,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Unregister a hook handler (backward-compatible function).
|
|
246
|
+
|
|
247
|
+
Delegates to the global registry instance.
|
|
248
|
+
"""
|
|
249
|
+
registry = get_registry()
|
|
250
|
+
registry.unregister(model, event, handler_cls, method_name)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def clear_hooks() -> None:
|
|
254
|
+
"""
|
|
255
|
+
Clear all registered hooks (backward-compatible function).
|
|
256
|
+
|
|
257
|
+
Delegates to the global registry instance.
|
|
258
|
+
Useful for testing.
|
|
259
|
+
"""
|
|
260
|
+
registry = get_registry()
|
|
261
|
+
registry.clear()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def list_all_hooks() -> dict[tuple[type, str], list[HookInfo]]:
|
|
265
|
+
"""
|
|
266
|
+
List all registered hooks (backward-compatible function).
|
|
267
|
+
|
|
268
|
+
Delegates to the global registry instance.
|
|
269
|
+
"""
|
|
270
|
+
registry = get_registry()
|
|
271
|
+
return registry.list_all()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Expose hooks dictionary for testing purposes
|
|
275
|
+
# This provides backward compatibility with tests that expect to access _hooks directly
|
|
276
|
+
_hooks = get_registry().hooks
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: django-bulk-hooks
|
|
3
|
+
Version: 0.2.100
|
|
4
|
+
Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: django,bulk,hooks
|
|
7
|
+
Author: Konrad Beck
|
|
8
|
+
Author-email: konrad.beck@merchantcapital.co.za
|
|
9
|
+
Requires-Python: >=3.11,<4.0
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: django (>=5.2.0,<6.0.0)
|
|
16
|
+
Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
|
|
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
|
+
|
|
37
|
+
## 🚀 Quickstart
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install django-bulk-hooks
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Define Your Model
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from django.db import models
|
|
47
|
+
from django_bulk_hooks.models import HookModelMixin
|
|
48
|
+
|
|
49
|
+
class Account(HookModelMixin):
|
|
50
|
+
balance = models.DecimalField(max_digits=10, decimal_places=2)
|
|
51
|
+
# The HookModelMixin automatically provides BulkHookManager
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Create a Hook Handler
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from django_bulk_hooks import hook, AFTER_UPDATE, Hook
|
|
58
|
+
from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
59
|
+
from .models import Account
|
|
60
|
+
|
|
61
|
+
class AccountHooks(Hook):
|
|
62
|
+
@hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
63
|
+
def log_balance_change(self, new_records, old_records):
|
|
64
|
+
print("Accounts updated:", [a.pk for a in new_records])
|
|
65
|
+
|
|
66
|
+
@hook(BEFORE_CREATE, model=Account)
|
|
67
|
+
def before_create(self, new_records, old_records):
|
|
68
|
+
for account in new_records:
|
|
69
|
+
if account.balance < 0:
|
|
70
|
+
raise ValueError("Account cannot have negative balance")
|
|
71
|
+
|
|
72
|
+
@hook(AFTER_DELETE, model=Account)
|
|
73
|
+
def after_delete(self, new_records, old_records):
|
|
74
|
+
print("Accounts deleted:", [a.pk for a in old_records])
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 🛠 Supported Hook Events
|
|
78
|
+
|
|
79
|
+
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
80
|
+
- `BEFORE_UPDATE`, `AFTER_UPDATE`
|
|
81
|
+
- `BEFORE_DELETE`, `AFTER_DELETE`
|
|
82
|
+
|
|
83
|
+
## 🔄 Lifecycle Events
|
|
84
|
+
|
|
85
|
+
### Individual Model Operations
|
|
86
|
+
|
|
87
|
+
The `HookModelMixin` automatically hooks hooks for individual model operations:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
# These will hook BEFORE_CREATE and AFTER_CREATE hooks
|
|
91
|
+
account = Account.objects.create(balance=100.00)
|
|
92
|
+
account.save() # for new instances
|
|
93
|
+
|
|
94
|
+
# These will hook BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
95
|
+
account.balance = 200.00
|
|
96
|
+
account.save() # for existing instances
|
|
97
|
+
|
|
98
|
+
# This will hook BEFORE_DELETE and AFTER_DELETE hooks
|
|
99
|
+
account.delete()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Bulk Operations
|
|
103
|
+
|
|
104
|
+
Bulk operations also hook the same hooks:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# Bulk create - hooks BEFORE_CREATE and AFTER_CREATE hooks
|
|
108
|
+
accounts = [
|
|
109
|
+
Account(balance=100.00),
|
|
110
|
+
Account(balance=200.00),
|
|
111
|
+
]
|
|
112
|
+
Account.objects.bulk_create(accounts)
|
|
113
|
+
|
|
114
|
+
# Bulk update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
115
|
+
for account in accounts:
|
|
116
|
+
account.balance *= 1.1
|
|
117
|
+
Account.objects.bulk_update(accounts) # fields are auto-detected
|
|
118
|
+
|
|
119
|
+
# Bulk delete - hooks BEFORE_DELETE and AFTER_DELETE hooks
|
|
120
|
+
Account.objects.bulk_delete(accounts)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Queryset Operations
|
|
124
|
+
|
|
125
|
+
Queryset operations are also supported:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
# Queryset update - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
129
|
+
Account.objects.update(balance=0.00)
|
|
130
|
+
|
|
131
|
+
# Queryset delete - hooks BEFORE_DELETE and AFTER_DELETE hooks
|
|
132
|
+
Account.objects.delete()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Subquery Support in Updates
|
|
136
|
+
|
|
137
|
+
When using `Subquery` objects in update operations, the computed values are automatically available in hooks. The system efficiently refreshes all instances in bulk for optimal performance:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from django.db.models import Subquery, OuterRef, Sum
|
|
141
|
+
|
|
142
|
+
def aggregate_revenue_by_ids(self, ids: Iterable[int]) -> int:
|
|
143
|
+
return self.find_by_ids(ids).update(
|
|
144
|
+
revenue=Subquery(
|
|
145
|
+
FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef("pk"))
|
|
146
|
+
.filter(is_revenue=True)
|
|
147
|
+
.values("daily_financial_aggregate_id")
|
|
148
|
+
.annotate(revenue_sum=Sum("amount"))
|
|
149
|
+
.values("revenue_sum")[:1],
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# In your hooks, you can now access the computed revenue value:
|
|
154
|
+
class FinancialAggregateHooks(Hook):
|
|
155
|
+
@hook(AFTER_UPDATE, model=DailyFinancialAggregate)
|
|
156
|
+
def log_revenue_update(self, new_records, old_records):
|
|
157
|
+
for new_record in new_records:
|
|
158
|
+
# This will now contain the computed value, not the Subquery object
|
|
159
|
+
print(f"Updated revenue: {new_record.revenue}")
|
|
160
|
+
|
|
161
|
+
# Bulk operations are optimized for performance:
|
|
162
|
+
def bulk_aggregate_revenue(self, ids: Iterable[int]) -> int:
|
|
163
|
+
# This will efficiently refresh all instances in a single query
|
|
164
|
+
return self.filter(id__in=ids).update(
|
|
165
|
+
revenue=Subquery(
|
|
166
|
+
FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef("pk"))
|
|
167
|
+
.filter(is_revenue=True)
|
|
168
|
+
.values("daily_financial_aggregate_id")
|
|
169
|
+
.annotate(revenue_sum=Sum("amount"))
|
|
170
|
+
.values("revenue_sum")[:1],
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## 🧠 Why?
|
|
176
|
+
|
|
177
|
+
Django's `bulk_` methods bypass signals and `save()`. This package fills that gap with:
|
|
178
|
+
|
|
179
|
+
- Hooks that behave consistently across creates/updates/deletes
|
|
180
|
+
- **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`
|
|
181
|
+
- Scalable performance via chunking (default 200)
|
|
182
|
+
- Support for `@hook` decorators and centralized hook classes
|
|
183
|
+
- **NEW**: Automatic hook hooking for admin operations and other Django features
|
|
184
|
+
- **NEW**: Proper ordering guarantees for old/new record pairing in hooks (Salesforce-like behavior)
|
|
185
|
+
|
|
186
|
+
## 📦 Usage Examples
|
|
187
|
+
|
|
188
|
+
### Individual Model Operations
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
# These automatically hook hooks
|
|
192
|
+
account = Account.objects.create(balance=100.00)
|
|
193
|
+
account.balance = 200.00
|
|
194
|
+
account.save()
|
|
195
|
+
account.delete()
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Bulk Operations
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
# These also hook hooks
|
|
202
|
+
Account.objects.bulk_create(accounts)
|
|
203
|
+
Account.objects.bulk_update(accounts) # fields are auto-detected
|
|
204
|
+
Account.objects.bulk_delete(accounts)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Advanced Hook Usage
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
class AdvancedAccountHooks(Hook):
|
|
211
|
+
@hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
212
|
+
def validate_balance_change(self, new_records, old_records):
|
|
213
|
+
for new_account, old_account in zip(new_records, old_records):
|
|
214
|
+
if new_account.balance < 0 and old_account.balance >= 0:
|
|
215
|
+
raise ValueError("Cannot set negative balance")
|
|
216
|
+
|
|
217
|
+
@hook(AFTER_CREATE, model=Account)
|
|
218
|
+
def send_welcome_email(self, new_records, old_records):
|
|
219
|
+
for account in new_records:
|
|
220
|
+
# Send welcome email logic here
|
|
221
|
+
pass
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Salesforce-like Ordering Guarantees
|
|
225
|
+
|
|
226
|
+
The system ensures that `old_records` and `new_records` are always properly paired, regardless of the order in which you pass objects to bulk operations:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
class LoanAccountHooks(Hook):
|
|
230
|
+
@hook(BEFORE_UPDATE, model=LoanAccount)
|
|
231
|
+
def validate_account_number(self, new_records, old_records):
|
|
232
|
+
# old_records[i] always corresponds to new_records[i]
|
|
233
|
+
for new_account, old_account in zip(new_records, old_records):
|
|
234
|
+
if old_account.account_number != new_account.account_number:
|
|
235
|
+
raise ValidationError("Account number cannot be changed")
|
|
236
|
+
|
|
237
|
+
# This works correctly even with reordered objects:
|
|
238
|
+
accounts = [account1, account2, account3] # IDs: 1, 2, 3
|
|
239
|
+
reordered = [account3, account1, account2] # IDs: 3, 1, 2
|
|
240
|
+
|
|
241
|
+
# The hook will still receive properly paired old/new records
|
|
242
|
+
LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## 🧩 Integration with Other Managers
|
|
246
|
+
|
|
247
|
+
### Recommended: QuerySet-based Composition (New Approach)
|
|
248
|
+
|
|
249
|
+
For the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from django_bulk_hooks.queryset import HookQuerySet
|
|
253
|
+
from queryable_properties.managers import QueryablePropertiesManager
|
|
254
|
+
|
|
255
|
+
class MyManager(QueryablePropertiesManager):
|
|
256
|
+
"""Manager that combines queryable properties with hooks"""
|
|
257
|
+
|
|
258
|
+
def get_queryset(self):
|
|
259
|
+
# Get the QueryableProperties QuerySet
|
|
260
|
+
qs = super().get_queryset()
|
|
261
|
+
# Apply hooks on top of it
|
|
262
|
+
return HookQuerySet.with_hooks(qs)
|
|
263
|
+
|
|
264
|
+
class Article(models.Model):
|
|
265
|
+
title = models.CharField(max_length=100)
|
|
266
|
+
published = models.BooleanField(default=False)
|
|
267
|
+
|
|
268
|
+
objects = MyManager()
|
|
269
|
+
|
|
270
|
+
# This gives you both queryable properties AND hooks
|
|
271
|
+
# No inheritance conflicts, no MRO issues!
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Alternative: Explicit Hook Application
|
|
275
|
+
|
|
276
|
+
For more control, you can apply hooks explicitly:
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
class MyManager(QueryablePropertiesManager):
|
|
280
|
+
def get_queryset(self):
|
|
281
|
+
return super().get_queryset()
|
|
282
|
+
|
|
283
|
+
def with_hooks(self):
|
|
284
|
+
"""Apply hooks to this queryset"""
|
|
285
|
+
return HookQuerySet.with_hooks(self.get_queryset())
|
|
286
|
+
|
|
287
|
+
# Usage:
|
|
288
|
+
Article.objects.with_hooks().filter(published=True).update(title="Updated")
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Legacy: Manager Inheritance (Not Recommended)
|
|
292
|
+
|
|
293
|
+
The old inheritance approach still works but is not recommended due to potential MRO conflicts:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from django_bulk_hooks.manager import BulkHookManager
|
|
297
|
+
from queryable_properties.managers import QueryablePropertiesManager
|
|
298
|
+
|
|
299
|
+
class MyManager(BulkHookManager, QueryablePropertiesManager):
|
|
300
|
+
pass # ⚠️ Can cause inheritance conflicts
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**Why the new approach is better:**
|
|
304
|
+
- ✅ No inheritance conflicts
|
|
305
|
+
- ✅ No MRO (Method Resolution Order) issues
|
|
306
|
+
- ✅ Works with any manager combination
|
|
307
|
+
- ✅ Cleaner and more maintainable
|
|
308
|
+
- ✅ Follows Django's queryset enhancement patterns
|
|
309
|
+
|
|
310
|
+
Framework needs to:
|
|
311
|
+
Register these methods
|
|
312
|
+
Know when to execute them (BEFORE_UPDATE, AFTER_UPDATE)
|
|
313
|
+
Execute them in priority order
|
|
314
|
+
Pass ChangeSet to them
|
|
315
|
+
Handle errors (rollback on failure)
|
|
316
|
+
|
|
317
|
+
## 📝 License
|
|
318
|
+
|
|
319
|
+
MIT © 2024 Augend / Konrad Beck
|
|
320
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
django_bulk_hooks/__init__.py,sha256=ZKjEi9Sj3lRr3hcEfknXAr1UXXwERzUCNgMkNXhW0mk,2119
|
|
2
|
+
django_bulk_hooks/changeset.py,sha256=qnMD3bR2cNh8ZM8J6ASR5ly5Rjx-tPzXBYkqIjKGW98,6568
|
|
3
|
+
django_bulk_hooks/conditions.py,sha256=ar4pGjtxLKmgSIlO4S6aZFKmaBNchLtxMmWpkn4g9RU,8114
|
|
4
|
+
django_bulk_hooks/constants.py,sha256=PxpEETaO6gdENcTPoXS586lerGKVP3nmjpDvOkmhYxI,509
|
|
5
|
+
django_bulk_hooks/context.py,sha256=mqaC5-yESDTA5ruI7fuXlt8qSgKuOFp0mjq7h1-4HdQ,1926
|
|
6
|
+
django_bulk_hooks/decorators.py,sha256=TdkO4FJyFrVU2zqK6Y_6JjEJ4v3nbKkk7aa22jN10sk,11994
|
|
7
|
+
django_bulk_hooks/dispatcher.py,sha256=EiFthoPKsgEsJEh1Xf0X2hS3-QJ1a0qFijPJ9ebng6k,26994
|
|
8
|
+
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
9
|
+
django_bulk_hooks/factory.py,sha256=ezrVM5U023KZqOBbJXb6lYUP-pE7WJmi8Olh2Ew-7RA,18085
|
|
10
|
+
django_bulk_hooks/handler.py,sha256=SRCrMzgolrruTkvMnYBFmXLR-ABiw0JiH3605PEdCZM,4207
|
|
11
|
+
django_bulk_hooks/helpers.py,sha256=3rH9TJkdCPF7Vu--0tDaZzJg9Yxcv7yoSF1K1_-0psQ,8048
|
|
12
|
+
django_bulk_hooks/manager.py,sha256=sn4ALCuxRydjIJ91kB81Dhj4PitwytGa4wzxPos4I2Q,4096
|
|
13
|
+
django_bulk_hooks/models.py,sha256=H16AuIiRjkwTD-YDA9S_sMYfAzAFoBgKqiq4TvJuJ9M,3325
|
|
14
|
+
django_bulk_hooks/operations/__init__.py,sha256=BtJYjmRhe_sScivLsniDaZmBkm0ZLvcmzXFKL7QY2Xg,550
|
|
15
|
+
django_bulk_hooks/operations/analyzer.py,sha256=Fw4rjkhpfT8b2A4c7CSMfFRtLUFVimCCz_eGIBtcNiI,15126
|
|
16
|
+
django_bulk_hooks/operations/bulk_executor.py,sha256=URoZVkR-vKMgwLRrNe9aqoRV2i6vLJPr6N-E8CW5npY,29116
|
|
17
|
+
django_bulk_hooks/operations/coordinator.py,sha256=3n9bKpcn3_X-zos0tYX6JWS77JleeYMVawZu2DZ1LC4,34973
|
|
18
|
+
django_bulk_hooks/operations/field_utils.py,sha256=o07oKib6cN8YfFB13O-1YksFr_W9LMlA4Q0rKYskrco,14518
|
|
19
|
+
django_bulk_hooks/operations/mti_handler.py,sha256=00djtjfZ0rrOfiEii8TS1aBarC0qDpCBsFfWGrljvsc,26946
|
|
20
|
+
django_bulk_hooks/operations/mti_plans.py,sha256=HIRJgogHPpm6MV7nZZ-sZhMLUnozpZPV2SzwQHLRzYc,3667
|
|
21
|
+
django_bulk_hooks/operations/record_classifier.py,sha256=It85hJC2K-UsEOLbTR-QBdY5UPV-acQIJ91TSGa7pYo,7053
|
|
22
|
+
django_bulk_hooks/queryset.py,sha256=tPIkNESb47fTIpTrR6xUtc-k3gCFR15W0Xt2-HmvlJo,6811
|
|
23
|
+
django_bulk_hooks/registry.py,sha256=4HxP1mVK2z4VzvlohbEw2359wM21UJZJYagJJ1komM0,7947
|
|
24
|
+
django_bulk_hooks-0.2.100.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
25
|
+
django_bulk_hooks-0.2.100.dist-info/METADATA,sha256=nEA9mRH3Cmy-WDAUF4M3rUOzQw4GOgOXSN_0vmCP9UY,10556
|
|
26
|
+
django_bulk_hooks-0.2.100.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
27
|
+
django_bulk_hooks-0.2.100.dist-info/RECORD,,
|
django_bulk_hooks/engine.py
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
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
|
-
def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
12
|
-
hooks = get_hooks(model_cls, event)
|
|
13
|
-
|
|
14
|
-
if not hooks:
|
|
15
|
-
return
|
|
16
|
-
|
|
17
|
-
# For BEFORE_* events, run model.clean() first for validation
|
|
18
|
-
if event.startswith("before_"):
|
|
19
|
-
for instance in new_instances:
|
|
20
|
-
try:
|
|
21
|
-
instance.clean()
|
|
22
|
-
except ValidationError as e:
|
|
23
|
-
logger.error("Validation failed for %s: %s", instance, e)
|
|
24
|
-
raise
|
|
25
|
-
except Exception as e:
|
|
26
|
-
# Handle RelatedObjectDoesNotExist and other exceptions that might occur
|
|
27
|
-
# when accessing foreign key fields on unsaved objects
|
|
28
|
-
if "RelatedObjectDoesNotExist" in str(type(e).__name__):
|
|
29
|
-
logger.debug("Skipping validation for unsaved object with unset foreign keys: %s", e)
|
|
30
|
-
continue
|
|
31
|
-
else:
|
|
32
|
-
logger.error("Unexpected error during validation for %s: %s", instance, e)
|
|
33
|
-
raise
|
|
34
|
-
|
|
35
|
-
for handler_cls, method_name, condition, priority in hooks:
|
|
36
|
-
handler_instance = handler_cls()
|
|
37
|
-
func = getattr(handler_instance, method_name)
|
|
38
|
-
|
|
39
|
-
to_process_new = []
|
|
40
|
-
to_process_old = []
|
|
41
|
-
|
|
42
|
-
for new, original in zip(
|
|
43
|
-
new_instances,
|
|
44
|
-
original_instances or [None] * len(new_instances),
|
|
45
|
-
strict=True,
|
|
46
|
-
):
|
|
47
|
-
if not condition or condition.check(new, original):
|
|
48
|
-
to_process_new.append(new)
|
|
49
|
-
to_process_old.append(original)
|
|
50
|
-
|
|
51
|
-
if to_process_new:
|
|
52
|
-
# Call the function with keyword arguments
|
|
53
|
-
func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
|