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

@@ -14,6 +14,7 @@ from django.db.models import QuerySet
14
14
  from django_bulk_hooks.helpers import build_changeset_for_create
15
15
  from django_bulk_hooks.helpers import build_changeset_for_delete
16
16
  from django_bulk_hooks.helpers import build_changeset_for_update
17
+ from django_bulk_hooks.helpers import extract_pks
17
18
 
18
19
  logger = logging.getLogger(__name__)
19
20
 
@@ -46,55 +47,104 @@ class BulkOperationCoordinator:
46
47
  self._executor = None
47
48
  self._dispatcher = None
48
49
 
50
+ def _get_or_create_service(self, service_name, service_class, *args, **kwargs):
51
+ """
52
+ Generic lazy service initialization.
53
+
54
+ Args:
55
+ service_name: Name of the service attribute (e.g., 'analyzer')
56
+ service_class: The class to instantiate
57
+ *args, **kwargs: Arguments to pass to the service constructor
58
+
59
+ Returns:
60
+ The service instance
61
+ """
62
+ attr_name = f"_{service_name}"
63
+ if getattr(self, attr_name) is None:
64
+ setattr(self, attr_name, service_class(*args, **kwargs))
65
+ return getattr(self, attr_name)
66
+
49
67
  @property
50
68
  def analyzer(self):
51
69
  """Get or create ModelAnalyzer"""
52
- if self._analyzer is None:
53
- from django_bulk_hooks.operations.analyzer import ModelAnalyzer
70
+ from django_bulk_hooks.operations.analyzer import ModelAnalyzer
54
71
 
55
- self._analyzer = ModelAnalyzer(self.model_cls)
56
- return self._analyzer
72
+ return self._get_or_create_service("analyzer", ModelAnalyzer, self.model_cls)
57
73
 
58
74
  @property
59
75
  def mti_handler(self):
60
76
  """Get or create MTIHandler"""
61
- if self._mti_handler is None:
62
- from django_bulk_hooks.operations.mti_handler import MTIHandler
77
+ from django_bulk_hooks.operations.mti_handler import MTIHandler
63
78
 
64
- self._mti_handler = MTIHandler(self.model_cls)
65
- return self._mti_handler
79
+ return self._get_or_create_service("mti_handler", MTIHandler, self.model_cls)
66
80
 
67
81
  @property
68
82
  def record_classifier(self):
69
83
  """Get or create RecordClassifier"""
70
- if self._record_classifier is None:
71
- from django_bulk_hooks.operations.record_classifier import RecordClassifier
84
+ from django_bulk_hooks.operations.record_classifier import RecordClassifier
72
85
 
73
- self._record_classifier = RecordClassifier(self.model_cls)
74
- return self._record_classifier
86
+ return self._get_or_create_service("record_classifier", RecordClassifier, self.model_cls)
75
87
 
76
88
  @property
77
89
  def executor(self):
78
90
  """Get or create BulkExecutor"""
79
- if self._executor is None:
80
- from django_bulk_hooks.operations.bulk_executor import BulkExecutor
81
-
82
- self._executor = BulkExecutor(
83
- queryset=self.queryset,
84
- analyzer=self.analyzer,
85
- mti_handler=self.mti_handler,
86
- record_classifier=self.record_classifier,
87
- )
88
- return self._executor
91
+ from django_bulk_hooks.operations.bulk_executor import BulkExecutor
92
+
93
+ return self._get_or_create_service(
94
+ "executor",
95
+ BulkExecutor,
96
+ queryset=self.queryset,
97
+ analyzer=self.analyzer,
98
+ mti_handler=self.mti_handler,
99
+ record_classifier=self.record_classifier,
100
+ )
89
101
 
90
102
  @property
91
103
  def dispatcher(self):
92
104
  """Get or create Dispatcher"""
93
- if self._dispatcher is None:
94
- from django_bulk_hooks.dispatcher import get_dispatcher
105
+ from django_bulk_hooks.dispatcher import get_dispatcher
106
+
107
+ return self._get_or_create_service("dispatcher", get_dispatcher)
108
+
109
+ @property
110
+ def inheritance_chain(self):
111
+ """Single source of truth for inheritance chain"""
112
+ return self.mti_handler.get_inheritance_chain()
113
+
114
+ def _validate_objects_for_operation(self, objs, operation_type):
115
+ """
116
+ Validate objects exist and return appropriate empty result.
117
+
118
+ Args:
119
+ objs: List of objects to validate
120
+ operation_type: 'create', 'update', 'delete', or 'validate'
121
+
122
+ Returns:
123
+ Appropriate empty result for the operation type, or None if objects exist
124
+ """
125
+ if not objs:
126
+ empty_results = {
127
+ "create": objs,
128
+ "update": 0,
129
+ "delete": (0, {}),
130
+ "validate": None,
131
+ }
132
+ return empty_results[operation_type]
133
+ return None # Continue with operation
95
134
 
96
- self._dispatcher = get_dispatcher()
97
- return self._dispatcher
135
+ def _dispatch_hooks_for_models(self, models_in_chain, changeset, event_suffix, bypass_hooks=False):
136
+ """
137
+ Dispatch hooks for all models in inheritance chain.
138
+
139
+ Args:
140
+ models_in_chain: List of model classes in MTI inheritance chain
141
+ changeset: The changeset to use as base
142
+ event_suffix: Event name suffix (e.g., 'before_create', 'validate_update')
143
+ bypass_hooks: Whether to skip hook execution
144
+ """
145
+ for model_cls in models_in_chain:
146
+ model_changeset = self._build_changeset_for_model(changeset, model_cls)
147
+ self.dispatcher.dispatch(model_changeset, event_suffix, bypass_hooks=bypass_hooks)
98
148
 
99
149
  # ==================== PUBLIC API ====================
100
150
 
@@ -126,8 +176,9 @@ class BulkOperationCoordinator:
126
176
  Returns:
127
177
  List of created objects
128
178
  """
129
- if not objs:
130
- return objs
179
+ empty_result = self._validate_objects_for_operation(objs, "create")
180
+ if empty_result is not None:
181
+ return empty_result
131
182
 
132
183
  # Validate
133
184
  self.analyzer.validate_for_create(objs)
@@ -141,10 +192,8 @@ class BulkOperationCoordinator:
141
192
  if self.mti_handler.is_mti_model():
142
193
  query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
143
194
  logger.info(f"MTI model detected: querying {query_model.__name__} for unique fields {unique_fields}")
144
-
145
- existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
146
- objs, unique_fields, query_model=query_model
147
- )
195
+
196
+ existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(objs, unique_fields, query_model=query_model)
148
197
  logger.info(f"Upsert operation: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new records")
149
198
  logger.debug(f"Existing record IDs: {existing_record_ids}")
150
199
  logger.debug(f"Existing PKs map: {existing_pks_map}")
@@ -203,8 +252,9 @@ class BulkOperationCoordinator:
203
252
  Returns:
204
253
  Number of objects updated
205
254
  """
206
- if not objs:
207
- return 0
255
+ empty_result = self._validate_objects_for_operation(objs, "update")
256
+ if empty_result is not None:
257
+ return empty_result
208
258
 
209
259
  # Validate
210
260
  self.analyzer.validate_for_update(objs)
@@ -349,19 +399,11 @@ class BulkOperationCoordinator:
349
399
  changeset.operation_meta["allows_modifications"] = True
350
400
 
351
401
  # Step 5: Get MTI inheritance chain
352
- models_in_chain = [self.model_cls]
353
- if self.mti_handler.is_mti_model():
354
- models_in_chain.extend(self.mti_handler.get_parent_models())
402
+ models_in_chain = self._get_models_in_chain(self.model_cls)
355
403
 
356
404
  # Step 6: Run VALIDATE hooks (if not bypassed)
357
405
  if not bypass_validation:
358
- for model_cls in models_in_chain:
359
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
360
- self.dispatcher.dispatch(
361
- model_changeset,
362
- "validate_update",
363
- bypass_hooks=False,
364
- )
406
+ self._dispatch_hooks_for_models(models_in_chain, changeset, "validate_update", bypass_hooks=False)
365
407
 
366
408
  # Step 7: Run BEFORE_UPDATE hooks with modification tracking
367
409
  modified_fields = self._run_before_update_hooks_with_tracking(
@@ -378,13 +420,7 @@ class BulkOperationCoordinator:
378
420
  pre_after_hook_state = self._snapshot_instance_state(new_instances)
379
421
 
380
422
  # Step 10: Run AFTER_UPDATE hooks (read-only side effects)
381
- for model_cls in models_in_chain:
382
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
383
- self.dispatcher.dispatch(
384
- model_changeset,
385
- "after_update",
386
- bypass_hooks=False,
387
- )
423
+ self._dispatch_hooks_for_models(models_in_chain, changeset, "after_update", bypass_hooks=False)
388
424
 
389
425
  # Step 11: Auto-persist AFTER_UPDATE modifications (if any)
390
426
  after_modified_fields = self._detect_modifications(new_instances, pre_after_hook_state)
@@ -408,13 +444,7 @@ class BulkOperationCoordinator:
408
444
  pre_hook_state = self._snapshot_instance_state(instances)
409
445
 
410
446
  # Run BEFORE_UPDATE hooks
411
- for model_cls in models_in_chain:
412
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
413
- self.dispatcher.dispatch(
414
- model_changeset,
415
- "before_update",
416
- bypass_hooks=False,
417
- )
447
+ self._dispatch_hooks_for_models(models_in_chain, changeset, "before_update", bypass_hooks=False)
418
448
 
419
449
  # Detect modifications
420
450
  return self._detect_modifications(instances, pre_hook_state)
@@ -517,8 +547,9 @@ class BulkOperationCoordinator:
517
547
  """
518
548
  # Get objects
519
549
  objs = list(self.queryset)
520
- if not objs:
521
- return 0, {}
550
+ empty_result = self._validate_objects_for_operation(objs, "delete")
551
+ if empty_result is not None:
552
+ return empty_result
522
553
 
523
554
  # Validate
524
555
  self.analyzer.validate_for_delete(objs)
@@ -553,13 +584,20 @@ class BulkOperationCoordinator:
553
584
  Returns:
554
585
  None
555
586
  """
556
- if not objs:
587
+ empty_result = self._validate_objects_for_operation(objs, "validate")
588
+ if empty_result is not None:
557
589
  return
558
590
 
559
591
  # Auto-detect if is_create not specified
560
592
  if is_create is None:
561
593
  is_create = objs[0].pk is None
562
594
 
595
+ # Use centralized validation logic (consistent with other operations)
596
+ if is_create:
597
+ self.analyzer.validate_for_create(objs)
598
+ else:
599
+ self.analyzer.validate_for_update(objs)
600
+
563
601
  # Build changeset based on operation type
564
602
  if is_create:
565
603
  changeset = build_changeset_for_create(self.model_cls, objs)
@@ -569,8 +607,9 @@ class BulkOperationCoordinator:
569
607
  changeset = build_changeset_for_update(self.model_cls, objs, {})
570
608
  event = "validate_update"
571
609
 
572
- # Dispatch validation event only
573
- self.dispatcher.dispatch(changeset, event, bypass_hooks=False)
610
+ # Dispatch validation event for entire inheritance chain
611
+ models_in_chain = self._get_models_in_chain(self.model_cls)
612
+ self._dispatch_hooks_for_models(models_in_chain, changeset, event)
574
613
 
575
614
  # ==================== MTI PARENT HOOK SUPPORT ====================
576
615
 
@@ -598,6 +637,21 @@ class BulkOperationCoordinator:
598
637
  operation_meta=original_changeset.operation_meta,
599
638
  )
600
639
 
640
+ def _get_models_in_chain(self, model_cls):
641
+ """
642
+ Get all models in the inheritance chain for hook dispatching.
643
+
644
+ DEPRECATED: Use self.inheritance_chain property instead for consistency.
645
+ This method is kept for backward compatibility.
646
+
647
+ Args:
648
+ model_cls: The model class to start from
649
+
650
+ Returns:
651
+ List of model classes in inheritance order [child, parent1, parent2, ...]
652
+ """
653
+ return self.inheritance_chain
654
+
601
655
  def _execute_with_mti_hooks(
602
656
  self,
603
657
  changeset,
@@ -627,21 +681,14 @@ class BulkOperationCoordinator:
627
681
  return operation()
628
682
 
629
683
  # Get all models in inheritance chain
630
- models_in_chain = [changeset.model_cls]
631
- if self.mti_handler.is_mti_model():
632
- parent_models = self.mti_handler.get_parent_models()
633
- models_in_chain.extend(parent_models)
684
+ models_in_chain = self._get_models_in_chain(changeset.model_cls)
634
685
 
635
686
  # VALIDATE phase - for all models in chain
636
687
  if not bypass_validation:
637
- for model_cls in models_in_chain:
638
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
639
- self.dispatcher.dispatch(model_changeset, f"validate_{event_prefix}", bypass_hooks=False)
688
+ self._dispatch_hooks_for_models(models_in_chain, changeset, f"validate_{event_prefix}")
640
689
 
641
690
  # BEFORE phase - for all models in chain
642
- for model_cls in models_in_chain:
643
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
644
- self.dispatcher.dispatch(model_changeset, f"before_{event_prefix}", bypass_hooks=False)
691
+ self._dispatch_hooks_for_models(models_in_chain, changeset, f"before_{event_prefix}")
645
692
 
646
693
  # Execute the actual operation
647
694
  result = operation()
@@ -660,14 +707,10 @@ class BulkOperationCoordinator:
660
707
 
661
708
  changeset = build_changeset_for_create(changeset.model_cls, result)
662
709
 
663
- for model_cls in models_in_chain:
664
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
665
- self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
710
+ self._dispatch_hooks_for_models(models_in_chain, changeset, f"after_{event_prefix}")
666
711
  else:
667
712
  # Non-create operations (update, delete)
668
- for model_cls in models_in_chain:
669
- model_changeset = self._build_changeset_for_model(changeset, model_cls)
670
- self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
713
+ self._dispatch_hooks_for_models(models_in_chain, changeset, f"after_{event_prefix}")
671
714
 
672
715
  return result
673
716
 
@@ -768,7 +811,7 @@ class BulkOperationCoordinator:
768
811
  for model_cls, objs in objects_by_model.items():
769
812
  if hasattr(model_cls, "created_at") and hasattr(model_cls, "updated_at"):
770
813
  # Bulk fetch timestamps for all objects of this model
771
- pks = [obj.pk for obj in objs if obj.pk is not None]
814
+ pks = extract_pks(objs)
772
815
  if pks:
773
816
  timestamp_map = {
774
817
  record["pk"]: (record["created_at"], record["updated_at"])
@@ -806,9 +849,7 @@ class BulkOperationCoordinator:
806
849
 
807
850
  create_changeset = build_changeset_for_create(self.model_cls, created_objects)
808
851
 
809
- for model_cls in models_in_chain:
810
- model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
811
- self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
852
+ self._dispatch_hooks_for_models(models_in_chain, create_changeset, "after_create", bypass_hooks=False)
812
853
 
813
854
  # Dispatch after_update hooks for updated objects
814
855
  if updated_objects:
@@ -824,9 +865,7 @@ class BulkOperationCoordinator:
824
865
  old_records_map=old_records_map,
825
866
  )
826
867
 
827
- for model_cls in models_in_chain:
828
- model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
829
- self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
868
+ self._dispatch_hooks_for_models(models_in_chain, update_changeset, "after_update", bypass_hooks=False)
830
869
 
831
870
  # Clean up temporary metadata
832
871
  self._cleanup_upsert_metadata(result_objects)
@@ -65,10 +65,7 @@ def get_field_values_for_db(obj, field_names, model_cls=None):
65
65
  if model_cls is None:
66
66
  model_cls = obj.__class__
67
67
 
68
- return {
69
- field_name: get_field_value_for_db(obj, field_name, model_cls)
70
- for field_name in field_names
71
- }
68
+ return {field_name: get_field_value_for_db(obj, field_name, model_cls) for field_name in field_names}
72
69
 
73
70
 
74
71
  def normalize_field_name_to_db(field_name, model_cls):
@@ -91,4 +88,139 @@ def normalize_field_name_to_db(field_name, model_cls):
91
88
  return field.attname # Returns 'business_id' for 'business' field
92
89
  return field_name
93
90
  except Exception: # noqa: BLE001
94
- return field_name
91
+ return field_name
92
+
93
+
94
+ def get_changed_fields(old_obj, new_obj, model_cls, skip_auto_fields=False):
95
+ """
96
+ Get field names that have changed between two model instances.
97
+
98
+ Uses Django's field.get_prep_value() for proper database-level comparison.
99
+ This is the canonical implementation used by both RecordChange and ModelAnalyzer.
100
+
101
+ Args:
102
+ old_obj: The old model instance
103
+ new_obj: The new model instance
104
+ model_cls: The Django model class
105
+ skip_auto_fields: Whether to skip auto_created fields (default False)
106
+
107
+ Returns:
108
+ Set of field names that have changed
109
+ """
110
+ changed = set()
111
+
112
+ for field in model_cls._meta.fields:
113
+ # Skip primary key fields - they shouldn't change
114
+ if field.primary_key:
115
+ continue
116
+
117
+ # Optionally skip auto-created fields (for bulk operations)
118
+ if skip_auto_fields and field.auto_created:
119
+ continue
120
+
121
+ old_val = getattr(old_obj, field.name, None)
122
+ new_val = getattr(new_obj, field.name, None)
123
+
124
+ # Use field's get_prep_value for database-ready comparison
125
+ # This handles timezone conversions, type coercions, etc.
126
+ try:
127
+ old_prep = field.get_prep_value(old_val)
128
+ new_prep = field.get_prep_value(new_val)
129
+ if old_prep != new_prep:
130
+ changed.add(field.name)
131
+ except (TypeError, ValueError):
132
+ # Fallback to direct comparison if get_prep_value fails
133
+ if old_val != new_val:
134
+ changed.add(field.name)
135
+
136
+ return changed
137
+
138
+
139
+ def get_auto_fields(model_cls, include_auto_now_add=True):
140
+ """
141
+ Get auto fields from a model.
142
+
143
+ Args:
144
+ model_cls: Django model class
145
+ include_auto_now_add: Whether to include auto_now_add fields
146
+
147
+ Returns:
148
+ List of field names
149
+ """
150
+ fields = []
151
+ for field in model_cls._meta.fields:
152
+ if getattr(field, "auto_now", False) or (include_auto_now_add and getattr(field, "auto_now_add", False)):
153
+ fields.append(field.name)
154
+ return fields
155
+
156
+
157
+ def get_auto_now_only_fields(model_cls):
158
+ """Get only auto_now fields (excluding auto_now_add)."""
159
+ return get_auto_fields(model_cls, include_auto_now_add=False)
160
+
161
+
162
+ def get_fk_fields(model_cls):
163
+ """Get foreign key field names for a model."""
164
+ return [field.name for field in model_cls._meta.concrete_fields if field.is_relation and not field.many_to_many]
165
+
166
+
167
+ def collect_auto_now_fields_for_inheritance_chain(inheritance_chain):
168
+ """Collect auto_now fields across an MTI inheritance chain."""
169
+ all_auto_now = set()
170
+ for model_cls in inheritance_chain:
171
+ all_auto_now.update(get_auto_now_only_fields(model_cls))
172
+ return all_auto_now
173
+
174
+
175
+ def handle_auto_now_fields_for_inheritance_chain(models, instances, for_update=True):
176
+ """
177
+ Unified auto-now field handling for any inheritance chain.
178
+
179
+ This replaces the separate collect/pre_save logic with a single comprehensive
180
+ method that handles collection, pre-saving, and field inclusion for updates.
181
+
182
+ Args:
183
+ models: List of model classes in inheritance chain
184
+ instances: List of model instances to process
185
+ for_update: Whether this is for an update operation (vs create)
186
+
187
+ Returns:
188
+ Set of auto_now field names that should be included in updates
189
+ """
190
+ all_auto_now_fields = set()
191
+
192
+ for model_cls in models:
193
+ for field in model_cls._meta.local_fields:
194
+ # For updates, only include auto_now (not auto_now_add)
195
+ # For creates, include both
196
+ if getattr(field, "auto_now", False) or (not for_update and getattr(field, "auto_now_add", False)):
197
+ all_auto_now_fields.add(field.name)
198
+
199
+ # Pre-save the field on instances
200
+ for instance in instances:
201
+ if for_update:
202
+ # For updates, only pre-save auto_now fields
203
+ field.pre_save(instance, add=False)
204
+ else:
205
+ # For creates, pre-save both auto_now and auto_now_add
206
+ field.pre_save(instance, add=True)
207
+
208
+ return all_auto_now_fields
209
+
210
+
211
+ def pre_save_auto_now_fields(objects, inheritance_chain):
212
+ """Pre-save auto_now fields across inheritance chain."""
213
+ # DEPRECATED: Use handle_auto_now_fields_for_inheritance_chain instead
214
+ auto_now_fields = collect_auto_now_fields_for_inheritance_chain(inheritance_chain)
215
+
216
+ for field_name in auto_now_fields:
217
+ # Find which model has this field
218
+ for model_cls in inheritance_chain:
219
+ try:
220
+ field = model_cls._meta.get_field(field_name)
221
+ if getattr(field, "auto_now", False):
222
+ for obj in objects:
223
+ field.pre_save(obj, add=False)
224
+ break
225
+ except Exception:
226
+ continue
@@ -217,6 +217,12 @@ class MTIHandler:
217
217
  child_obj = self._create_child_instance_template(obj, inheritance_chain[-1])
218
218
  child_objects.append(child_obj)
219
219
 
220
+ # Pre-compute child-specific fields for execution efficiency
221
+ from django_bulk_hooks.helpers import get_fields_for_model, filter_field_names_for_model
222
+
223
+ child_unique_fields = get_fields_for_model(inheritance_chain[-1], unique_fields or [])
224
+ child_update_fields = get_fields_for_model(inheritance_chain[-1], update_fields or [])
225
+
220
226
  return MTICreatePlan(
221
227
  inheritance_chain=inheritance_chain,
222
228
  parent_levels=parent_levels,
@@ -228,6 +234,8 @@ class MTIHandler:
228
234
  update_conflicts=update_conflicts,
229
235
  unique_fields=unique_fields or [],
230
236
  update_fields=update_fields or [],
237
+ child_unique_fields=child_unique_fields,
238
+ child_update_fields=child_update_fields,
231
239
  )
232
240
 
233
241
  def _build_parent_levels(
@@ -366,14 +374,14 @@ class MTIHandler:
366
374
  def _get_auto_now_fields_for_model(self, model_class, model_fields_by_name):
367
375
  """
368
376
  Get auto_now (not auto_now_add) fields for a specific model.
369
-
377
+
370
378
  Only includes fields that exist in model_fields_by_name to ensure
371
379
  they're valid local fields for this model level.
372
-
380
+
373
381
  Args:
374
382
  model_class: Model class to get fields for
375
383
  model_fields_by_name: Dict of valid field names for this model level
376
-
384
+
377
385
  Returns:
378
386
  List of auto_now field names (excluding auto_now_add)
379
387
  """
@@ -458,14 +466,15 @@ class MTIHandler:
458
466
  if hasattr(source_obj._state, "db"):
459
467
  parent_obj._state.db = source_obj._state.db
460
468
 
461
- # Handle auto_now_add and auto_now fields
462
- for field in parent_model._meta.local_fields:
463
- if getattr(field, "auto_now_add", False):
464
- if getattr(parent_obj, field.name) is None:
465
- field.pre_save(parent_obj, add=True)
466
- setattr(parent_obj, field.attname, field.value_from_object(parent_obj))
467
- elif getattr(field, "auto_now", False):
468
- field.pre_save(parent_obj, add=True)
469
+ # Use unified auto-now field handling
470
+ from django_bulk_hooks.operations.field_utils import handle_auto_now_fields_for_inheritance_chain
471
+
472
+ # Handle auto fields for this single parent model
473
+ handle_auto_now_fields_for_inheritance_chain(
474
+ [parent_model],
475
+ [parent_obj],
476
+ for_update=False, # MTI create is like insert
477
+ )
469
478
 
470
479
  return parent_obj
471
480
 
@@ -516,14 +525,15 @@ class MTIHandler:
516
525
  if hasattr(source_obj._state, "db"):
517
526
  child_obj._state.db = source_obj._state.db
518
527
 
519
- # Handle auto_now_add and auto_now fields
520
- for field in child_model._meta.local_fields:
521
- if getattr(field, "auto_now_add", False):
522
- if getattr(child_obj, field.name) is None:
523
- field.pre_save(child_obj, add=True)
524
- setattr(child_obj, field.attname, field.value_from_object(child_obj))
525
- elif getattr(field, "auto_now", False):
526
- field.pre_save(child_obj, add=True)
528
+ # Use unified auto-now field handling
529
+ from django_bulk_hooks.operations.field_utils import handle_auto_now_fields_for_inheritance_chain
530
+
531
+ # Handle auto fields for this single child model
532
+ handle_auto_now_fields_for_inheritance_chain(
533
+ [child_model],
534
+ [child_obj],
535
+ for_update=False, # MTI create is like insert
536
+ )
527
537
 
528
538
  return child_obj
529
539