django-bulk-hooks 0.1.81__tar.gz → 0.1.83__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.81 → django_bulk_hooks-0.1.83}/PKG-INFO +1 -1
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/__init__.py +50 -47
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/conditions.py +351 -308
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/engine.py +53 -44
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/models.py +19 -13
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/pyproject.toml +1 -1
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/LICENSE +0 -0
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/README.md +0 -0
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/handler.py +0 -0
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/queryset.py +0 -0
- {django_bulk_hooks-0.1.81 → django_bulk_hooks-0.1.83}/django_bulk_hooks/registry.py +0 -0
|
@@ -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
|
+
]
|
|
@@ -1,308 +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
|
-
if
|
|
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
|
-
self
|
|
194
|
-
|
|
195
|
-
def
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
self.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
self.
|
|
254
|
-
self.
|
|
255
|
-
|
|
256
|
-
def check(self, instance, original_instance=None):
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
self.field = field
|
|
274
|
-
self.value = value
|
|
275
|
-
|
|
276
|
-
def check(self, instance, original_instance=None):
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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)
|
|
@@ -1,44 +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, 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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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)
|
|
@@ -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
|
|
|
@@ -50,32 +57,31 @@ class HookModelMixin(models.Model):
|
|
|
50
57
|
|
|
51
58
|
def save(self, *args, **kwargs):
|
|
52
59
|
is_create = self.pk is None
|
|
60
|
+
ctx = HookContext(self.__class__)
|
|
53
61
|
|
|
54
62
|
if is_create:
|
|
55
|
-
# For create operations,
|
|
56
|
-
ctx = HookContext(self.__class__)
|
|
57
|
-
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
58
|
-
|
|
63
|
+
# For create operations, let Django save first so relationships are set up
|
|
59
64
|
super().save(*args, **kwargs)
|
|
60
|
-
|
|
65
|
+
|
|
66
|
+
# Now run hooks after Django has set up the object
|
|
67
|
+
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
61
68
|
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
62
69
|
else:
|
|
63
70
|
# For update operations, we need to get the old record
|
|
64
71
|
try:
|
|
65
72
|
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
|
|
74
|
+
# Let Django save first
|
|
69
75
|
super().save(*args, **kwargs)
|
|
70
|
-
|
|
76
|
+
|
|
77
|
+
# Now run hooks after Django has set up the object
|
|
78
|
+
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
71
79
|
run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
|
|
72
80
|
except self.__class__.DoesNotExist:
|
|
73
81
|
# If the old instance doesn't exist, treat as create
|
|
74
|
-
ctx = HookContext(self.__class__)
|
|
75
|
-
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
76
|
-
|
|
77
82
|
super().save(*args, **kwargs)
|
|
78
|
-
|
|
83
|
+
|
|
84
|
+
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
79
85
|
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
80
86
|
|
|
81
87
|
return self
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|