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

@@ -0,0 +1,696 @@
1
+ """
2
+ Multi-table inheritance (MTI) handler service.
3
+
4
+ Handles detection and planning for multi-table inheritance operations.
5
+ This handler is pure logic - it does not execute database operations.
6
+ It returns plans (data structures) that the BulkExecutor executes.
7
+ """
8
+
9
+ import logging
10
+ from typing import Dict
11
+ from typing import List
12
+ from typing import Optional
13
+ from typing import Set
14
+ from typing import Tuple
15
+
16
+ from django.db.models import AutoField
17
+ from django.db.models import Model
18
+ from django.db.models import UniqueConstraint
19
+
20
+ from django_bulk_hooks.helpers import get_fields_for_model
21
+ from django_bulk_hooks.operations.field_utils import get_field_value_for_db
22
+ from django_bulk_hooks.operations.field_utils import handle_auto_now_fields_for_inheritance_chain
23
+ from django_bulk_hooks.operations.mti_plans import ModelFieldGroup
24
+ from django_bulk_hooks.operations.mti_plans import MTICreatePlan
25
+ from django_bulk_hooks.operations.mti_plans import MTIUpdatePlan
26
+ from django_bulk_hooks.operations.mti_plans import ParentLevel
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class MTIHandler:
32
+ """
33
+ Handles multi-table inheritance (MTI) operation planning.
34
+
35
+ This service detects MTI models and builds execution plans without
36
+ executing database operations.
37
+
38
+ Responsibilities:
39
+ - Detect MTI models
40
+ - Build inheritance chains
41
+ - Create parent/child instances (in-memory only)
42
+ - Return execution plans for bulk operations
43
+ """
44
+
45
+ def __init__(self, model_cls: type[Model]) -> None:
46
+ """
47
+ Initialize MTI handler for a specific model.
48
+
49
+ Args:
50
+ model_cls: The Django model class to handle
51
+ """
52
+ self.model_cls = model_cls
53
+ self._inheritance_chain: Optional[List[type[Model]]] = None
54
+
55
+ def is_mti_model(self) -> bool:
56
+ """
57
+ Determine if the model uses multi-table inheritance.
58
+
59
+ Returns:
60
+ True if model has concrete parent models, False otherwise
61
+ """
62
+ for parent in self.model_cls._meta.parents.keys():
63
+ if self._is_concrete_parent(parent):
64
+ return True
65
+ return False
66
+
67
+ def get_inheritance_chain(self) -> List[type[Model]]:
68
+ """
69
+ Get the complete inheritance chain from root to child.
70
+
71
+ Returns:
72
+ Model classes ordered from root parent to current model.
73
+ Returns empty list if not MTI model.
74
+ """
75
+ if self._inheritance_chain is None:
76
+ self._inheritance_chain = self._compute_chain()
77
+ return self._inheritance_chain
78
+
79
+ def get_parent_models(self) -> List[type[Model]]:
80
+ """
81
+ Get all parent models in the inheritance chain.
82
+
83
+ Returns:
84
+ Parent model classes (excludes current model)
85
+ """
86
+ chain = self.get_inheritance_chain()
87
+ return chain[:-1] if len(chain) > 1 else []
88
+
89
+ def get_local_fields_for_model(self, model_cls: type[Model]) -> list:
90
+ """
91
+ Get fields defined directly on a specific model.
92
+
93
+ Args:
94
+ model_cls: Model class to get fields for
95
+
96
+ Returns:
97
+ Field objects defined on this model
98
+ """
99
+ return list(model_cls._meta.local_fields)
100
+
101
+ def find_model_with_unique_fields(self, unique_fields: List[str]) -> type[Model]:
102
+ """
103
+ Find which model in the chain contains all unique constraint fields.
104
+
105
+ For MTI upsert operations, determines if parent records exist to
106
+ properly fire AFTER_CREATE vs AFTER_UPDATE hooks.
107
+
108
+ Args:
109
+ unique_fields: List of field names forming the unique constraint
110
+
111
+ Returns:
112
+ Model class containing all unique fields
113
+ """
114
+ if not unique_fields:
115
+ return self.model_cls
116
+
117
+ inheritance_chain = self.get_inheritance_chain()
118
+
119
+ if len(inheritance_chain) > 1:
120
+ # Walk from child to parent to find model with all unique fields
121
+ for model in reversed(inheritance_chain):
122
+ model_field_names = {f.name for f in model._meta.local_fields}
123
+ if all(field in model_field_names for field in unique_fields):
124
+ return model
125
+
126
+ return self.model_cls
127
+
128
+ def build_create_plan(
129
+ self,
130
+ objs: List[Model],
131
+ batch_size: Optional[int] = None,
132
+ update_conflicts: bool = False,
133
+ unique_fields: Optional[List[str]] = None,
134
+ update_fields: Optional[List[str]] = None,
135
+ existing_record_ids: Optional[Set[int]] = None,
136
+ existing_pks_map: Optional[Dict[int, int]] = None,
137
+ ) -> Optional[MTICreatePlan]:
138
+ """
139
+ Build an execution plan for bulk creating MTI model instances.
140
+
141
+ Does not execute database operations - returns a plan for execution.
142
+
143
+ Args:
144
+ objs: Model instances to create
145
+ batch_size: Number of objects per batch
146
+ update_conflicts: Enable UPSERT on conflict
147
+ unique_fields: Fields for conflict detection
148
+ update_fields: Fields to update on conflict
149
+ existing_record_ids: Set of id() for existing DB objects
150
+ existing_pks_map: Dict mapping id(obj) -> pk for existing records
151
+
152
+ Returns:
153
+ MTICreatePlan object or None if no objects
154
+
155
+ Raises:
156
+ ValueError: If called on non-MTI model
157
+ """
158
+ if not objs:
159
+ return None
160
+
161
+ inheritance_chain = self.get_inheritance_chain()
162
+ if len(inheritance_chain) <= 1:
163
+ raise ValueError(f"build_create_plan called on non-MTI model: {self.model_cls.__name__}")
164
+
165
+ batch_size = batch_size or len(objs)
166
+ existing_record_ids = existing_record_ids or set()
167
+ existing_pks_map = existing_pks_map or {}
168
+
169
+ # Set PKs on existing objects for proper updates
170
+ self._set_existing_pks(objs, existing_pks_map)
171
+
172
+ # Build parent levels
173
+ parent_levels = self._build_parent_levels(
174
+ objs=objs,
175
+ inheritance_chain=inheritance_chain,
176
+ update_conflicts=update_conflicts,
177
+ unique_fields=unique_fields,
178
+ update_fields=update_fields,
179
+ existing_record_ids=existing_record_ids,
180
+ existing_pks_map=existing_pks_map,
181
+ )
182
+
183
+ # Build child templates without parent links
184
+ child_objects = [self._create_child_instance_template(obj, inheritance_chain[-1]) for obj in objs]
185
+
186
+ # Pre-compute child-specific fields
187
+ child_unique_fields = get_fields_for_model(inheritance_chain[-1], unique_fields or [])
188
+ child_update_fields = get_fields_for_model(inheritance_chain[-1], update_fields or [])
189
+
190
+ return MTICreatePlan(
191
+ inheritance_chain=inheritance_chain,
192
+ parent_levels=parent_levels,
193
+ child_objects=child_objects,
194
+ child_model=inheritance_chain[-1],
195
+ original_objects=objs,
196
+ batch_size=batch_size,
197
+ existing_record_ids=existing_record_ids,
198
+ update_conflicts=update_conflicts,
199
+ unique_fields=unique_fields or [],
200
+ update_fields=update_fields or [],
201
+ child_unique_fields=child_unique_fields,
202
+ child_update_fields=child_update_fields,
203
+ )
204
+
205
+ def build_update_plan(
206
+ self,
207
+ objs: List[Model],
208
+ fields: List[str],
209
+ batch_size: Optional[int] = None,
210
+ ) -> Optional[MTIUpdatePlan]:
211
+ """
212
+ Build an execution plan for bulk updating MTI model instances.
213
+
214
+ Does not execute database operations - returns a plan for execution.
215
+
216
+ Args:
217
+ objs: Model instances to update
218
+ fields: Field names to update (auto_now fields included by executor)
219
+ batch_size: Number of objects per batch
220
+
221
+ Returns:
222
+ MTIUpdatePlan object or None if no objects
223
+
224
+ Raises:
225
+ ValueError: If called on non-MTI model
226
+ """
227
+ if not objs:
228
+ return None
229
+
230
+ inheritance_chain = self.get_inheritance_chain()
231
+ if len(inheritance_chain) <= 1:
232
+ raise ValueError(f"build_update_plan called on non-MTI model: {self.model_cls.__name__}")
233
+
234
+ batch_size = batch_size or len(objs)
235
+
236
+ # Group fields by model
237
+ field_groups = self._group_fields_by_model(inheritance_chain, fields)
238
+
239
+ return MTIUpdatePlan(
240
+ inheritance_chain=inheritance_chain,
241
+ field_groups=field_groups,
242
+ objects=objs,
243
+ batch_size=batch_size,
244
+ )
245
+
246
+ # ==================== Private Helper Methods ====================
247
+
248
+ def _is_concrete_parent(self, parent: type[Model]) -> bool:
249
+ """Check if parent is a concrete (non-abstract, non-proxy) model."""
250
+ return not parent._meta.abstract and parent._meta.concrete_model != self.model_cls._meta.concrete_model
251
+
252
+ def _compute_chain(self) -> List[type[Model]]:
253
+ """
254
+ Compute the inheritance chain from root parent to child.
255
+
256
+ Returns:
257
+ Model classes in order [RootParent, ..., Child]
258
+ """
259
+ chain = []
260
+ current_model = self.model_cls
261
+
262
+ while current_model:
263
+ if not current_model._meta.proxy and not current_model._meta.abstract:
264
+ chain.append(current_model)
265
+ logger.debug(
266
+ f"MTI_CHAIN_ADD: {current_model.__name__} (abstract={current_model._meta.abstract}, proxy={current_model._meta.proxy})"
267
+ )
268
+
269
+ # Get concrete parent models
270
+ parents = [parent for parent in current_model._meta.parents.keys() if not parent._meta.proxy and not parent._meta.abstract]
271
+ logger.debug(f"MTI_PARENTS: {current_model.__name__} concrete parents: {[p.__name__ for p in parents]}")
272
+
273
+ current_model = parents[0] if parents else None
274
+
275
+ chain.reverse() # Root to child order
276
+ logger.debug(f"MTI_CHAIN_FINAL: {[m.__name__ for m in chain]} (length={len(chain)})")
277
+ return chain
278
+
279
+ def _set_existing_pks(self, objs: List[Model], existing_pks_map: Dict[int, int]) -> None:
280
+ """Set primary keys on existing objects for proper updates."""
281
+ if not existing_pks_map:
282
+ return
283
+
284
+ for obj in objs:
285
+ obj_id = id(obj)
286
+ if obj_id in existing_pks_map:
287
+ pk_value = existing_pks_map[obj_id]
288
+ obj.pk = pk_value
289
+ obj.id = pk_value
290
+
291
+ def _build_parent_levels(
292
+ self,
293
+ objs: List[Model],
294
+ inheritance_chain: List[type[Model]],
295
+ update_conflicts: bool,
296
+ unique_fields: Optional[List[str]],
297
+ update_fields: Optional[List[str]],
298
+ existing_record_ids: Set[int],
299
+ existing_pks_map: Dict[int, int],
300
+ ) -> List[ParentLevel]:
301
+ """
302
+ Build parent level objects for each level in the inheritance chain.
303
+
304
+ Pure in-memory object creation - no DB operations.
305
+
306
+ Returns:
307
+ List of ParentLevel objects
308
+ """
309
+ parent_levels = []
310
+ parent_instances_map: Dict[int, Dict[type[Model], Model]] = {}
311
+
312
+ for level_idx, model_class in enumerate(inheritance_chain[:-1]):
313
+ parent_objs_for_level = []
314
+
315
+ for obj in objs:
316
+ # Get parent from previous level if exists
317
+ current_parent = self._get_previous_level_parent(obj, level_idx, inheritance_chain, parent_instances_map)
318
+
319
+ # Create parent instance
320
+ parent_obj = self._create_parent_instance(obj, model_class, current_parent)
321
+ parent_objs_for_level.append(parent_obj)
322
+
323
+ # Store in map
324
+ if id(obj) not in parent_instances_map:
325
+ parent_instances_map[id(obj)] = {}
326
+ parent_instances_map[id(obj)][model_class] = parent_obj
327
+
328
+ # Determine upsert parameters
329
+ upsert_config = self._determine_level_upsert_config(
330
+ model_class=model_class,
331
+ update_conflicts=update_conflicts,
332
+ unique_fields=unique_fields,
333
+ update_fields=update_fields,
334
+ )
335
+
336
+ # Create parent level
337
+ parent_level = ParentLevel(
338
+ model_class=model_class,
339
+ objects=parent_objs_for_level,
340
+ original_object_map={id(p): id(o) for p, o in zip(parent_objs_for_level, objs)},
341
+ update_conflicts=upsert_config["update_conflicts"],
342
+ unique_fields=upsert_config["unique_fields"],
343
+ update_fields=upsert_config["update_fields"],
344
+ )
345
+ parent_levels.append(parent_level)
346
+
347
+ return parent_levels
348
+
349
+ def _get_previous_level_parent(
350
+ self,
351
+ obj: Model,
352
+ level_idx: int,
353
+ inheritance_chain: List[type[Model]],
354
+ parent_instances_map: Dict[int, Dict[type[Model], Model]],
355
+ ) -> Optional[Model]:
356
+ """Get parent instance from previous level if it exists."""
357
+ if level_idx == 0:
358
+ return None
359
+
360
+ prev_parents = parent_instances_map.get(id(obj), {})
361
+ return prev_parents.get(inheritance_chain[level_idx - 1])
362
+
363
+ def _determine_level_upsert_config(
364
+ self,
365
+ model_class: type[Model],
366
+ update_conflicts: bool,
367
+ unique_fields: Optional[List[str]],
368
+ update_fields: Optional[List[str]],
369
+ ) -> Dict[str, any]:
370
+ """
371
+ Determine upsert configuration for a specific parent level.
372
+
373
+ Returns:
374
+ Dict with keys: update_conflicts, unique_fields, update_fields
375
+ """
376
+ if not update_conflicts:
377
+ return {
378
+ "update_conflicts": False,
379
+ "unique_fields": [],
380
+ "update_fields": [],
381
+ }
382
+
383
+ model_fields_by_name = {f.name: f for f in model_class._meta.local_fields}
384
+
385
+ # Normalize unique fields
386
+ normalized_unique = self._normalize_unique_fields(unique_fields or [], model_fields_by_name)
387
+
388
+ # Check if this level has matching constraint
389
+ if normalized_unique and self._has_matching_constraint(model_class, normalized_unique):
390
+ return self._build_constraint_based_upsert(model_class, model_fields_by_name, normalized_unique, update_fields)
391
+
392
+ # Fallback: PK-based upsert for parent levels without constraint
393
+ return self._build_pk_based_upsert(model_class, model_fields_by_name)
394
+
395
+ def _normalize_unique_fields(self, unique_fields: List[str], model_fields_by_name: Dict[str, any]) -> List[str]:
396
+ """Normalize unique fields, handling _id suffix for FK fields."""
397
+ normalized = []
398
+ for field_name in unique_fields:
399
+ if field_name in model_fields_by_name:
400
+ normalized.append(field_name)
401
+ elif field_name.endswith("_id") and field_name[:-3] in model_fields_by_name:
402
+ normalized.append(field_name[:-3])
403
+ return normalized
404
+
405
+ def _build_constraint_based_upsert(
406
+ self,
407
+ model_class: type[Model],
408
+ model_fields_by_name: Dict[str, any],
409
+ normalized_unique: List[str],
410
+ update_fields: Optional[List[str]],
411
+ ) -> Dict[str, any]:
412
+ """Build upsert config for levels with matching unique constraints."""
413
+ filtered_updates = [uf for uf in (update_fields or []) if uf in model_fields_by_name]
414
+
415
+ # Add auto_now fields (critical for timestamp updates)
416
+ auto_now_fields = self._get_auto_now_fields(model_class, model_fields_by_name)
417
+ if auto_now_fields:
418
+ filtered_updates = list(set(filtered_updates) | set(auto_now_fields))
419
+
420
+ # Use dummy update if no real updates (prevents constraint violations)
421
+ if not filtered_updates and normalized_unique:
422
+ filtered_updates = [normalized_unique[0]]
423
+
424
+ if filtered_updates:
425
+ return {
426
+ "update_conflicts": True,
427
+ "unique_fields": normalized_unique,
428
+ "update_fields": filtered_updates,
429
+ }
430
+
431
+ return {"update_conflicts": False, "unique_fields": [], "update_fields": []}
432
+
433
+ def _build_pk_based_upsert(self, model_class: type[Model], model_fields_by_name: Dict[str, any]) -> Dict[str, any]:
434
+ """Build PK-based upsert config for parent levels without constraints."""
435
+ pk_field = model_class._meta.pk
436
+ if not pk_field or pk_field.name not in model_fields_by_name:
437
+ return {"update_conflicts": False, "unique_fields": [], "update_fields": []}
438
+
439
+ pk_field_name = pk_field.name
440
+
441
+ # Prefer auto_now fields, fallback to any non-PK field
442
+ update_fields_for_upsert = self._get_auto_now_fields(model_class, model_fields_by_name)
443
+
444
+ if not update_fields_for_upsert:
445
+ non_pk_fields = [name for name in model_fields_by_name.keys() if name != pk_field_name]
446
+ if non_pk_fields:
447
+ update_fields_for_upsert = [non_pk_fields[0]]
448
+
449
+ if update_fields_for_upsert:
450
+ return {
451
+ "update_conflicts": True,
452
+ "unique_fields": [pk_field_name],
453
+ "update_fields": update_fields_for_upsert,
454
+ }
455
+
456
+ return {"update_conflicts": False, "unique_fields": [], "update_fields": []}
457
+
458
+ def _get_auto_now_fields(self, model_class: type[Model], model_fields_by_name: Dict[str, any]) -> List[str]:
459
+ """
460
+ Get auto_now (not auto_now_add) fields for a model.
461
+
462
+ Args:
463
+ model_class: Model class to get fields for
464
+ model_fields_by_name: Dict of valid field names
465
+
466
+ Returns:
467
+ List of auto_now field names
468
+ """
469
+ auto_now_fields = []
470
+ for field in model_class._meta.local_fields:
471
+ if getattr(field, "auto_now", False) and not getattr(field, "auto_now_add", False) and field.name in model_fields_by_name:
472
+ auto_now_fields.append(field.name)
473
+ return auto_now_fields
474
+
475
+ def _has_matching_constraint(self, model_class: type[Model], normalized_unique: List[str]) -> bool:
476
+ """Check if model has a unique constraint matching the given fields."""
477
+ provided_set = set(normalized_unique)
478
+
479
+ # Check UniqueConstraints
480
+ constraint_sets = self._get_unique_constraint_sets(model_class)
481
+
482
+ # Check unique_together
483
+ unique_together_sets = self._get_unique_together_sets(model_class)
484
+
485
+ # Check individual unique fields
486
+ unique_field_sets = self._get_unique_field_sets(model_class)
487
+
488
+ all_constraint_sets = constraint_sets + unique_together_sets + unique_field_sets
489
+
490
+ return any(provided_set == set(group) for group in all_constraint_sets)
491
+
492
+ def _get_unique_constraint_sets(self, model_class: type[Model]) -> List[Tuple[str, ...]]:
493
+ """Get unique constraint field sets."""
494
+ try:
495
+ return [tuple(c.fields) for c in model_class._meta.constraints if isinstance(c, UniqueConstraint)]
496
+ except Exception:
497
+ return []
498
+
499
+ def _get_unique_together_sets(self, model_class: type[Model]) -> List[Tuple[str, ...]]:
500
+ """Get unique_together field sets."""
501
+ unique_together = getattr(model_class._meta, "unique_together", ()) or ()
502
+
503
+ if isinstance(unique_together, tuple) and unique_together:
504
+ if not isinstance(unique_together[0], (list, tuple)):
505
+ unique_together = (unique_together,)
506
+
507
+ return [tuple(group) for group in unique_together]
508
+
509
+ def _get_unique_field_sets(self, model_class: type[Model]) -> List[Tuple[str, ...]]:
510
+ """Get individual unique field sets."""
511
+ return [(field.name,) for field in model_class._meta.local_fields if field.unique and not field.primary_key]
512
+
513
+ def _create_parent_instance(
514
+ self,
515
+ source_obj: Model,
516
+ parent_model: type[Model],
517
+ current_parent: Optional[Model],
518
+ ) -> Model:
519
+ """
520
+ Create a parent instance from source object (in-memory only).
521
+
522
+ Args:
523
+ source_obj: Original object with data
524
+ parent_model: Parent model class to create
525
+ current_parent: Parent from previous level (if any)
526
+
527
+ Returns:
528
+ Parent model instance (not saved)
529
+ """
530
+ parent_obj = parent_model()
531
+
532
+ # Copy field values
533
+ self._copy_fields_to_parent(parent_obj, source_obj, parent_model)
534
+
535
+ # Link to parent from previous level
536
+ if current_parent is not None:
537
+ self._link_to_parent(parent_obj, current_parent, parent_model)
538
+
539
+ # Copy object state
540
+ self._copy_object_state(parent_obj, source_obj)
541
+
542
+ # Handle auto_now fields
543
+ handle_auto_now_fields_for_inheritance_chain([parent_model], [parent_obj], for_update=False)
544
+
545
+ return parent_obj
546
+
547
+ def _copy_fields_to_parent(self, parent_obj: Model, source_obj: Model, parent_model: type[Model]) -> None:
548
+ """Copy field values from source to parent instance."""
549
+ for field in parent_model._meta.local_fields:
550
+ # Handle AutoField (PK) specially for existing records
551
+ if isinstance(field, AutoField):
552
+ if hasattr(source_obj, "pk") and source_obj.pk is not None:
553
+ setattr(parent_obj, field.attname, source_obj.pk)
554
+ continue
555
+
556
+ if hasattr(source_obj, field.name):
557
+ value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
558
+ if value is not None:
559
+ setattr(parent_obj, field.attname, value)
560
+
561
+ def _link_to_parent(self, parent_obj: Model, current_parent: Model, parent_model: type[Model]) -> None:
562
+ """Link parent object to its parent from previous level."""
563
+ for field in parent_model._meta.local_fields:
564
+ if hasattr(field, "remote_field") and field.remote_field and field.remote_field.model == current_parent.__class__:
565
+ setattr(parent_obj, field.name, current_parent)
566
+ break
567
+
568
+ def _create_child_instance_template(self, source_obj: Model, child_model: type[Model]) -> Model:
569
+ """
570
+ Create a child instance template (in-memory, no parent links).
571
+
572
+ Executor will add parent links after creating parent objects.
573
+
574
+ Args:
575
+ source_obj: Original object with data
576
+ child_model: Child model class
577
+
578
+ Returns:
579
+ Child model instance (not saved, no parent links)
580
+ """
581
+ child_obj = child_model()
582
+
583
+ # Get inherited field names to skip
584
+ parent_fields = self._get_inherited_field_names(child_model)
585
+
586
+ # Copy child-specific fields only
587
+ for field in child_model._meta.local_fields:
588
+ if isinstance(field, AutoField):
589
+ continue
590
+
591
+ # Skip parent link fields
592
+ if self._is_parent_link_field(child_model, field):
593
+ continue
594
+
595
+ # Skip inherited fields
596
+ if field.name in parent_fields:
597
+ continue
598
+
599
+ if hasattr(source_obj, field.name):
600
+ value = get_field_value_for_db(source_obj, field.name, source_obj.__class__)
601
+ if value is not None:
602
+ setattr(child_obj, field.attname, value)
603
+
604
+ # Copy object state
605
+ self._copy_object_state(child_obj, source_obj)
606
+
607
+ # Handle auto_now fields
608
+ handle_auto_now_fields_for_inheritance_chain([child_model], [child_obj], for_update=False)
609
+
610
+ return child_obj
611
+
612
+ def _get_inherited_field_names(self, child_model: type[Model]) -> Set[str]:
613
+ """Get field names inherited from parent models."""
614
+ parent_fields = set()
615
+ for parent_model in child_model._meta.parents.keys():
616
+ parent_fields.update(f.name for f in parent_model._meta.local_fields)
617
+ return parent_fields
618
+
619
+ def _is_parent_link_field(self, child_model: type[Model], field: any) -> bool:
620
+ """Check if field is a parent link field."""
621
+ if not field.is_relation or not hasattr(field, "related_model"):
622
+ return False
623
+ return child_model._meta.get_ancestor_link(field.related_model) == field
624
+
625
+ def _copy_object_state(self, target_obj: Model, source_obj: Model) -> None:
626
+ """Copy Django object state from source to target."""
627
+ if hasattr(source_obj, "_state") and hasattr(target_obj, "_state"):
628
+ target_obj._state.adding = source_obj._state.adding
629
+ if hasattr(source_obj._state, "db"):
630
+ target_obj._state.db = source_obj._state.db
631
+
632
+ def _group_fields_by_model(self, inheritance_chain: List[type[Model]], fields: List[str]) -> List[ModelFieldGroup]:
633
+ """
634
+ Group fields by the model they belong to in the inheritance chain.
635
+
636
+ Args:
637
+ inheritance_chain: Models in order from root to child
638
+ fields: Field names to group
639
+
640
+ Returns:
641
+ List of ModelFieldGroup objects
642
+ """
643
+ field_groups = []
644
+
645
+ logger.debug(
646
+ f"MTI_UPDATE_FIELD_GROUPING: Processing {len(fields)} fields "
647
+ f"for {len(inheritance_chain)} models: "
648
+ f"{[m.__name__ for m in inheritance_chain]}"
649
+ )
650
+
651
+ for model_idx, model in enumerate(inheritance_chain):
652
+ model_fields = self._get_fields_for_model(model, fields)
653
+
654
+ if model_fields:
655
+ filter_field = self._get_filter_field_for_model(model, model_idx, inheritance_chain)
656
+
657
+ field_groups.append(
658
+ ModelFieldGroup(
659
+ model_class=model,
660
+ fields=model_fields,
661
+ filter_field=filter_field,
662
+ )
663
+ )
664
+
665
+ return field_groups
666
+
667
+ def _get_fields_for_model(self, model: type[Model], fields: List[str]) -> List[str]:
668
+ """Get fields that belong to specific model (excluding auto_now_add)."""
669
+ model_fields = []
670
+
671
+ for field_name in fields:
672
+ try:
673
+ field = self.model_cls._meta.get_field(field_name)
674
+
675
+ if field in model._meta.local_fields:
676
+ # Skip auto_now_add fields for updates
677
+ if not getattr(field, "auto_now_add", False):
678
+ model_fields.append(field_name)
679
+ logger.debug(f"MTI_UPDATE_FIELD_ASSIGNED: '{field_name}' → {model.__name__}")
680
+ except Exception as e:
681
+ logger.debug(f"MTI_UPDATE_FIELD_ERROR: '{field_name}' on {model.__name__}: {e}")
682
+
683
+ return model_fields
684
+
685
+ def _get_filter_field_for_model(self, model: type[Model], model_idx: int, inheritance_chain: List[type[Model]]) -> str:
686
+ """Get the field to use for filtering in bulk updates."""
687
+ if model_idx == 0:
688
+ return "pk"
689
+
690
+ # Find parent link
691
+ for parent_model in inheritance_chain:
692
+ if parent_model in model._meta.parents:
693
+ parent_link = model._meta.parents[parent_model]
694
+ return parent_link.attname
695
+
696
+ return "pk"