django-bulk-hooks 0.1.231__py3-none-any.whl → 0.1.233__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,11 @@
1
1
  import logging
2
2
 
3
- from django.db import models, transaction, connections
3
+ from django.db import models, transaction
4
4
  from django.db.models import AutoField, Case, Value, When
5
+
5
6
  from django_bulk_hooks import engine
7
+
8
+ logger = logging.getLogger(__name__)
6
9
  from django_bulk_hooks.constants import (
7
10
  AFTER_CREATE,
8
11
  AFTER_DELETE,
@@ -16,8 +19,6 @@ from django_bulk_hooks.constants import (
16
19
  )
17
20
  from django_bulk_hooks.context import HookContext
18
21
 
19
- logger = logging.getLogger(__name__)
20
-
21
22
 
22
23
  class HookQuerySetMixin:
23
24
  """
@@ -26,24 +27,12 @@ class HookQuerySetMixin:
26
27
  """
27
28
 
28
29
  @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
- """
30
+ def delete(self):
36
31
  objs = list(self)
37
32
  if not objs:
38
33
  return 0
39
34
 
40
35
  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
36
  ctx = HookContext(model_cls)
48
37
 
49
38
  # Run validation hooks first
@@ -61,19 +50,7 @@ class HookQuerySetMixin:
61
50
  return result
62
51
 
63
52
  @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
-
53
+ def update(self, **kwargs):
77
54
  instances = list(self)
78
55
  if not instances:
79
56
  return 0
@@ -81,105 +58,85 @@ class HookQuerySetMixin:
81
58
  model_cls = self.model
82
59
  pks = [obj.pk for obj in instances]
83
60
 
84
- # Load originals for hook comparison
61
+ # Load originals for hook comparison and ensure they match the order of instances
62
+ # Use the base manager to avoid recursion
85
63
  original_map = {
86
64
  obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
87
65
  }
88
66
  originals = [original_map.get(obj.pk) for obj in instances]
89
67
 
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)
68
+ # Resolve subqueries to actual values before applying to instances
69
+ resolved_kwargs = self._resolve_subquery_values(kwargs)
106
70
 
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
71
+ # Apply resolved field updates to instances
72
+ for obj in instances:
73
+ for field, value in resolved_kwargs.items():
74
+ setattr(obj, field, value)
75
+
76
+ # Check if we're in a bulk operation context to prevent double hook execution
77
+ from django_bulk_hooks.context import get_bypass_hooks
78
+
79
+ current_bypass_hooks = get_bypass_hooks()
80
+
81
+ # If we're in a bulk operation context, skip hooks to prevent double execution
82
+ if current_bypass_hooks:
83
+ logger.debug("update: skipping hooks (bulk context)")
84
+ ctx = HookContext(model_cls, bypass_hooks=True)
85
+ else:
86
+ logger.debug("update: running hooks (standalone)")
87
+ ctx = HookContext(model_cls, bypass_hooks=False)
88
+ # Run validation hooks first
89
+ engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
90
+ # Then run BEFORE_UPDATE hooks
146
91
  engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
147
92
 
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
93
+ # Use Django's built-in update logic directly
94
+ # Call the base QuerySet implementation to avoid recursion
95
+ # Use original kwargs so Django can handle subqueries at database level
96
+ update_count = super().update(**kwargs)
154
97
 
155
- is_mti = _is_mti(model_cls)
98
+ # Since we resolved subqueries upfront, we don't need the post-refresh logic
156
99
 
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)
100
+ # Run AFTER_UPDATE hooks only for standalone updates
101
+ if not current_bypass_hooks:
102
+ logger.debug("update: running AFTER_UPDATE")
103
+ engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
161
104
  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]
105
+ logger.debug("update: skipping AFTER_UPDATE (bulk context)")
106
+
107
+ return update_count
108
+
109
+ def _resolve_subquery_values(self, kwargs):
110
+ """
111
+ Resolve Subquery objects to their actual values by evaluating them
112
+ against the database. This ensures hooks receive resolved values
113
+ instead of raw Subquery objects.
114
+ """
115
+ resolved_kwargs = {}
116
+ for field, value in kwargs.items():
117
+ if hasattr(value, "query") and hasattr(value, "resolve_expression"):
118
+ # This is a subquery - we need to resolve it
119
+ try:
120
+ # Create a temporary queryset to evaluate the subquery
121
+ temp_qs = self.model._default_manager.all()
122
+ temp_qs = temp_qs.annotate(_temp_field=value)
123
+ temp_qs = temp_qs.values("_temp_field")
124
+
125
+ # Get the resolved value (assuming single result for update context)
126
+ resolved_value = temp_qs.first()["_temp_field"]
127
+ resolved_kwargs[field] = resolved_value
128
+ except Exception:
129
+ # If resolution fails, use the original subquery
130
+ # Django's update() will handle it at the database level
131
+ logger.warning(
132
+ f"Failed to resolve subquery for field {field}, using original"
133
+ )
134
+ resolved_kwargs[field] = value
170
135
  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)
136
+ # Not a subquery, use as-is
137
+ resolved_kwargs[field] = value
176
138
 
177
- # Run AFTER_UPDATE hooks only if not bypassed
178
- if not bypass_hooks:
179
- ctx = HookContext(model_cls)
180
- engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
181
-
182
- return result
139
+ return resolved_kwargs
183
140
 
184
141
  @transaction.atomic
185
142
  def bulk_create(
@@ -192,34 +149,37 @@ class HookQuerySetMixin:
192
149
  unique_fields=None,
193
150
  bypass_hooks=False,
194
151
  bypass_validation=False,
195
- ) -> list:
152
+ ):
196
153
  """
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.
154
+ Insert each of the instances into the database. Behaves like Django's bulk_create,
155
+ but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
156
+ passed through to the correct logic. For MTI, only a subset of options may be supported.
204
157
  """
205
158
  model_cls = self.model
206
159
 
207
- # Validate inputs
208
- if not isinstance(objs, (list, tuple)):
209
- raise TypeError("objs must be a list or tuple")
160
+ # When you bulk insert you don't get the primary keys back (if it's an
161
+ # autoincrement, except if can_return_rows_from_bulk_insert=True), so
162
+ # you can't insert into the child tables which references this. There
163
+ # are two workarounds:
164
+ # 1) This could be implemented if you didn't have an autoincrement pk
165
+ # 2) You could do it by doing O(n) normal inserts into the parent
166
+ # tables to get the primary keys back and then doing a single bulk
167
+ # insert into the childmost table.
168
+ # We currently set the primary keys on the objects when using
169
+ # PostgreSQL via the RETURNING ID clause. It should be possible for
170
+ # Oracle as well, but the semantics for extracting the primary keys is
171
+ # trickier so it's not done yet.
172
+ if batch_size is not None and batch_size <= 0:
173
+ raise ValueError("Batch size must be a positive integer.")
210
174
 
211
175
  if not objs:
212
176
  return objs
213
177
 
214
178
  if any(not isinstance(obj, model_cls) for obj in objs):
215
179
  raise TypeError(
216
- f"bulk_create expected instances of {model_cls.__name__}, "
217
- f"but got {set(type(obj).__name__ for obj in objs)}"
180
+ f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
218
181
  )
219
182
 
220
- if batch_size is not None and batch_size <= 0:
221
- raise ValueError("batch_size must be a positive integer.")
222
-
223
183
  # Check for MTI - if we detect multi-table inheritance, we need special handling
224
184
  # This follows Django's approach: check that the parents share the same concrete model
225
185
  # with our model to detect the inheritance pattern ConcreteGrandParent ->
@@ -233,12 +193,12 @@ class HookQuerySetMixin:
233
193
 
234
194
  # Fire hooks before DB ops
235
195
  if not bypass_hooks:
236
- ctx = HookContext(model_cls, bypass_hooks=False)
196
+ ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
237
197
  if not bypass_validation:
238
198
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
239
199
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
240
200
  else:
241
- ctx = HookContext(model_cls, bypass_hooks=True)
201
+ ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
242
202
  logger.debug("bulk_create bypassed hooks")
243
203
 
244
204
  # For MTI models, we need to handle them specially
@@ -280,129 +240,78 @@ class HookQuerySetMixin:
280
240
  @transaction.atomic
281
241
  def bulk_update(
282
242
  self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
283
- ) -> int:
243
+ ):
284
244
  """
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
245
+ Bulk update objects in the database with MTI support.
296
246
  """
297
247
  model_cls = self.model
298
248
 
299
249
  if not objs:
300
250
  return []
301
251
 
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
252
+ if any(not isinstance(obj, model_cls) for obj in objs):
253
+ raise TypeError(
254
+ f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
336
255
  )
337
256
 
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
- )
257
+ logger.debug(
258
+ f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
259
+ )
344
260
 
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
261
+ # Check for MTI
262
+ is_mti = False
263
+ for parent in model_cls._meta.all_parents:
264
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
265
+ is_mti = True
266
+ break
351
267
 
352
- if _is_mti(model_cls):
353
- # Use MTI-aware bulk update across tables
268
+ if not bypass_hooks:
269
+ logger.debug("bulk_update: hooks will run in update()")
270
+ ctx = HookContext(model_cls, bypass_hooks=False)
271
+ originals = [None] * len(objs) # Placeholder for after_update call
272
+ else:
273
+ logger.debug("bulk_update: hooks bypassed")
274
+ ctx = HookContext(model_cls, bypass_hooks=True)
275
+ originals = [None] * len(
276
+ objs
277
+ ) # Ensure originals is defined for after_update call
278
+
279
+ # Handle auto_now fields like Django's update_or_create does
280
+ fields_set = set(fields)
281
+ pk_fields = model_cls._meta.pk_fields
282
+ for field in model_cls._meta.local_concrete_fields:
283
+ # Only add auto_now fields (like updated_at) that aren't already in the fields list
284
+ # Don't include auto_now_add fields (like created_at) as they should only be set on creation
285
+ if hasattr(field, "auto_now") and field.auto_now:
286
+ if field.name not in fields_set and field.name not in pk_fields:
287
+ fields_set.add(field.name)
288
+ if field.name != field.attname:
289
+ fields_set.add(field.attname)
290
+ fields = list(fields_set)
291
+
292
+ # Handle MTI models differently
293
+ if is_mti:
354
294
  result = self._mti_bulk_update(objs, fields, **kwargs)
355
295
  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)
296
+ # For single-table models, use Django's built-in bulk_update
297
+ django_kwargs = {
298
+ k: v
299
+ for k, v in kwargs.items()
300
+ if k not in ["bypass_hooks", "bypass_validation"]
301
+ }
302
+ logger.debug("Calling Django bulk_update")
303
+ result = super().bulk_update(objs, fields, **django_kwargs)
304
+ logger.debug(f"Django bulk_update done: {result}")
360
305
 
361
- # Run AFTER_UPDATE hooks
306
+ # Note: We don't run AFTER_UPDATE hooks here to prevent double execution
307
+ # The update() method will handle all hook execution based on thread-local state
362
308
  if not bypass_hooks:
363
- ctx = HookContext(model_cls)
364
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
309
+ logger.debug("bulk_update: skipping AFTER_UPDATE (update() will handle)")
310
+ else:
311
+ logger.debug("bulk_update: hooks bypassed")
365
312
 
366
313
  return result
367
314
 
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
315
  def _detect_modified_fields(self, new_instances, original_instances):
407
316
  """
408
317
  Detect fields that were modified during BEFORE_UPDATE hooks by comparing
@@ -504,83 +413,50 @@ class HookQuerySetMixin:
504
413
  # Then we can use Django's bulk_create for the child objects
505
414
  parent_objects_map = {}
506
415
 
507
- # Step 1: Insert into parent tables to get primary keys back
416
+ # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
417
+ # Get bypass_hooks from kwargs
508
418
  bypass_hooks = kwargs.get("bypass_hooks", False)
509
419
  bypass_validation = kwargs.get("bypass_validation", False)
510
420
 
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}
421
+ for obj in batch:
422
+ parent_instances = {}
423
+ current_parent = None
517
424
  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
- ]
425
+ parent_obj = self._create_parent_instance(
426
+ obj, model_class, current_parent
427
+ )
522
428
 
429
+ # Fire parent hooks if not bypassed
523
430
  if not bypass_hooks:
524
431
  ctx = HookContext(model_class)
525
432
  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)
433
+ engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
434
+ engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
435
+
436
+ # Use Django's base manager to create the object and get PKs back
437
+ # This bypasses hooks and the MTI exception
438
+ field_values = {
439
+ field.name: getattr(parent_obj, field.name)
440
+ for field in model_class._meta.local_fields
441
+ if hasattr(parent_obj, field.name)
442
+ and getattr(parent_obj, field.name) is not None
443
+ }
444
+ created_obj = model_class._base_manager.using(self.db).create(
445
+ **field_values
532
446
  )
533
447
 
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
- )
448
+ # Update the parent_obj with the created object's PK
449
+ parent_obj.pk = created_obj.pk
450
+ parent_obj._state.adding = False
451
+ parent_obj._state.db = self.db
573
452
 
574
- parent_obj.pk = created_obj.pk
575
- parent_obj._state.adding = False
576
- parent_obj._state.db = self.db
577
-
578
- if not bypass_hooks:
579
- engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
453
+ # Fire AFTER_CREATE hooks for parent
454
+ if not bypass_hooks:
455
+ engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
580
456
 
581
- parent_instances[model_class] = parent_obj
582
- current_parent = parent_obj
583
- parent_objects_map[id(obj)] = parent_instances
457
+ parent_instances[model_class] = parent_obj
458
+ current_parent = parent_obj
459
+ parent_objects_map[id(obj)] = parent_instances
584
460
 
585
461
  # Step 2: Create all child objects and do single bulk insert into childmost table
586
462
  child_model = inheritance_chain[-1]
@@ -806,8 +682,7 @@ class HookQuerySetMixin:
806
682
  # For MTI, we need to handle parent links correctly
807
683
  # The root model (first in chain) has its own PK
808
684
  # 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]
685
+ root_model = inheritance_chain[0]
811
686
 
812
687
  # Get the primary keys from the objects
813
688
  # If objects have pk set but are not loaded from DB, use those PKs
@@ -891,7 +766,7 @@ class HookQuerySetMixin:
891
766
  **{f"{filter_field}__in": pks}
892
767
  ).update(**case_statements)
893
768
  total_updated += updated_count
894
- except Exception:
769
+ except Exception as e:
895
770
  import traceback
896
771
 
897
772
  traceback.print_exc()