django-bulk-hooks 0.1.101__tar.gz → 0.1.103__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.103/PKG-INFO +217 -0
- django_bulk_hooks-0.1.103/README.md +197 -0
- django_bulk_hooks-0.1.103/django_bulk_hooks/__init__.py +4 -0
- django_bulk_hooks-0.1.103/django_bulk_hooks/conditions.py +181 -0
- django_bulk_hooks-0.1.103/django_bulk_hooks/decorators.py +136 -0
- django_bulk_hooks-0.1.103/django_bulk_hooks/engine.py +43 -0
- {django_bulk_hooks-0.1.101 → django_bulk_hooks-0.1.103}/django_bulk_hooks/handler.py +4 -29
- django_bulk_hooks-0.1.103/django_bulk_hooks/manager.py +203 -0
- django_bulk_hooks-0.1.103/django_bulk_hooks/models.py +95 -0
- django_bulk_hooks-0.1.103/django_bulk_hooks/priority.py +16 -0
- {django_bulk_hooks-0.1.101 → django_bulk_hooks-0.1.103}/django_bulk_hooks/queryset.py +3 -2
- {django_bulk_hooks-0.1.101 → django_bulk_hooks-0.1.103}/django_bulk_hooks/registry.py +6 -5
- {django_bulk_hooks-0.1.101 → django_bulk_hooks-0.1.103}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.101/PKG-INFO +0 -295
- django_bulk_hooks-0.1.101/README.md +0 -275
- django_bulk_hooks-0.1.101/django_bulk_hooks/__init__.py +0 -62
- django_bulk_hooks-0.1.101/django_bulk_hooks/conditions.py +0 -358
- django_bulk_hooks-0.1.101/django_bulk_hooks/decorators.py +0 -76
- django_bulk_hooks-0.1.101/django_bulk_hooks/engine.py +0 -138
- django_bulk_hooks-0.1.101/django_bulk_hooks/manager.py +0 -296
- django_bulk_hooks-0.1.101/django_bulk_hooks/models.py +0 -145
- {django_bulk_hooks-0.1.101 → django_bulk_hooks-0.1.103}/LICENSE +0 -0
- {django_bulk_hooks-0.1.101 → django_bulk_hooks-0.1.103}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.101 → django_bulk_hooks-0.1.103}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.101 → django_bulk_hooks-0.1.103}/django_bulk_hooks/enums.py +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-bulk-hooks
|
|
3
|
+
Version: 0.1.103
|
|
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
|
+
|
|
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 triggers hooks for individual model operations:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
# These will trigger BEFORE_CREATE and AFTER_CREATE hooks
|
|
91
|
+
account = Account.objects.create(balance=100.00)
|
|
92
|
+
account.save() # for new instances
|
|
93
|
+
|
|
94
|
+
# These will trigger BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
95
|
+
account.balance = 200.00
|
|
96
|
+
account.save() # for existing instances
|
|
97
|
+
|
|
98
|
+
# This will trigger BEFORE_DELETE and AFTER_DELETE hooks
|
|
99
|
+
account.delete()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Bulk Operations
|
|
103
|
+
|
|
104
|
+
Bulk operations also trigger the same hooks:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# Bulk create - triggers 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 - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
115
|
+
for account in accounts:
|
|
116
|
+
account.balance *= 1.1
|
|
117
|
+
Account.objects.bulk_update(accounts, ['balance'])
|
|
118
|
+
|
|
119
|
+
# Bulk delete - triggers 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 - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
129
|
+
Account.objects.update(balance=0.00)
|
|
130
|
+
|
|
131
|
+
# Queryset delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
|
|
132
|
+
Account.objects.delete()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## 🧠 Why?
|
|
136
|
+
|
|
137
|
+
Django's `bulk_` methods bypass signals and `save()`. This package fills that gap with:
|
|
138
|
+
|
|
139
|
+
- Hooks that behave consistently across creates/updates/deletes
|
|
140
|
+
- **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`
|
|
141
|
+
- Scalable performance via chunking (default 200)
|
|
142
|
+
- Support for `@hook` decorators and centralized hook classes
|
|
143
|
+
- **NEW**: Automatic hook triggering for admin operations and other Django features
|
|
144
|
+
- **NEW**: Proper ordering guarantees for old/new record pairing in hooks (Salesforce-like behavior)
|
|
145
|
+
|
|
146
|
+
## 📦 Usage Examples
|
|
147
|
+
|
|
148
|
+
### Individual Model Operations
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
# These automatically trigger hooks
|
|
152
|
+
account = Account.objects.create(balance=100.00)
|
|
153
|
+
account.balance = 200.00
|
|
154
|
+
account.save()
|
|
155
|
+
account.delete()
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Bulk Operations
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
# These also trigger hooks
|
|
162
|
+
Account.objects.bulk_create(accounts)
|
|
163
|
+
Account.objects.bulk_update(accounts, ['balance'])
|
|
164
|
+
Account.objects.bulk_delete(accounts)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Advanced Hook Usage
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
class AdvancedAccountHooks(Hook):
|
|
171
|
+
@hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
172
|
+
def validate_balance_change(self, new_records, old_records):
|
|
173
|
+
for new_account, old_account in zip(new_records, old_records):
|
|
174
|
+
if new_account.balance < 0 and old_account.balance >= 0:
|
|
175
|
+
raise ValueError("Cannot set negative balance")
|
|
176
|
+
|
|
177
|
+
@hook(AFTER_CREATE, model=Account)
|
|
178
|
+
def send_welcome_email(self, new_records, old_records):
|
|
179
|
+
for account in new_records:
|
|
180
|
+
# Send welcome email logic here
|
|
181
|
+
pass
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Salesforce-like Ordering Guarantees
|
|
185
|
+
|
|
186
|
+
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:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
class LoanAccountHooks(Hook):
|
|
190
|
+
@hook(BEFORE_UPDATE, model=LoanAccount)
|
|
191
|
+
def validate_account_number(self, new_records, old_records):
|
|
192
|
+
# old_records[i] always corresponds to new_records[i]
|
|
193
|
+
for new_account, old_account in zip(new_records, old_records):
|
|
194
|
+
if old_account.account_number != new_account.account_number:
|
|
195
|
+
raise ValidationError("Account number cannot be changed")
|
|
196
|
+
|
|
197
|
+
# This works correctly even with reordered objects:
|
|
198
|
+
accounts = [account1, account2, account3] # IDs: 1, 2, 3
|
|
199
|
+
reordered = [account3, account1, account2] # IDs: 3, 1, 2
|
|
200
|
+
|
|
201
|
+
# The hook will still receive properly paired old/new records
|
|
202
|
+
LoanAccount.objects.bulk_update(reordered, ['balance'])
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## 🧩 Integration with Queryable Properties
|
|
206
|
+
|
|
207
|
+
You can extend from `BulkHookManager` to support formula fields or property querying.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
class MyManager(BulkHookManager, QueryablePropertiesManager):
|
|
211
|
+
pass
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## 📝 License
|
|
215
|
+
|
|
216
|
+
MIT © 2024 Augend / Konrad Beck
|
|
217
|
+
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
|
|
18
|
+
## 🚀 Quickstart
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install django-bulk-hooks
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Define Your Model
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from django.db import models
|
|
28
|
+
from django_bulk_hooks.models import HookModelMixin
|
|
29
|
+
|
|
30
|
+
class Account(HookModelMixin):
|
|
31
|
+
balance = models.DecimalField(max_digits=10, decimal_places=2)
|
|
32
|
+
# The HookModelMixin automatically provides BulkHookManager
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Create a Hook Handler
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from django_bulk_hooks import hook, AFTER_UPDATE, Hook
|
|
39
|
+
from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
40
|
+
from .models import Account
|
|
41
|
+
|
|
42
|
+
class AccountHooks(Hook):
|
|
43
|
+
@hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
44
|
+
def log_balance_change(self, new_records, old_records):
|
|
45
|
+
print("Accounts updated:", [a.pk for a in new_records])
|
|
46
|
+
|
|
47
|
+
@hook(BEFORE_CREATE, model=Account)
|
|
48
|
+
def before_create(self, new_records, old_records):
|
|
49
|
+
for account in new_records:
|
|
50
|
+
if account.balance < 0:
|
|
51
|
+
raise ValueError("Account cannot have negative balance")
|
|
52
|
+
|
|
53
|
+
@hook(AFTER_DELETE, model=Account)
|
|
54
|
+
def after_delete(self, new_records, old_records):
|
|
55
|
+
print("Accounts deleted:", [a.pk for a in old_records])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 🛠 Supported Hook Events
|
|
59
|
+
|
|
60
|
+
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
61
|
+
- `BEFORE_UPDATE`, `AFTER_UPDATE`
|
|
62
|
+
- `BEFORE_DELETE`, `AFTER_DELETE`
|
|
63
|
+
|
|
64
|
+
## 🔄 Lifecycle Events
|
|
65
|
+
|
|
66
|
+
### Individual Model Operations
|
|
67
|
+
|
|
68
|
+
The `HookModelMixin` automatically triggers hooks for individual model operations:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# These will trigger BEFORE_CREATE and AFTER_CREATE hooks
|
|
72
|
+
account = Account.objects.create(balance=100.00)
|
|
73
|
+
account.save() # for new instances
|
|
74
|
+
|
|
75
|
+
# These will trigger BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
76
|
+
account.balance = 200.00
|
|
77
|
+
account.save() # for existing instances
|
|
78
|
+
|
|
79
|
+
# This will trigger BEFORE_DELETE and AFTER_DELETE hooks
|
|
80
|
+
account.delete()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Bulk Operations
|
|
84
|
+
|
|
85
|
+
Bulk operations also trigger the same hooks:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
# Bulk create - triggers BEFORE_CREATE and AFTER_CREATE hooks
|
|
89
|
+
accounts = [
|
|
90
|
+
Account(balance=100.00),
|
|
91
|
+
Account(balance=200.00),
|
|
92
|
+
]
|
|
93
|
+
Account.objects.bulk_create(accounts)
|
|
94
|
+
|
|
95
|
+
# Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
96
|
+
for account in accounts:
|
|
97
|
+
account.balance *= 1.1
|
|
98
|
+
Account.objects.bulk_update(accounts, ['balance'])
|
|
99
|
+
|
|
100
|
+
# Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
|
|
101
|
+
Account.objects.bulk_delete(accounts)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Queryset Operations
|
|
105
|
+
|
|
106
|
+
Queryset operations are also supported:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
# Queryset update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
|
|
110
|
+
Account.objects.update(balance=0.00)
|
|
111
|
+
|
|
112
|
+
# Queryset delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
|
|
113
|
+
Account.objects.delete()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 🧠 Why?
|
|
117
|
+
|
|
118
|
+
Django's `bulk_` methods bypass signals and `save()`. This package fills that gap with:
|
|
119
|
+
|
|
120
|
+
- Hooks that behave consistently across creates/updates/deletes
|
|
121
|
+
- **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`
|
|
122
|
+
- Scalable performance via chunking (default 200)
|
|
123
|
+
- Support for `@hook` decorators and centralized hook classes
|
|
124
|
+
- **NEW**: Automatic hook triggering for admin operations and other Django features
|
|
125
|
+
- **NEW**: Proper ordering guarantees for old/new record pairing in hooks (Salesforce-like behavior)
|
|
126
|
+
|
|
127
|
+
## 📦 Usage Examples
|
|
128
|
+
|
|
129
|
+
### Individual Model Operations
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# These automatically trigger hooks
|
|
133
|
+
account = Account.objects.create(balance=100.00)
|
|
134
|
+
account.balance = 200.00
|
|
135
|
+
account.save()
|
|
136
|
+
account.delete()
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Bulk Operations
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
# These also trigger hooks
|
|
143
|
+
Account.objects.bulk_create(accounts)
|
|
144
|
+
Account.objects.bulk_update(accounts, ['balance'])
|
|
145
|
+
Account.objects.bulk_delete(accounts)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Advanced Hook Usage
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
class AdvancedAccountHooks(Hook):
|
|
152
|
+
@hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
153
|
+
def validate_balance_change(self, new_records, old_records):
|
|
154
|
+
for new_account, old_account in zip(new_records, old_records):
|
|
155
|
+
if new_account.balance < 0 and old_account.balance >= 0:
|
|
156
|
+
raise ValueError("Cannot set negative balance")
|
|
157
|
+
|
|
158
|
+
@hook(AFTER_CREATE, model=Account)
|
|
159
|
+
def send_welcome_email(self, new_records, old_records):
|
|
160
|
+
for account in new_records:
|
|
161
|
+
# Send welcome email logic here
|
|
162
|
+
pass
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Salesforce-like Ordering Guarantees
|
|
166
|
+
|
|
167
|
+
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:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
class LoanAccountHooks(Hook):
|
|
171
|
+
@hook(BEFORE_UPDATE, model=LoanAccount)
|
|
172
|
+
def validate_account_number(self, new_records, old_records):
|
|
173
|
+
# old_records[i] always corresponds to new_records[i]
|
|
174
|
+
for new_account, old_account in zip(new_records, old_records):
|
|
175
|
+
if old_account.account_number != new_account.account_number:
|
|
176
|
+
raise ValidationError("Account number cannot be changed")
|
|
177
|
+
|
|
178
|
+
# This works correctly even with reordered objects:
|
|
179
|
+
accounts = [account1, account2, account3] # IDs: 1, 2, 3
|
|
180
|
+
reordered = [account3, account1, account2] # IDs: 3, 1, 2
|
|
181
|
+
|
|
182
|
+
# The hook will still receive properly paired old/new records
|
|
183
|
+
LoanAccount.objects.bulk_update(reordered, ['balance'])
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## 🧩 Integration with Queryable Properties
|
|
187
|
+
|
|
188
|
+
You can extend from `BulkHookManager` to support formula fields or property querying.
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
class MyManager(BulkHookManager, QueryablePropertiesManager):
|
|
192
|
+
pass
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## 📝 License
|
|
196
|
+
|
|
197
|
+
MIT © 2024 Augend / Konrad Beck
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
def resolve_dotted_attr(instance, dotted_path):
|
|
2
|
+
"""
|
|
3
|
+
Recursively resolve a dotted attribute path, e.g., "type.category".
|
|
4
|
+
"""
|
|
5
|
+
for attr in dotted_path.split("."):
|
|
6
|
+
if instance is None:
|
|
7
|
+
return None
|
|
8
|
+
instance = getattr(instance, attr, None)
|
|
9
|
+
return instance
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HookCondition:
|
|
13
|
+
def check(self, instance, original_instance=None):
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
def __call__(self, instance, original_instance=None):
|
|
17
|
+
return self.check(instance, original_instance)
|
|
18
|
+
|
|
19
|
+
def __and__(self, other):
|
|
20
|
+
return AndCondition(self, other)
|
|
21
|
+
|
|
22
|
+
def __or__(self, other):
|
|
23
|
+
return OrCondition(self, other)
|
|
24
|
+
|
|
25
|
+
def __invert__(self):
|
|
26
|
+
return NotCondition(self)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IsNotEqual(HookCondition):
|
|
30
|
+
def __init__(self, field, value, only_on_change=False):
|
|
31
|
+
self.field = field
|
|
32
|
+
self.value = value
|
|
33
|
+
self.only_on_change = only_on_change
|
|
34
|
+
|
|
35
|
+
def check(self, instance, original_instance=None):
|
|
36
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
37
|
+
if self.only_on_change:
|
|
38
|
+
if original_instance is None:
|
|
39
|
+
return False
|
|
40
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
41
|
+
return previous == self.value and current != self.value
|
|
42
|
+
else:
|
|
43
|
+
return current != self.value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class IsEqual(HookCondition):
|
|
47
|
+
def __init__(self, field, value, only_on_change=False):
|
|
48
|
+
self.field = field
|
|
49
|
+
self.value = value
|
|
50
|
+
self.only_on_change = only_on_change
|
|
51
|
+
|
|
52
|
+
def check(self, instance, original_instance=None):
|
|
53
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
54
|
+
if self.only_on_change:
|
|
55
|
+
if original_instance is None:
|
|
56
|
+
return False
|
|
57
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
58
|
+
return previous != self.value and current == self.value
|
|
59
|
+
else:
|
|
60
|
+
return current == self.value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HasChanged(HookCondition):
|
|
64
|
+
def __init__(self, field, has_changed=True):
|
|
65
|
+
self.field = field
|
|
66
|
+
self.has_changed = has_changed
|
|
67
|
+
|
|
68
|
+
def check(self, instance, original_instance=None):
|
|
69
|
+
if not original_instance:
|
|
70
|
+
return False
|
|
71
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
72
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
73
|
+
return (current != previous) == self.has_changed
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class WasEqual(HookCondition):
|
|
77
|
+
def __init__(self, field, value, only_on_change=False):
|
|
78
|
+
"""
|
|
79
|
+
Check if a field's original value was `value`.
|
|
80
|
+
If only_on_change is True, only return True when the field has changed away from that value.
|
|
81
|
+
"""
|
|
82
|
+
self.field = field
|
|
83
|
+
self.value = value
|
|
84
|
+
self.only_on_change = only_on_change
|
|
85
|
+
|
|
86
|
+
def check(self, instance, original_instance=None):
|
|
87
|
+
if original_instance is None:
|
|
88
|
+
return False
|
|
89
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
90
|
+
if self.only_on_change:
|
|
91
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
92
|
+
return previous == self.value and current != self.value
|
|
93
|
+
else:
|
|
94
|
+
return previous == self.value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ChangesTo(HookCondition):
|
|
98
|
+
def __init__(self, field, value):
|
|
99
|
+
"""
|
|
100
|
+
Check if a field's value has changed to `value`.
|
|
101
|
+
Only returns True when original value != value and current value == value.
|
|
102
|
+
"""
|
|
103
|
+
self.field = field
|
|
104
|
+
self.value = value
|
|
105
|
+
|
|
106
|
+
def check(self, instance, original_instance=None):
|
|
107
|
+
if original_instance is None:
|
|
108
|
+
return False
|
|
109
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
110
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
111
|
+
return previous != self.value and current == self.value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class IsGreaterThan(HookCondition):
|
|
115
|
+
def __init__(self, field, value):
|
|
116
|
+
self.field = field
|
|
117
|
+
self.value = value
|
|
118
|
+
|
|
119
|
+
def check(self, instance, original_instance=None):
|
|
120
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
121
|
+
return current is not None and current > self.value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class IsGreaterThanOrEqual(HookCondition):
|
|
125
|
+
def __init__(self, field, value):
|
|
126
|
+
self.field = field
|
|
127
|
+
self.value = value
|
|
128
|
+
|
|
129
|
+
def check(self, instance, original_instance=None):
|
|
130
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
131
|
+
return current is not None and current >= self.value
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class IsLessThan(HookCondition):
|
|
135
|
+
def __init__(self, field, value):
|
|
136
|
+
self.field = field
|
|
137
|
+
self.value = value
|
|
138
|
+
|
|
139
|
+
def check(self, instance, original_instance=None):
|
|
140
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
141
|
+
return current is not None and current < self.value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class IsLessThanOrEqual(HookCondition):
|
|
145
|
+
def __init__(self, field, value):
|
|
146
|
+
self.field = field
|
|
147
|
+
self.value = value
|
|
148
|
+
|
|
149
|
+
def check(self, instance, original_instance=None):
|
|
150
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
151
|
+
return current is not None and current <= self.value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class AndCondition(HookCondition):
|
|
155
|
+
def __init__(self, cond1, cond2):
|
|
156
|
+
self.cond1 = cond1
|
|
157
|
+
self.cond2 = cond2
|
|
158
|
+
|
|
159
|
+
def check(self, instance, original_instance=None):
|
|
160
|
+
return self.cond1.check(instance, original_instance) and self.cond2.check(
|
|
161
|
+
instance, original_instance
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class OrCondition(HookCondition):
|
|
166
|
+
def __init__(self, cond1, cond2):
|
|
167
|
+
self.cond1 = cond1
|
|
168
|
+
self.cond2 = cond2
|
|
169
|
+
|
|
170
|
+
def check(self, instance, original_instance=None):
|
|
171
|
+
return self.cond1.check(instance, original_instance) or self.cond2.check(
|
|
172
|
+
instance, original_instance
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class NotCondition(HookCondition):
|
|
177
|
+
def __init__(self, cond):
|
|
178
|
+
self.cond = cond
|
|
179
|
+
|
|
180
|
+
def check(self, instance, original_instance=None):
|
|
181
|
+
return not self.cond.check(instance, original_instance)
|