django-bulk-hooks 0.1.102__py3-none-any.whl → 0.1.104__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 +3 -49
- django_bulk_hooks/conditions.py +66 -261
- django_bulk_hooks/decorators.py +3 -48
- django_bulk_hooks/engine.py +9 -37
- django_bulk_hooks/handler.py +1 -1
- django_bulk_hooks/manager.py +151 -162
- django_bulk_hooks/models.py +35 -66
- django_bulk_hooks/priority.py +16 -0
- django_bulk_hooks/queryset.py +3 -2
- django_bulk_hooks/registry.py +3 -2
- django_bulk_hooks-0.1.104.dist-info/METADATA +217 -0
- django_bulk_hooks-0.1.104.dist-info/RECORD +17 -0
- {django_bulk_hooks-0.1.102.dist-info → django_bulk_hooks-0.1.104.dist-info}/WHEEL +1 -1
- django_bulk_hooks-0.1.102.dist-info/METADATA +0 -228
- django_bulk_hooks-0.1.102.dist-info/RECORD +0 -16
- {django_bulk_hooks-0.1.102.dist-info → django_bulk_hooks-0.1.104.dist-info}/LICENSE +0 -0
django_bulk_hooks/manager.py
CHANGED
|
@@ -17,113 +17,118 @@ from django_bulk_hooks.queryset import HookQuerySet
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class BulkHookManager(models.Manager):
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def __init__(self):
|
|
25
|
-
super().__init__()
|
|
26
|
-
self._chunk_size = self.DEFAULT_CHUNK_SIZE
|
|
27
|
-
self._related_chunk_size = self.DEFAULT_RELATED_CHUNK_SIZE
|
|
28
|
-
self._prefetch_related_fields = set()
|
|
29
|
-
self._select_related_fields = set()
|
|
30
|
-
|
|
31
|
-
def configure(
|
|
32
|
-
self,
|
|
33
|
-
chunk_size=None,
|
|
34
|
-
related_chunk_size=None,
|
|
35
|
-
select_related=None,
|
|
36
|
-
prefetch_related=None,
|
|
37
|
-
):
|
|
38
|
-
"""
|
|
39
|
-
Configure bulk operation parameters for this manager.
|
|
20
|
+
CHUNK_SIZE = 200
|
|
21
|
+
|
|
22
|
+
def get_queryset(self):
|
|
23
|
+
return HookQuerySet(self.model, using=self._db)
|
|
40
24
|
|
|
41
|
-
|
|
42
|
-
chunk_size: Number of objects to process in each bulk operation chunk
|
|
43
|
-
related_chunk_size: Number of objects to fetch in each related object query
|
|
44
|
-
select_related: List of fields to always select_related in bulk operations
|
|
45
|
-
prefetch_related: List of fields to always prefetch_related in bulk operations
|
|
25
|
+
def _has_multi_table_inheritance(self, model_cls):
|
|
46
26
|
"""
|
|
47
|
-
if
|
|
48
|
-
self._chunk_size = chunk_size
|
|
49
|
-
if related_chunk_size is not None:
|
|
50
|
-
self._related_chunk_size = related_chunk_size
|
|
51
|
-
if select_related:
|
|
52
|
-
self._select_related_fields.update(select_related)
|
|
53
|
-
if prefetch_related:
|
|
54
|
-
self._prefetch_related_fields.update(prefetch_related)
|
|
55
|
-
|
|
56
|
-
def _load_originals_optimized(self, pks, fields_to_fetch=None):
|
|
27
|
+
Check if this model uses multi-table inheritance.
|
|
57
28
|
"""
|
|
58
|
-
|
|
29
|
+
return (
|
|
30
|
+
model_cls._meta.parents and
|
|
31
|
+
not all(parent._meta.abstract for parent in model_cls._meta.parents.values())
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def _get_base_model(self, model_cls):
|
|
59
35
|
"""
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# Only select specific fields if provided and not empty
|
|
63
|
-
if fields_to_fetch and len(fields_to_fetch) > 0:
|
|
64
|
-
queryset = queryset.only("pk", *fields_to_fetch)
|
|
65
|
-
|
|
66
|
-
# Apply configured related field optimizations
|
|
67
|
-
if self._select_related_fields:
|
|
68
|
-
queryset = queryset.select_related(*self._select_related_fields)
|
|
69
|
-
if self._prefetch_related_fields:
|
|
70
|
-
queryset = queryset.prefetch_related(*self._prefetch_related_fields)
|
|
71
|
-
|
|
72
|
-
# Batch load in chunks to avoid memory issues
|
|
73
|
-
all_originals = []
|
|
74
|
-
for i in range(0, len(pks), self._related_chunk_size):
|
|
75
|
-
chunk_pks = pks[i : i + self._related_chunk_size]
|
|
76
|
-
chunk_originals = list(queryset.filter(pk__in=chunk_pks))
|
|
77
|
-
all_originals.extend(chunk_originals)
|
|
78
|
-
|
|
79
|
-
return all_originals
|
|
80
|
-
|
|
81
|
-
def _get_fields_to_fetch(self, objs, fields):
|
|
36
|
+
Get the base model (first non-abstract parent or self).
|
|
82
37
|
"""
|
|
83
|
-
|
|
84
|
-
|
|
38
|
+
base_model = model_cls
|
|
39
|
+
while base_model._meta.parents:
|
|
40
|
+
parent = next(iter(base_model._meta.parents.values()))
|
|
41
|
+
if not parent._meta.abstract:
|
|
42
|
+
base_model = parent
|
|
43
|
+
else:
|
|
44
|
+
break
|
|
45
|
+
return base_model
|
|
46
|
+
|
|
47
|
+
def _extract_base_objects(self, objs, model_cls):
|
|
85
48
|
"""
|
|
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
|
-
|
|
49
|
+
Extract base model objects from inherited objects.
|
|
50
|
+
"""
|
|
51
|
+
base_model = self._get_base_model(model_cls)
|
|
52
|
+
base_objects = []
|
|
53
|
+
|
|
54
|
+
for obj in objs:
|
|
55
|
+
base_obj = base_model()
|
|
56
|
+
for field in base_model._meta.fields:
|
|
57
|
+
if field.name != 'id' and hasattr(obj, field.name):
|
|
58
|
+
setattr(base_obj, field.name, getattr(obj, field.name))
|
|
59
|
+
base_objects.append(base_obj)
|
|
60
|
+
|
|
61
|
+
return base_objects
|
|
62
|
+
|
|
63
|
+
def _extract_child_objects(self, objs, model_cls):
|
|
64
|
+
"""
|
|
65
|
+
Extract child model objects from inherited objects.
|
|
66
|
+
"""
|
|
67
|
+
child_objects = []
|
|
68
|
+
|
|
69
|
+
for obj in objs:
|
|
70
|
+
child_obj = model_cls()
|
|
71
|
+
child_obj.pk = obj.pk # Set the same PK as base
|
|
72
|
+
|
|
73
|
+
# Copy only fields specific to this model
|
|
74
|
+
for field in model_cls._meta.fields:
|
|
75
|
+
if (field.name != 'id' and
|
|
76
|
+
field.model == model_cls and
|
|
77
|
+
hasattr(obj, field.name)):
|
|
78
|
+
setattr(child_obj, field.name, getattr(obj, field.name))
|
|
79
|
+
|
|
80
|
+
child_objects.append(child_obj)
|
|
81
|
+
|
|
82
|
+
return child_objects
|
|
83
|
+
|
|
84
|
+
def _bulk_create_inherited(self, objs, **kwargs):
|
|
85
|
+
"""
|
|
86
|
+
Handle bulk create for inherited models by handling each table separately.
|
|
87
|
+
"""
|
|
88
|
+
if not objs:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
model_cls = self.model
|
|
92
|
+
result = []
|
|
93
|
+
|
|
94
|
+
# Group objects by their actual class
|
|
95
|
+
objects_by_class = {}
|
|
96
|
+
for obj in objs:
|
|
97
|
+
obj_class = obj.__class__
|
|
98
|
+
if obj_class not in objects_by_class:
|
|
99
|
+
objects_by_class[obj_class] = []
|
|
100
|
+
objects_by_class[obj_class].append(obj)
|
|
101
|
+
|
|
102
|
+
for obj_class, class_objects in objects_by_class.items():
|
|
103
|
+
# Check if this class has multi-table inheritance
|
|
104
|
+
parent_models = [p for p in obj_class._meta.get_parent_list()
|
|
105
|
+
if not p._meta.abstract]
|
|
106
|
+
|
|
107
|
+
if not parent_models:
|
|
108
|
+
# No inheritance, use standard bulk_create
|
|
109
|
+
chunk_result = super(models.Manager, self).bulk_create(class_objects, **kwargs)
|
|
110
|
+
result.extend(chunk_result)
|
|
116
111
|
continue
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
112
|
+
|
|
113
|
+
# Handle multi-table inheritance
|
|
114
|
+
# Step 1: Bulk create base objects without hooks
|
|
115
|
+
base_objects = self._extract_base_objects(class_objects, obj_class)
|
|
116
|
+
created_base = super(models.Manager, self).bulk_create(base_objects, **kwargs)
|
|
117
|
+
|
|
118
|
+
# Step 2: Update original objects with base IDs
|
|
119
|
+
for obj, base_obj in zip(class_objects, created_base):
|
|
120
|
+
obj.pk = base_obj.pk
|
|
121
|
+
obj._state.adding = False
|
|
122
|
+
|
|
123
|
+
# Step 3: Bulk create child objects without hooks
|
|
124
|
+
child_objects = self._extract_child_objects(class_objects, obj_class)
|
|
125
|
+
if child_objects:
|
|
126
|
+
# Use _base_manager to avoid recursion
|
|
127
|
+
obj_class._base_manager.bulk_create(child_objects, **kwargs)
|
|
128
|
+
|
|
129
|
+
result.extend(class_objects)
|
|
130
|
+
|
|
131
|
+
return result
|
|
127
132
|
|
|
128
133
|
@transaction.atomic
|
|
129
134
|
def bulk_update(
|
|
@@ -140,42 +145,36 @@ class BulkHookManager(models.Manager):
|
|
|
140
145
|
)
|
|
141
146
|
|
|
142
147
|
if not bypass_hooks:
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
originals = self._load_originals_optimized(pks, fields_to_fetch)
|
|
149
|
-
|
|
150
|
-
# Create a mapping for quick lookup
|
|
151
|
-
original_map = {obj.pk: obj for obj in originals}
|
|
152
|
-
|
|
153
|
-
# Align originals with new instances
|
|
154
|
-
aligned_originals = [original_map.get(obj.pk) for obj in objs]
|
|
148
|
+
# Load originals for hook comparison and ensure they match the order of new instances
|
|
149
|
+
original_map = {
|
|
150
|
+
obj.pk: obj for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
|
|
151
|
+
}
|
|
152
|
+
originals = [original_map.get(obj.pk) for obj in objs]
|
|
155
153
|
|
|
156
154
|
ctx = HookContext(model_cls)
|
|
157
155
|
|
|
158
156
|
# Run validation hooks first
|
|
159
157
|
if not bypass_validation:
|
|
160
|
-
engine.run(model_cls, VALIDATE_UPDATE, objs,
|
|
158
|
+
engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
|
|
161
159
|
|
|
162
160
|
# Then run business logic hooks
|
|
163
|
-
engine.run(model_cls, BEFORE_UPDATE, objs,
|
|
161
|
+
engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
|
|
164
162
|
|
|
165
163
|
# Automatically detect fields that were modified during BEFORE_UPDATE hooks
|
|
166
|
-
modified_fields = self._detect_modified_fields(objs,
|
|
164
|
+
modified_fields = self._detect_modified_fields(objs, originals)
|
|
167
165
|
if modified_fields:
|
|
166
|
+
# Convert to set for efficient union operation
|
|
168
167
|
fields_set = set(fields)
|
|
169
168
|
fields_set.update(modified_fields)
|
|
170
169
|
fields = list(fields_set)
|
|
171
170
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
172
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
173
|
+
# Call the base implementation to avoid re-triggering this method
|
|
175
174
|
super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
|
|
176
175
|
|
|
177
176
|
if not bypass_hooks:
|
|
178
|
-
engine.run(model_cls, AFTER_UPDATE, objs,
|
|
177
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
179
178
|
|
|
180
179
|
return objs
|
|
181
180
|
|
|
@@ -187,17 +186,11 @@ class BulkHookManager(models.Manager):
|
|
|
187
186
|
if not original_instances:
|
|
188
187
|
return set()
|
|
189
188
|
|
|
190
|
-
# Create a mapping of pk to original instance for efficient lookup
|
|
191
|
-
original_map = {obj.pk: obj for obj in original_instances if obj.pk is not None}
|
|
192
|
-
|
|
193
189
|
modified_fields = set()
|
|
194
190
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
original = original_map.get(new_instance.pk)
|
|
200
|
-
if not original:
|
|
191
|
+
# Since original_instances is now ordered to match new_instances, we can zip them directly
|
|
192
|
+
for new_instance, original in zip(new_instances, original_instances):
|
|
193
|
+
if new_instance.pk is None or original is None:
|
|
201
194
|
continue
|
|
202
195
|
|
|
203
196
|
# Compare all fields to detect changes
|
|
@@ -224,42 +217,43 @@ class BulkHookManager(models.Manager):
|
|
|
224
217
|
|
|
225
218
|
@transaction.atomic
|
|
226
219
|
def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
|
|
227
|
-
if not objs:
|
|
228
|
-
return []
|
|
229
|
-
|
|
230
220
|
model_cls = self.model
|
|
231
|
-
result = []
|
|
232
221
|
|
|
233
222
|
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
234
223
|
raise TypeError(
|
|
235
224
|
f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
236
225
|
)
|
|
237
226
|
|
|
227
|
+
# Check if this model uses multi-table inheritance
|
|
228
|
+
has_multi_table_inheritance = self._has_multi_table_inheritance(model_cls)
|
|
229
|
+
|
|
230
|
+
result = []
|
|
231
|
+
|
|
238
232
|
if not bypass_hooks:
|
|
239
233
|
ctx = HookContext(model_cls)
|
|
240
234
|
|
|
241
|
-
#
|
|
235
|
+
# Run validation hooks first
|
|
242
236
|
if not bypass_validation:
|
|
243
|
-
|
|
244
|
-
chunk = objs[i : i + self._chunk_size]
|
|
245
|
-
engine.run(model_cls, VALIDATE_CREATE, chunk, ctx=ctx)
|
|
237
|
+
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
246
238
|
|
|
247
|
-
#
|
|
248
|
-
|
|
249
|
-
chunk = objs[i : i + self._chunk_size]
|
|
250
|
-
engine.run(model_cls, BEFORE_CREATE, chunk, ctx=ctx)
|
|
239
|
+
# Then run business logic hooks
|
|
240
|
+
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
251
241
|
|
|
252
242
|
# Perform bulk create in chunks
|
|
253
|
-
for i in range(0, len(objs), self.
|
|
254
|
-
chunk = objs[i : i + self.
|
|
255
|
-
|
|
243
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
244
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
245
|
+
|
|
246
|
+
if has_multi_table_inheritance:
|
|
247
|
+
# Use our multi-table bulk create
|
|
248
|
+
created_chunk = self._bulk_create_inherited(chunk, **kwargs)
|
|
249
|
+
else:
|
|
250
|
+
# Use Django's standard bulk create
|
|
251
|
+
created_chunk = super(models.Manager, self).bulk_create(chunk, **kwargs)
|
|
252
|
+
|
|
256
253
|
result.extend(created_chunk)
|
|
257
254
|
|
|
258
255
|
if not bypass_hooks:
|
|
259
|
-
|
|
260
|
-
for i in range(0, len(result), self._chunk_size):
|
|
261
|
-
chunk = result[i : i + self._chunk_size]
|
|
262
|
-
engine.run(model_cls, AFTER_CREATE, chunk, ctx=ctx)
|
|
256
|
+
engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
|
|
263
257
|
|
|
264
258
|
return result
|
|
265
259
|
|
|
@@ -271,7 +265,6 @@ class BulkHookManager(models.Manager):
|
|
|
271
265
|
return []
|
|
272
266
|
|
|
273
267
|
model_cls = self.model
|
|
274
|
-
chunk_size = batch_size or self._chunk_size
|
|
275
268
|
|
|
276
269
|
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
277
270
|
raise TypeError(
|
|
@@ -281,25 +274,21 @@ class BulkHookManager(models.Manager):
|
|
|
281
274
|
ctx = HookContext(model_cls)
|
|
282
275
|
|
|
283
276
|
if not bypass_hooks:
|
|
284
|
-
#
|
|
285
|
-
|
|
286
|
-
|
|
277
|
+
# Run validation hooks first
|
|
278
|
+
if not bypass_validation:
|
|
279
|
+
engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
|
|
287
280
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
engine.run(model_cls, BEFORE_DELETE, chunk, ctx=ctx)
|
|
281
|
+
# Then run business logic hooks
|
|
282
|
+
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
291
283
|
|
|
292
|
-
# Collect PKs and delete in chunks
|
|
293
284
|
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
285
|
+
|
|
286
|
+
# Use base manager for the actual deletion to prevent recursion
|
|
287
|
+
# The hooks have already been fired above, so we don't need them again
|
|
288
|
+
model_cls._base_manager.filter(pk__in=pks).delete()
|
|
297
289
|
|
|
298
290
|
if not bypass_hooks:
|
|
299
|
-
|
|
300
|
-
for i in range(0, len(objs), chunk_size):
|
|
301
|
-
chunk = objs[i : i + chunk_size]
|
|
302
|
-
engine.run(model_cls, AFTER_DELETE, chunk, ctx=ctx)
|
|
291
|
+
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
303
292
|
|
|
304
293
|
return objs
|
|
305
294
|
|
django_bulk_hooks/models.py
CHANGED
|
@@ -14,35 +14,6 @@ from django_bulk_hooks.constants import (
|
|
|
14
14
|
from django_bulk_hooks.context import HookContext
|
|
15
15
|
from django_bulk_hooks.engine import run
|
|
16
16
|
from django_bulk_hooks.manager import BulkHookManager
|
|
17
|
-
from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
|
|
18
|
-
from functools import wraps
|
|
19
|
-
import contextlib
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@contextlib.contextmanager
|
|
23
|
-
def patch_foreign_key_behavior():
|
|
24
|
-
"""
|
|
25
|
-
Temporarily patches Django's foreign key descriptor to return None instead of raising
|
|
26
|
-
RelatedObjectDoesNotExist when accessing an unset foreign key field.
|
|
27
|
-
"""
|
|
28
|
-
original_get = ForwardManyToOneDescriptor.__get__
|
|
29
|
-
|
|
30
|
-
@wraps(original_get)
|
|
31
|
-
def safe_get(self, instance, cls=None):
|
|
32
|
-
if instance is None:
|
|
33
|
-
return self
|
|
34
|
-
try:
|
|
35
|
-
return original_get(self, instance, cls)
|
|
36
|
-
except self.RelatedObjectDoesNotExist:
|
|
37
|
-
return None
|
|
38
|
-
|
|
39
|
-
# Patch the descriptor
|
|
40
|
-
ForwardManyToOneDescriptor.__get__ = safe_get
|
|
41
|
-
try:
|
|
42
|
-
yield
|
|
43
|
-
finally:
|
|
44
|
-
# Restore original behavior
|
|
45
|
-
ForwardManyToOneDescriptor.__get__ = original_get
|
|
46
17
|
|
|
47
18
|
|
|
48
19
|
class HookModelMixin(models.Model):
|
|
@@ -57,70 +28,68 @@ class HookModelMixin(models.Model):
|
|
|
57
28
|
This ensures that when Django calls clean() (like in admin forms),
|
|
58
29
|
it triggers the VALIDATE_* hooks for validation only.
|
|
59
30
|
"""
|
|
60
|
-
# Call Django's clean first
|
|
61
31
|
super().clean()
|
|
62
32
|
|
|
63
|
-
# Skip hook validation during admin form validation
|
|
64
|
-
# This prevents RelatedObjectDoesNotExist errors when Django hasn't
|
|
65
|
-
# fully set up the object's relationships yet
|
|
66
|
-
if hasattr(self, '_state') and getattr(self._state, 'validating', False):
|
|
67
|
-
return
|
|
68
|
-
|
|
69
33
|
# Determine if this is a create or update operation
|
|
70
34
|
is_create = self.pk is None
|
|
71
35
|
|
|
72
36
|
if is_create:
|
|
73
37
|
# For create operations, run VALIDATE_CREATE hooks for validation
|
|
74
38
|
ctx = HookContext(self.__class__)
|
|
75
|
-
|
|
76
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
39
|
+
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
77
40
|
else:
|
|
78
41
|
# For update operations, run VALIDATE_UPDATE hooks for validation
|
|
79
42
|
try:
|
|
80
43
|
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
81
44
|
ctx = HookContext(self.__class__)
|
|
82
|
-
|
|
83
|
-
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
45
|
+
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
84
46
|
except self.__class__.DoesNotExist:
|
|
85
47
|
# If the old instance doesn't exist, treat as create
|
|
86
48
|
ctx = HookContext(self.__class__)
|
|
87
|
-
|
|
88
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
49
|
+
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
89
50
|
|
|
90
51
|
def save(self, *args, **kwargs):
|
|
91
52
|
is_create = self.pk is None
|
|
92
|
-
ctx = HookContext(self.__class__)
|
|
93
53
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
54
|
+
if is_create:
|
|
55
|
+
# For create operations, we don't have old records
|
|
56
|
+
ctx = HookContext(self.__class__)
|
|
57
|
+
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
58
|
+
|
|
59
|
+
super().save(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
62
|
+
else:
|
|
63
|
+
# For update operations, we need to get the old record
|
|
64
|
+
try:
|
|
65
|
+
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
66
|
+
ctx = HookContext(self.__class__)
|
|
67
|
+
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
68
|
+
|
|
69
|
+
super().save(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
|
|
72
|
+
except self.__class__.DoesNotExist:
|
|
73
|
+
# If the old instance doesn't exist, treat as create
|
|
74
|
+
ctx = HookContext(self.__class__)
|
|
98
75
|
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
76
|
+
|
|
99
77
|
super().save(*args, **kwargs)
|
|
78
|
+
|
|
100
79
|
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
101
|
-
else:
|
|
102
|
-
# For update operations
|
|
103
|
-
try:
|
|
104
|
-
old_instance = self.__class__.objects.get(pk=self.pk)
|
|
105
|
-
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
106
|
-
super().save(*args, **kwargs)
|
|
107
|
-
run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
|
|
108
|
-
except self.__class__.DoesNotExist:
|
|
109
|
-
# If the old instance doesn't exist, treat as create
|
|
110
|
-
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
111
|
-
super().save(*args, **kwargs)
|
|
112
|
-
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
113
80
|
|
|
114
81
|
return self
|
|
115
82
|
|
|
116
83
|
def delete(self, *args, **kwargs):
|
|
117
84
|
ctx = HookContext(self.__class__)
|
|
118
85
|
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
86
|
+
# Run validation hooks first
|
|
87
|
+
run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
|
|
88
|
+
|
|
89
|
+
# Then run business logic hooks
|
|
90
|
+
run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
|
|
91
|
+
|
|
92
|
+
result = super().delete(*args, **kwargs)
|
|
93
|
+
|
|
94
|
+
run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
|
|
126
95
|
return result
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Priority(IntEnum):
|
|
5
|
+
"""
|
|
6
|
+
Named priorities for django-bulk-hooks hooks.
|
|
7
|
+
|
|
8
|
+
Lower values run earlier (higher priority).
|
|
9
|
+
Hooks are sorted in ascending order.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
HIGHEST = 0 # runs first
|
|
13
|
+
HIGH = 25 # runs early
|
|
14
|
+
NORMAL = 50 # default ordering
|
|
15
|
+
LOW = 75 # runs later
|
|
16
|
+
LOWEST = 100 # runs last
|
django_bulk_hooks/queryset.py
CHANGED
|
@@ -18,8 +18,9 @@ class HookQuerySet(models.QuerySet):
|
|
|
18
18
|
model_cls = self.model
|
|
19
19
|
pks = [obj.pk for obj in instances]
|
|
20
20
|
|
|
21
|
-
# Load originals for hook comparison
|
|
22
|
-
|
|
21
|
+
# Load originals for hook comparison and ensure they match the order of instances
|
|
22
|
+
original_map = {obj.pk: obj for obj in model_cls.objects.filter(pk__in=pks)}
|
|
23
|
+
originals = [original_map.get(obj.pk) for obj in instances]
|
|
23
24
|
|
|
24
25
|
# Apply field updates to instances
|
|
25
26
|
for obj in instances:
|
django_bulk_hooks/registry.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
2
|
from typing import Union
|
|
3
3
|
|
|
4
|
-
from django_bulk_hooks.
|
|
4
|
+
from django_bulk_hooks.priority import Priority
|
|
5
5
|
|
|
6
6
|
_hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
|
|
7
7
|
|
|
@@ -17,7 +17,8 @@ def register_hook(
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def get_hooks(model, event):
|
|
20
|
-
|
|
20
|
+
hooks = _hooks.get((model, event), [])
|
|
21
|
+
return hooks
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
def list_all_hooks():
|