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.
- django_bulk_hooks/__init__.py +53 -50
- django_bulk_hooks/changeset.py +214 -0
- django_bulk_hooks/conditions.py +230 -351
- django_bulk_hooks/constants.py +4 -0
- django_bulk_hooks/context.py +49 -9
- django_bulk_hooks/decorators.py +219 -96
- django_bulk_hooks/dispatcher.py +588 -0
- django_bulk_hooks/factory.py +541 -0
- django_bulk_hooks/handler.py +106 -167
- django_bulk_hooks/helpers.py +258 -0
- django_bulk_hooks/manager.py +134 -208
- django_bulk_hooks/models.py +89 -101
- django_bulk_hooks/operations/__init__.py +18 -0
- django_bulk_hooks/operations/analyzer.py +466 -0
- django_bulk_hooks/operations/bulk_executor.py +757 -0
- django_bulk_hooks/operations/coordinator.py +928 -0
- django_bulk_hooks/operations/field_utils.py +341 -0
- django_bulk_hooks/operations/mti_handler.py +696 -0
- django_bulk_hooks/operations/mti_plans.py +103 -0
- django_bulk_hooks/operations/record_classifier.py +196 -0
- django_bulk_hooks/queryset.py +233 -43
- django_bulk_hooks/registry.py +276 -25
- django_bulk_hooks-0.2.100.dist-info/METADATA +320 -0
- django_bulk_hooks-0.2.100.dist-info/RECORD +27 -0
- django_bulk_hooks/engine.py +0 -53
- django_bulk_hooks-0.1.83.dist-info/METADATA +0 -228
- django_bulk_hooks-0.1.83.dist-info/RECORD +0 -16
- {django_bulk_hooks-0.1.83.dist-info → django_bulk_hooks-0.2.100.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.83.dist-info → django_bulk_hooks-0.2.100.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for field value extraction and normalization.
|
|
3
|
+
|
|
4
|
+
Single source of truth for converting model instance field values
|
|
5
|
+
to their database representation. This eliminates duplicate FK handling
|
|
6
|
+
logic scattered across multiple components.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from django.db.models import ForeignKey
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_field_value_for_db(obj, field_name, model_cls=None):
|
|
17
|
+
"""
|
|
18
|
+
Get a field value from an object in its database-ready form.
|
|
19
|
+
|
|
20
|
+
For regular fields: returns the field value as-is
|
|
21
|
+
For FK fields: returns the PK (integer) instead of the related object
|
|
22
|
+
|
|
23
|
+
This ensures consistent handling across all operations (upsert classification,
|
|
24
|
+
bulk create, bulk update, etc.)
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
obj: Model instance
|
|
28
|
+
field_name: Name of the field to extract
|
|
29
|
+
model_cls: Model class (optional, will infer from obj if not provided)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Database-ready value (FK as integer, regular fields as-is)
|
|
33
|
+
"""
|
|
34
|
+
if model_cls is None:
|
|
35
|
+
model_cls = obj.__class__
|
|
36
|
+
|
|
37
|
+
logger.debug("FIELD_VALUE_EXTRACTION: Getting field '%s' from %s instance (pk=%s)",
|
|
38
|
+
field_name, obj.__class__.__name__, getattr(obj, 'pk', 'None'))
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
field = model_cls._meta.get_field(field_name)
|
|
42
|
+
logger.debug("FIELD_VALUE_FIELD_FOUND: Field '%s' is %s on %s",
|
|
43
|
+
field_name, type(field).__name__, field.model.__name__)
|
|
44
|
+
except Exception: # noqa: BLE001
|
|
45
|
+
# Field doesn't exist - just get attribute
|
|
46
|
+
logger.debug("FIELD_VALUE_FIELD_NOT_FOUND: Field '%s' not found via _meta.get_field, falling back to getattr", field_name)
|
|
47
|
+
value = getattr(obj, field_name, None)
|
|
48
|
+
logger.debug("FIELD_VALUE_FALLBACK: getattr result for '%s': %s (type: %s)", field_name, value, type(value))
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
# Check if it's a ForeignKey
|
|
52
|
+
if isinstance(field, ForeignKey):
|
|
53
|
+
return _extract_fk_value(obj, field_name, field, model_cls)
|
|
54
|
+
|
|
55
|
+
# Regular field - get normally
|
|
56
|
+
return _extract_regular_value(obj, field_name, model_cls)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_fk_value(obj, field_name, field, model_cls):
|
|
60
|
+
"""Extract value for a ForeignKey field with MTI handling."""
|
|
61
|
+
attname = field.attname
|
|
62
|
+
|
|
63
|
+
# Check if field was explicitly set on this instance
|
|
64
|
+
# If attname is in __dict__, the field was explicitly set (even to None)
|
|
65
|
+
field_was_explicitly_set = attname in obj.__dict__
|
|
66
|
+
|
|
67
|
+
logger.debug("🔍 FK_EXTRACT_START: field='%s', attname='%s', obj_class=%s, target_model=%s, obj.pk=%s",
|
|
68
|
+
field_name, attname, obj.__class__.__name__, model_cls.__name__, getattr(obj, 'pk', 'None'))
|
|
69
|
+
logger.debug("🔍 FK_DICT_CHECK: '%s' in obj.__dict__ = %s, __dict__ keys = %s",
|
|
70
|
+
attname, field_was_explicitly_set, list(obj.__dict__.keys()))
|
|
71
|
+
|
|
72
|
+
# Try direct access first
|
|
73
|
+
# For MTI scenarios, try __dict__ first to avoid Django descriptor database lookups
|
|
74
|
+
if obj.__class__ != model_cls and attname in obj.__dict__:
|
|
75
|
+
value = obj.__dict__[attname]
|
|
76
|
+
logger.debug("🔍 FK_DIRECT_ACCESS_DICT: obj.__dict__['%s'] = %s (type: %s) [MTI bypass]",
|
|
77
|
+
attname, value, type(value).__name__ if value is not None else 'None')
|
|
78
|
+
else:
|
|
79
|
+
value = getattr(obj, attname, None)
|
|
80
|
+
logger.debug("🔍 FK_DIRECT_ACCESS: getattr(obj, '%s') = %s (type: %s)",
|
|
81
|
+
attname, value, type(value).__name__ if value is not None else 'None')
|
|
82
|
+
|
|
83
|
+
# For MTI scenarios where parent field access fails on child instance
|
|
84
|
+
# OR when the _id field is None but the relationship field is set (common in MTI)
|
|
85
|
+
if value is None:
|
|
86
|
+
logger.debug("🔍 FK_VALUE_NONE: Trying MTI fallback strategies...")
|
|
87
|
+
|
|
88
|
+
# Try accessing via relationship object (this handles the MTI case where
|
|
89
|
+
# obj.business is set but obj.business_id is None)
|
|
90
|
+
rel_value = getattr(obj, field_name, None)
|
|
91
|
+
logger.debug("🔍 FK_RELATION_ACCESS: getattr(obj, '%s') = %s (type: %s)",
|
|
92
|
+
field_name, rel_value, type(rel_value).__name__ if rel_value is not None else 'None')
|
|
93
|
+
|
|
94
|
+
if rel_value is not None:
|
|
95
|
+
logger.debug("🔍 FK_RELATION_ATTRS: hasattr(rel_value, 'pk') = %s, pk_value = %s",
|
|
96
|
+
hasattr(rel_value, 'pk'), getattr(rel_value, 'pk', 'NO_PK_ATTR'))
|
|
97
|
+
|
|
98
|
+
if hasattr(rel_value, 'pk'):
|
|
99
|
+
value = rel_value.pk
|
|
100
|
+
logger.debug("✅ FK_MTI_RELATION_SUCCESS: Extracted pk=%s from relationship object", value)
|
|
101
|
+
else:
|
|
102
|
+
logger.debug("❌ FK_RELATION_NO_PK: Relationship object exists but has no pk attribute")
|
|
103
|
+
else:
|
|
104
|
+
logger.debug("🔍 FK_RELATION_NULL: Relationship field is None, checking if DB refresh needed")
|
|
105
|
+
is_mti_case = obj.__class__ != model_cls
|
|
106
|
+
logger.debug("🔍 FK_MTI_CHECK: is_mti=%s (obj_class=%s vs model_cls=%s), explicitly_set=%s",
|
|
107
|
+
is_mti_case, obj.__class__.__name__, model_cls.__name__, field_was_explicitly_set)
|
|
108
|
+
|
|
109
|
+
if is_mti_case and not field_was_explicitly_set:
|
|
110
|
+
logger.debug("🔄 FK_DB_REFRESH: Attempting database refresh for MTI field...")
|
|
111
|
+
value = _try_db_refresh_for_field(obj, model_cls, attname)
|
|
112
|
+
elif is_mti_case and field_was_explicitly_set:
|
|
113
|
+
logger.debug("⚠️ FK_EXPLICIT_NULL: MTI field was explicitly set to None, skipping DB refresh")
|
|
114
|
+
else:
|
|
115
|
+
logger.debug("ℹ️ FK_NO_REFRESH: Not an MTI case, keeping None value")
|
|
116
|
+
|
|
117
|
+
logger.debug("🏁 FK_EXTRACT_FINAL: field='%s' → value=%s (type: %s), explicitly_set=%s",
|
|
118
|
+
field_name, value, type(value).__name__ if value is not None else 'None', field_was_explicitly_set)
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _extract_regular_value(obj, field_name, model_cls):
|
|
123
|
+
"""Extract value for a regular field with MTI handling."""
|
|
124
|
+
# Check if field was explicitly set on this instance
|
|
125
|
+
field_was_explicitly_set = field_name in obj.__dict__
|
|
126
|
+
|
|
127
|
+
value = getattr(obj, field_name, None)
|
|
128
|
+
|
|
129
|
+
# For MTI scenarios where parent field access fails on child instance
|
|
130
|
+
# Only try fallback if the field was NOT explicitly set
|
|
131
|
+
if value is None and obj.__class__ != model_cls and not field_was_explicitly_set:
|
|
132
|
+
logger.debug("FIELD_VALUE_MTI_REGULAR_FALLBACK: Direct access to '%s' failed on %s, trying MTI fallback",
|
|
133
|
+
field_name, obj.__class__.__name__)
|
|
134
|
+
value = _try_db_refresh_for_field(obj, model_cls, field_name)
|
|
135
|
+
|
|
136
|
+
logger.debug("FIELD_VALUE_REGULAR_EXTRACTION: Regular field '%s', value: %s (type: %s), explicitly_set: %s",
|
|
137
|
+
field_name, value, type(value), field_was_explicitly_set)
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _try_db_refresh_for_field(obj, model_cls, field_name):
|
|
142
|
+
"""Try to get field value from database for MTI parent fields."""
|
|
143
|
+
logger.debug("🔄 DB_REFRESH_START: Attempting to fetch field='%s' for pk=%s from model=%s",
|
|
144
|
+
field_name, getattr(obj, 'pk', 'None'), model_cls.__name__)
|
|
145
|
+
try:
|
|
146
|
+
if hasattr(obj, 'pk') and obj.pk is not None:
|
|
147
|
+
logger.debug("🔄 DB_REFRESH_QUERY: Querying %s.objects.filter(pk=%s).first()",
|
|
148
|
+
model_cls.__name__, obj.pk)
|
|
149
|
+
parent_instance = model_cls.objects.filter(pk=obj.pk).first()
|
|
150
|
+
|
|
151
|
+
if parent_instance:
|
|
152
|
+
value = getattr(parent_instance, field_name, None)
|
|
153
|
+
logger.debug("✅ DB_REFRESH_SUCCESS: Found parent instance, field='%s' value=%s (type: %s)",
|
|
154
|
+
field_name, value, type(value).__name__ if value is not None else 'None')
|
|
155
|
+
return value
|
|
156
|
+
else:
|
|
157
|
+
logger.debug("❌ DB_REFRESH_NO_PARENT: No parent instance found for pk=%s in %s",
|
|
158
|
+
obj.pk, model_cls.__name__)
|
|
159
|
+
else:
|
|
160
|
+
logger.debug("❌ DB_REFRESH_NO_PK: Object has no pk or pk is None")
|
|
161
|
+
except Exception as e: # noqa: BLE001
|
|
162
|
+
logger.debug("❌ DB_REFRESH_ERROR: Database refresh failed: %s", e)
|
|
163
|
+
|
|
164
|
+
logger.debug("🔄 DB_REFRESH_RETURN_NONE: Returning None")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_field_values_for_db(obj, field_names, model_cls=None):
|
|
169
|
+
"""
|
|
170
|
+
Get multiple field values from an object in database-ready form.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
obj: Model instance
|
|
174
|
+
field_names: List of field names
|
|
175
|
+
model_cls: Model class (optional)
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Dict of {field_name: db_value}
|
|
179
|
+
"""
|
|
180
|
+
if model_cls is None:
|
|
181
|
+
model_cls = obj.__class__
|
|
182
|
+
|
|
183
|
+
return {field_name: get_field_value_for_db(obj, field_name, model_cls) for field_name in field_names}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def normalize_field_name_to_db(field_name, model_cls):
|
|
187
|
+
"""
|
|
188
|
+
Normalize a field name to its database column name.
|
|
189
|
+
|
|
190
|
+
For FK fields referenced by relationship name, returns the attname (e.g., 'business' -> 'business_id')
|
|
191
|
+
For regular fields, returns as-is.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
field_name: Field name (can be 'business' or 'business_id')
|
|
195
|
+
model_cls: Model class
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Database column name
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
field = model_cls._meta.get_field(field_name)
|
|
202
|
+
if isinstance(field, ForeignKey):
|
|
203
|
+
return field.attname # Returns 'business_id' for 'business' field
|
|
204
|
+
return field_name
|
|
205
|
+
except Exception: # noqa: BLE001
|
|
206
|
+
return field_name
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_changed_fields(old_obj, new_obj, model_cls, skip_auto_fields=False):
|
|
210
|
+
"""
|
|
211
|
+
Get field names that have changed between two model instances.
|
|
212
|
+
|
|
213
|
+
Uses Django's field.get_prep_value() for proper database-level comparison.
|
|
214
|
+
This is the canonical implementation used by both RecordChange and ModelAnalyzer.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
old_obj: The old model instance
|
|
218
|
+
new_obj: The new model instance
|
|
219
|
+
model_cls: The Django model class
|
|
220
|
+
skip_auto_fields: Whether to skip auto_created fields (default False)
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Set of field names that have changed
|
|
224
|
+
"""
|
|
225
|
+
changed = set()
|
|
226
|
+
|
|
227
|
+
for field in model_cls._meta.fields:
|
|
228
|
+
# Skip primary key fields - they shouldn't change
|
|
229
|
+
if field.primary_key:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Optionally skip auto-created fields (for bulk operations)
|
|
233
|
+
if skip_auto_fields and field.auto_created:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
old_val = getattr(old_obj, field.name, None)
|
|
237
|
+
new_val = getattr(new_obj, field.name, None)
|
|
238
|
+
|
|
239
|
+
# Use field's get_prep_value for database-ready comparison
|
|
240
|
+
# This handles timezone conversions, type coercions, etc.
|
|
241
|
+
try:
|
|
242
|
+
old_prep = field.get_prep_value(old_val)
|
|
243
|
+
new_prep = field.get_prep_value(new_val)
|
|
244
|
+
if old_prep != new_prep:
|
|
245
|
+
changed.add(field.name)
|
|
246
|
+
except (TypeError, ValueError):
|
|
247
|
+
# Fallback to direct comparison if get_prep_value fails
|
|
248
|
+
if old_val != new_val:
|
|
249
|
+
changed.add(field.name)
|
|
250
|
+
|
|
251
|
+
return changed
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_auto_fields(model_cls, include_auto_now_add=True):
|
|
255
|
+
"""
|
|
256
|
+
Get auto fields from a model.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
model_cls: Django model class
|
|
260
|
+
include_auto_now_add: Whether to include auto_now_add fields
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
List of field names
|
|
264
|
+
"""
|
|
265
|
+
fields = []
|
|
266
|
+
for field in model_cls._meta.fields:
|
|
267
|
+
if getattr(field, "auto_now", False) or (include_auto_now_add and getattr(field, "auto_now_add", False)):
|
|
268
|
+
fields.append(field.name)
|
|
269
|
+
return fields
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_auto_now_only_fields(model_cls):
|
|
273
|
+
"""Get only auto_now fields (excluding auto_now_add)."""
|
|
274
|
+
return get_auto_fields(model_cls, include_auto_now_add=False)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def get_fk_fields(model_cls):
|
|
278
|
+
"""Get foreign key field names for a model."""
|
|
279
|
+
return [field.name for field in model_cls._meta.concrete_fields if field.is_relation and not field.many_to_many]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def collect_auto_now_fields_for_inheritance_chain(inheritance_chain):
|
|
283
|
+
"""Collect auto_now fields across an MTI inheritance chain."""
|
|
284
|
+
all_auto_now = set()
|
|
285
|
+
for model_cls in inheritance_chain:
|
|
286
|
+
all_auto_now.update(get_auto_now_only_fields(model_cls))
|
|
287
|
+
return all_auto_now
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def handle_auto_now_fields_for_inheritance_chain(models, instances, for_update=True):
|
|
291
|
+
"""
|
|
292
|
+
Unified auto-now field handling for any inheritance chain.
|
|
293
|
+
|
|
294
|
+
This replaces the separate collect/pre_save logic with a single comprehensive
|
|
295
|
+
method that handles collection, pre-saving, and field inclusion for updates.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
models: List of model classes in inheritance chain
|
|
299
|
+
instances: List of model instances to process
|
|
300
|
+
for_update: Whether this is for an update operation (vs create)
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Set of auto_now field names that should be included in updates
|
|
304
|
+
"""
|
|
305
|
+
all_auto_now_fields = set()
|
|
306
|
+
|
|
307
|
+
for model_cls in models:
|
|
308
|
+
for field in model_cls._meta.local_fields:
|
|
309
|
+
# For updates, only include auto_now (not auto_now_add)
|
|
310
|
+
# For creates, include both
|
|
311
|
+
if getattr(field, "auto_now", False) or (not for_update and getattr(field, "auto_now_add", False)):
|
|
312
|
+
all_auto_now_fields.add(field.name)
|
|
313
|
+
|
|
314
|
+
# Pre-save the field on instances
|
|
315
|
+
for instance in instances:
|
|
316
|
+
if for_update:
|
|
317
|
+
# For updates, only pre-save auto_now fields
|
|
318
|
+
field.pre_save(instance, add=False)
|
|
319
|
+
else:
|
|
320
|
+
# For creates, pre-save both auto_now and auto_now_add
|
|
321
|
+
field.pre_save(instance, add=True)
|
|
322
|
+
|
|
323
|
+
return all_auto_now_fields
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def pre_save_auto_now_fields(objects, inheritance_chain):
|
|
327
|
+
"""Pre-save auto_now fields across inheritance chain."""
|
|
328
|
+
# DEPRECATED: Use handle_auto_now_fields_for_inheritance_chain instead
|
|
329
|
+
auto_now_fields = collect_auto_now_fields_for_inheritance_chain(inheritance_chain)
|
|
330
|
+
|
|
331
|
+
for field_name in auto_now_fields:
|
|
332
|
+
# Find which model has this field
|
|
333
|
+
for model_cls in inheritance_chain:
|
|
334
|
+
try:
|
|
335
|
+
field = model_cls._meta.get_field(field_name)
|
|
336
|
+
if getattr(field, "auto_now", False):
|
|
337
|
+
for obj in objects:
|
|
338
|
+
field.pre_save(obj, add=False)
|
|
339
|
+
break
|
|
340
|
+
except Exception:
|
|
341
|
+
continue
|