django-bulk-hooks 0.1.226__tar.gz → 0.1.228__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.
Files changed (20) hide show
  1. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/PKG-INFO +32 -16
  2. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/README.md +29 -13
  3. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/engine.py +30 -15
  4. django_bulk_hooks-0.1.228/django_bulk_hooks/enums.py +3 -0
  5. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/handler.py +35 -13
  6. django_bulk_hooks-0.1.228/django_bulk_hooks/priority.py +16 -0
  7. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/queryset.py +197 -170
  8. django_bulk_hooks-0.1.228/django_bulk_hooks/registry.py +91 -0
  9. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/pyproject.toml +1 -1
  10. django_bulk_hooks-0.1.226/django_bulk_hooks/enums.py +0 -17
  11. django_bulk_hooks-0.1.226/django_bulk_hooks/priority.py +0 -16
  12. django_bulk_hooks-0.1.226/django_bulk_hooks/registry.py +0 -34
  13. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/LICENSE +0 -0
  14. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/__init__.py +0 -0
  15. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/conditions.py +0 -0
  16. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/constants.py +0 -0
  17. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/context.py +0 -0
  18. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/decorators.py +0 -0
  19. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/manager.py +0 -0
  20. {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/models.py +0 -0
@@ -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.228
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`
@@ -40,21 +40,37 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
40
40
  from .models import Account
41
41
 
42
42
  class AccountHooks(Hook):
43
- @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
44
- def log_balance_change(self, new_records, old_records):
45
- print("Accounts updated:", [a.pk for a in new_records])
46
-
47
- @hook(BEFORE_CREATE, model=Account)
48
- def before_create(self, new_records, old_records):
49
- for account in new_records:
50
- if account.balance < 0:
51
- raise ValueError("Account cannot have negative balance")
52
-
53
- @hook(AFTER_DELETE, model=Account)
54
- def after_delete(self, new_records, old_records):
55
- print("Accounts deleted:", [a.pk for a in old_records])
43
+ @hook(AFTER_UPDATE, condition=WhenFieldHasChanged('balance'))
44
+ def _notify_balance_change(self, new_records, old_records, **kwargs):
45
+ for new_record, old_record in zip(new_records, old_records):
46
+ if old_record and new_record.balance != old_record.balance:
47
+ print(f"Balance changed from {old_record.balance} to {new_record.balance}")
48
+ ```
49
+
50
+ ### Bulk Operations with Hooks
51
+
52
+ ```python
53
+ # For complete hook execution, use the update() method
54
+ accounts = Account.objects.filter(active=True)
55
+ accounts.update(balance=1000) # Runs all hooks automatically
56
+
57
+ # For bulk operations with hooks
58
+ accounts = Account.objects.filter(active=True)
59
+ instances = list(accounts)
60
+
61
+ # bulk_update now runs complete hook cycle by default
62
+ accounts.bulk_update(instances, ['balance']) # Runs VALIDATE → BEFORE → DB update → AFTER
63
+
64
+ # To skip hooks (for performance or when called from update())
65
+ accounts.bulk_update(instances, ['balance'], bypass_hooks=True)
56
66
  ```
57
67
 
68
+ ### Understanding Hook Execution
69
+
70
+ - **`update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
71
+ - **`bulk_update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
72
+ - **`bypass_hooks=True`**: Skips all hooks for performance or to prevent double execution
73
+
58
74
  ## 🛠 Supported Hook Events
59
75
 
60
76
  - `BEFORE_CREATE`, `AFTER_CREATE`
@@ -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,17 @@ 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 (highest priority first)
47
+ # Registry now sorts by priority (highest first)
43
48
  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)
49
+ logger.debug(f"Processing {handler_cls.__name__}.{method_name} (priority: {priority})")
50
+
51
+ try:
52
+ handler_instance = handler_cls()
53
+ func = getattr(handler_instance, method_name)
54
+ except Exception as e:
55
+ logger.error(f"Failed to instantiate {handler_cls.__name__}: {e}")
56
+ continue
47
57
 
48
58
  to_process_new = []
49
59
  to_process_old = []
@@ -57,18 +67,23 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
57
67
  to_process_new.append(new)
58
68
  to_process_old.append(original)
59
69
  else:
60
- condition_result = condition.check(new, original)
61
- if condition_result:
62
- to_process_new.append(new)
63
- to_process_old.append(original)
70
+ try:
71
+ condition_result = condition.check(new, original)
72
+ if condition_result:
73
+ to_process_new.append(new)
74
+ to_process_old.append(original)
75
+ except Exception as e:
76
+ logger.error(f"Condition check failed for {handler_cls.__name__}.{method_name}: {e}")
77
+ continue
64
78
 
65
79
  if to_process_new:
66
80
  logger.debug(f"Executing {handler_cls.__name__}.{method_name} for {len(to_process_new)} records")
67
81
  try:
68
82
  func(
69
83
  new_records=to_process_new,
70
- old_records=to_process_old if any(to_process_old) else None,
84
+ old_records=to_process_old if any(x is not None for x in to_process_old) else None,
71
85
  )
72
86
  except Exception as e:
73
- logger.debug(f"Hook execution failed: {e}")
87
+ logger.error(f"Hook execution failed in {handler_cls.__name__}.{method_name}: {e}")
88
+ # Re-raise the exception to ensure proper error handling
74
89
  raise
@@ -0,0 +1,3 @@
1
+ from django_bulk_hooks.priority import Priority
2
+
3
+ DEFAULT_PRIORITY = Priority.NORMAL
@@ -61,28 +61,44 @@ class HookContextState:
61
61
  return hook_vars.model
62
62
 
63
63
 
64
- Hook = HookContextState()
65
-
66
-
67
64
  class HookMeta(type):
68
- _registered = set()
69
-
65
+ """Metaclass that automatically registers hooks when Hook classes are defined."""
66
+
70
67
  def __new__(mcs, name, bases, namespace):
71
68
  cls = super().__new__(mcs, name, bases, namespace)
72
- for method_name, method in namespace.items():
73
- if hasattr(method, "hooks_hooks"):
74
- for model_cls, event, condition, priority in method.hooks_hooks:
75
- key = (model_cls, event, cls, method_name)
76
- if key not in HookMeta._registered:
69
+
70
+ # Register hooks for this class, including inherited methods
71
+ # We need to check all methods in the MRO to handle inheritance
72
+ for attr_name in dir(cls):
73
+ if attr_name.startswith('_'):
74
+ continue
75
+
76
+ try:
77
+ attr = getattr(cls, attr_name)
78
+ if callable(attr) and hasattr(attr, "hooks_hooks"):
79
+ for model_cls, event, condition, priority in attr.hooks_hooks:
80
+ # Create a unique key for this hook registration
81
+ key = (model_cls, event, cls, attr_name)
82
+
83
+ # Register the hook
77
84
  register_hook(
78
85
  model=model_cls,
79
86
  event=event,
80
87
  handler_cls=cls,
81
- method_name=method_name,
88
+ method_name=attr_name,
82
89
  condition=condition,
83
90
  priority=priority,
84
91
  )
85
- HookMeta._registered.add(key)
92
+
93
+ logger.debug(
94
+ f"Registered hook {cls.__name__}.{attr_name} "
95
+ f"for {model_cls.__name__}.{event} with priority {priority}"
96
+ )
97
+ except Exception as e:
98
+ # Skip attributes that can't be accessed
99
+ logger.debug(f"Skipping attribute {attr_name}: {e}")
100
+ continue
101
+
86
102
  return cls
87
103
 
88
104
 
@@ -123,7 +139,7 @@ class Hook(metaclass=HookMeta):
123
139
  hook_vars.event = event
124
140
  hook_vars.model = model
125
141
 
126
- hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
142
+ hooks = get_hooks(model, event)
127
143
 
128
144
  def _execute():
129
145
  new_local = new_records or []
@@ -152,6 +168,8 @@ class Hook(metaclass=HookMeta):
152
168
  logger.exception(
153
169
  "Error in hook %s.%s", handler_cls.__name__, method_name
154
170
  )
171
+ # Re-raise the exception to ensure proper error handling
172
+ raise
155
173
 
156
174
  conn = transaction.get_connection()
157
175
  try:
@@ -165,3 +183,7 @@ class Hook(metaclass=HookMeta):
165
183
  hook_vars.event = None
166
184
  hook_vars.model = None
167
185
  hook_vars.depth -= 1
186
+
187
+
188
+ # Create a global Hook instance for context access
189
+ HookContext = HookContextState()
@@ -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
+ Higher values run earlier (higher priority).
9
+ Hooks are sorted in descending order.
10
+ """
11
+
12
+ LOWEST = 0 # runs last
13
+ LOW = 25 # runs later
14
+ NORMAL = 50 # default ordering
15
+ HIGH = 75 # runs early
16
+ HIGHEST = 100 # runs first
@@ -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,77 @@ 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
-
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)
107
- else:
108
- ctx = HookContext(model_cls, bypass_hooks=False)
109
95
 
110
- # For subquery cases, we need special handling
96
+ # Run hooks only if not bypassed
97
+ if not bypass_hooks:
98
+ ctx = HookContext(model_cls)
99
+ # Run VALIDATE_UPDATE hooks
100
+ engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
101
+
102
+ # For subqueries, we need to compute the values and apply them to instances
103
+ # before running BEFORE_UPDATE hooks
111
104
  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)
105
+ # Create a temporary update to compute the values
106
+ # We'll use a subquery to compute values without actually updating
107
+ for field_name, value in kwargs.items():
108
+ if (hasattr(value, "query") and hasattr(value.query, "model")) or \
109
+ (hasattr(value, "get_source_expressions") and value.get_source_expressions()):
110
+ # This is a complex expression - compute it for each instance
111
+ for instance in instances:
112
+ # Create a single-instance queryset to compute the value
113
+ single_qs = model_cls._base_manager.filter(pk=instance.pk)
114
+ computed_values = single_qs.annotate(computed_field=value).values_list('computed_field', flat=True)
115
+ if computed_values:
116
+ setattr(instance, field_name, computed_values[0])
149
117
  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:
118
+ # For simple updates, apply the values directly
119
+ for obj in instances:
120
+ for field, value in kwargs.items():
121
+ setattr(obj, field, value)
122
+
123
+ # Run BEFORE_UPDATE hooks with updated instances
124
+ engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
125
+
126
+ if has_subquery:
127
+ # For complex expressions, use Django's native update
128
+ # This handles Subquery, Case, F expressions, etc. correctly
129
+ result = super().update(**kwargs)
130
+
131
+ # After updating with complex expressions, we need to reload the instances
132
+ # to get the computed values for the AFTER_UPDATE hooks
133
+ if not bypass_hooks:
134
+ # Reload instances to get computed values
135
+ updated_instances = list(model_cls._base_manager.filter(pk__in=pks))
136
+ # Maintain the original order
137
+ updated_map = {obj.pk: obj for obj in updated_instances}
138
+ instances = [updated_map.get(obj.pk, obj) for obj in instances]
139
+ else:
140
+ # For simple field updates, instances have already been updated in the hook section
141
+ # Perform database update using Django's native bulk_update
142
+ # We use the base manager to avoid recursion
143
+ base_manager = model_cls._base_manager
144
+ fields_to_update = list(kwargs.keys())
145
+ base_manager.bulk_update(instances, fields_to_update)
146
+ result = len(instances)
147
+
148
+ # Run AFTER_UPDATE hooks only if not bypassed
149
+ if not bypass_hooks:
150
+ ctx = HookContext(model_cls)
161
151
  engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
162
-
163
- return update_count
152
+
153
+ return result
164
154
 
165
155
  @transaction.atomic
166
156
  def bulk_create(
@@ -175,35 +165,32 @@ class HookQuerySetMixin:
175
165
  bypass_validation=False,
176
166
  ):
177
167
  """
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.
168
+ Insert each of the instances into the database with complete hook support.
169
+
170
+ This method runs the complete hook cycle:
171
+ VALIDATE_CREATE → BEFORE_CREATE → DB create → AFTER_CREATE
172
+
173
+ Behaves like Django's bulk_create but supports multi-table inheritance (MTI)
174
+ models and hooks. All arguments are supported and passed through to the correct logic.
181
175
  """
182
176
  model_cls = self.model
183
177
 
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.")
178
+ # Validate inputs
179
+ if not isinstance(objs, (list, tuple)):
180
+ raise TypeError("objs must be a list or tuple")
198
181
 
199
182
  if not objs:
200
183
  return objs
201
184
 
202
185
  if any(not isinstance(obj, model_cls) for obj in objs):
203
186
  raise TypeError(
204
- f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
187
+ f"bulk_create expected instances of {model_cls.__name__}, "
188
+ f"but got {set(type(obj).__name__ for obj in objs)}"
205
189
  )
206
190
 
191
+ if batch_size is not None and batch_size <= 0:
192
+ raise ValueError("batch_size must be a positive integer.")
193
+
207
194
  # Check for MTI - if we detect multi-table inheritance, we need special handling
208
195
  # This follows Django's approach: check that the parents share the same concrete model
209
196
  # with our model to detect the inheritance pattern ConcreteGrandParent ->
@@ -217,12 +204,12 @@ class HookQuerySetMixin:
217
204
 
218
205
  # Fire hooks before DB ops
219
206
  if not bypass_hooks:
220
- ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
207
+ ctx = HookContext(model_cls, bypass_hooks=False)
221
208
  if not bypass_validation:
222
209
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
223
210
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
224
211
  else:
225
- ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
212
+ ctx = HookContext(model_cls, bypass_hooks=True)
226
213
  logger.debug("bulk_create bypassed hooks")
227
214
 
228
215
  # For MTI models, we need to handle them specially
@@ -266,76 +253,116 @@ class HookQuerySetMixin:
266
253
  self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
267
254
  ):
268
255
  """
269
- Bulk update objects in the database with MTI support.
256
+ Bulk update objects in the database with complete hook support.
257
+
258
+ This method always runs the complete hook cycle:
259
+ VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
260
+
261
+ Args:
262
+ objs: List of model instances to update
263
+ fields: List of field names to update
264
+ bypass_hooks: DEPRECATED - kept for backward compatibility only
265
+ bypass_validation: DEPRECATED - kept for backward compatibility only
266
+ **kwargs: Additional arguments passed to Django's bulk_update
270
267
  """
271
268
  model_cls = self.model
272
269
 
273
270
  if not objs:
274
271
  return []
275
272
 
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
- )
273
+ # Validate inputs
274
+ if not isinstance(objs, (list, tuple)):
275
+ raise TypeError("objs must be a list or tuple")
280
276
 
281
- logger.debug(
282
- f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
283
- )
277
+ if not isinstance(fields, (list, tuple)):
278
+ raise TypeError("fields must be a list or tuple")
284
279
 
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
280
+ if not objs:
281
+ return []
282
+
283
+ if not fields:
284
+ raise ValueError("fields cannot be empty")
291
285
 
286
+ # Validate that all objects are instances of the model
287
+ for obj in objs:
288
+ if not isinstance(obj, model_cls):
289
+ raise TypeError(
290
+ f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
291
+ )
292
+ if obj.pk is None:
293
+ raise ValueError("All objects must have a primary key")
294
+
295
+ # Load originals for hook comparison
296
+ pks = [obj.pk for obj in objs]
297
+ original_map = {
298
+ obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
299
+ }
300
+ originals = [original_map.get(obj.pk) for obj in objs]
301
+
302
+ # Run VALIDATE_UPDATE hooks
303
+ if not bypass_validation:
304
+ ctx = HookContext(model_cls)
305
+ engine.run(
306
+ model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx
307
+ )
308
+
309
+ # Run BEFORE_UPDATE hooks
292
310
  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}")
311
+ ctx = HookContext(model_cls)
312
+ engine.run(
313
+ model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx
314
+ )
315
+
316
+ # Perform database update using Django's native bulk_update
317
+ # We use the base manager to avoid recursion
318
+ base_manager = model_cls._base_manager
319
+ result = base_manager.bulk_update(objs, fields, **kwargs)
329
320
 
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
321
+ # Run AFTER_UPDATE hooks
332
322
  if not bypass_hooks:
333
- logger.debug("bulk_update: skipping AFTER_UPDATE (update() will handle)")
334
- else:
335
- logger.debug("bulk_update: hooks bypassed")
323
+ ctx = HookContext(model_cls)
324
+ engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
336
325
 
337
326
  return result
338
327
 
328
+ @transaction.atomic
329
+ def bulk_delete(self, objs, **kwargs):
330
+ """
331
+ Delete the given objects from the database with complete hook support.
332
+
333
+ This method runs the complete hook cycle:
334
+ VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
335
+
336
+ This is a convenience method that provides a bulk_delete interface
337
+ similar to bulk_create and bulk_update.
338
+ """
339
+ model_cls = self.model
340
+
341
+ # Extract custom kwargs
342
+ bypass_hooks = kwargs.pop("bypass_hooks", False)
343
+
344
+ # Validate inputs
345
+ if not isinstance(objs, (list, tuple)):
346
+ raise TypeError("objs must be a list or tuple")
347
+
348
+ if not objs:
349
+ return 0
350
+
351
+ # Validate that all objects are instances of the model
352
+ for obj in objs:
353
+ if not isinstance(obj, model_cls):
354
+ raise TypeError(
355
+ f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
356
+ )
357
+
358
+ # Get the pks to delete
359
+ pks = [obj.pk for obj in objs if obj.pk is not None]
360
+ if not pks:
361
+ return 0
362
+
363
+ # Use the delete() method which already has hook support
364
+ return self.filter(pk__in=pks).delete()
365
+
339
366
  def _detect_modified_fields(self, new_instances, original_instances):
340
367
  """
341
368
  Detect fields that were modified during BEFORE_UPDATE hooks by comparing
@@ -0,0 +1,91 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from typing import Union
4
+
5
+ from django_bulk_hooks.priority import Priority
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ _hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
10
+
11
+
12
+ def register_hook(
13
+ model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
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
+
30
+ key = (model, event)
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
43
+ hooks.append((handler_cls, method_name, condition, priority))
44
+
45
+ # Sort by priority (highest numbers execute first)
46
+ hooks.sort(key=lambda x: x[3], reverse=True)
47
+
48
+ logger.debug(
49
+ f"Registered {handler_cls.__name__}.{method_name} "
50
+ f"for {model.__name__}.{event} with priority {priority}"
51
+ )
52
+
53
+
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
+
68
+ key = (model, event)
69
+ hooks = _hooks.get(key, [])
70
+
71
+ # Log hook discovery for debugging
72
+ if hooks:
73
+ logger.debug(f"Found {len(hooks)} hooks for {model.__name__}.{event}")
74
+ for handler_cls, method_name, condition, priority in hooks:
75
+ logger.debug(f" - {handler_cls.__name__}.{method_name} (priority: {priority})")
76
+ else:
77
+ logger.debug(f"No hooks found for {model.__name__}.{event}")
78
+
79
+ return hooks
80
+
81
+
82
+ def list_all_hooks():
83
+ """Debug function to list all registered hooks."""
84
+ return _hooks
85
+
86
+
87
+ def clear_hooks():
88
+ """Clear all registered hooks (mainly for testing)."""
89
+ global _hooks
90
+ _hooks.clear()
91
+ logger.debug("All hooks cleared")
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.226"
3
+ version = "0.1.228"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"
@@ -1,17 +0,0 @@
1
- from enum import IntEnum
2
-
3
-
4
- class Priority(IntEnum):
5
- """
6
- Named priorities for django-bulk-hooks hooks.
7
- Replaces module-level constants with a clean IntEnum.
8
- """
9
-
10
- HIGHEST = 0 # runs first
11
- HIGH = 25 # runs early
12
- NORMAL = 50 # default ordering
13
- LOW = 75 # runs late
14
- LOWEST = 100 # runs last
15
-
16
-
17
- DEFAULT_PRIORITY = Priority.NORMAL
@@ -1,16 +0,0 @@
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
@@ -1,34 +0,0 @@
1
- import logging
2
- from collections.abc import Callable
3
- from typing import Union
4
-
5
- from django_bulk_hooks.priority import Priority
6
-
7
- logger = logging.getLogger(__name__)
8
-
9
- _hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
10
-
11
-
12
- def register_hook(
13
- model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
14
- ):
15
- key = (model, event)
16
- hooks = _hooks.setdefault(key, [])
17
- hooks.append((handler_cls, method_name, condition, priority))
18
- # keep sorted by priority
19
- hooks.sort(key=lambda x: x[3])
20
- logger.debug(f"Registered {handler_cls.__name__}.{method_name} for {model.__name__}.{event}")
21
-
22
-
23
- def get_hooks(model, event):
24
- key = (model, event)
25
- 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")
29
- return hooks
30
-
31
-
32
- def list_all_hooks():
33
- """Debug function to list all registered hooks"""
34
- return _hooks