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

@@ -96,30 +96,11 @@ class RecordChange:
96
96
  if self.old_record is None:
97
97
  return set()
98
98
 
99
- changed = set()
100
- model_cls = self.new_record.__class__
99
+ # Import here to avoid circular dependency
100
+ from .operations.field_utils import get_changed_fields
101
101
 
102
- for field in model_cls._meta.fields:
103
- # Skip primary key - it shouldn't change
104
- if field.primary_key:
105
- continue
106
-
107
- old_val = getattr(self.old_record, field.name, None)
108
- new_val = getattr(self.new_record, field.name, None)
109
-
110
- # Use field's get_prep_value for proper comparison
111
- # This handles database-level transformations (e.g., timezone conversions)
112
- try:
113
- old_prep = field.get_prep_value(old_val)
114
- new_prep = field.get_prep_value(new_val)
115
- if old_prep != new_prep:
116
- changed.add(field.name)
117
- except Exception:
118
- # Fallback to direct comparison if get_prep_value fails
119
- if old_val != new_val:
120
- changed.add(field.name)
121
-
122
- return changed
102
+ model_cls = self.new_record.__class__
103
+ return get_changed_fields(self.old_record, self.new_record, model_cls)
123
104
 
124
105
 
125
106
  class ChangeSet:
@@ -8,9 +8,26 @@ NOTE: These helpers are pure changeset builders - they don't fetch data.
8
8
  Data fetching is the responsibility of ModelAnalyzer.
9
9
  """
10
10
 
11
+ import logging
12
+
11
13
  from django_bulk_hooks.changeset import ChangeSet
12
14
  from django_bulk_hooks.changeset import RecordChange
13
15
 
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def extract_pks(objects):
20
+ """
21
+ Extract non-None primary keys from objects.
22
+
23
+ Args:
24
+ objects: Iterable of model instances or objects with pk attribute
25
+
26
+ Returns:
27
+ List of non-None primary key values
28
+ """
29
+ return [obj.pk for obj in objects if obj.pk is not None]
30
+
14
31
 
15
32
  def build_changeset_for_update(
16
33
  model_cls, instances, update_kwargs, old_records_map=None, **meta,
@@ -83,6 +100,79 @@ def build_changeset_for_delete(model_cls, instances, **meta):
83
100
  return ChangeSet(model_cls, changes, "delete", meta)
84
101
 
85
102
 
103
+ def get_fields_for_model(model_cls, field_names, include_relations=False):
104
+ """
105
+ Get field objects for the given model from a list of field names.
106
+
107
+ Handles field name normalization (e.g., 'field_id' -> 'field').
108
+ Only returns fields that actually exist on the model.
109
+
110
+ Args:
111
+ model_cls: Django model class
112
+ field_names: List of field names (strings)
113
+ include_relations: Whether to include relation fields (default False)
114
+
115
+ Returns:
116
+ List of field objects that exist on the model, in the same order as field_names
117
+ """
118
+ if not field_names:
119
+ return []
120
+
121
+ # Build lookup dict of available fields
122
+ fields_by_name = {}
123
+ # Use local_fields for child tables, get_fields() for parent tables that need inherited fields
124
+ fields_to_check = model_cls._meta.local_fields if not include_relations else model_cls._meta.get_fields()
125
+ for field in fields_to_check:
126
+ if not include_relations and (field.many_to_many or field.one_to_many):
127
+ continue
128
+ fields_by_name[field.name] = field
129
+
130
+ # Handle field name normalization and preserve order
131
+ result = []
132
+ seen = set()
133
+
134
+ for name in field_names:
135
+ # Try original name first
136
+ if name in fields_by_name and name not in seen:
137
+ result.append(fields_by_name[name])
138
+ seen.add(name)
139
+ # Try normalized name (field_id -> field)
140
+ elif name.endswith('_id') and name[:-3] in fields_by_name and name[:-3] not in seen:
141
+ result.append(fields_by_name[name[:-3]])
142
+ seen.add(name[:-3])
143
+
144
+ return result
145
+
146
+
147
+ def filter_field_names_for_model(model_cls, field_names):
148
+ """
149
+ Filter a list of field names to only those that exist on the model.
150
+
151
+ Handles field name normalization (e.g., 'field_id' -> 'field').
152
+
153
+ Args:
154
+ model_cls: Django model class
155
+ field_names: List of field names (strings)
156
+
157
+ Returns:
158
+ List of field names that exist on the model
159
+ """
160
+ if not field_names:
161
+ return []
162
+
163
+ # Get all available field names
164
+ available_names = {field.name for field in model_cls._meta.local_fields}
165
+
166
+ result = []
167
+ for name in field_names:
168
+ if name in available_names:
169
+ result.append(name)
170
+ elif name.endswith('_id') and name[:-3] in available_names:
171
+ result.append(name[:-3])
172
+
173
+ return result
174
+
175
+
86
176
  def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
87
177
  """
88
178
  Dispatch hooks for an operation using the dispatcher.
@@ -98,3 +188,58 @@ def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
98
188
 
99
189
  dispatcher = get_dispatcher()
100
190
  dispatcher.dispatch(changeset, event, bypass_hooks=bypass_hooks)
191
+
192
+
193
+ def tag_upsert_metadata(result_objects, existing_record_ids, existing_pks_map):
194
+ """
195
+ Tag objects with metadata indicating whether they were created or updated.
196
+
197
+ Args:
198
+ result_objects: List of objects returned from bulk operation
199
+ existing_record_ids: Set of id() for objects that existed before
200
+ existing_pks_map: Dict mapping id(obj) -> pk for existing records
201
+ """
202
+ existing_pks = set(existing_pks_map.values())
203
+
204
+ created_count = 0
205
+ updated_count = 0
206
+
207
+ for obj in result_objects:
208
+ # Use PK to determine if this record was created or updated
209
+ was_created = obj.pk not in existing_pks
210
+ obj._bulk_hooks_was_created = was_created
211
+ obj._bulk_hooks_upsert_metadata = True
212
+
213
+ if was_created:
214
+ created_count += 1
215
+ else:
216
+ updated_count += 1
217
+
218
+ logger.info(
219
+ f"Tagged upsert metadata: {created_count} created, {updated_count} updated "
220
+ f"(total={len(result_objects)}, existing_pks={len(existing_pks)})"
221
+ )
222
+
223
+
224
+ def was_created(obj):
225
+ """Check if an object was created in an upsert operation."""
226
+ return getattr(obj, '_bulk_hooks_was_created', False)
227
+
228
+
229
+ def is_upsert_result(obj):
230
+ """Check if an object has upsert metadata."""
231
+ return getattr(obj, '_bulk_hooks_upsert_metadata', False)
232
+
233
+
234
+ def cleanup_upsert_metadata(objects):
235
+ """
236
+ Clean up upsert metadata after hook execution.
237
+
238
+ Args:
239
+ objects: Objects to clean up
240
+ """
241
+ for obj in objects:
242
+ if hasattr(obj, '_bulk_hooks_was_created'):
243
+ delattr(obj, '_bulk_hooks_was_created')
244
+ if hasattr(obj, '_bulk_hooks_upsert_metadata'):
245
+ delattr(obj, '_bulk_hooks_upsert_metadata')
@@ -3,6 +3,20 @@ from django.db import models
3
3
  from django_bulk_hooks.queryset import HookQuerySet
4
4
 
5
5
 
6
+ def _delegate_to_queryset(self, method_name, *args, **kwargs):
7
+ """
8
+ Generic delegation to queryset method.
9
+
10
+ Args:
11
+ method_name: Name of the method to call on the queryset
12
+ *args, **kwargs: Arguments to pass to the method
13
+
14
+ Returns:
15
+ Result of the queryset method call
16
+ """
17
+ return getattr(self.get_queryset(), method_name)(*args, **kwargs)
18
+
19
+
6
20
  class BulkHookManager(models.Manager):
7
21
  """
8
22
  Manager that provides hook-aware bulk operations.
@@ -47,15 +61,17 @@ class BulkHookManager(models.Manager):
47
61
  Delegate to QuerySet's bulk_create implementation.
48
62
  This follows Django's pattern where Manager methods call QuerySet methods.
49
63
  """
50
- return self.get_queryset().bulk_create(
64
+ return _delegate_to_queryset(
65
+ self,
66
+ "bulk_create",
51
67
  objs,
52
- bypass_hooks=bypass_hooks,
53
- bypass_validation=bypass_validation,
54
68
  batch_size=batch_size,
55
69
  ignore_conflicts=ignore_conflicts,
56
70
  update_conflicts=update_conflicts,
57
71
  update_fields=update_fields,
58
72
  unique_fields=unique_fields,
73
+ bypass_hooks=bypass_hooks,
74
+ bypass_validation=bypass_validation,
59
75
  **kwargs,
60
76
  )
61
77
 
@@ -77,7 +93,9 @@ class BulkHookManager(models.Manager):
77
93
  """
78
94
  if fields is not None:
79
95
  kwargs["fields"] = fields
80
- return self.get_queryset().bulk_update(
96
+ return _delegate_to_queryset(
97
+ self,
98
+ "bulk_update",
81
99
  objs,
82
100
  bypass_hooks=bypass_hooks,
83
101
  bypass_validation=bypass_validation,
@@ -96,11 +114,13 @@ class BulkHookManager(models.Manager):
96
114
  Delegate to QuerySet's bulk_delete implementation.
97
115
  This follows Django's pattern where Manager methods call QuerySet methods.
98
116
  """
99
- return self.get_queryset().bulk_delete(
117
+ return _delegate_to_queryset(
118
+ self,
119
+ "bulk_delete",
100
120
  objs,
121
+ batch_size=batch_size,
101
122
  bypass_hooks=bypass_hooks,
102
123
  bypass_validation=bypass_validation,
103
- batch_size=batch_size,
104
124
  **kwargs,
105
125
  )
106
126
 
@@ -109,14 +129,14 @@ class BulkHookManager(models.Manager):
109
129
  Delegate to QuerySet's delete implementation.
110
130
  This follows Django's pattern where Manager methods call QuerySet methods.
111
131
  """
112
- return self.get_queryset().delete()
132
+ return _delegate_to_queryset(self, "delete")
113
133
 
114
134
  def update(self, **kwargs):
115
135
  """
116
136
  Delegate to QuerySet's update implementation.
117
137
  This follows Django's pattern where Manager methods call QuerySet methods.
118
138
  """
119
- return self.get_queryset().update(**kwargs)
139
+ return _delegate_to_queryset(self, "update", **kwargs)
120
140
 
121
141
  def save(self, obj):
122
142
  """
@@ -7,7 +7,19 @@ from django_bulk_hooks.manager import BulkHookManager
7
7
  logger = logging.getLogger(__name__)
8
8
 
9
9
 
10
- class HookModelMixin(models.Model):
10
+ class TimestampMixin(models.Model):
11
+ """Mixin providing standard created_at and updated_at timestamp fields."""
12
+
13
+ created_at = models.DateTimeField(auto_now_add=True, help_text="When this record was created")
14
+ updated_at = models.DateTimeField(auto_now=True, help_text="When this record was last updated")
15
+
16
+ class Meta:
17
+ abstract = True
18
+
19
+
20
+ class HookModelMixin(TimestampMixin, models.Model):
21
+ """Combined mixin providing timestamps and hook functionality."""
22
+
11
23
  objects = BulkHookManager()
12
24
 
13
25
  class Meta:
@@ -9,6 +9,9 @@ This service handles all model analysis needs:
9
9
 
10
10
  import logging
11
11
 
12
+ from django_bulk_hooks.helpers import extract_pks
13
+ from .field_utils import get_changed_fields, get_auto_fields, get_fk_fields
14
+
12
15
  logger = logging.getLogger(__name__)
13
16
 
14
17
 
@@ -29,8 +32,40 @@ class ModelAnalyzer:
29
32
  """
30
33
  self.model_cls = model_cls
31
34
 
35
+ # Define validation requirements per operation
36
+ VALIDATION_REQUIREMENTS = {
37
+ "bulk_create": ["types"],
38
+ "bulk_update": ["types", "has_pks"],
39
+ "delete": ["types"],
40
+ }
41
+
32
42
  # ========== Validation Methods ==========
33
43
 
44
+ def validate_for_operation(self, objs, operation):
45
+ """
46
+ Centralized validation method that applies operation-specific checks.
47
+
48
+ Args:
49
+ objs: List of model instances
50
+ operation: String identifier for the operation
51
+
52
+ Returns:
53
+ True if validation passes
54
+
55
+ Raises:
56
+ TypeError: If type validation fails
57
+ ValueError: If PK validation fails
58
+ """
59
+ requirements = self.VALIDATION_REQUIREMENTS.get(operation, [])
60
+
61
+ # Apply each required validation check
62
+ if "types" in requirements:
63
+ self._check_types(objs, operation)
64
+ if "has_pks" in requirements:
65
+ self._check_has_pks(objs, operation)
66
+
67
+ return True
68
+
34
69
  def validate_for_create(self, objs):
35
70
  """
36
71
  Validate objects for bulk_create operation.
@@ -41,8 +76,7 @@ class ModelAnalyzer:
41
76
  Raises:
42
77
  TypeError: If objects are not instances of model_cls
43
78
  """
44
- self._check_types(objs, operation="bulk_create")
45
- return True
79
+ return self.validate_for_operation(objs, "bulk_create")
46
80
 
47
81
  def validate_for_update(self, objs):
48
82
  """
@@ -55,9 +89,7 @@ class ModelAnalyzer:
55
89
  TypeError: If objects are not instances of model_cls
56
90
  ValueError: If objects don't have primary keys
57
91
  """
58
- self._check_types(objs, operation="bulk_update")
59
- self._check_has_pks(objs, operation="bulk_update")
60
- return True
92
+ return self.validate_for_operation(objs, "bulk_update")
61
93
 
62
94
  def validate_for_delete(self, objs):
63
95
  """
@@ -69,8 +101,7 @@ class ModelAnalyzer:
69
101
  Raises:
70
102
  TypeError: If objects are not instances of model_cls
71
103
  """
72
- self._check_types(objs, operation="delete")
73
- return True
104
+ return self.validate_for_operation(objs, "delete")
74
105
 
75
106
  def _check_types(self, objs, operation="operation"):
76
107
  """Check that all objects are instances of the model class"""
@@ -109,7 +140,7 @@ class ModelAnalyzer:
109
140
  Returns:
110
141
  Dict[pk, instance] for O(1) lookups
111
142
  """
112
- pks = [obj.pk for obj in instances if obj.pk is not None]
143
+ pks = extract_pks(instances)
113
144
  if not pks:
114
145
  return {}
115
146
 
@@ -124,15 +155,7 @@ class ModelAnalyzer:
124
155
  Returns:
125
156
  list: Field names with auto_now behavior
126
157
  """
127
- auto_now_fields = []
128
- for field in self.model_cls._meta.fields:
129
- if getattr(field, "auto_now", False) or getattr(
130
- field,
131
- "auto_now_add",
132
- False,
133
- ):
134
- auto_now_fields.append(field.name)
135
- return auto_now_fields
158
+ return get_auto_fields(self.model_cls, include_auto_now_add=True)
136
159
 
137
160
  def get_fk_fields(self):
138
161
  """
@@ -141,7 +164,7 @@ class ModelAnalyzer:
141
164
  Returns:
142
165
  list: FK field names
143
166
  """
144
- return [field.name for field in self.model_cls._meta.concrete_fields if field.is_relation and not field.many_to_many]
167
+ return get_fk_fields(self.model_cls)
145
168
 
146
169
  def detect_changed_fields(self, objs):
147
170
  """
@@ -179,25 +202,9 @@ class ModelAnalyzer:
179
202
  # Object doesn't exist in DB, skip
180
203
  continue
181
204
 
182
- # Check each field for changes
183
- for field in self.model_cls._meta.fields:
184
- # Skip primary key and auto fields
185
- if field.primary_key or field.auto_created:
186
- continue
187
-
188
- old_val = getattr(old_obj, field.name, None)
189
- new_val = getattr(obj, field.name, None)
190
-
191
- # Use field's get_prep_value for proper comparison
192
- try:
193
- old_prep = field.get_prep_value(old_val)
194
- new_prep = field.get_prep_value(new_val)
195
- if old_prep != new_prep:
196
- changed_fields_set.add(field.name)
197
- except (TypeError, ValueError):
198
- # Fallback to direct comparison
199
- if old_val != new_val:
200
- changed_fields_set.add(field.name)
205
+ # Use canonical field comparison (skips auto_created fields)
206
+ changed_fields = get_changed_fields(old_obj, obj, self.model_cls, skip_auto_fields=True)
207
+ changed_fields_set.update(changed_fields)
201
208
 
202
209
  # Return as sorted list for deterministic behavior
203
210
  return sorted(changed_fields_set)