django-bulk-hooks 0.1.226__py3-none-any.whl → 0.1.227__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/engine.py +27 -14
- django_bulk_hooks/handler.py +21 -12
- django_bulk_hooks/queryset.py +168 -172
- django_bulk_hooks/registry.py +59 -6
- {django_bulk_hooks-0.1.226.dist-info → django_bulk_hooks-0.1.227.dist-info}/METADATA +32 -16
- {django_bulk_hooks-0.1.226.dist-info → django_bulk_hooks-0.1.227.dist-info}/RECORD +8 -8
- {django_bulk_hooks-0.1.226.dist-info → django_bulk_hooks-0.1.227.dist-info}/WHEEL +1 -1
- {django_bulk_hooks-0.1.226.dist-info → django_bulk_hooks-0.1.227.dist-info}/LICENSE +0 -0
django_bulk_hooks/engine.py
CHANGED
|
@@ -10,6 +10,13 @@ logger = logging.getLogger(__name__)
|
|
|
10
10
|
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
11
11
|
"""
|
|
12
12
|
Run hooks for a given model, event, and records.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
model_cls: The Django model class
|
|
16
|
+
event: The hook event (e.g., 'before_create', 'after_update')
|
|
17
|
+
new_records: List of new/updated records
|
|
18
|
+
old_records: List of original records (for comparison)
|
|
19
|
+
ctx: Optional hook context
|
|
13
20
|
"""
|
|
14
21
|
if not new_records:
|
|
15
22
|
return
|
|
@@ -20,14 +27,11 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
20
27
|
if not hooks:
|
|
21
28
|
return
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
stack = traceback.format_stack()
|
|
26
|
-
logger.debug(f"engine.run {model_cls.__name__}.{event} {len(new_records)} records")
|
|
30
|
+
logger.debug(f"Running {len(hooks)} hooks for {model_cls.__name__}.{event} ({len(new_records)} records)")
|
|
27
31
|
|
|
28
32
|
# Check if we're in a bypass context
|
|
29
33
|
if ctx and hasattr(ctx, 'bypass_hooks') and ctx.bypass_hooks:
|
|
30
|
-
logger.debug("
|
|
34
|
+
logger.debug("Hook execution bypassed")
|
|
31
35
|
return
|
|
32
36
|
|
|
33
37
|
# For BEFORE_* events, run model.clean() first for validation
|
|
@@ -39,11 +43,16 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
39
43
|
logger.error("Validation failed for %s: %s", instance, e)
|
|
40
44
|
raise
|
|
41
45
|
|
|
42
|
-
# Process hooks
|
|
46
|
+
# Process hooks in priority order
|
|
43
47
|
for handler_cls, method_name, condition, priority in hooks:
|
|
44
|
-
logger.debug(f"Processing {handler_cls.__name__}.{method_name}")
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
logger.debug(f"Processing {handler_cls.__name__}.{method_name} (priority: {priority})")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
handler_instance = handler_cls()
|
|
52
|
+
func = getattr(handler_instance, method_name)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Failed to instantiate {handler_cls.__name__}: {e}")
|
|
55
|
+
continue
|
|
47
56
|
|
|
48
57
|
to_process_new = []
|
|
49
58
|
to_process_old = []
|
|
@@ -57,10 +66,14 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
57
66
|
to_process_new.append(new)
|
|
58
67
|
to_process_old.append(original)
|
|
59
68
|
else:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
try:
|
|
70
|
+
condition_result = condition.check(new, original)
|
|
71
|
+
if condition_result:
|
|
72
|
+
to_process_new.append(new)
|
|
73
|
+
to_process_old.append(original)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.error(f"Condition check failed for {handler_cls.__name__}.{method_name}: {e}")
|
|
76
|
+
continue
|
|
64
77
|
|
|
65
78
|
if to_process_new:
|
|
66
79
|
logger.debug(f"Executing {handler_cls.__name__}.{method_name} for {len(to_process_new)} records")
|
|
@@ -70,5 +83,5 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
70
83
|
old_records=to_process_old if any(to_process_old) else None,
|
|
71
84
|
)
|
|
72
85
|
except Exception as e:
|
|
73
|
-
logger.
|
|
86
|
+
logger.error(f"Hook execution failed in {handler_cls.__name__}.{method_name}: {e}")
|
|
74
87
|
raise
|
django_bulk_hooks/handler.py
CHANGED
|
@@ -65,24 +65,33 @@ Hook = HookContextState()
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
class HookMeta(type):
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
"""Metaclass that automatically registers hooks when Hook classes are defined."""
|
|
69
|
+
|
|
70
70
|
def __new__(mcs, name, bases, namespace):
|
|
71
71
|
cls = super().__new__(mcs, name, bases, namespace)
|
|
72
|
+
|
|
73
|
+
# Register hooks for this class
|
|
72
74
|
for method_name, method in namespace.items():
|
|
73
75
|
if hasattr(method, "hooks_hooks"):
|
|
74
76
|
for model_cls, event, condition, priority in method.hooks_hooks:
|
|
77
|
+
# Create a unique key for this hook registration
|
|
75
78
|
key = (model_cls, event, cls, method_name)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
|
|
80
|
+
# Register the hook
|
|
81
|
+
register_hook(
|
|
82
|
+
model=model_cls,
|
|
83
|
+
event=event,
|
|
84
|
+
handler_cls=cls,
|
|
85
|
+
method_name=method_name,
|
|
86
|
+
condition=condition,
|
|
87
|
+
priority=priority,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
logger.debug(
|
|
91
|
+
f"Registered hook {cls.__name__}.{method_name} "
|
|
92
|
+
f"for {model_cls.__name__}.{event} with priority {priority}"
|
|
93
|
+
)
|
|
94
|
+
|
|
86
95
|
return cls
|
|
87
96
|
|
|
88
97
|
|
django_bulk_hooks/queryset.py
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
3
|
from django.db import models, transaction
|
|
4
|
-
from django.db.models import AutoField, Case,
|
|
5
|
-
|
|
4
|
+
from django.db.models import AutoField, Case, Value, When
|
|
6
5
|
from django_bulk_hooks import engine
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger(__name__)
|
|
9
6
|
from django_bulk_hooks.constants import (
|
|
10
7
|
AFTER_CREATE,
|
|
11
8
|
AFTER_DELETE,
|
|
@@ -19,6 +16,8 @@ from django_bulk_hooks.constants import (
|
|
|
19
16
|
)
|
|
20
17
|
from django_bulk_hooks.context import HookContext
|
|
21
18
|
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
22
21
|
|
|
23
22
|
class HookQuerySetMixin:
|
|
24
23
|
"""
|
|
@@ -28,11 +27,23 @@ class HookQuerySetMixin:
|
|
|
28
27
|
|
|
29
28
|
@transaction.atomic
|
|
30
29
|
def delete(self):
|
|
30
|
+
"""
|
|
31
|
+
Delete objects from the database with complete hook support.
|
|
32
|
+
|
|
33
|
+
This method runs the complete hook cycle:
|
|
34
|
+
VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
|
|
35
|
+
"""
|
|
31
36
|
objs = list(self)
|
|
32
37
|
if not objs:
|
|
33
38
|
return 0
|
|
34
39
|
|
|
35
40
|
model_cls = self.model
|
|
41
|
+
|
|
42
|
+
# Validate that all objects have primary keys
|
|
43
|
+
for obj in objs:
|
|
44
|
+
if obj.pk is None:
|
|
45
|
+
raise ValueError("Cannot delete objects without primary keys")
|
|
46
|
+
|
|
36
47
|
ctx = HookContext(model_cls)
|
|
37
48
|
|
|
38
49
|
# Run validation hooks first
|
|
@@ -51,6 +62,17 @@ class HookQuerySetMixin:
|
|
|
51
62
|
|
|
52
63
|
@transaction.atomic
|
|
53
64
|
def update(self, **kwargs):
|
|
65
|
+
"""
|
|
66
|
+
Update objects with field values and run complete hook cycle.
|
|
67
|
+
|
|
68
|
+
This method runs the complete hook cycle for all updates:
|
|
69
|
+
VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
|
|
70
|
+
|
|
71
|
+
Supports both simple field updates and complex expressions (Subquery, Case, etc.).
|
|
72
|
+
"""
|
|
73
|
+
# Extract custom parameters
|
|
74
|
+
bypass_hooks = kwargs.pop('bypass_hooks', False)
|
|
75
|
+
|
|
54
76
|
instances = list(self)
|
|
55
77
|
if not instances:
|
|
56
78
|
return 0
|
|
@@ -58,109 +80,49 @@ class HookQuerySetMixin:
|
|
|
58
80
|
model_cls = self.model
|
|
59
81
|
pks = [obj.pk for obj in instances]
|
|
60
82
|
|
|
61
|
-
# Load originals for hook comparison
|
|
62
|
-
# Use the base manager to avoid recursion
|
|
83
|
+
# Load originals for hook comparison
|
|
63
84
|
original_map = {
|
|
64
85
|
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
65
86
|
}
|
|
66
87
|
originals = [original_map.get(obj.pk) for obj in instances]
|
|
67
88
|
|
|
68
|
-
# Check if any of the update values are complex database expressions
|
|
89
|
+
# Check if any of the update values are complex database expressions
|
|
69
90
|
has_subquery = any(
|
|
70
|
-
(hasattr(value, "query") and hasattr(value, "
|
|
71
|
-
or hasattr(
|
|
72
|
-
value, "resolve_expression"
|
|
73
|
-
) # This catches Case, F expressions, etc.
|
|
91
|
+
(hasattr(value, "query") and hasattr(value.query, "model"))
|
|
92
|
+
or (hasattr(value, "get_source_expressions") and value.get_source_expressions())
|
|
74
93
|
for value in kwargs.values()
|
|
75
94
|
)
|
|
76
|
-
|
|
77
|
-
# Also check if any of the instances have complex expressions in their attributes
|
|
78
|
-
# This can happen when bulk_update creates Case expressions and applies them to instances
|
|
79
|
-
if not has_subquery and instances:
|
|
80
|
-
for instance in instances:
|
|
81
|
-
for field_name in kwargs.keys():
|
|
82
|
-
if hasattr(instance, field_name):
|
|
83
|
-
field_value = getattr(instance, field_name)
|
|
84
|
-
if hasattr(field_value, "resolve_expression"):
|
|
85
|
-
has_subquery = True
|
|
86
|
-
break
|
|
87
|
-
if has_subquery:
|
|
88
|
-
break
|
|
89
95
|
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# If we're in a bulk operation context, skip hooks to prevent double execution
|
|
103
|
-
if current_bypass_hooks:
|
|
104
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
105
|
-
# For bulk operations without hooks, execute update
|
|
106
|
-
update_count = super().update(**kwargs)
|
|
96
|
+
# Run hooks only if not bypassed
|
|
97
|
+
if not bypass_hooks:
|
|
98
|
+
# Run VALIDATE_UPDATE hooks
|
|
99
|
+
engine.run(model_cls, VALIDATE_UPDATE, instances, originals, HookContext(model_cls))
|
|
100
|
+
|
|
101
|
+
# Run BEFORE_UPDATE hooks
|
|
102
|
+
engine.run(model_cls, BEFORE_UPDATE, instances, originals, HookContext(model_cls))
|
|
103
|
+
|
|
104
|
+
if has_subquery:
|
|
105
|
+
# For complex expressions, use Django's native update
|
|
106
|
+
# This handles Subquery, Case, F expressions, etc. correctly
|
|
107
|
+
result = super().update(**kwargs)
|
|
107
108
|
else:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
update_count = super().update(**kwargs)
|
|
126
|
-
|
|
127
|
-
# Refresh instances to get computed subquery values BEFORE running BEFORE hooks
|
|
128
|
-
# Use the model's default manager to ensure queryable properties are properly handled
|
|
129
|
-
refreshed_instances = {
|
|
130
|
-
obj.pk: obj for obj in model_cls.objects.filter(pk__in=pks)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
# Update instances in memory with computed values
|
|
134
|
-
for instance in instances:
|
|
135
|
-
if instance.pk in refreshed_instances:
|
|
136
|
-
refreshed_instance = refreshed_instances[instance.pk]
|
|
137
|
-
# Update all fields except primary key with the computed values
|
|
138
|
-
for field in model_cls._meta.fields:
|
|
139
|
-
if field.name != "id":
|
|
140
|
-
setattr(
|
|
141
|
-
instance,
|
|
142
|
-
field.name,
|
|
143
|
-
getattr(refreshed_instance, field.name),
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
# Now run BEFORE_UPDATE hooks with resolved values
|
|
147
|
-
# Note: This is a trade-off - BEFORE hooks run after DB update for subquery cases
|
|
148
|
-
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
149
|
-
else:
|
|
150
|
-
# Normal case without subqueries - run hooks in proper order
|
|
151
|
-
# Run validation hooks first
|
|
152
|
-
engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
|
|
153
|
-
# Then run BEFORE_UPDATE hooks
|
|
154
|
-
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
155
|
-
|
|
156
|
-
# Execute update
|
|
157
|
-
update_count = super().update(**kwargs)
|
|
158
|
-
|
|
159
|
-
# Run AFTER_UPDATE hooks only for standalone updates
|
|
160
|
-
if not current_bypass_hooks:
|
|
161
|
-
engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
|
|
162
|
-
|
|
163
|
-
return update_count
|
|
109
|
+
# For simple field updates, apply changes to instances first
|
|
110
|
+
for obj in instances:
|
|
111
|
+
for field, value in kwargs.items():
|
|
112
|
+
setattr(obj, field, value)
|
|
113
|
+
|
|
114
|
+
# Perform database update using Django's native bulk_update
|
|
115
|
+
# We use the base manager to avoid recursion
|
|
116
|
+
base_manager = model_cls._base_manager
|
|
117
|
+
fields_to_update = list(kwargs.keys())
|
|
118
|
+
base_manager.bulk_update(instances, fields_to_update)
|
|
119
|
+
result = len(instances)
|
|
120
|
+
|
|
121
|
+
# Run AFTER_UPDATE hooks only if not bypassed
|
|
122
|
+
if not bypass_hooks:
|
|
123
|
+
engine.run(model_cls, AFTER_UPDATE, instances, originals, HookContext(model_cls))
|
|
124
|
+
|
|
125
|
+
return result
|
|
164
126
|
|
|
165
127
|
@transaction.atomic
|
|
166
128
|
def bulk_create(
|
|
@@ -175,35 +137,32 @@ class HookQuerySetMixin:
|
|
|
175
137
|
bypass_validation=False,
|
|
176
138
|
):
|
|
177
139
|
"""
|
|
178
|
-
Insert each of the instances into the database
|
|
179
|
-
|
|
180
|
-
|
|
140
|
+
Insert each of the instances into the database with complete hook support.
|
|
141
|
+
|
|
142
|
+
This method runs the complete hook cycle:
|
|
143
|
+
VALIDATE_CREATE → BEFORE_CREATE → DB create → AFTER_CREATE
|
|
144
|
+
|
|
145
|
+
Behaves like Django's bulk_create but supports multi-table inheritance (MTI)
|
|
146
|
+
models and hooks. All arguments are supported and passed through to the correct logic.
|
|
181
147
|
"""
|
|
182
148
|
model_cls = self.model
|
|
183
149
|
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# are two workarounds:
|
|
188
|
-
# 1) This could be implemented if you didn't have an autoincrement pk
|
|
189
|
-
# 2) You could do it by doing O(n) normal inserts into the parent
|
|
190
|
-
# tables to get the primary keys back and then doing a single bulk
|
|
191
|
-
# insert into the childmost table.
|
|
192
|
-
# We currently set the primary keys on the objects when using
|
|
193
|
-
# PostgreSQL via the RETURNING ID clause. It should be possible for
|
|
194
|
-
# Oracle as well, but the semantics for extracting the primary keys is
|
|
195
|
-
# trickier so it's not done yet.
|
|
196
|
-
if batch_size is not None and batch_size <= 0:
|
|
197
|
-
raise ValueError("Batch size must be a positive integer.")
|
|
150
|
+
# Validate inputs
|
|
151
|
+
if not isinstance(objs, (list, tuple)):
|
|
152
|
+
raise TypeError("objs must be a list or tuple")
|
|
198
153
|
|
|
199
154
|
if not objs:
|
|
200
155
|
return objs
|
|
201
156
|
|
|
202
157
|
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
203
158
|
raise TypeError(
|
|
204
|
-
f"bulk_create expected instances of {model_cls.__name__},
|
|
159
|
+
f"bulk_create expected instances of {model_cls.__name__}, "
|
|
160
|
+
f"but got {set(type(obj).__name__ for obj in objs)}"
|
|
205
161
|
)
|
|
206
162
|
|
|
163
|
+
if batch_size is not None and batch_size <= 0:
|
|
164
|
+
raise ValueError("batch_size must be a positive integer.")
|
|
165
|
+
|
|
207
166
|
# Check for MTI - if we detect multi-table inheritance, we need special handling
|
|
208
167
|
# This follows Django's approach: check that the parents share the same concrete model
|
|
209
168
|
# with our model to detect the inheritance pattern ConcreteGrandParent ->
|
|
@@ -217,12 +176,12 @@ class HookQuerySetMixin:
|
|
|
217
176
|
|
|
218
177
|
# Fire hooks before DB ops
|
|
219
178
|
if not bypass_hooks:
|
|
220
|
-
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
179
|
+
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
221
180
|
if not bypass_validation:
|
|
222
181
|
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
223
182
|
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
224
183
|
else:
|
|
225
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
184
|
+
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
226
185
|
logger.debug("bulk_create bypassed hooks")
|
|
227
186
|
|
|
228
187
|
# For MTI models, we need to handle them specially
|
|
@@ -266,76 +225,113 @@ class HookQuerySetMixin:
|
|
|
266
225
|
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
267
226
|
):
|
|
268
227
|
"""
|
|
269
|
-
Bulk update objects in the database with
|
|
228
|
+
Bulk update objects in the database with complete hook support.
|
|
229
|
+
|
|
230
|
+
This method always runs the complete hook cycle:
|
|
231
|
+
VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
objs: List of model instances to update
|
|
235
|
+
fields: List of field names to update
|
|
236
|
+
bypass_hooks: DEPRECATED - kept for backward compatibility only
|
|
237
|
+
bypass_validation: DEPRECATED - kept for backward compatibility only
|
|
238
|
+
**kwargs: Additional arguments passed to Django's bulk_update
|
|
270
239
|
"""
|
|
271
240
|
model_cls = self.model
|
|
272
241
|
|
|
273
242
|
if not objs:
|
|
274
243
|
return []
|
|
275
244
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
245
|
+
# Validate inputs
|
|
246
|
+
if not isinstance(objs, (list, tuple)):
|
|
247
|
+
raise TypeError("objs must be a list or tuple")
|
|
280
248
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
)
|
|
249
|
+
if not isinstance(fields, (list, tuple)):
|
|
250
|
+
raise TypeError("fields must be a list or tuple")
|
|
284
251
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
break
|
|
252
|
+
if not objs:
|
|
253
|
+
return []
|
|
254
|
+
|
|
255
|
+
if not fields:
|
|
256
|
+
raise ValueError("fields cannot be empty")
|
|
291
257
|
|
|
258
|
+
# Validate that all objects are instances of the model
|
|
259
|
+
for obj in objs:
|
|
260
|
+
if not isinstance(obj, model_cls):
|
|
261
|
+
raise TypeError(
|
|
262
|
+
f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
|
|
263
|
+
)
|
|
264
|
+
if obj.pk is None:
|
|
265
|
+
raise ValueError("All objects must have a primary key")
|
|
266
|
+
|
|
267
|
+
# Load originals for hook comparison
|
|
268
|
+
pks = [obj.pk for obj in objs]
|
|
269
|
+
original_map = {
|
|
270
|
+
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
271
|
+
}
|
|
272
|
+
originals = [original_map.get(obj.pk) for obj in objs]
|
|
273
|
+
|
|
274
|
+
# Run VALIDATE_UPDATE hooks
|
|
275
|
+
if not bypass_validation:
|
|
276
|
+
engine.run(
|
|
277
|
+
model_cls, VALIDATE_UPDATE, objs, originals, HookContext(model_cls)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Run BEFORE_UPDATE hooks
|
|
292
281
|
if not bypass_hooks:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
) # Ensure originals is defined for after_update call
|
|
302
|
-
|
|
303
|
-
# Handle auto_now fields like Django's update_or_create does
|
|
304
|
-
fields_set = set(fields)
|
|
305
|
-
pk_fields = model_cls._meta.pk_fields
|
|
306
|
-
for field in model_cls._meta.local_concrete_fields:
|
|
307
|
-
# Only add auto_now fields (like updated_at) that aren't already in the fields list
|
|
308
|
-
# Don't include auto_now_add fields (like created_at) as they should only be set on creation
|
|
309
|
-
if hasattr(field, "auto_now") and field.auto_now:
|
|
310
|
-
if field.name not in fields_set and field.name not in pk_fields:
|
|
311
|
-
fields_set.add(field.name)
|
|
312
|
-
if field.name != field.attname:
|
|
313
|
-
fields_set.add(field.attname)
|
|
314
|
-
fields = list(fields_set)
|
|
315
|
-
|
|
316
|
-
# Handle MTI models differently
|
|
317
|
-
if is_mti:
|
|
318
|
-
result = self._mti_bulk_update(objs, fields, **kwargs)
|
|
319
|
-
else:
|
|
320
|
-
# For single-table models, use Django's built-in bulk_update
|
|
321
|
-
django_kwargs = {
|
|
322
|
-
k: v
|
|
323
|
-
for k, v in kwargs.items()
|
|
324
|
-
if k not in ["bypass_hooks", "bypass_validation"]
|
|
325
|
-
}
|
|
326
|
-
logger.debug("Calling Django bulk_update")
|
|
327
|
-
result = super().bulk_update(objs, fields, **django_kwargs)
|
|
328
|
-
logger.debug(f"Django bulk_update done: {result}")
|
|
282
|
+
engine.run(
|
|
283
|
+
model_cls, BEFORE_UPDATE, objs, originals, HookContext(model_cls)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Perform database update using Django's native bulk_update
|
|
287
|
+
# We use the base manager to avoid recursion
|
|
288
|
+
base_manager = model_cls._base_manager
|
|
289
|
+
result = base_manager.bulk_update(objs, fields, **kwargs)
|
|
329
290
|
|
|
330
|
-
#
|
|
331
|
-
# The update() method will handle all hook execution based on thread-local state
|
|
291
|
+
# Run AFTER_UPDATE hooks
|
|
332
292
|
if not bypass_hooks:
|
|
333
|
-
|
|
334
|
-
else:
|
|
335
|
-
logger.debug("bulk_update: hooks bypassed")
|
|
293
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, HookContext(model_cls))
|
|
336
294
|
|
|
337
295
|
return result
|
|
338
296
|
|
|
297
|
+
@transaction.atomic
|
|
298
|
+
def bulk_delete(self, objs, **kwargs):
|
|
299
|
+
"""
|
|
300
|
+
Delete the given objects from the database with complete hook support.
|
|
301
|
+
|
|
302
|
+
This method runs the complete hook cycle:
|
|
303
|
+
VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
|
|
304
|
+
|
|
305
|
+
This is a convenience method that provides a bulk_delete interface
|
|
306
|
+
similar to bulk_create and bulk_update.
|
|
307
|
+
"""
|
|
308
|
+
model_cls = self.model
|
|
309
|
+
|
|
310
|
+
# Extract custom kwargs
|
|
311
|
+
bypass_hooks = kwargs.pop("bypass_hooks", False)
|
|
312
|
+
|
|
313
|
+
# Validate inputs
|
|
314
|
+
if not isinstance(objs, (list, tuple)):
|
|
315
|
+
raise TypeError("objs must be a list or tuple")
|
|
316
|
+
|
|
317
|
+
if not objs:
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
# Validate that all objects are instances of the model
|
|
321
|
+
for obj in objs:
|
|
322
|
+
if not isinstance(obj, model_cls):
|
|
323
|
+
raise TypeError(
|
|
324
|
+
f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Get the pks to delete
|
|
328
|
+
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
329
|
+
if not pks:
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
# Use the delete() method which already has hook support
|
|
333
|
+
return self.filter(pk__in=pks).delete()
|
|
334
|
+
|
|
339
335
|
def _detect_modified_fields(self, new_instances, original_instances):
|
|
340
336
|
"""
|
|
341
337
|
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
django_bulk_hooks/registry.py
CHANGED
|
@@ -12,23 +12,76 @@ _hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
|
|
|
12
12
|
def register_hook(
|
|
13
13
|
model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
|
|
14
14
|
):
|
|
15
|
+
"""
|
|
16
|
+
Register a hook for a specific model and event.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
model: The Django model class
|
|
20
|
+
event: The hook event (e.g., 'before_create', 'after_update')
|
|
21
|
+
handler_cls: The hook handler class
|
|
22
|
+
method_name: The method name in the handler class
|
|
23
|
+
condition: Optional condition for when the hook should run
|
|
24
|
+
priority: Hook execution priority (higher numbers execute first)
|
|
25
|
+
"""
|
|
26
|
+
if not model or not event or not handler_cls or not method_name:
|
|
27
|
+
logger.warning("Invalid hook registration parameters")
|
|
28
|
+
return
|
|
29
|
+
|
|
15
30
|
key = (model, event)
|
|
16
31
|
hooks = _hooks.setdefault(key, [])
|
|
32
|
+
|
|
33
|
+
# Check for duplicate registrations
|
|
34
|
+
existing = [h for h in hooks if h[0] == handler_cls and h[1] == method_name]
|
|
35
|
+
if existing:
|
|
36
|
+
logger.warning(
|
|
37
|
+
f"Hook {handler_cls.__name__}.{method_name} already registered "
|
|
38
|
+
f"for {model.__name__}.{event}"
|
|
39
|
+
)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Add the hook
|
|
17
43
|
hooks.append((handler_cls, method_name, condition, priority))
|
|
18
|
-
|
|
44
|
+
|
|
45
|
+
# Sort by priority (lowest numbers execute first, matching engine expectation)
|
|
19
46
|
hooks.sort(key=lambda x: x[3])
|
|
20
|
-
|
|
47
|
+
|
|
48
|
+
logger.debug(
|
|
49
|
+
f"Registered {handler_cls.__name__}.{method_name} "
|
|
50
|
+
f"for {model.__name__}.{event} with priority {priority}"
|
|
51
|
+
)
|
|
21
52
|
|
|
22
53
|
|
|
23
54
|
def get_hooks(model, event):
|
|
55
|
+
"""
|
|
56
|
+
Get all registered hooks for a specific model and event.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
model: The Django model class
|
|
60
|
+
event: The hook event
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of (handler_cls, method_name, condition, priority) tuples
|
|
64
|
+
"""
|
|
65
|
+
if not model or not event:
|
|
66
|
+
return []
|
|
67
|
+
|
|
24
68
|
key = (model, event)
|
|
25
69
|
hooks = _hooks.get(key, [])
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
70
|
+
|
|
71
|
+
# Log hook discovery for debugging
|
|
72
|
+
if hooks:
|
|
73
|
+
logger.debug(f"Found {len(hooks)} hooks for {model.__name__}.{event}")
|
|
74
|
+
|
|
29
75
|
return hooks
|
|
30
76
|
|
|
31
77
|
|
|
32
78
|
def list_all_hooks():
|
|
33
|
-
"""Debug function to list all registered hooks"""
|
|
79
|
+
"""Debug function to list all registered hooks."""
|
|
34
80
|
return _hooks
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clear_hooks():
|
|
84
|
+
"""Clear all registered hooks (mainly for testing)."""
|
|
85
|
+
global _hooks
|
|
86
|
+
_hooks.clear()
|
|
87
|
+
logger.debug("All hooks cleared")
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.227
|
|
4
4
|
Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
|
|
5
|
-
Home-page: https://github.com/AugendLimited/django-bulk-hooks
|
|
6
5
|
License: MIT
|
|
7
6
|
Keywords: django,bulk,hooks
|
|
8
7
|
Author: Konrad Beck
|
|
@@ -14,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
15
|
Requires-Dist: Django (>=4.0)
|
|
16
|
+
Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
|
|
17
17
|
Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
@@ -59,21 +59,37 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
|
59
59
|
from .models import Account
|
|
60
60
|
|
|
61
61
|
class AccountHooks(Hook):
|
|
62
|
-
@hook(AFTER_UPDATE,
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
62
|
+
@hook(AFTER_UPDATE, condition=WhenFieldHasChanged('balance'))
|
|
63
|
+
def _notify_balance_change(self, new_records, old_records, **kwargs):
|
|
64
|
+
for new_record, old_record in zip(new_records, old_records):
|
|
65
|
+
if old_record and new_record.balance != old_record.balance:
|
|
66
|
+
print(f"Balance changed from {old_record.balance} to {new_record.balance}")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Bulk Operations with Hooks
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# For complete hook execution, use the update() method
|
|
73
|
+
accounts = Account.objects.filter(active=True)
|
|
74
|
+
accounts.update(balance=1000) # Runs all hooks automatically
|
|
75
|
+
|
|
76
|
+
# For bulk operations with hooks
|
|
77
|
+
accounts = Account.objects.filter(active=True)
|
|
78
|
+
instances = list(accounts)
|
|
79
|
+
|
|
80
|
+
# bulk_update now runs complete hook cycle by default
|
|
81
|
+
accounts.bulk_update(instances, ['balance']) # Runs VALIDATE → BEFORE → DB update → AFTER
|
|
82
|
+
|
|
83
|
+
# To skip hooks (for performance or when called from update())
|
|
84
|
+
accounts.bulk_update(instances, ['balance'], bypass_hooks=True)
|
|
75
85
|
```
|
|
76
86
|
|
|
87
|
+
### Understanding Hook Execution
|
|
88
|
+
|
|
89
|
+
- **`update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
|
|
90
|
+
- **`bulk_update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
|
|
91
|
+
- **`bypass_hooks=True`**: Skips all hooks for performance or to prevent double execution
|
|
92
|
+
|
|
77
93
|
## 🛠 Supported Hook Events
|
|
78
94
|
|
|
79
95
|
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
@@ -3,15 +3,15 @@ django_bulk_hooks/conditions.py,sha256=V_f3Di2uCVUjoyfiU4BQCHmI4uUIRSRroApDcXlvn
|
|
|
3
3
|
django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
|
|
4
4
|
django_bulk_hooks/context.py,sha256=_NbGWTq9s66g0vbFIaqN4GlIHWQmFg3EQ44qY8YvvEg,1537
|
|
5
5
|
django_bulk_hooks/decorators.py,sha256=WD7Jn7QAvY8F4wOsYlIpjoM9-FdHXSKB7hH9ot-lkYQ,4896
|
|
6
|
-
django_bulk_hooks/engine.py,sha256=
|
|
6
|
+
django_bulk_hooks/engine.py,sha256=wiO6HvZkBSHzt1Q3IpXmVppJl30zlsoTheuPPCrGqdU,3118
|
|
7
7
|
django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
|
|
8
|
-
django_bulk_hooks/handler.py,sha256=
|
|
8
|
+
django_bulk_hooks/handler.py,sha256=IRgJ6uoyD1NifqzZpL2YxOqInJ4MFlS3vZkAa5ZTIVo,5210
|
|
9
9
|
django_bulk_hooks/manager.py,sha256=nfWiwU5-yAoxdnQsUMohxtyCpkV0MBv6X3wmipr9eQY,3697
|
|
10
10
|
django_bulk_hooks/models.py,sha256=mj4f93L64CN1XBS29RlS02WnZjCNoUkai97vKqjgZQ8,4575
|
|
11
11
|
django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
|
|
12
|
-
django_bulk_hooks/queryset.py,sha256=
|
|
13
|
-
django_bulk_hooks/registry.py,sha256=
|
|
14
|
-
django_bulk_hooks-0.1.
|
|
15
|
-
django_bulk_hooks-0.1.
|
|
16
|
-
django_bulk_hooks-0.1.
|
|
17
|
-
django_bulk_hooks-0.1.
|
|
12
|
+
django_bulk_hooks/queryset.py,sha256=xgnuwl8Ha9IhVzKEvjvCX5jUuRZgc0tWYwaKreuDvpw,32478
|
|
13
|
+
django_bulk_hooks/registry.py,sha256=h59veo8Qh4Afj8ZP_4Jqr-2S6ebXjUJ7pJTdfqzfXkE,2572
|
|
14
|
+
django_bulk_hooks-0.1.227.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
|
|
15
|
+
django_bulk_hooks-0.1.227.dist-info/METADATA,sha256=rY5wk0lHKwxaqEdbVz3BLw3DnRBjMhXSN4syZLuWGX4,9743
|
|
16
|
+
django_bulk_hooks-0.1.227.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
17
|
+
django_bulk_hooks-0.1.227.dist-info/RECORD,,
|
|
File without changes
|