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.

@@ -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
- import traceback
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("engine.run bypassed")
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
- handler_instance = handler_cls()
46
- func = getattr(handler_instance, method_name)
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
- condition_result = condition.check(new, original)
61
- if condition_result:
62
- to_process_new.append(new)
63
- to_process_old.append(original)
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.debug(f"Hook execution failed: {e}")
86
+ logger.error(f"Hook execution failed in {handler_cls.__name__}.{method_name}: {e}")
74
87
  raise
@@ -65,24 +65,33 @@ Hook = HookContextState()
65
65
 
66
66
 
67
67
  class HookMeta(type):
68
- _registered = set()
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
- if key not in HookMeta._registered:
77
- register_hook(
78
- model=model_cls,
79
- event=event,
80
- handler_cls=cls,
81
- method_name=method_name,
82
- condition=condition,
83
- priority=priority,
84
- )
85
- HookMeta._registered.add(key)
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
 
@@ -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, Field, Value, When
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 and ensure they match the order of instances
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 (Subquery, Case, etc.)
89
+ # Check if any of the update values are complex database expressions
69
90
  has_subquery = any(
70
- (hasattr(value, "query") and hasattr(value, "resolve_expression"))
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
- # Check if we're in a bulk operation context to prevent double hook execution
91
- from django_bulk_hooks.context import get_bypass_hooks
92
-
93
- current_bypass_hooks = get_bypass_hooks()
94
-
95
- # Apply field updates to instances for all cases (needed for hook inspection)
96
- for obj in instances:
97
- for field, value in kwargs.items():
98
- # For subquery fields, set the original Subquery object temporarily
99
- # We'll resolve it after database update if needed
100
- setattr(obj, field, value)
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
- ctx = HookContext(model_cls, bypass_hooks=False)
109
-
110
- # For subquery cases, we need special handling
111
- if has_subquery:
112
- # Run validation hooks first with Subquery objects (if validation doesn't access them)
113
- try:
114
- engine.run(
115
- model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx
116
- )
117
- except (TypeError, ValueError, AttributeError) as e:
118
- # If validation fails due to Subquery/Case comparison, skip validation for complex updates
119
- # This is a limitation - validation hooks cannot easily work with unresolved database expressions
120
- logger.warning(
121
- f"Skipping validation hooks for complex update due to: {e}"
122
- )
123
-
124
- # Execute the database update first to compute subquery values
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. Behaves like Django's bulk_create,
179
- but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
180
- passed through to the correct logic. For MTI, only a subset of options may be supported.
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
- # When you bulk insert you don't get the primary keys back (if it's an
185
- # autoincrement, except if can_return_rows_from_bulk_insert=True), so
186
- # you can't insert into the child tables which references this. There
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__}, but got {set(type(obj).__name__ for obj in objs)}"
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) # Pass bypass_hooks
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) # Pass bypass_hooks
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 MTI support.
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
- if any(not isinstance(obj, model_cls) for obj in objs):
277
- raise TypeError(
278
- f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
279
- )
245
+ # Validate inputs
246
+ if not isinstance(objs, (list, tuple)):
247
+ raise TypeError("objs must be a list or tuple")
280
248
 
281
- logger.debug(
282
- f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
283
- )
249
+ if not isinstance(fields, (list, tuple)):
250
+ raise TypeError("fields must be a list or tuple")
284
251
 
285
- # Check for MTI
286
- is_mti = False
287
- for parent in model_cls._meta.all_parents:
288
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
289
- is_mti = True
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
- logger.debug("bulk_update: hooks will run in update()")
294
- ctx = HookContext(model_cls, bypass_hooks=False)
295
- originals = [None] * len(objs) # Placeholder for after_update call
296
- else:
297
- logger.debug("bulk_update: hooks bypassed")
298
- ctx = HookContext(model_cls, bypass_hooks=True)
299
- originals = [None] * len(
300
- objs
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
- # Note: We don't run AFTER_UPDATE hooks here to prevent double execution
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
- logger.debug("bulk_update: skipping AFTER_UPDATE (update() will handle)")
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
@@ -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
- # keep sorted by priority
44
+
45
+ # Sort by priority (lowest numbers execute first, matching engine expectation)
19
46
  hooks.sort(key=lambda x: x[3])
20
- logger.debug(f"Registered {handler_cls.__name__}.{method_name} for {model.__name__}.{event}")
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
- # Only log when hooks are found or for specific events to reduce noise
27
- if hooks or event in ['after_update', 'before_update', 'after_create', 'before_create']:
28
- logger.debug(f"get_hooks {model.__name__}.{event} found {len(hooks)} hooks")
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
1
+ Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.226
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, 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])
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=t_kvgex6_iZEFc5LK-srBTZPe-1bdlYdip5LfWOc6lc,2411
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=xZt8iNdYF-ACz-MnKMY0co6scWINU5V5wC1lyDn844k,4854
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=1Tb-mXHNqzsrcDFU6YURooT1aHNpb56oFGF1ndBaXnw,34763
13
- django_bulk_hooks/registry.py,sha256=8UuhniiH5ChSeOKV1UUbqTEiIu25bZXvcHmkaRbxmME,1131
14
- django_bulk_hooks-0.1.226.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
- django_bulk_hooks-0.1.226.dist-info/METADATA,sha256=9szvW2lyMJr2LMSqDQu32TtD8TKbUgPQ-GsaU-t0jnI,9049
16
- django_bulk_hooks-0.1.226.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
17
- django_bulk_hooks-0.1.226.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.1
2
+ Generator: poetry-core 2.1.3
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any