django-bulk-hooks 0.1.231__py3-none-any.whl → 0.1.232__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,8 +1,10 @@
1
1
  import logging
2
+ from django.db import models, transaction
3
+ from django.db.models import AutoField, Case, Field, Value, When
2
4
 
3
- from django.db import models, transaction, connections
4
- from django.db.models import AutoField, Case, Value, When
5
5
  from django_bulk_hooks import engine
6
+
7
+ logger = logging.getLogger(__name__)
6
8
  from django_bulk_hooks.constants import (
7
9
  AFTER_CREATE,
8
10
  AFTER_DELETE,
@@ -16,8 +18,6 @@ from django_bulk_hooks.constants import (
16
18
  )
17
19
  from django_bulk_hooks.context import HookContext
18
20
 
19
- logger = logging.getLogger(__name__)
20
-
21
21
 
22
22
  class HookQuerySetMixin:
23
23
  """
@@ -26,24 +26,12 @@ class HookQuerySetMixin:
26
26
  """
27
27
 
28
28
  @transaction.atomic
29
- def delete(self) -> int:
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
- """
29
+ def delete(self):
36
30
  objs = list(self)
37
31
  if not objs:
38
32
  return 0
39
33
 
40
34
  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
-
47
35
  ctx = HookContext(model_cls)
48
36
 
49
37
  # Run validation hooks first
@@ -61,19 +49,7 @@ class HookQuerySetMixin:
61
49
  return result
62
50
 
63
51
  @transaction.atomic
64
- def update(self, **kwargs) -> int:
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
- bypass_validation = kwargs.pop('bypass_validation', False)
76
-
52
+ def update(self, **kwargs):
77
53
  instances = list(self)
78
54
  if not instances:
79
55
  return 0
@@ -81,105 +57,73 @@ class HookQuerySetMixin:
81
57
  model_cls = self.model
82
58
  pks = [obj.pk for obj in instances]
83
59
 
84
- # Load originals for hook comparison
60
+ # Load originals for hook comparison and ensure they match the order of instances
61
+ # Use the base manager to avoid recursion
85
62
  original_map = {
86
63
  obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
87
64
  }
88
65
  originals = [original_map.get(obj.pk) for obj in instances]
89
66
 
90
- # Identify complex database expressions (Subquery, Case, F, CombinedExpression, etc.)
91
- complex_fields = {}
92
- simple_fields = {}
93
- for field_name, value in kwargs.items():
94
- is_complex = (
95
- (hasattr(value, "query") and hasattr(value.query, "model"))
96
- or (
97
- hasattr(value, "get_source_expressions")
98
- and value.get_source_expressions()
99
- )
100
- )
101
- if is_complex:
102
- complex_fields[field_name] = value
103
- else:
104
- simple_fields[field_name] = value
105
- has_subquery = bool(complex_fields)
67
+ # Check if any of the update values are Subquery objects
68
+ has_subquery = any(
69
+ hasattr(value, "query") and hasattr(value, "resolve_expression")
70
+ for value in kwargs.values()
71
+ )
106
72
 
107
- # Run hooks only if not bypassed
108
- if not bypass_hooks:
109
- ctx = HookContext(model_cls)
110
- # Run VALIDATE_UPDATE hooks
111
- if not bypass_validation:
112
- engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
113
-
114
- # Resolve complex expressions in one shot per field and apply values
115
- if has_subquery:
116
- # Build annotations for complex fields
117
- annotations = {f"__computed_{name}": expr for name, expr in complex_fields.items()}
118
- annotation_aliases = list(annotations.keys())
119
- if annotations:
120
- computed_rows = (
121
- model_cls._base_manager.filter(pk__in=pks)
122
- .annotate(**annotations)
123
- .values("pk", *annotation_aliases)
124
- )
125
- computed_map = {}
126
- for row in computed_rows:
127
- pk = row["pk"]
128
- field_values = {}
129
- for fname in complex_fields.keys():
130
- alias = f"__computed_{fname}"
131
- field_values[fname] = row.get(alias)
132
- computed_map[pk] = field_values
133
-
134
- for instance in instances:
135
- values_for_instance = computed_map.get(instance.pk, {})
136
- for fname, fval in values_for_instance.items():
137
- setattr(instance, fname, fval)
138
-
139
- # Apply simple values directly
140
- if simple_fields:
141
- for obj in instances:
142
- for field, value in simple_fields.items():
143
- setattr(obj, field, value)
144
-
145
- # Run BEFORE_UPDATE hooks with updated instances
73
+ # Apply field updates to instances
74
+ for obj in instances:
75
+ for field, value in kwargs.items():
76
+ setattr(obj, field, value)
77
+
78
+ # Check if we're in a bulk operation context to prevent double hook execution
79
+ from django_bulk_hooks.context import get_bypass_hooks
80
+ current_bypass_hooks = get_bypass_hooks()
81
+
82
+ # If we're in a bulk operation context, skip hooks to prevent double execution
83
+ if current_bypass_hooks:
84
+ logger.debug("update: skipping hooks (bulk context)")
85
+ ctx = HookContext(model_cls, bypass_hooks=True)
86
+ else:
87
+ logger.debug("update: running hooks (standalone)")
88
+ ctx = HookContext(model_cls, bypass_hooks=False)
89
+ # Run validation hooks first
90
+ engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
91
+ # Then run BEFORE_UPDATE hooks
146
92
  engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
147
93
 
148
- # Determine if model uses MTI
149
- def _is_mti(m):
150
- for parent in m._meta.all_parents:
151
- if parent._meta.concrete_model is not m._meta.concrete_model:
152
- return True
153
- return False
94
+ # Use Django's built-in update logic directly
95
+ # Call the base QuerySet implementation to avoid recursion
96
+ update_count = super().update(**kwargs)
154
97
 
155
- is_mti = _is_mti(model_cls)
98
+ # If we used Subquery objects, refresh the instances to get computed values
99
+ if has_subquery and instances:
100
+ # Simple refresh of model fields without fetching related objects
101
+ # Subquery updates only affect the model's own fields, not relationships
102
+ refreshed_instances = {
103
+ obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
104
+ }
156
105
 
157
- if is_mti:
158
- # Use MTI-aware bulk update across tables
159
- fields_to_update = list(kwargs.keys())
160
- result = self._mti_bulk_update(instances, fields_to_update)
161
- else:
162
- if has_subquery:
163
- # For complex expressions on single-table models, use Django's native update
164
- result = super().update(**kwargs)
165
- if not bypass_hooks:
166
- # Reload instances to ensure we have DB-final values
167
- updated_instances = list(model_cls._base_manager.filter(pk__in=pks))
168
- updated_map = {obj.pk: obj for obj in updated_instances}
169
- instances = [updated_map.get(obj.pk, obj) for obj in instances]
170
- else:
171
- # Simple updates on single-table models
172
- base_manager = model_cls._base_manager
173
- fields_to_update = list(kwargs.keys())
174
- base_manager.bulk_update(instances, fields_to_update)
175
- result = len(instances)
106
+ # Bulk update all instances in memory
107
+ for instance in instances:
108
+ if instance.pk in refreshed_instances:
109
+ refreshed_instance = refreshed_instances[instance.pk]
110
+ # Update all fields except primary key
111
+ for field in model_cls._meta.fields:
112
+ if field.name != "id":
113
+ setattr(
114
+ instance,
115
+ field.name,
116
+ getattr(refreshed_instance, field.name),
117
+ )
176
118
 
177
- # Run AFTER_UPDATE hooks only if not bypassed
178
- if not bypass_hooks:
179
- ctx = HookContext(model_cls)
119
+ # Run AFTER_UPDATE hooks only for standalone updates
120
+ if not current_bypass_hooks:
121
+ logger.debug("update: running AFTER_UPDATE")
180
122
  engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
181
-
182
- return result
123
+ else:
124
+ logger.debug("update: skipping AFTER_UPDATE (bulk context)")
125
+
126
+ return update_count
183
127
 
184
128
  @transaction.atomic
185
129
  def bulk_create(
@@ -192,34 +136,37 @@ class HookQuerySetMixin:
192
136
  unique_fields=None,
193
137
  bypass_hooks=False,
194
138
  bypass_validation=False,
195
- ) -> list:
139
+ ):
196
140
  """
197
- Insert each of the instances into the database with complete hook support.
198
-
199
- This method runs the complete hook cycle:
200
- VALIDATE_CREATE → BEFORE_CREATE → DB create → AFTER_CREATE
201
-
202
- Behaves like Django's bulk_create but supports multi-table inheritance (MTI)
203
- models and hooks. All arguments are supported and passed through to the correct logic.
141
+ Insert each of the instances into the database. Behaves like Django's bulk_create,
142
+ but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
143
+ passed through to the correct logic. For MTI, only a subset of options may be supported.
204
144
  """
205
145
  model_cls = self.model
206
146
 
207
- # Validate inputs
208
- if not isinstance(objs, (list, tuple)):
209
- raise TypeError("objs must be a list or tuple")
147
+ # When you bulk insert you don't get the primary keys back (if it's an
148
+ # autoincrement, except if can_return_rows_from_bulk_insert=True), so
149
+ # you can't insert into the child tables which references this. There
150
+ # are two workarounds:
151
+ # 1) This could be implemented if you didn't have an autoincrement pk
152
+ # 2) You could do it by doing O(n) normal inserts into the parent
153
+ # tables to get the primary keys back and then doing a single bulk
154
+ # insert into the childmost table.
155
+ # We currently set the primary keys on the objects when using
156
+ # PostgreSQL via the RETURNING ID clause. It should be possible for
157
+ # Oracle as well, but the semantics for extracting the primary keys is
158
+ # trickier so it's not done yet.
159
+ if batch_size is not None and batch_size <= 0:
160
+ raise ValueError("Batch size must be a positive integer.")
210
161
 
211
162
  if not objs:
212
163
  return objs
213
164
 
214
165
  if any(not isinstance(obj, model_cls) for obj in objs):
215
166
  raise TypeError(
216
- f"bulk_create expected instances of {model_cls.__name__}, "
217
- f"but got {set(type(obj).__name__ for obj in objs)}"
167
+ f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
218
168
  )
219
169
 
220
- if batch_size is not None and batch_size <= 0:
221
- raise ValueError("batch_size must be a positive integer.")
222
-
223
170
  # Check for MTI - if we detect multi-table inheritance, we need special handling
224
171
  # This follows Django's approach: check that the parents share the same concrete model
225
172
  # with our model to detect the inheritance pattern ConcreteGrandParent ->
@@ -233,12 +180,12 @@ class HookQuerySetMixin:
233
180
 
234
181
  # Fire hooks before DB ops
235
182
  if not bypass_hooks:
236
- ctx = HookContext(model_cls, bypass_hooks=False)
183
+ ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
237
184
  if not bypass_validation:
238
185
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
239
186
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
240
187
  else:
241
- ctx = HookContext(model_cls, bypass_hooks=True)
188
+ ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
242
189
  logger.debug("bulk_create bypassed hooks")
243
190
 
244
191
  # For MTI models, we need to handle them specially
@@ -280,129 +227,74 @@ class HookQuerySetMixin:
280
227
  @transaction.atomic
281
228
  def bulk_update(
282
229
  self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
283
- ) -> int:
230
+ ):
284
231
  """
285
- Bulk update objects in the database with complete hook support.
286
-
287
- This method always runs the complete hook cycle:
288
- VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
289
-
290
- Args:
291
- objs: List of model instances to update
292
- fields: List of field names to update
293
- bypass_hooks: DEPRECATED - kept for backward compatibility only
294
- bypass_validation: DEPRECATED - kept for backward compatibility only
295
- **kwargs: Additional arguments passed to Django's bulk_update
232
+ Bulk update objects in the database with MTI support.
296
233
  """
297
234
  model_cls = self.model
298
235
 
299
236
  if not objs:
300
237
  return []
301
238
 
302
- # Validate inputs
303
- if not isinstance(objs, (list, tuple)):
304
- raise TypeError("objs must be a list or tuple")
305
-
306
- if not isinstance(fields, (list, tuple)):
307
- raise TypeError("fields must be a list or tuple")
308
-
309
- if not objs:
310
- return []
311
-
312
- if not fields:
313
- raise ValueError("fields cannot be empty")
314
-
315
- # Validate that all objects are instances of the model
316
- for obj in objs:
317
- if not isinstance(obj, model_cls):
318
- raise TypeError(
319
- f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
320
- )
321
- if obj.pk is None:
322
- raise ValueError("All objects must have a primary key")
323
-
324
- # Load originals for hook comparison
325
- pks = [obj.pk for obj in objs]
326
- original_map = {
327
- obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
328
- }
329
- originals = [original_map.get(obj.pk) for obj in objs]
330
-
331
- # Run VALIDATE_UPDATE hooks
332
- if not bypass_validation:
333
- ctx = HookContext(model_cls)
334
- engine.run(
335
- model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx
239
+ if any(not isinstance(obj, model_cls) for obj in objs):
240
+ raise TypeError(
241
+ f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
336
242
  )
337
243
 
338
- # Run BEFORE_UPDATE hooks
339
- if not bypass_hooks:
340
- ctx = HookContext(model_cls)
341
- engine.run(
342
- model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx
343
- )
244
+ logger.debug(f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}")
344
245
 
345
- # Determine if model uses MTI
346
- def _is_mti(m):
347
- for parent in m._meta.all_parents:
348
- if parent._meta.concrete_model is not m._meta.concrete_model:
349
- return True
350
- return False
246
+ # Check for MTI
247
+ is_mti = False
248
+ for parent in model_cls._meta.all_parents:
249
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
250
+ is_mti = True
251
+ break
351
252
 
352
- if _is_mti(model_cls):
353
- # Use MTI-aware bulk update across tables
253
+ if not bypass_hooks:
254
+ logger.debug("bulk_update: hooks will run in update()")
255
+ ctx = HookContext(model_cls, bypass_hooks=False)
256
+ originals = [None] * len(objs) # Placeholder for after_update call
257
+ else:
258
+ logger.debug("bulk_update: hooks bypassed")
259
+ ctx = HookContext(model_cls, bypass_hooks=True)
260
+ originals = [None] * len(objs) # Ensure originals is defined for after_update call
261
+
262
+ # Handle auto_now fields like Django's update_or_create does
263
+ fields_set = set(fields)
264
+ pk_fields = model_cls._meta.pk_fields
265
+ for field in model_cls._meta.local_concrete_fields:
266
+ # Only add auto_now fields (like updated_at) that aren't already in the fields list
267
+ # Don't include auto_now_add fields (like created_at) as they should only be set on creation
268
+ if hasattr(field, "auto_now") and field.auto_now:
269
+ if field.name not in fields_set and field.name not in pk_fields:
270
+ fields_set.add(field.name)
271
+ if field.name != field.attname:
272
+ fields_set.add(field.attname)
273
+ fields = list(fields_set)
274
+
275
+ # Handle MTI models differently
276
+ if is_mti:
354
277
  result = self._mti_bulk_update(objs, fields, **kwargs)
355
278
  else:
356
- # Perform database update using Django's native bulk_update
357
- # We use the base manager to avoid recursion
358
- base_manager = model_cls._base_manager
359
- result = base_manager.bulk_update(objs, fields, **kwargs)
279
+ # For single-table models, use Django's built-in bulk_update
280
+ django_kwargs = {
281
+ k: v
282
+ for k, v in kwargs.items()
283
+ if k not in ["bypass_hooks", "bypass_validation"]
284
+ }
285
+ logger.debug("Calling Django bulk_update")
286
+ result = super().bulk_update(objs, fields, **django_kwargs)
287
+ logger.debug(f"Django bulk_update done: {result}")
360
288
 
361
- # Run AFTER_UPDATE hooks
289
+ # Note: We don't run AFTER_UPDATE hooks here to prevent double execution
290
+ # The update() method will handle all hook execution based on thread-local state
362
291
  if not bypass_hooks:
363
- ctx = HookContext(model_cls)
364
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
292
+ logger.debug("bulk_update: skipping AFTER_UPDATE (update() will handle)")
293
+ else:
294
+ logger.debug("bulk_update: hooks bypassed")
365
295
 
366
296
  return result
367
297
 
368
- @transaction.atomic
369
- def bulk_delete(self, objs, **kwargs) -> int:
370
- """
371
- Delete the given objects from the database with complete hook support.
372
-
373
- This method runs the complete hook cycle:
374
- VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
375
-
376
- This is a convenience method that provides a bulk_delete interface
377
- similar to bulk_create and bulk_update.
378
- """
379
- model_cls = self.model
380
-
381
- # Extract custom kwargs
382
- kwargs.pop("bypass_hooks", False)
383
-
384
- # Validate inputs
385
- if not isinstance(objs, (list, tuple)):
386
- raise TypeError("objs must be a list or tuple")
387
-
388
- if not objs:
389
- return 0
390
-
391
- # Validate that all objects are instances of the model
392
- for obj in objs:
393
- if not isinstance(obj, model_cls):
394
- raise TypeError(
395
- f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
396
- )
397
-
398
- # Get the pks to delete
399
- pks = [obj.pk for obj in objs if obj.pk is not None]
400
- if not pks:
401
- return 0
402
-
403
- # Use the delete() method which already has hook support
404
- return self.filter(pk__in=pks).delete()
405
-
406
298
  def _detect_modified_fields(self, new_instances, original_instances):
407
299
  """
408
300
  Detect fields that were modified during BEFORE_UPDATE hooks by comparing
@@ -504,83 +396,50 @@ class HookQuerySetMixin:
504
396
  # Then we can use Django's bulk_create for the child objects
505
397
  parent_objects_map = {}
506
398
 
507
- # Step 1: Insert into parent tables to get primary keys back
399
+ # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
400
+ # Get bypass_hooks from kwargs
508
401
  bypass_hooks = kwargs.get("bypass_hooks", False)
509
402
  bypass_validation = kwargs.get("bypass_validation", False)
510
403
 
511
- # If DB supports returning rows from bulk insert, batch per parent model
512
- supports_returning = connections[self.db].features.can_return_rows_from_bulk_insert
513
-
514
- if supports_returning:
515
- # For each parent level in the chain, create instances in batch preserving order
516
- current_parents_per_obj = {id(obj): None for obj in batch}
404
+ for obj in batch:
405
+ parent_instances = {}
406
+ current_parent = None
517
407
  for model_class in inheritance_chain[:-1]:
518
- parent_objs = [
519
- self._create_parent_instance(obj, model_class, current_parents_per_obj[id(obj)])
520
- for obj in batch
521
- ]
408
+ parent_obj = self._create_parent_instance(
409
+ obj, model_class, current_parent
410
+ )
522
411
 
412
+ # Fire parent hooks if not bypassed
523
413
  if not bypass_hooks:
524
414
  ctx = HookContext(model_class)
525
415
  if not bypass_validation:
526
- engine.run(model_class, VALIDATE_CREATE, parent_objs, ctx=ctx)
527
- engine.run(model_class, BEFORE_CREATE, parent_objs, ctx=ctx)
528
-
529
- # Bulk insert parents using base manager to avoid hook recursion
530
- created_parents = model_class._base_manager.using(self.db).bulk_create(
531
- parent_objs, batch_size=len(parent_objs)
416
+ engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
417
+ engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
418
+
419
+ # Use Django's base manager to create the object and get PKs back
420
+ # This bypasses hooks and the MTI exception
421
+ field_values = {
422
+ field.name: getattr(parent_obj, field.name)
423
+ for field in model_class._meta.local_fields
424
+ if hasattr(parent_obj, field.name)
425
+ and getattr(parent_obj, field.name) is not None
426
+ }
427
+ created_obj = model_class._base_manager.using(self.db).create(
428
+ **field_values
532
429
  )
533
430
 
534
- # After create hooks
535
- if not bypass_hooks:
536
- engine.run(model_class, AFTER_CREATE, created_parents, ctx=ctx)
537
-
538
- # Update maps and state for next parent level
539
- for obj, parent_obj in zip(batch, created_parents):
540
- # Ensure state reflects saved
541
- parent_obj._state.adding = False
542
- parent_obj._state.db = self.db
543
- # Record for this object and level
544
- if id(obj) not in parent_objects_map:
545
- parent_objects_map[id(obj)] = {}
546
- parent_objects_map[id(obj)][model_class] = parent_obj
547
- current_parents_per_obj[id(obj)] = parent_obj
548
- else:
549
- # Fallback: per-row parent inserts (original behavior)
550
- for obj in batch:
551
- parent_instances = {}
552
- current_parent = None
553
- for model_class in inheritance_chain[:-1]:
554
- parent_obj = self._create_parent_instance(
555
- obj, model_class, current_parent
556
- )
557
-
558
- if not bypass_hooks:
559
- ctx = HookContext(model_class)
560
- if not bypass_validation:
561
- engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
562
- engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
563
-
564
- field_values = {
565
- field.name: getattr(parent_obj, field.name)
566
- for field in model_class._meta.local_fields
567
- if hasattr(parent_obj, field.name)
568
- and getattr(parent_obj, field.name) is not None
569
- }
570
- created_obj = model_class._base_manager.using(self.db).create(
571
- **field_values
572
- )
573
-
574
- parent_obj.pk = created_obj.pk
575
- parent_obj._state.adding = False
576
- parent_obj._state.db = self.db
431
+ # Update the parent_obj with the created object's PK
432
+ parent_obj.pk = created_obj.pk
433
+ parent_obj._state.adding = False
434
+ parent_obj._state.db = self.db
577
435
 
578
- if not bypass_hooks:
579
- engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
436
+ # Fire AFTER_CREATE hooks for parent
437
+ if not bypass_hooks:
438
+ engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
580
439
 
581
- parent_instances[model_class] = parent_obj
582
- current_parent = parent_obj
583
- parent_objects_map[id(obj)] = parent_instances
440
+ parent_instances[model_class] = parent_obj
441
+ current_parent = parent_obj
442
+ parent_objects_map[id(obj)] = parent_instances
584
443
 
585
444
  # Step 2: Create all child objects and do single bulk insert into childmost table
586
445
  child_model = inheritance_chain[-1]
@@ -806,8 +665,7 @@ class HookQuerySetMixin:
806
665
  # For MTI, we need to handle parent links correctly
807
666
  # The root model (first in chain) has its own PK
808
667
  # Child models use the parent link to reference the root PK
809
- # Root model (first in chain) has its own PK; kept for clarity
810
- # root_model = inheritance_chain[0]
668
+ root_model = inheritance_chain[0]
811
669
 
812
670
  # Get the primary keys from the objects
813
671
  # If objects have pk set but are not loaded from DB, use those PKs
@@ -891,7 +749,7 @@ class HookQuerySetMixin:
891
749
  **{f"{filter_field}__in": pks}
892
750
  ).update(**case_statements)
893
751
  total_updated += updated_count
894
- except Exception:
752
+ except Exception as e:
895
753
  import traceback
896
754
 
897
755
  traceback.print_exc()