django-bulk-hooks 0.2.11__py3-none-any.whl → 0.2.12__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.

@@ -1,246 +1,246 @@
1
- """
2
- HookDispatcher: Single execution path for all hooks.
3
-
4
- Provides deterministic, priority-ordered hook execution,
5
- similar to Salesforce's hook framework.
6
- """
7
-
8
- import logging
9
- from typing import Optional
10
-
11
- logger = logging.getLogger(__name__)
12
-
13
-
14
- class HookDispatcher:
15
- """
16
- Single execution path for all hooks.
17
-
18
- Responsibilities:
19
- - Execute hooks in priority order
20
- - Filter records based on conditions
21
- - Provide ChangeSet context to hooks
22
- - Fail-fast error propagation
23
- - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
24
- """
25
-
26
- def __init__(self, registry):
27
- """
28
- Initialize the dispatcher.
29
-
30
- Args:
31
- registry: The hook registry (provides get_hooks method)
32
- """
33
- self.registry = registry
34
-
35
- def execute_operation_with_hooks(
36
- self,
37
- changeset,
38
- operation,
39
- event_prefix,
40
- bypass_hooks=False,
41
- bypass_validation=False,
42
- ):
43
- """
44
- Execute operation with full hook lifecycle.
45
-
46
- This is the high-level method that coordinates the complete lifecycle:
47
- 1. VALIDATE_{event}
48
- 2. BEFORE_{event}
49
- 3. Actual operation
50
- 4. AFTER_{event}
51
-
52
- Args:
53
- changeset: ChangeSet for the operation
54
- operation: Callable that performs the actual DB operation
55
- event_prefix: 'create', 'update', or 'delete'
56
- bypass_hooks: Skip all hooks if True
57
- bypass_validation: Skip validation hooks if True
58
-
59
- Returns:
60
- Result of operation
61
- """
62
- if bypass_hooks:
63
- return operation()
64
-
65
- # VALIDATE phase
66
- if not bypass_validation:
67
- self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
68
-
69
- # BEFORE phase
70
- self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
71
-
72
- # Execute the actual operation
73
- result = operation()
74
-
75
- # AFTER phase - use result if operation returns modified data
76
- if result and isinstance(result, list) and event_prefix == "create":
77
- # For create, rebuild changeset with assigned PKs
78
- from django_bulk_hooks.helpers import build_changeset_for_create
79
-
80
- changeset = build_changeset_for_create(changeset.model_cls, result)
81
-
82
- self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
83
-
84
- return result
85
-
86
- def dispatch(self, changeset, event, bypass_hooks=False):
87
- """
88
- Dispatch hooks for a changeset with deterministic ordering.
89
-
90
- This is the single execution path for ALL hooks in the system.
91
-
92
- Args:
93
- changeset: ChangeSet instance with record changes
94
- event: Event name (e.g., 'after_update', 'before_create')
95
- bypass_hooks: If True, skip all hook execution
96
-
97
- Raises:
98
- Exception: Any exception raised by a hook (fails fast)
99
- RecursionError: If hooks create an infinite loop (Python's built-in limit)
100
- """
101
- if bypass_hooks:
102
- return
103
-
104
- # Get hooks sorted by priority (deterministic order)
105
- hooks = self.registry.get_hooks(changeset.model_cls, event)
106
-
107
- if not hooks:
108
- return
109
-
110
- # Execute hooks in priority order
111
- logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
112
- for handler_cls, method_name, condition, priority in hooks:
113
- logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
114
- self._execute_hook(handler_cls, method_name, condition, changeset)
115
-
116
- def _execute_hook(self, handler_cls, method_name, condition, changeset):
117
- """
118
- Execute a single hook with condition checking.
119
-
120
- Args:
121
- handler_cls: The hook handler class
122
- method_name: Name of the method to call
123
- condition: Optional condition to filter records
124
- changeset: ChangeSet with all record changes
125
- """
126
- # Filter records based on condition
127
- if condition:
128
- filtered_changes = [
129
- change
130
- for change in changeset.changes
131
- if condition.check(change.new_record, change.old_record)
132
- ]
133
-
134
- if not filtered_changes:
135
- # No records match condition, skip this hook
136
- return
137
-
138
- # Create filtered changeset
139
- from django_bulk_hooks.changeset import ChangeSet
140
-
141
- filtered_changeset = ChangeSet(
142
- changeset.model_cls,
143
- filtered_changes,
144
- changeset.operation_type,
145
- changeset.operation_meta,
146
- )
147
- else:
148
- # No condition, use full changeset
149
- filtered_changeset = changeset
150
-
151
- # Use DI factory to create handler instance
152
- from django_bulk_hooks.factory import create_hook_instance
153
-
154
- handler = create_hook_instance(handler_cls)
155
- method = getattr(handler, method_name)
156
-
157
- # Check if method has @select_related decorator
158
- preload_func = getattr(method, "_select_related_preload", None)
159
- if preload_func:
160
- # Preload relationships to prevent N+1 queries
161
- try:
162
- model_cls_override = getattr(handler, "model_cls", None)
163
-
164
- # Get FK fields being updated to avoid preloading conflicting relationships
165
- skip_fields = changeset.operation_meta.get('fk_fields_being_updated', set())
166
-
167
- # Preload for new_records
168
- if filtered_changeset.new_records:
169
- logger.debug(
170
- f"Preloading relationships for {len(filtered_changeset.new_records)} "
171
- f"new_records for {handler_cls.__name__}.{method_name}"
172
- )
173
- preload_func(
174
- filtered_changeset.new_records,
175
- model_cls=model_cls_override,
176
- skip_fields=skip_fields
177
- )
178
-
179
- # Also preload for old_records (for conditions that check previous values)
180
- if filtered_changeset.old_records:
181
- logger.debug(
182
- f"Preloading relationships for {len(filtered_changeset.old_records)} "
183
- f"old_records for {handler_cls.__name__}.{method_name}"
184
- )
185
- preload_func(
186
- filtered_changeset.old_records,
187
- model_cls=model_cls_override,
188
- skip_fields=skip_fields
189
- )
190
- except Exception:
191
- logger.debug(
192
- "select_related preload failed for %s.%s",
193
- handler_cls.__name__,
194
- method_name,
195
- exc_info=True,
196
- )
197
-
198
- # Execute hook with ChangeSet
199
- #
200
- # ARCHITECTURE NOTE: Hook Contract
201
- # ====================================
202
- # All hooks must accept **kwargs for forward compatibility.
203
- # We pass: changeset, new_records, old_records
204
- #
205
- # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
206
- # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
207
- #
208
- # This is standard Python framework design (see Django signals, Flask hooks, etc.)
209
- logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
210
- try:
211
- method(
212
- changeset=filtered_changeset,
213
- new_records=filtered_changeset.new_records,
214
- old_records=filtered_changeset.old_records,
215
- )
216
- logger.info(f" ✅ Completed: {handler_cls.__name__}.{method_name}")
217
- except Exception as e:
218
- # Fail-fast: re-raise to rollback transaction
219
- logger.error(
220
- f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
221
- exc_info=True,
222
- )
223
- raise
224
-
225
-
226
- # Global dispatcher instance
227
- _dispatcher: Optional[HookDispatcher] = None
228
-
229
-
230
- def get_dispatcher():
231
- """
232
- Get the global dispatcher instance.
233
-
234
- Creates the dispatcher on first access (singleton pattern).
235
-
236
- Returns:
237
- HookDispatcher instance
238
- """
239
- global _dispatcher
240
- if _dispatcher is None:
241
- # Import here to avoid circular dependency
242
- from django_bulk_hooks.registry import get_registry
243
-
244
- # Create dispatcher with the registry instance
245
- _dispatcher = HookDispatcher(get_registry())
246
- return _dispatcher
1
+ """
2
+ HookDispatcher: Single execution path for all hooks.
3
+
4
+ Provides deterministic, priority-ordered hook execution,
5
+ similar to Salesforce's hook framework.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class HookDispatcher:
15
+ """
16
+ Single execution path for all hooks.
17
+
18
+ Responsibilities:
19
+ - Execute hooks in priority order
20
+ - Filter records based on conditions
21
+ - Provide ChangeSet context to hooks
22
+ - Fail-fast error propagation
23
+ - Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
24
+ """
25
+
26
+ def __init__(self, registry):
27
+ """
28
+ Initialize the dispatcher.
29
+
30
+ Args:
31
+ registry: The hook registry (provides get_hooks method)
32
+ """
33
+ self.registry = registry
34
+
35
+ def execute_operation_with_hooks(
36
+ self,
37
+ changeset,
38
+ operation,
39
+ event_prefix,
40
+ bypass_hooks=False,
41
+ bypass_validation=False,
42
+ ):
43
+ """
44
+ Execute operation with full hook lifecycle.
45
+
46
+ This is the high-level method that coordinates the complete lifecycle:
47
+ 1. VALIDATE_{event}
48
+ 2. BEFORE_{event}
49
+ 3. Actual operation
50
+ 4. AFTER_{event}
51
+
52
+ Args:
53
+ changeset: ChangeSet for the operation
54
+ operation: Callable that performs the actual DB operation
55
+ event_prefix: 'create', 'update', or 'delete'
56
+ bypass_hooks: Skip all hooks if True
57
+ bypass_validation: Skip validation hooks if True
58
+
59
+ Returns:
60
+ Result of operation
61
+ """
62
+ if bypass_hooks:
63
+ return operation()
64
+
65
+ # VALIDATE phase
66
+ if not bypass_validation:
67
+ self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
68
+
69
+ # BEFORE phase
70
+ self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
71
+
72
+ # Execute the actual operation
73
+ result = operation()
74
+
75
+ # AFTER phase - use result if operation returns modified data
76
+ if result and isinstance(result, list) and event_prefix == "create":
77
+ # For create, rebuild changeset with assigned PKs
78
+ from django_bulk_hooks.helpers import build_changeset_for_create
79
+
80
+ changeset = build_changeset_for_create(changeset.model_cls, result)
81
+
82
+ self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
83
+
84
+ return result
85
+
86
+ def dispatch(self, changeset, event, bypass_hooks=False):
87
+ """
88
+ Dispatch hooks for a changeset with deterministic ordering.
89
+
90
+ This is the single execution path for ALL hooks in the system.
91
+
92
+ Args:
93
+ changeset: ChangeSet instance with record changes
94
+ event: Event name (e.g., 'after_update', 'before_create')
95
+ bypass_hooks: If True, skip all hook execution
96
+
97
+ Raises:
98
+ Exception: Any exception raised by a hook (fails fast)
99
+ RecursionError: If hooks create an infinite loop (Python's built-in limit)
100
+ """
101
+ if bypass_hooks:
102
+ return
103
+
104
+ # Get hooks sorted by priority (deterministic order)
105
+ hooks = self.registry.get_hooks(changeset.model_cls, event)
106
+
107
+ if not hooks:
108
+ return
109
+
110
+ # Execute hooks in priority order
111
+ logger.info(f"🔥 HOOKS: Executing {len(hooks)} hooks for {changeset.model_cls.__name__}.{event}")
112
+ for handler_cls, method_name, condition, priority in hooks:
113
+ logger.info(f" → {handler_cls.__name__}.{method_name} (priority={priority})")
114
+ self._execute_hook(handler_cls, method_name, condition, changeset)
115
+
116
+ def _execute_hook(self, handler_cls, method_name, condition, changeset):
117
+ """
118
+ Execute a single hook with condition checking.
119
+
120
+ Args:
121
+ handler_cls: The hook handler class
122
+ method_name: Name of the method to call
123
+ condition: Optional condition to filter records
124
+ changeset: ChangeSet with all record changes
125
+ """
126
+ # Filter records based on condition
127
+ if condition:
128
+ filtered_changes = [
129
+ change
130
+ for change in changeset.changes
131
+ if condition.check(change.new_record, change.old_record)
132
+ ]
133
+
134
+ if not filtered_changes:
135
+ # No records match condition, skip this hook
136
+ return
137
+
138
+ # Create filtered changeset
139
+ from django_bulk_hooks.changeset import ChangeSet
140
+
141
+ filtered_changeset = ChangeSet(
142
+ changeset.model_cls,
143
+ filtered_changes,
144
+ changeset.operation_type,
145
+ changeset.operation_meta,
146
+ )
147
+ else:
148
+ # No condition, use full changeset
149
+ filtered_changeset = changeset
150
+
151
+ # Use DI factory to create handler instance
152
+ from django_bulk_hooks.factory import create_hook_instance
153
+
154
+ handler = create_hook_instance(handler_cls)
155
+ method = getattr(handler, method_name)
156
+
157
+ # Check if method has @select_related decorator
158
+ preload_func = getattr(method, "_select_related_preload", None)
159
+ if preload_func:
160
+ # Preload relationships to prevent N+1 queries
161
+ try:
162
+ model_cls_override = getattr(handler, "model_cls", None)
163
+
164
+ # Get FK fields being updated to avoid preloading conflicting relationships
165
+ skip_fields = changeset.operation_meta.get('fk_fields_being_updated', set())
166
+
167
+ # Preload for new_records
168
+ if filtered_changeset.new_records:
169
+ logger.debug(
170
+ f"Preloading relationships for {len(filtered_changeset.new_records)} "
171
+ f"new_records for {handler_cls.__name__}.{method_name}"
172
+ )
173
+ preload_func(
174
+ filtered_changeset.new_records,
175
+ model_cls=model_cls_override,
176
+ skip_fields=skip_fields
177
+ )
178
+
179
+ # Also preload for old_records (for conditions that check previous values)
180
+ if filtered_changeset.old_records:
181
+ logger.debug(
182
+ f"Preloading relationships for {len(filtered_changeset.old_records)} "
183
+ f"old_records for {handler_cls.__name__}.{method_name}"
184
+ )
185
+ preload_func(
186
+ filtered_changeset.old_records,
187
+ model_cls=model_cls_override,
188
+ skip_fields=skip_fields
189
+ )
190
+ except Exception:
191
+ logger.debug(
192
+ "select_related preload failed for %s.%s",
193
+ handler_cls.__name__,
194
+ method_name,
195
+ exc_info=True,
196
+ )
197
+
198
+ # Execute hook with ChangeSet
199
+ #
200
+ # ARCHITECTURE NOTE: Hook Contract
201
+ # ====================================
202
+ # All hooks must accept **kwargs for forward compatibility.
203
+ # We pass: changeset, new_records, old_records
204
+ #
205
+ # Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
206
+ # New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
207
+ #
208
+ # This is standard Python framework design (see Django signals, Flask hooks, etc.)
209
+ logger.info(f" 🚀 Executing: {handler_cls.__name__}.{method_name}")
210
+ try:
211
+ method(
212
+ changeset=filtered_changeset,
213
+ new_records=filtered_changeset.new_records,
214
+ old_records=filtered_changeset.old_records,
215
+ )
216
+ logger.info(f" ✅ Completed: {handler_cls.__name__}.{method_name}")
217
+ except Exception as e:
218
+ # Fail-fast: re-raise to rollback transaction
219
+ logger.error(
220
+ f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
221
+ exc_info=True,
222
+ )
223
+ raise
224
+
225
+
226
+ # Global dispatcher instance
227
+ _dispatcher: Optional[HookDispatcher] = None
228
+
229
+
230
+ def get_dispatcher():
231
+ """
232
+ Get the global dispatcher instance.
233
+
234
+ Creates the dispatcher on first access (singleton pattern).
235
+
236
+ Returns:
237
+ HookDispatcher instance
238
+ """
239
+ global _dispatcher
240
+ if _dispatcher is None:
241
+ # Import here to avoid circular dependency
242
+ from django_bulk_hooks.registry import get_registry
243
+
244
+ # Create dispatcher with the registry instance
245
+ _dispatcher = HookDispatcher(get_registry())
246
+ return _dispatcher
@@ -212,40 +212,23 @@ class BulkOperationCoordinator:
212
212
  self, update_kwargs, bypass_hooks=False, bypass_validation=False
213
213
  ):
214
214
  """
215
- Execute queryset update with hooks.
215
+ Execute queryset update with hooks - optimized for performance.
216
216
 
217
- ARCHITECTURE: Application-Layer Update with Expression Resolution
218
- ===================================================================
217
+ ARCHITECTURE: Database-Level Update with AFTER-Only Hooks
218
+ ==========================================================
219
219
 
220
- When hooks are enabled, queryset.update() is transformed into bulk_update()
221
- to allow BEFORE hooks to modify records. This is a deliberate design choice:
222
-
223
- 1. Fetch instances from the queryset (we need them for hooks anyway)
224
- 2. Resolve SQL expressions (F(), Subquery, Case, etc.) to concrete values
225
- 3. Apply resolved values to instances
226
- 4. Run BEFORE hooks (which can now modify the instances)
227
- 5. Use bulk_update() to persist the (possibly modified) instances
228
- 6. Run AFTER hooks with final state
220
+ Uses native Django queryset.update() for maximum performance,
221
+ then fetches results and runs AFTER hooks.
229
222
 
230
223
  This approach:
231
- - ✅ Allows BEFORE hooks to modify values (feature request)
232
- - ✅ Preserves SQL expression semantics (materializes them correctly)
233
- - ✅ Eliminates the double-fetch (was fetching before AND after)
234
- - More efficient than previous implementation
235
- - ✅ Maintains Salesforce-like hook contract
236
-
237
- SQL expressions are resolved per-instance using Django's annotate(),
238
- which ensures correct evaluation of:
239
- - F() expressions: F('balance') + 100
240
- - Subquery: Subquery(related.aggregate(...))
241
- - Case/When: Case(When(...))
242
- - Database functions: Upper(), Concat(), etc.
243
- - Any other Django Expression
224
+ - ✅ Uses native SQL UPDATE (fastest for aggregations, F(), Subquery)
225
+ - ✅ Maintains framework patterns (MTI support, changeset building)
226
+ - ✅ Runs AFTER_UPDATE hooks with old/new state
227
+ - BEFORE_UPDATE hooks cannot modify values (use bulk_update() instead)
244
228
 
245
- Trade-off:
246
- - Uses bulk_update() internally (slightly different SQL than queryset.update)
247
- - Expression resolution may add overhead for complex expressions
248
- - But eliminates the refetch, so overall more efficient
229
+ For cases where BEFORE hooks need to modify values, use bulk_update():
230
+ - MyModel.objects.bulk_update(objs, fields) # Allows BEFORE modifications
231
+ - MyModel.objects.update(...) # Fast, AFTER hooks only
249
232
 
250
233
  Args:
251
234
  update_kwargs: Dict of fields to update
@@ -255,12 +238,7 @@ class BulkOperationCoordinator:
255
238
  Returns:
256
239
  Number of objects updated
257
240
  """
258
- # Fetch instances from queryset
259
- instances = list(self.queryset)
260
- if not instances:
261
- return 0
262
-
263
- # Check both parameter and context for bypass_hooks
241
+ # Check bypass early
264
242
  from django_bulk_hooks.context import get_bypass_hooks
265
243
  should_bypass = bypass_hooks or get_bypass_hooks()
266
244
 
@@ -268,58 +246,65 @@ class BulkOperationCoordinator:
268
246
  # No hooks - use original queryset.update() for max performance
269
247
  return BaseQuerySet.update(self.queryset, **update_kwargs)
270
248
 
271
- # Resolve expressions and apply to instances
272
- # Delegate to analyzer for expression resolution and value application
273
- fields_to_update = self.analyzer.apply_update_values(instances, update_kwargs)
274
-
275
- # Now instances have the resolved values applied
276
- # Fetch old records for comparison (single bulk query)
277
- old_records_map = self.analyzer.fetch_old_records_map(instances)
278
-
279
- # Detect FK fields being updated to prevent @select_related conflicts
280
- fk_fields_being_updated = self._get_fk_fields_being_updated(update_kwargs)
281
-
282
- # Build changeset for VALIDATE and BEFORE hooks
283
- # instances now have the "intended" values from update_kwargs
284
- changeset = build_changeset_for_update(
285
- self.model_cls,
286
- instances,
287
- update_kwargs,
288
- old_records_map=old_records_map,
249
+ # Delegate to specialized queryset update handler
250
+ return self._execute_queryset_update_with_hooks(
251
+ update_kwargs=update_kwargs,
252
+ bypass_validation=bypass_validation,
289
253
  )
290
254
 
291
- # Add FK field info to changeset meta for dispatcher to use
292
- if fk_fields_being_updated:
293
- changeset.operation_meta['fk_fields_being_updated'] = fk_fields_being_updated
294
-
295
- # Execute VALIDATE and BEFORE hooks
296
- # Hooks can now modify the instances and changes will persist
297
- if not bypass_validation:
298
- self.dispatcher.dispatch(changeset, "validate_update", bypass_hooks=False)
299
- self.dispatcher.dispatch(changeset, "before_update", bypass_hooks=False)
300
-
301
- # COORDINATION LOGIC: Determine all fields to persist
302
- # Hooks may have modified fields beyond the original update_kwargs.
303
- # We need to detect those changes and include them in bulk_update.
304
- # This is coordination between: hooks → field detection → executor
305
- additional_changed_fields = self.analyzer.detect_changed_fields(instances)
306
- all_fields_to_update = list(set(fields_to_update) | set(additional_changed_fields))
255
+ def _execute_queryset_update_with_hooks(
256
+ self, update_kwargs, bypass_validation=False
257
+ ):
258
+ """
259
+ Execute queryset update with hooks - fast path using native Django update.
307
260
 
308
- # Use bulk_update with all modified fields (original + hook modifications)
309
- result = self.executor.bulk_update(instances, all_fields_to_update, batch_size=None)
310
-
311
- # Build changeset for AFTER hooks
312
- # No refetch needed! instances already have final state from bulk_update
313
- changeset_after = build_changeset_for_update(
261
+ This method follows framework patterns but is optimized for database-level
262
+ operations where BEFORE hooks cannot modify values.
263
+
264
+ Args:
265
+ update_kwargs: Dict of fields to update
266
+ bypass_validation: Skip validation hooks if True
267
+
268
+ Returns:
269
+ Number of objects updated
270
+ """
271
+ # 1. Fetch old state (before DB update)
272
+ old_instances = list(self.queryset)
273
+ if not old_instances:
274
+ return 0
275
+ old_records_map = {inst.pk: inst for inst in old_instances}
276
+
277
+ # 2. Execute native Django update (FAST)
278
+ result = BaseQuerySet.update(self.queryset, **update_kwargs)
279
+
280
+ if result == 0:
281
+ return 0
282
+
283
+ # 3. Fetch new state (after DB update)
284
+ new_instances = list(self.queryset)
285
+
286
+ # 4. Build changeset (using framework helper)
287
+ changeset = build_changeset_for_update(
314
288
  self.model_cls,
315
- instances,
289
+ new_instances,
316
290
  update_kwargs,
317
291
  old_records_map=old_records_map,
318
292
  )
319
-
320
- # Execute AFTER hooks with final state
321
- self.dispatcher.dispatch(changeset_after, "after_update", bypass_hooks=False)
322
-
293
+
294
+ # Mark that this is a queryset update (for potential hook inspection)
295
+ changeset.operation_meta['is_queryset_update'] = True
296
+ changeset.operation_meta['allows_before_modifications'] = False
297
+
298
+ # 5. Get MTI chain (follow framework pattern)
299
+ models_in_chain = [self.model_cls]
300
+ if self.mti_handler.is_mti_model():
301
+ models_in_chain.extend(self.mti_handler.get_parent_models())
302
+
303
+ # 6. AFTER hooks only (following MTI pattern from _execute_with_mti_hooks)
304
+ for model_cls in models_in_chain:
305
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
306
+ self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
307
+
323
308
  return result
324
309
 
325
310
  @transaction.atomic
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.2.11
3
+ Version: 0.2.12
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -4,7 +4,7 @@ django_bulk_hooks/conditions.py,sha256=qtGjToKXC8FPUPK31Mib-GMzc9GSdrH90M2pT3CIs
4
4
  django_bulk_hooks/constants.py,sha256=PxpEETaO6gdENcTPoXS586lerGKVP3nmjpDvOkmhYxI,509
5
5
  django_bulk_hooks/context.py,sha256=mqaC5-yESDTA5ruI7fuXlt8qSgKuOFp0mjq7h1-4HdQ,1926
6
6
  django_bulk_hooks/decorators.py,sha256=hc8MSG5XXEiT5kgsf4Opzpj8jAb-OYqcvssuZxCIncQ,11894
7
- django_bulk_hooks/dispatcher.py,sha256=k3ndhY8e2N2nIHn22hFrCsDd3U5RVbPQtQ6Xf9E8UQE,8923
7
+ django_bulk_hooks/dispatcher.py,sha256=-aXu3hiVHvEqLU2jFHLXy8idMtVWRtkgMCRKtsw48sI,8677
8
8
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
9
9
  django_bulk_hooks/factory.py,sha256=JmjQiJPfAnytXrO6r6qOadX5yX0-sfpbZ9V8nwX3MAg,20013
10
10
  django_bulk_hooks/handler.py,sha256=2-k0GPWGSQ6acfvV0qJgDH8aa0z51DqdpX5vSJ6Uawk,4759
@@ -14,12 +14,12 @@ django_bulk_hooks/models.py,sha256=62tn5wL55EjJVOsZofMluhEJB8bH7CzBvH0vd214_RY,2
14
14
  django_bulk_hooks/operations/__init__.py,sha256=5L5NnwiFw8Yn5WO6-38eGdCYBkA0URpwyDcAdeYfc5w,550
15
15
  django_bulk_hooks/operations/analyzer.py,sha256=s6FM53ho1raPdKU-VjjW0SWymXyrJe0I_Wu8XsXFdSY,9065
16
16
  django_bulk_hooks/operations/bulk_executor.py,sha256=7VJgeTFcMQ9ZELvCV6WR6udUPJNL6Kf-w9iEva6pIPA,18271
17
- django_bulk_hooks/operations/coordinator.py,sha256=tCQA0yfnt1bh8hLR_g_WlZc2tRpnWDbTb9aWKaAfWmo,18174
17
+ django_bulk_hooks/operations/coordinator.py,sha256=lk66FDgrDz_PUdeZ-S92NuMST4ikyncuzqXVCza5t_g,16966
18
18
  django_bulk_hooks/operations/mti_handler.py,sha256=eIH-tImMqcWR5lLQr6Ca-HeVYta-UkXk5X5fcpS885Y,18245
19
19
  django_bulk_hooks/operations/mti_plans.py,sha256=fHUYbrUAHq8UXqxgAD43oHdTxOnEkmpxoOD4Qrzfqk8,2878
20
20
  django_bulk_hooks/queryset.py,sha256=ody4MXrRREL27Ts2ey1UpS0tb5Dxnw-6kN3unxPQ3zY,5860
21
21
  django_bulk_hooks/registry.py,sha256=UPerNhtVz_9tKZqrYSZD2LhjAcs4F6hVUuk8L5oOeHc,8821
22
- django_bulk_hooks-0.2.11.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
23
- django_bulk_hooks-0.2.11.dist-info/METADATA,sha256=XjBb-_Q9gbnDEim8E7qQ4MY-iUUourgH8nNEkdFvHCI,9265
24
- django_bulk_hooks-0.2.11.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
25
- django_bulk_hooks-0.2.11.dist-info/RECORD,,
22
+ django_bulk_hooks-0.2.12.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
23
+ django_bulk_hooks-0.2.12.dist-info/METADATA,sha256=5ubalzTGTjktdwSdiBl96cC5NYMDgEV2iuggxriROec,9265
24
+ django_bulk_hooks-0.2.12.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
25
+ django_bulk_hooks-0.2.12.dist-info/RECORD,,