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,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