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.
- django_bulk_hooks/changeset.py +214 -230
- django_bulk_hooks/conditions.py +7 -3
- django_bulk_hooks/decorators.py +5 -15
- django_bulk_hooks/dispatcher.py +2 -6
- django_bulk_hooks/handler.py +2 -2
- django_bulk_hooks/helpers.py +251 -100
- django_bulk_hooks/manager.py +150 -130
- django_bulk_hooks/models.py +74 -75
- django_bulk_hooks/operations/analyzer.py +319 -312
- django_bulk_hooks/operations/bulk_executor.py +109 -126
- django_bulk_hooks/operations/coordinator.py +125 -86
- django_bulk_hooks/operations/field_utils.py +137 -5
- django_bulk_hooks/operations/mti_handler.py +29 -19
- django_bulk_hooks/operations/mti_plans.py +103 -99
- django_bulk_hooks/operations/record_classifier.py +1 -1
- django_bulk_hooks/queryset.py +8 -2
- django_bulk_hooks/registry.py +0 -2
- {django_bulk_hooks-0.2.60.dist-info → django_bulk_hooks-0.2.62.dist-info}/METADATA +1 -1
- django_bulk_hooks-0.2.62.dist-info/RECORD +27 -0
- django_bulk_hooks-0.2.60.dist-info/RECORD +0 -27
- {django_bulk_hooks-0.2.60.dist-info → django_bulk_hooks-0.2.62.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.2.60.dist-info → django_bulk_hooks-0.2.62.dist-info}/WHEEL +0 -0
|
@@ -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
|
-
|
|
53
|
-
from django_bulk_hooks.operations.analyzer import ModelAnalyzer
|
|
70
|
+
from django_bulk_hooks.operations.analyzer import ModelAnalyzer
|
|
54
71
|
|
|
55
|
-
|
|
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
|
-
|
|
62
|
-
from django_bulk_hooks.operations.mti_handler import MTIHandler
|
|
77
|
+
from django_bulk_hooks.operations.mti_handler import MTIHandler
|
|
63
78
|
|
|
64
|
-
|
|
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
|
-
|
|
71
|
-
from django_bulk_hooks.operations.record_classifier import RecordClassifier
|
|
84
|
+
from django_bulk_hooks.operations.record_classifier import RecordClassifier
|
|
72
85
|
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
|
573
|
-
self.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
#
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|