django-bulk-hooks 0.2.9__py3-none-any.whl → 0.2.93__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.
@@ -0,0 +1,335 @@
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
+ value = getattr(obj, attname, None)
74
+ logger.debug("🔍 FK_DIRECT_ACCESS: getattr(obj, '%s') = %s (type: %s)",
75
+ attname, value, type(value).__name__ if value is not None else 'None')
76
+
77
+ # For MTI scenarios where parent field access fails on child instance
78
+ # OR when the _id field is None but the relationship field is set (common in MTI)
79
+ if value is None:
80
+ logger.debug("🔍 FK_VALUE_NONE: Trying MTI fallback strategies...")
81
+
82
+ # Try accessing via relationship object (this handles the MTI case where
83
+ # obj.business is set but obj.business_id is None)
84
+ rel_value = getattr(obj, field_name, None)
85
+ logger.debug("🔍 FK_RELATION_ACCESS: getattr(obj, '%s') = %s (type: %s)",
86
+ field_name, rel_value, type(rel_value).__name__ if rel_value is not None else 'None')
87
+
88
+ if rel_value is not None:
89
+ logger.debug("🔍 FK_RELATION_ATTRS: hasattr(rel_value, 'pk') = %s, pk_value = %s",
90
+ hasattr(rel_value, 'pk'), getattr(rel_value, 'pk', 'NO_PK_ATTR'))
91
+
92
+ if hasattr(rel_value, 'pk'):
93
+ value = rel_value.pk
94
+ logger.debug("✅ FK_MTI_RELATION_SUCCESS: Extracted pk=%s from relationship object", value)
95
+ else:
96
+ logger.debug("❌ FK_RELATION_NO_PK: Relationship object exists but has no pk attribute")
97
+ else:
98
+ logger.debug("🔍 FK_RELATION_NULL: Relationship field is None, checking if DB refresh needed")
99
+ is_mti_case = obj.__class__ != model_cls
100
+ logger.debug("🔍 FK_MTI_CHECK: is_mti=%s (obj_class=%s vs model_cls=%s), explicitly_set=%s",
101
+ is_mti_case, obj.__class__.__name__, model_cls.__name__, field_was_explicitly_set)
102
+
103
+ if is_mti_case and not field_was_explicitly_set:
104
+ logger.debug("🔄 FK_DB_REFRESH: Attempting database refresh for MTI field...")
105
+ value = _try_db_refresh_for_field(obj, model_cls, attname)
106
+ elif is_mti_case and field_was_explicitly_set:
107
+ logger.debug("⚠️ FK_EXPLICIT_NULL: MTI field was explicitly set to None, skipping DB refresh")
108
+ else:
109
+ logger.debug("ℹ️ FK_NO_REFRESH: Not an MTI case, keeping None value")
110
+
111
+ logger.debug("🏁 FK_EXTRACT_FINAL: field='%s' → value=%s (type: %s), explicitly_set=%s",
112
+ field_name, value, type(value).__name__ if value is not None else 'None', field_was_explicitly_set)
113
+ return value
114
+
115
+
116
+ def _extract_regular_value(obj, field_name, model_cls):
117
+ """Extract value for a regular field with MTI handling."""
118
+ # Check if field was explicitly set on this instance
119
+ field_was_explicitly_set = field_name in obj.__dict__
120
+
121
+ value = getattr(obj, field_name, None)
122
+
123
+ # For MTI scenarios where parent field access fails on child instance
124
+ # Only try fallback if the field was NOT explicitly set
125
+ if value is None and obj.__class__ != model_cls and not field_was_explicitly_set:
126
+ logger.debug("FIELD_VALUE_MTI_REGULAR_FALLBACK: Direct access to '%s' failed on %s, trying MTI fallback",
127
+ field_name, obj.__class__.__name__)
128
+ value = _try_db_refresh_for_field(obj, model_cls, field_name)
129
+
130
+ logger.debug("FIELD_VALUE_REGULAR_EXTRACTION: Regular field '%s', value: %s (type: %s), explicitly_set: %s",
131
+ field_name, value, type(value), field_was_explicitly_set)
132
+ return value
133
+
134
+
135
+ def _try_db_refresh_for_field(obj, model_cls, field_name):
136
+ """Try to get field value from database for MTI parent fields."""
137
+ logger.debug("🔄 DB_REFRESH_START: Attempting to fetch field='%s' for pk=%s from model=%s",
138
+ field_name, getattr(obj, 'pk', 'None'), model_cls.__name__)
139
+ try:
140
+ if hasattr(obj, 'pk') and obj.pk is not None:
141
+ logger.debug("🔄 DB_REFRESH_QUERY: Querying %s.objects.filter(pk=%s).first()",
142
+ model_cls.__name__, obj.pk)
143
+ parent_instance = model_cls.objects.filter(pk=obj.pk).first()
144
+
145
+ if parent_instance:
146
+ value = getattr(parent_instance, field_name, None)
147
+ logger.debug("✅ DB_REFRESH_SUCCESS: Found parent instance, field='%s' value=%s (type: %s)",
148
+ field_name, value, type(value).__name__ if value is not None else 'None')
149
+ return value
150
+ else:
151
+ logger.debug("❌ DB_REFRESH_NO_PARENT: No parent instance found for pk=%s in %s",
152
+ obj.pk, model_cls.__name__)
153
+ else:
154
+ logger.debug("❌ DB_REFRESH_NO_PK: Object has no pk or pk is None")
155
+ except Exception as e: # noqa: BLE001
156
+ logger.debug("❌ DB_REFRESH_ERROR: Database refresh failed: %s", e)
157
+
158
+ logger.debug("🔄 DB_REFRESH_RETURN_NONE: Returning None")
159
+ return None
160
+
161
+
162
+ def get_field_values_for_db(obj, field_names, model_cls=None):
163
+ """
164
+ Get multiple field values from an object in database-ready form.
165
+
166
+ Args:
167
+ obj: Model instance
168
+ field_names: List of field names
169
+ model_cls: Model class (optional)
170
+
171
+ Returns:
172
+ Dict of {field_name: db_value}
173
+ """
174
+ if model_cls is None:
175
+ model_cls = obj.__class__
176
+
177
+ return {field_name: get_field_value_for_db(obj, field_name, model_cls) for field_name in field_names}
178
+
179
+
180
+ def normalize_field_name_to_db(field_name, model_cls):
181
+ """
182
+ Normalize a field name to its database column name.
183
+
184
+ For FK fields referenced by relationship name, returns the attname (e.g., 'business' -> 'business_id')
185
+ For regular fields, returns as-is.
186
+
187
+ Args:
188
+ field_name: Field name (can be 'business' or 'business_id')
189
+ model_cls: Model class
190
+
191
+ Returns:
192
+ Database column name
193
+ """
194
+ try:
195
+ field = model_cls._meta.get_field(field_name)
196
+ if isinstance(field, ForeignKey):
197
+ return field.attname # Returns 'business_id' for 'business' field
198
+ return field_name
199
+ except Exception: # noqa: BLE001
200
+ return field_name
201
+
202
+
203
+ def get_changed_fields(old_obj, new_obj, model_cls, skip_auto_fields=False):
204
+ """
205
+ Get field names that have changed between two model instances.
206
+
207
+ Uses Django's field.get_prep_value() for proper database-level comparison.
208
+ This is the canonical implementation used by both RecordChange and ModelAnalyzer.
209
+
210
+ Args:
211
+ old_obj: The old model instance
212
+ new_obj: The new model instance
213
+ model_cls: The Django model class
214
+ skip_auto_fields: Whether to skip auto_created fields (default False)
215
+
216
+ Returns:
217
+ Set of field names that have changed
218
+ """
219
+ changed = set()
220
+
221
+ for field in model_cls._meta.fields:
222
+ # Skip primary key fields - they shouldn't change
223
+ if field.primary_key:
224
+ continue
225
+
226
+ # Optionally skip auto-created fields (for bulk operations)
227
+ if skip_auto_fields and field.auto_created:
228
+ continue
229
+
230
+ old_val = getattr(old_obj, field.name, None)
231
+ new_val = getattr(new_obj, field.name, None)
232
+
233
+ # Use field's get_prep_value for database-ready comparison
234
+ # This handles timezone conversions, type coercions, etc.
235
+ try:
236
+ old_prep = field.get_prep_value(old_val)
237
+ new_prep = field.get_prep_value(new_val)
238
+ if old_prep != new_prep:
239
+ changed.add(field.name)
240
+ except (TypeError, ValueError):
241
+ # Fallback to direct comparison if get_prep_value fails
242
+ if old_val != new_val:
243
+ changed.add(field.name)
244
+
245
+ return changed
246
+
247
+
248
+ def get_auto_fields(model_cls, include_auto_now_add=True):
249
+ """
250
+ Get auto fields from a model.
251
+
252
+ Args:
253
+ model_cls: Django model class
254
+ include_auto_now_add: Whether to include auto_now_add fields
255
+
256
+ Returns:
257
+ List of field names
258
+ """
259
+ fields = []
260
+ for field in model_cls._meta.fields:
261
+ if getattr(field, "auto_now", False) or (include_auto_now_add and getattr(field, "auto_now_add", False)):
262
+ fields.append(field.name)
263
+ return fields
264
+
265
+
266
+ def get_auto_now_only_fields(model_cls):
267
+ """Get only auto_now fields (excluding auto_now_add)."""
268
+ return get_auto_fields(model_cls, include_auto_now_add=False)
269
+
270
+
271
+ def get_fk_fields(model_cls):
272
+ """Get foreign key field names for a model."""
273
+ return [field.name for field in model_cls._meta.concrete_fields if field.is_relation and not field.many_to_many]
274
+
275
+
276
+ def collect_auto_now_fields_for_inheritance_chain(inheritance_chain):
277
+ """Collect auto_now fields across an MTI inheritance chain."""
278
+ all_auto_now = set()
279
+ for model_cls in inheritance_chain:
280
+ all_auto_now.update(get_auto_now_only_fields(model_cls))
281
+ return all_auto_now
282
+
283
+
284
+ def handle_auto_now_fields_for_inheritance_chain(models, instances, for_update=True):
285
+ """
286
+ Unified auto-now field handling for any inheritance chain.
287
+
288
+ This replaces the separate collect/pre_save logic with a single comprehensive
289
+ method that handles collection, pre-saving, and field inclusion for updates.
290
+
291
+ Args:
292
+ models: List of model classes in inheritance chain
293
+ instances: List of model instances to process
294
+ for_update: Whether this is for an update operation (vs create)
295
+
296
+ Returns:
297
+ Set of auto_now field names that should be included in updates
298
+ """
299
+ all_auto_now_fields = set()
300
+
301
+ for model_cls in models:
302
+ for field in model_cls._meta.local_fields:
303
+ # For updates, only include auto_now (not auto_now_add)
304
+ # For creates, include both
305
+ if getattr(field, "auto_now", False) or (not for_update and getattr(field, "auto_now_add", False)):
306
+ all_auto_now_fields.add(field.name)
307
+
308
+ # Pre-save the field on instances
309
+ for instance in instances:
310
+ if for_update:
311
+ # For updates, only pre-save auto_now fields
312
+ field.pre_save(instance, add=False)
313
+ else:
314
+ # For creates, pre-save both auto_now and auto_now_add
315
+ field.pre_save(instance, add=True)
316
+
317
+ return all_auto_now_fields
318
+
319
+
320
+ def pre_save_auto_now_fields(objects, inheritance_chain):
321
+ """Pre-save auto_now fields across inheritance chain."""
322
+ # DEPRECATED: Use handle_auto_now_fields_for_inheritance_chain instead
323
+ auto_now_fields = collect_auto_now_fields_for_inheritance_chain(inheritance_chain)
324
+
325
+ for field_name in auto_now_fields:
326
+ # Find which model has this field
327
+ for model_cls in inheritance_chain:
328
+ try:
329
+ field = model_cls._meta.get_field(field_name)
330
+ if getattr(field, "auto_now", False):
331
+ for obj in objects:
332
+ field.pre_save(obj, add=False)
333
+ break
334
+ except Exception:
335
+ continue