django-bulk-hooks 0.1.80__py3-none-any.whl → 0.1.82__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 +50 -47
- django_bulk_hooks/conditions.py +351 -233
- django_bulk_hooks/engine.py +53 -66
- django_bulk_hooks/models.py +7 -0
- django_bulk_hooks-0.1.82.dist-info/METADATA +228 -0
- {django_bulk_hooks-0.1.80.dist-info → django_bulk_hooks-0.1.82.dist-info}/RECORD +8 -8
- django_bulk_hooks-0.1.80.dist-info/METADATA +0 -92
- {django_bulk_hooks-0.1.80.dist-info → django_bulk_hooks-0.1.82.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.80.dist-info → django_bulk_hooks-0.1.82.dist-info}/WHEEL +0 -0
django_bulk_hooks/__init__.py
CHANGED
|
@@ -1,47 +1,50 @@
|
|
|
1
|
-
from django_bulk_hooks.constants import (
|
|
2
|
-
AFTER_CREATE,
|
|
3
|
-
AFTER_DELETE,
|
|
4
|
-
AFTER_UPDATE,
|
|
5
|
-
BEFORE_CREATE,
|
|
6
|
-
BEFORE_DELETE,
|
|
7
|
-
BEFORE_UPDATE,
|
|
8
|
-
VALIDATE_CREATE,
|
|
9
|
-
VALIDATE_DELETE,
|
|
10
|
-
VALIDATE_UPDATE,
|
|
11
|
-
)
|
|
12
|
-
from django_bulk_hooks.conditions import (
|
|
13
|
-
ChangesTo,
|
|
14
|
-
HasChanged,
|
|
15
|
-
IsEqual,
|
|
16
|
-
IsNotEqual,
|
|
17
|
-
WasEqual,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
from django_bulk_hooks.
|
|
23
|
-
from django_bulk_hooks.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
1
|
+
from django_bulk_hooks.constants import (
|
|
2
|
+
AFTER_CREATE,
|
|
3
|
+
AFTER_DELETE,
|
|
4
|
+
AFTER_UPDATE,
|
|
5
|
+
BEFORE_CREATE,
|
|
6
|
+
BEFORE_DELETE,
|
|
7
|
+
BEFORE_UPDATE,
|
|
8
|
+
VALIDATE_CREATE,
|
|
9
|
+
VALIDATE_DELETE,
|
|
10
|
+
VALIDATE_UPDATE,
|
|
11
|
+
)
|
|
12
|
+
from django_bulk_hooks.conditions import (
|
|
13
|
+
ChangesTo,
|
|
14
|
+
HasChanged,
|
|
15
|
+
IsEqual,
|
|
16
|
+
IsNotEqual,
|
|
17
|
+
WasEqual,
|
|
18
|
+
safe_get_related_object,
|
|
19
|
+
safe_get_related_attr,
|
|
20
|
+
is_field_set,
|
|
21
|
+
)
|
|
22
|
+
from django_bulk_hooks.decorators import hook, select_related
|
|
23
|
+
from django_bulk_hooks.handler import HookHandler
|
|
24
|
+
from django_bulk_hooks.models import HookModelMixin
|
|
25
|
+
from django_bulk_hooks.enums import Priority
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"HookHandler",
|
|
29
|
+
"HookModelMixin",
|
|
30
|
+
"BEFORE_CREATE",
|
|
31
|
+
"AFTER_CREATE",
|
|
32
|
+
"BEFORE_UPDATE",
|
|
33
|
+
"AFTER_UPDATE",
|
|
34
|
+
"BEFORE_DELETE",
|
|
35
|
+
"AFTER_DELETE",
|
|
36
|
+
"VALIDATE_CREATE",
|
|
37
|
+
"VALIDATE_UPDATE",
|
|
38
|
+
"VALIDATE_DELETE",
|
|
39
|
+
"safe_get_related_object",
|
|
40
|
+
"safe_get_related_attr",
|
|
41
|
+
"is_field_set",
|
|
42
|
+
"Priority",
|
|
43
|
+
"hook",
|
|
44
|
+
"select_related",
|
|
45
|
+
"ChangesTo",
|
|
46
|
+
"HasChanged",
|
|
47
|
+
"IsEqual",
|
|
48
|
+
"IsNotEqual",
|
|
49
|
+
"WasEqual",
|
|
50
|
+
]
|
django_bulk_hooks/conditions.py
CHANGED
|
@@ -1,233 +1,351 @@
|
|
|
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
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
self.
|
|
220
|
-
self.
|
|
221
|
-
|
|
222
|
-
def check(self, instance, original_instance=None):
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 is_field_set(instance, field_name):
|
|
33
|
+
"""
|
|
34
|
+
Check if a foreign key field is set without raising RelatedObjectDoesNotExist.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
instance: The model instance
|
|
38
|
+
field_name: The foreign key field name
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if the field is set, False otherwise
|
|
42
|
+
"""
|
|
43
|
+
# Check the foreign key ID field first
|
|
44
|
+
fk_field_name = f"{field_name}_id"
|
|
45
|
+
if hasattr(instance, fk_field_name):
|
|
46
|
+
fk_value = getattr(instance, fk_field_name, None)
|
|
47
|
+
return fk_value is not None
|
|
48
|
+
|
|
49
|
+
# Fallback to checking the field directly
|
|
50
|
+
try:
|
|
51
|
+
return getattr(instance, field_name) is not None
|
|
52
|
+
except Exception:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def safe_get_related_attr(instance, field_name, attr_name=None):
|
|
57
|
+
"""
|
|
58
|
+
Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
|
|
59
|
+
|
|
60
|
+
This is particularly useful in hooks where objects might not have their related
|
|
61
|
+
fields populated yet (e.g., during bulk_create operations or on unsaved objects).
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
instance: The model instance
|
|
65
|
+
field_name: The foreign key field name
|
|
66
|
+
attr_name: Optional attribute name to access on the related object
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The related object, the attribute value, or None if not available
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
# Instead of: loan_transaction.status.name (which might fail)
|
|
73
|
+
# Use: safe_get_related_attr(loan_transaction, 'status', 'name')
|
|
74
|
+
|
|
75
|
+
status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
|
|
76
|
+
if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
|
|
77
|
+
# Process the transaction
|
|
78
|
+
pass
|
|
79
|
+
"""
|
|
80
|
+
# For unsaved objects, check the foreign key ID field first
|
|
81
|
+
if instance.pk is None:
|
|
82
|
+
fk_field_name = f"{field_name}_id"
|
|
83
|
+
if hasattr(instance, fk_field_name):
|
|
84
|
+
fk_value = getattr(instance, fk_field_name, None)
|
|
85
|
+
if fk_value is None:
|
|
86
|
+
return None
|
|
87
|
+
# If we have an ID but the object isn't loaded, try to load it
|
|
88
|
+
try:
|
|
89
|
+
field = instance._meta.get_field(field_name)
|
|
90
|
+
if hasattr(field, 'related_model'):
|
|
91
|
+
related_obj = field.related_model.objects.get(id=fk_value)
|
|
92
|
+
if attr_name is None:
|
|
93
|
+
return related_obj
|
|
94
|
+
return getattr(related_obj, attr_name, None)
|
|
95
|
+
except (field.related_model.DoesNotExist, AttributeError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# For saved objects or when the above doesn't work, use the original method
|
|
99
|
+
related_obj = safe_get_related_object(instance, field_name)
|
|
100
|
+
if related_obj is None:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
if attr_name is None:
|
|
104
|
+
return related_obj
|
|
105
|
+
|
|
106
|
+
return getattr(related_obj, attr_name, None)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def safe_get_related_attr_with_fallback(instance, field_name, attr_name=None, fallback_value=None):
|
|
110
|
+
"""
|
|
111
|
+
Enhanced version of safe_get_related_attr that provides fallback handling.
|
|
112
|
+
|
|
113
|
+
This function is especially useful for bulk operations where related objects
|
|
114
|
+
might not be fully loaded or might not exist yet.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
instance: The model instance
|
|
118
|
+
field_name: The foreign key field name
|
|
119
|
+
attr_name: Optional attribute name to access on the related object
|
|
120
|
+
fallback_value: Value to return if the related object or attribute doesn't exist
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The related object, the attribute value, or fallback_value if not available
|
|
124
|
+
"""
|
|
125
|
+
# First try the standard safe access
|
|
126
|
+
result = safe_get_related_attr(instance, field_name, attr_name)
|
|
127
|
+
if result is not None:
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
# If that fails, try to get the foreign key ID and fetch the object directly
|
|
131
|
+
fk_field_name = f"{field_name}_id"
|
|
132
|
+
if hasattr(instance, fk_field_name):
|
|
133
|
+
fk_id = getattr(instance, fk_field_name)
|
|
134
|
+
if fk_id is not None:
|
|
135
|
+
try:
|
|
136
|
+
# Get the field to determine the related model
|
|
137
|
+
field = instance._meta.get_field(field_name)
|
|
138
|
+
if field.is_relation and not field.many_to_many and not field.one_to_many:
|
|
139
|
+
# Try to fetch the related object directly
|
|
140
|
+
related_obj = field.related_model.objects.get(pk=fk_id)
|
|
141
|
+
if attr_name is None:
|
|
142
|
+
return related_obj
|
|
143
|
+
return getattr(related_obj, attr_name, fallback_value)
|
|
144
|
+
except (field.related_model.DoesNotExist, AttributeError):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
return fallback_value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolve_dotted_attr(instance, dotted_path):
|
|
151
|
+
"""
|
|
152
|
+
Recursively resolve a dotted attribute path, e.g., "type.category".
|
|
153
|
+
This function is designed to work with pre-loaded foreign keys to avoid queries.
|
|
154
|
+
"""
|
|
155
|
+
if instance is None:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
current = instance
|
|
159
|
+
for attr in dotted_path.split("."):
|
|
160
|
+
if current is None:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
# Check if this is a foreign key that might trigger a query
|
|
164
|
+
if hasattr(current, '_meta') and hasattr(current._meta, 'get_field'):
|
|
165
|
+
try:
|
|
166
|
+
field = current._meta.get_field(attr)
|
|
167
|
+
if field.is_relation and not field.many_to_many and not field.one_to_many:
|
|
168
|
+
# For foreign keys, use safe access to prevent RelatedObjectDoesNotExist
|
|
169
|
+
current = safe_get_related_object(current, attr)
|
|
170
|
+
else:
|
|
171
|
+
current = getattr(current, attr, None)
|
|
172
|
+
except Exception:
|
|
173
|
+
# If field lookup fails, fall back to regular attribute access
|
|
174
|
+
current = getattr(current, attr, None)
|
|
175
|
+
else:
|
|
176
|
+
# Not a model instance, use regular attribute access
|
|
177
|
+
current = getattr(current, attr, None)
|
|
178
|
+
|
|
179
|
+
return current
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class HookCondition:
|
|
183
|
+
def check(self, instance, original_instance=None):
|
|
184
|
+
raise NotImplementedError
|
|
185
|
+
|
|
186
|
+
def __call__(self, instance, original_instance=None):
|
|
187
|
+
return self.check(instance, original_instance)
|
|
188
|
+
|
|
189
|
+
def __and__(self, other):
|
|
190
|
+
return AndCondition(self, other)
|
|
191
|
+
|
|
192
|
+
def __or__(self, other):
|
|
193
|
+
return OrCondition(self, other)
|
|
194
|
+
|
|
195
|
+
def __invert__(self):
|
|
196
|
+
return NotCondition(self)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class IsNotEqual(HookCondition):
|
|
200
|
+
def __init__(self, field, value, only_on_change=False):
|
|
201
|
+
self.field = field
|
|
202
|
+
self.value = value
|
|
203
|
+
self.only_on_change = only_on_change
|
|
204
|
+
|
|
205
|
+
def check(self, instance, original_instance=None):
|
|
206
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
207
|
+
if self.only_on_change:
|
|
208
|
+
if original_instance is None:
|
|
209
|
+
return False
|
|
210
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
211
|
+
return previous == self.value and current != self.value
|
|
212
|
+
else:
|
|
213
|
+
return current != self.value
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class IsEqual(HookCondition):
|
|
217
|
+
def __init__(self, field, value, only_on_change=False):
|
|
218
|
+
self.field = field
|
|
219
|
+
self.value = value
|
|
220
|
+
self.only_on_change = only_on_change
|
|
221
|
+
|
|
222
|
+
def check(self, instance, original_instance=None):
|
|
223
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
224
|
+
if self.only_on_change:
|
|
225
|
+
if original_instance is None:
|
|
226
|
+
return False
|
|
227
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
228
|
+
return previous != self.value and current == self.value
|
|
229
|
+
else:
|
|
230
|
+
return current == self.value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class HasChanged(HookCondition):
|
|
234
|
+
def __init__(self, field, has_changed=True):
|
|
235
|
+
self.field = field
|
|
236
|
+
self.has_changed = has_changed
|
|
237
|
+
|
|
238
|
+
def check(self, instance, original_instance=None):
|
|
239
|
+
if not original_instance:
|
|
240
|
+
return False
|
|
241
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
242
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
243
|
+
return (current != previous) == self.has_changed
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class WasEqual(HookCondition):
|
|
247
|
+
def __init__(self, field, value, only_on_change=False):
|
|
248
|
+
"""
|
|
249
|
+
Check if a field's original value was `value`.
|
|
250
|
+
If only_on_change is True, only return True when the field has changed away from that value.
|
|
251
|
+
"""
|
|
252
|
+
self.field = field
|
|
253
|
+
self.value = value
|
|
254
|
+
self.only_on_change = only_on_change
|
|
255
|
+
|
|
256
|
+
def check(self, instance, original_instance=None):
|
|
257
|
+
if original_instance is None:
|
|
258
|
+
return False
|
|
259
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
260
|
+
if self.only_on_change:
|
|
261
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
262
|
+
return previous == self.value and current != self.value
|
|
263
|
+
else:
|
|
264
|
+
return previous == self.value
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class ChangesTo(HookCondition):
|
|
268
|
+
def __init__(self, field, value):
|
|
269
|
+
"""
|
|
270
|
+
Check if a field's value has changed to `value`.
|
|
271
|
+
Only returns True when original value != value and current value == value.
|
|
272
|
+
"""
|
|
273
|
+
self.field = field
|
|
274
|
+
self.value = value
|
|
275
|
+
|
|
276
|
+
def check(self, instance, original_instance=None):
|
|
277
|
+
if original_instance is None:
|
|
278
|
+
return False
|
|
279
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
280
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
281
|
+
return previous != self.value and current == self.value
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class IsGreaterThan(HookCondition):
|
|
285
|
+
def __init__(self, field, value):
|
|
286
|
+
self.field = field
|
|
287
|
+
self.value = value
|
|
288
|
+
|
|
289
|
+
def check(self, instance, original_instance=None):
|
|
290
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
291
|
+
return current is not None and current > self.value
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class IsGreaterThanOrEqual(HookCondition):
|
|
295
|
+
def __init__(self, field, value):
|
|
296
|
+
self.field = field
|
|
297
|
+
self.value = value
|
|
298
|
+
|
|
299
|
+
def check(self, instance, original_instance=None):
|
|
300
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
301
|
+
return current is not None and current >= self.value
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class IsLessThan(HookCondition):
|
|
305
|
+
def __init__(self, field, value):
|
|
306
|
+
self.field = field
|
|
307
|
+
self.value = value
|
|
308
|
+
|
|
309
|
+
def check(self, instance, original_instance=None):
|
|
310
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
311
|
+
return current is not None and current < self.value
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class IsLessThanOrEqual(HookCondition):
|
|
315
|
+
def __init__(self, field, value):
|
|
316
|
+
self.field = field
|
|
317
|
+
self.value = value
|
|
318
|
+
|
|
319
|
+
def check(self, instance, original_instance=None):
|
|
320
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
321
|
+
return current is not None and current <= self.value
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class AndCondition(HookCondition):
|
|
325
|
+
def __init__(self, cond1, cond2):
|
|
326
|
+
self.cond1 = cond1
|
|
327
|
+
self.cond2 = cond2
|
|
328
|
+
|
|
329
|
+
def check(self, instance, original_instance=None):
|
|
330
|
+
return self.cond1.check(instance, original_instance) and self.cond2.check(
|
|
331
|
+
instance, original_instance
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class OrCondition(HookCondition):
|
|
336
|
+
def __init__(self, cond1, cond2):
|
|
337
|
+
self.cond1 = cond1
|
|
338
|
+
self.cond2 = cond2
|
|
339
|
+
|
|
340
|
+
def check(self, instance, original_instance=None):
|
|
341
|
+
return self.cond1.check(instance, original_instance) or self.cond2.check(
|
|
342
|
+
instance, original_instance
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class NotCondition(HookCondition):
|
|
347
|
+
def __init__(self, cond):
|
|
348
|
+
self.cond = cond
|
|
349
|
+
|
|
350
|
+
def check(self, instance, original_instance=None):
|
|
351
|
+
return not self.cond.check(instance, original_instance)
|
django_bulk_hooks/engine.py
CHANGED
|
@@ -1,66 +1,53 @@
|
|
|
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
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger(__name__)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
for new, original in zip(
|
|
56
|
-
new_instances,
|
|
57
|
-
original_instances or [None] * len(new_instances),
|
|
58
|
-
strict=True,
|
|
59
|
-
):
|
|
60
|
-
if not condition or condition.check(new, original):
|
|
61
|
-
to_process_new.append(new)
|
|
62
|
-
to_process_old.append(original)
|
|
63
|
-
|
|
64
|
-
if to_process_new:
|
|
65
|
-
# Call the function with keyword arguments
|
|
66
|
-
func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) 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
|
+
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)
|
django_bulk_hooks/models.py
CHANGED
|
@@ -28,8 +28,15 @@ class HookModelMixin(models.Model):
|
|
|
28
28
|
This ensures that when Django calls clean() (like in admin forms),
|
|
29
29
|
it triggers the VALIDATE_* hooks for validation only.
|
|
30
30
|
"""
|
|
31
|
+
# Call Django's clean first
|
|
31
32
|
super().clean()
|
|
32
33
|
|
|
34
|
+
# Skip hook validation during admin form validation
|
|
35
|
+
# This prevents RelatedObjectDoesNotExist errors when Django hasn't
|
|
36
|
+
# fully set up the object's relationships yet
|
|
37
|
+
if hasattr(self, '_state') and getattr(self._state, 'validating', False):
|
|
38
|
+
return
|
|
39
|
+
|
|
33
40
|
# Determine if this is a create or update operation
|
|
34
41
|
is_create = self.pk is None
|
|
35
42
|
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: django-bulk-hooks
|
|
3
|
+
Version: 0.1.82
|
|
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 (>=4.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
|
+
- **NEW**: Safe handling of related objects to prevent `RelatedObjectDoesNotExist` errors
|
|
37
|
+
|
|
38
|
+
## 🚀 Quickstart
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install django-bulk-hooks
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Define Your Model
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from django.db import models
|
|
48
|
+
from django_bulk_hooks.models import HookModelMixin
|
|
49
|
+
|
|
50
|
+
class Account(HookModelMixin):
|
|
51
|
+
balance = models.DecimalField(max_digits=10, decimal_places=2)
|
|
52
|
+
# The HookModelMixin automatically provides BulkHookManager
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Create a Hook Handler
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from django_bulk_hooks import hook, AFTER_UPDATE, Hook
|
|
59
|
+
from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
60
|
+
from .models import Account
|
|
61
|
+
|
|
62
|
+
class AccountHooks(HookHandler):
|
|
63
|
+
@hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
64
|
+
def log_balance_change(self, new_records, old_records):
|
|
65
|
+
print("Accounts updated:", [a.pk for a in new_records])
|
|
66
|
+
|
|
67
|
+
@hook(BEFORE_CREATE, model=Account)
|
|
68
|
+
def before_create(self, new_records, old_records):
|
|
69
|
+
for account in new_records:
|
|
70
|
+
if account.balance < 0:
|
|
71
|
+
raise ValueError("Account cannot have negative balance")
|
|
72
|
+
|
|
73
|
+
@hook(AFTER_DELETE, model=Account)
|
|
74
|
+
def after_delete(self, new_records, old_records):
|
|
75
|
+
print("Accounts deleted:", [a.pk for a in old_records])
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Advanced Hook Usage
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
class AdvancedAccountHooks(HookHandler):
|
|
82
|
+
@hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
83
|
+
def validate_balance_change(self, new_records, old_records):
|
|
84
|
+
for new_account, old_account in zip(new_records, old_records):
|
|
85
|
+
if new_account.balance < 0 and old_account.balance >= 0:
|
|
86
|
+
raise ValueError("Cannot set negative balance")
|
|
87
|
+
|
|
88
|
+
@hook(AFTER_CREATE, model=Account)
|
|
89
|
+
def send_welcome_email(self, new_records, old_records):
|
|
90
|
+
for account in new_records:
|
|
91
|
+
# Send welcome email logic here
|
|
92
|
+
pass
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## 🔒 Safely Handling Related Objects
|
|
96
|
+
|
|
97
|
+
One of the most common issues when working with hooks is the `RelatedObjectDoesNotExist` exception. This occurs when you try to access a related object that doesn't exist or hasn't been saved yet.
|
|
98
|
+
|
|
99
|
+
### The Problem
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# ❌ DANGEROUS: This can raise RelatedObjectDoesNotExist
|
|
103
|
+
@hook(AFTER_CREATE, model=Transaction)
|
|
104
|
+
def process_transaction(self, new_records, old_records):
|
|
105
|
+
for transaction in new_records:
|
|
106
|
+
# This will fail if transaction.status is None or doesn't exist
|
|
107
|
+
if transaction.status.name == "COMPLETE":
|
|
108
|
+
# Process the transaction
|
|
109
|
+
pass
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### The Solution
|
|
113
|
+
|
|
114
|
+
Use the `safe_get_related_attr` utility function to safely access related object attributes:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from django_bulk_hooks.conditions import safe_get_related_attr
|
|
118
|
+
|
|
119
|
+
# ✅ SAFE: Use safe_get_related_attr to handle None values
|
|
120
|
+
@hook(AFTER_CREATE, model=Transaction)
|
|
121
|
+
def process_transaction(self, new_records, old_records):
|
|
122
|
+
for transaction in new_records:
|
|
123
|
+
# Safely get the status name, returns None if status doesn't exist
|
|
124
|
+
status_name = safe_get_related_attr(transaction, 'status', 'name')
|
|
125
|
+
|
|
126
|
+
if status_name == "COMPLETE":
|
|
127
|
+
# Process the transaction
|
|
128
|
+
pass
|
|
129
|
+
elif status_name is None:
|
|
130
|
+
# Handle case where status is not set
|
|
131
|
+
print(f"Transaction {transaction.id} has no status")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Complete Example
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from django.db import models
|
|
138
|
+
from django_bulk_hooks import hook
|
|
139
|
+
from django_bulk_hooks.conditions import safe_get_related_attr
|
|
140
|
+
|
|
141
|
+
class Status(models.Model):
|
|
142
|
+
name = models.CharField(max_length=50)
|
|
143
|
+
|
|
144
|
+
class Transaction(HookModelMixin, models.Model):
|
|
145
|
+
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
|
146
|
+
status = models.ForeignKey(Status, on_delete=models.CASCADE, null=True, blank=True)
|
|
147
|
+
category = models.ForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True)
|
|
148
|
+
|
|
149
|
+
class TransactionHandler:
|
|
150
|
+
@hook(Transaction, "before_create")
|
|
151
|
+
def set_default_status(self, new_records, old_records=None):
|
|
152
|
+
"""Set default status for new transactions."""
|
|
153
|
+
default_status = Status.objects.filter(name="PENDING").first()
|
|
154
|
+
for transaction in new_records:
|
|
155
|
+
if transaction.status is None:
|
|
156
|
+
transaction.status = default_status
|
|
157
|
+
|
|
158
|
+
@hook(Transaction, "after_create")
|
|
159
|
+
def process_transactions(self, new_records, old_records=None):
|
|
160
|
+
"""Process transactions based on their status."""
|
|
161
|
+
for transaction in new_records:
|
|
162
|
+
# ✅ SAFE: Get status name safely
|
|
163
|
+
status_name = safe_get_related_attr(transaction, 'status', 'name')
|
|
164
|
+
|
|
165
|
+
if status_name == "COMPLETE":
|
|
166
|
+
self._process_complete_transaction(transaction)
|
|
167
|
+
elif status_name == "FAILED":
|
|
168
|
+
self._process_failed_transaction(transaction)
|
|
169
|
+
elif status_name is None:
|
|
170
|
+
print(f"Transaction {transaction.id} has no status")
|
|
171
|
+
|
|
172
|
+
# ✅ SAFE: Check for related object existence
|
|
173
|
+
category = safe_get_related_attr(transaction, 'category')
|
|
174
|
+
if category:
|
|
175
|
+
print(f"Transaction {transaction.id} belongs to category: {category.name}")
|
|
176
|
+
|
|
177
|
+
def _process_complete_transaction(self, transaction):
|
|
178
|
+
# Process complete transaction logic
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def _process_failed_transaction(self, transaction):
|
|
182
|
+
# Process failed transaction logic
|
|
183
|
+
pass
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Best Practices for Related Objects
|
|
187
|
+
|
|
188
|
+
1. **Always use `safe_get_related_attr`** when accessing related object attributes in hooks
|
|
189
|
+
2. **Set default values in `BEFORE_CREATE` hooks** to ensure related objects exist
|
|
190
|
+
3. **Handle None cases explicitly** to avoid unexpected behavior
|
|
191
|
+
4. **Use bulk operations efficiently** by fetching related objects once and reusing them
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
class EfficientTransactionHandler:
|
|
195
|
+
@hook(Transaction, "before_create")
|
|
196
|
+
def prepare_transactions(self, new_records, old_records=None):
|
|
197
|
+
"""Efficiently prepare transactions for bulk creation."""
|
|
198
|
+
# Get default objects once to avoid multiple queries
|
|
199
|
+
default_status = Status.objects.filter(name="PENDING").first()
|
|
200
|
+
default_category = Category.objects.filter(name="GENERAL").first()
|
|
201
|
+
|
|
202
|
+
for transaction in new_records:
|
|
203
|
+
if transaction.status is None:
|
|
204
|
+
transaction.status = default_status
|
|
205
|
+
if transaction.category is None:
|
|
206
|
+
transaction.category = default_category
|
|
207
|
+
|
|
208
|
+
@hook(Transaction, "after_create")
|
|
209
|
+
def post_creation_processing(self, new_records, old_records=None):
|
|
210
|
+
"""Process transactions after creation."""
|
|
211
|
+
# Group by status for efficient processing
|
|
212
|
+
transactions_by_status = {}
|
|
213
|
+
|
|
214
|
+
for transaction in new_records:
|
|
215
|
+
status_name = safe_get_related_attr(transaction, 'status', 'name')
|
|
216
|
+
if status_name not in transactions_by_status:
|
|
217
|
+
transactions_by_status[status_name] = []
|
|
218
|
+
transactions_by_status[status_name].append(transaction)
|
|
219
|
+
|
|
220
|
+
# Process each group
|
|
221
|
+
for status_name, transactions in transactions_by_status.items():
|
|
222
|
+
if status_name == "COMPLETE":
|
|
223
|
+
self._batch_process_complete(transactions)
|
|
224
|
+
elif status_name == "FAILED":
|
|
225
|
+
self._batch_process_failed(transactions)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
This approach ensures your hooks are robust and won't fail due to missing related objects, while also being efficient with database queries.
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
django_bulk_hooks/__init__.py,sha256=
|
|
2
|
-
django_bulk_hooks/conditions.py,sha256=
|
|
1
|
+
django_bulk_hooks/__init__.py,sha256=2PcJ6xz7t7Du0nmLO_5732G6u_oZTygogG0fKESRHHk,1082
|
|
2
|
+
django_bulk_hooks/conditions.py,sha256=cif5R4C_N2HosuYiB03STc2rHb800EJV0Nby-ZPac1s,12460
|
|
3
3
|
django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
|
|
4
4
|
django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
|
|
5
5
|
django_bulk_hooks/decorators.py,sha256=zstmb27dKcOHu3Atg7cauewCTzPvUmq03mzVKJRi56o,7230
|
|
6
|
-
django_bulk_hooks/engine.py,sha256=
|
|
6
|
+
django_bulk_hooks/engine.py,sha256=b1AO8qyl2VMt1CHyefn1Gt1_ARaV80yBZ3mGXsJ9_eA,2021
|
|
7
7
|
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
8
8
|
django_bulk_hooks/handler.py,sha256=Qpg_zT6SsQiTlhduvzXxPdG6uynjyR2fBjj-R6HZiXI,4861
|
|
9
9
|
django_bulk_hooks/manager.py,sha256=-V128ACxPAz82ua4jQRFUkjAKtKW4MN5ppz0bHcv5s4,7138
|
|
10
|
-
django_bulk_hooks/models.py,sha256=
|
|
10
|
+
django_bulk_hooks/models.py,sha256=S4CLMcZeOv0XWCmmC7ieOExL73WmfWUKXPYD3N9wrVc,3666
|
|
11
11
|
django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
|
|
12
12
|
django_bulk_hooks/registry.py,sha256=Vh78exKYcdZhM27120kQm-iXGOjd_kf9ZUYBZ8eQ2V0,683
|
|
13
|
-
django_bulk_hooks-0.1.
|
|
14
|
-
django_bulk_hooks-0.1.
|
|
15
|
-
django_bulk_hooks-0.1.
|
|
16
|
-
django_bulk_hooks-0.1.
|
|
13
|
+
django_bulk_hooks-0.1.82.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
14
|
+
django_bulk_hooks-0.1.82.dist-info/METADATA,sha256=gYgpti0VC1zlAh2NlINn-lyAPPCXkB0K1XzIq4WgsxM,9051
|
|
15
|
+
django_bulk_hooks-0.1.82.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
16
|
+
django_bulk_hooks-0.1.82.dist-info/RECORD,,
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.1.80
|
|
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 (>=4.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(HookHandler):
|
|
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
|
-
### Advanced Hook Usage
|
|
78
|
-
|
|
79
|
-
```python
|
|
80
|
-
class AdvancedAccountHooks(HookHandler):
|
|
81
|
-
@hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
82
|
-
def validate_balance_change(self, new_records, old_records):
|
|
83
|
-
for new_account, old_account in zip(new_records, old_records):
|
|
84
|
-
if new_account.balance < 0 and old_account.balance >= 0:
|
|
85
|
-
raise ValueError("Cannot set negative balance")
|
|
86
|
-
|
|
87
|
-
@hook(AFTER_CREATE, model=Account)
|
|
88
|
-
def send_welcome_email(self, new_records, old_records):
|
|
89
|
-
for account in new_records:
|
|
90
|
-
# Send welcome email logic here
|
|
91
|
-
pass
|
|
92
|
-
```
|
|
File without changes
|
|
File without changes
|