django-bulk-hooks 0.1.262__tar.gz → 0.1.264__tar.gz

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.

Files changed (17) hide show
  1. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/PKG-INFO +5 -5
  2. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/README.md +3 -3
  3. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/decorators.py +17 -12
  4. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/manager.py +3 -6
  5. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/queryset.py +70 -10
  6. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/pyproject.toml +2 -2
  7. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/LICENSE +0 -0
  8. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/__init__.py +0 -0
  9. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/conditions.py +0 -0
  10. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/constants.py +0 -0
  11. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/context.py +0 -0
  12. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/engine.py +0 -0
  13. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/enums.py +0 -0
  14. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/handler.py +0 -0
  15. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/models.py +0 -0
  16. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/priority.py +0 -0
  17. {django_bulk_hooks-0.1.262 → django_bulk_hooks-0.1.264}/django_bulk_hooks/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.262
3
+ Version: 0.1.264
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
- Requires-Dist: Django (>=4.0)
15
+ Requires-Dist: django (>=5.2.0,<6.0.0)
16
16
  Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
17
17
  Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
18
  Description-Content-Type: text/markdown
@@ -114,7 +114,7 @@ Account.objects.bulk_create(accounts)
114
114
  # Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
115
115
  for account in accounts:
116
116
  account.balance *= 1.1
117
- Account.objects.bulk_update(accounts, ['balance'])
117
+ Account.objects.bulk_update(accounts) # fields are auto-detected
118
118
 
119
119
  # Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
120
120
  Account.objects.bulk_delete(accounts)
@@ -200,7 +200,7 @@ account.delete()
200
200
  ```python
201
201
  # These also trigger hooks
202
202
  Account.objects.bulk_create(accounts)
203
- Account.objects.bulk_update(accounts, ['balance'])
203
+ Account.objects.bulk_update(accounts) # fields are auto-detected
204
204
  Account.objects.bulk_delete(accounts)
205
205
  ```
206
206
 
@@ -239,7 +239,7 @@ accounts = [account1, account2, account3] # IDs: 1, 2, 3
239
239
  reordered = [account3, account1, account2] # IDs: 3, 1, 2
240
240
 
241
241
  # The hook will still receive properly paired old/new records
242
- LoanAccount.objects.bulk_update(reordered, ['balance'])
242
+ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
243
243
  ```
244
244
 
245
245
  ## 🧩 Integration with Other Managers
@@ -95,7 +95,7 @@ Account.objects.bulk_create(accounts)
95
95
  # Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
96
96
  for account in accounts:
97
97
  account.balance *= 1.1
98
- Account.objects.bulk_update(accounts, ['balance'])
98
+ Account.objects.bulk_update(accounts) # fields are auto-detected
99
99
 
100
100
  # Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
101
101
  Account.objects.bulk_delete(accounts)
@@ -181,7 +181,7 @@ account.delete()
181
181
  ```python
182
182
  # These also trigger hooks
183
183
  Account.objects.bulk_create(accounts)
184
- Account.objects.bulk_update(accounts, ['balance'])
184
+ Account.objects.bulk_update(accounts) # fields are auto-detected
185
185
  Account.objects.bulk_delete(accounts)
186
186
  ```
187
187
 
@@ -220,7 +220,7 @@ accounts = [account1, account2, account3] # IDs: 1, 2, 3
220
220
  reordered = [account3, account1, account2] # IDs: 3, 1, 2
221
221
 
222
222
  # The hook will still receive properly paired old/new records
223
- LoanAccount.objects.bulk_update(reordered, ['balance'])
223
+ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
224
224
  ```
225
225
 
226
226
  ## 🧩 Integration with Other Managers
@@ -82,17 +82,18 @@ def select_related(*related_fields):
82
82
  ids_to_fetch.append(obj.pk)
83
83
 
84
84
  # Always validate fields for nested field errors, regardless of whether we need to fetch
85
- for field in related_fields:
86
- if "." in field or "__" in field:
87
- raise ValueError(
88
- f"@select_related does not support nested fields like '{field}'"
89
- )
85
+ # Note: We allow nested fields as Django's select_related supports them
90
86
 
91
87
  fetched = {}
92
88
  if ids_to_fetch:
93
89
  # Validate fields before passing to select_related
94
90
  validated_fields = []
95
91
  for field in related_fields:
92
+ # For nested fields (containing __), let Django's select_related handle validation
93
+ if "__" in field:
94
+ validated_fields.append(field)
95
+ continue
96
+
96
97
  try:
97
98
  # Handle Mock objects that don't have _meta
98
99
  if hasattr(model_cls, "_meta"):
@@ -112,9 +113,13 @@ def select_related(*related_fields):
112
113
 
113
114
  if validated_fields:
114
115
  # Use the base manager to avoid recursion
115
- fetched = model_cls._base_manager.select_related(
116
- *validated_fields
117
- ).in_bulk(ids_to_fetch)
116
+ try:
117
+ fetched = model_cls._base_manager.select_related(
118
+ *validated_fields
119
+ ).in_bulk(ids_to_fetch)
120
+ except Exception:
121
+ # If select_related fails (e.g., invalid nested fields), skip preloading
122
+ fetched = {}
118
123
 
119
124
  for obj in new_records:
120
125
  preloaded = fetched.get(obj.pk)
@@ -126,8 +131,8 @@ def select_related(*related_fields):
126
131
  if field in obj._state.fields_cache:
127
132
  # don't override values that were explicitly set or already loaded
128
133
  continue
129
- if "." in field or "__" in field:
130
- # This should have been caught earlier, but just in case
134
+ if "." in field:
135
+ # Skip fields with dots (legacy format, not supported)
131
136
  continue
132
137
 
133
138
  try:
@@ -181,8 +186,8 @@ def bulk_hook(model_cls, event, when=None, priority=None):
181
186
  def __init__(self):
182
187
  self.func = func
183
188
 
184
- def handle(self, new_instances, original_instances):
185
- return self.func(new_instances, original_instances)
189
+ def handle(self, new_records=None, old_records=None, **kwargs):
190
+ return self.func(new_records, old_records)
186
191
 
187
192
  # Register the hook using the registry
188
193
  register_hook(
@@ -51,7 +51,7 @@ class BulkHookManager(models.Manager):
51
51
  )
52
52
 
53
53
  def bulk_update(
54
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
54
+ self, objs, bypass_hooks=False, bypass_validation=False, **kwargs
55
55
  ):
56
56
  """
57
57
  Delegate to QuerySet's bulk_update implementation.
@@ -59,7 +59,6 @@ class BulkHookManager(models.Manager):
59
59
  """
60
60
  return self.get_queryset().bulk_update(
61
61
  objs,
62
- fields,
63
62
  bypass_hooks=bypass_hooks,
64
63
  bypass_validation=bypass_validation,
65
64
  **kwargs,
@@ -104,10 +103,8 @@ class BulkHookManager(models.Manager):
104
103
  Save a single object using the appropriate bulk operation.
105
104
  """
106
105
  if obj.pk:
107
- self.bulk_update(
108
- [obj],
109
- fields=[field.name for field in obj._meta.fields if field.name != "id"],
110
- )
106
+ # bulk_update now auto-detects changed fields
107
+ self.bulk_update([obj])
111
108
  else:
112
109
  self.bulk_create([obj])
113
110
  return obj
@@ -846,12 +846,70 @@ class HookQuerySetMixin:
846
846
 
847
847
  return result
848
848
 
849
+ def _detect_changed_fields(self, objs):
850
+ """
851
+ Auto-detect which fields have changed by comparing objects with database values.
852
+ Returns a set of field names that have changed across all objects.
853
+ """
854
+ if not objs:
855
+ return set()
856
+
857
+ model_cls = self.model
858
+ changed_fields = set()
859
+
860
+ # Get primary key field names
861
+ pk_fields = [f.name for f in model_cls._meta.pk_fields]
862
+ if not pk_fields:
863
+ pk_fields = ['pk']
864
+
865
+ # Get all object PKs
866
+ obj_pks = []
867
+ for obj in objs:
868
+ if hasattr(obj, 'pk') and obj.pk is not None:
869
+ obj_pks.append(obj.pk)
870
+ else:
871
+ # Skip objects without PKs
872
+ continue
873
+
874
+ if not obj_pks:
875
+ return set()
876
+
877
+ # Fetch current database values for all objects
878
+ existing_objs = {obj.pk: obj for obj in model_cls.objects.filter(pk__in=obj_pks)}
879
+
880
+ # Compare each object's current values with database values
881
+ for obj in objs:
882
+ if obj.pk not in existing_objs:
883
+ continue
884
+
885
+ db_obj = existing_objs[obj.pk]
886
+
887
+ # Check all concrete fields for changes
888
+ for field in model_cls._meta.concrete_fields:
889
+ field_name = field.name
890
+
891
+ # Skip primary key fields
892
+ if field_name in pk_fields:
893
+ continue
894
+
895
+ # Get current value from object
896
+ current_value = getattr(obj, field_name, None)
897
+ # Get database value
898
+ db_value = getattr(db_obj, field_name, None)
899
+
900
+ # Compare values (handle None cases)
901
+ if current_value != db_value:
902
+ changed_fields.add(field_name)
903
+
904
+ return changed_fields
905
+
849
906
  @transaction.atomic
850
907
  def bulk_update(
851
- self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
908
+ self, objs, bypass_hooks=False, bypass_validation=False, **kwargs
852
909
  ):
853
910
  """
854
911
  Bulk update objects in the database with MTI support.
912
+ Automatically detects which fields have changed by comparing with database values.
855
913
  """
856
914
  model_cls = self.model
857
915
 
@@ -863,10 +921,14 @@ class HookQuerySetMixin:
863
921
  f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
864
922
  )
865
923
 
924
+ # Auto-detect changed fields by comparing with database values
925
+ changed_fields = self._detect_changed_fields(objs)
926
+ logger.debug(f"Auto-detected changed fields: {changed_fields}")
927
+
866
928
  logger.debug(
867
- f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} fields={fields}"
929
+ f"bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} changed_fields={changed_fields}"
868
930
  )
869
- print(f"DEBUG: bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} fields={fields}")
931
+ print(f"DEBUG: bulk_update {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)} changed_fields={changed_fields}")
870
932
 
871
933
  # Check for MTI
872
934
  is_mti = False
@@ -887,7 +949,7 @@ class HookQuerySetMixin:
887
949
  ) # Ensure originals is defined for after_update call
888
950
 
889
951
  # Handle auto_now fields like Django's update_or_create does
890
- fields_set = set(fields)
952
+ fields_set = set(changed_fields)
891
953
  pk_fields = model_cls._meta.pk_fields
892
954
  pk_field_names = [f.name for f in pk_fields]
893
955
  auto_now_fields = []
@@ -921,7 +983,6 @@ class HookQuerySetMixin:
921
983
 
922
984
  logger.debug(f"Auto_now fields detected: {auto_now_fields}")
923
985
  print(f"DEBUG: Auto_now fields detected: {auto_now_fields}")
924
- fields = list(fields_set)
925
986
 
926
987
  # Set auto_now field values to current timestamp
927
988
  if auto_now_fields:
@@ -949,7 +1010,6 @@ class HookQuerySetMixin:
949
1010
  # Add this field to the update fields if it's not already there and not a primary key
950
1011
  if field.name not in fields_set and field.name not in pk_field_names:
951
1012
  fields_set.add(field.name)
952
- fields.append(field.name)
953
1013
  logger.debug(f"Custom field {field.name} updated via pre_save() for object {obj.pk}")
954
1014
  print(f"DEBUG: Custom field {field.name} updated via pre_save() for object {obj.pk}")
955
1015
  except Exception as e:
@@ -958,7 +1018,7 @@ class HookQuerySetMixin:
958
1018
 
959
1019
  # Handle MTI models differently
960
1020
  if is_mti:
961
- result = self._mti_bulk_update(objs, fields, **kwargs)
1021
+ result = self._mti_bulk_update(objs, list(fields_set), **kwargs)
962
1022
  else:
963
1023
  # For single-table models, use Django's built-in bulk_update
964
1024
  django_kwargs = {
@@ -970,12 +1030,12 @@ class HookQuerySetMixin:
970
1030
  print("DEBUG: Calling Django bulk_update")
971
1031
  # Build a per-object concrete value map to avoid leaking expressions into hooks
972
1032
  value_map = {}
973
- logger.debug(f"Building value map for {len(objs)} objects with fields: {fields}")
1033
+ logger.debug(f"Building value map for {len(objs)} objects with fields: {list(fields_set)}")
974
1034
  for obj in objs:
975
1035
  if obj.pk is None:
976
1036
  continue
977
1037
  field_values = {}
978
- for field_name in fields:
1038
+ for field_name in fields_set:
979
1039
  # Capture raw values assigned on the object (not expressions)
980
1040
  field_values[field_name] = getattr(obj, field_name)
981
1041
  if field_name in auto_now_fields:
@@ -988,7 +1048,7 @@ class HookQuerySetMixin:
988
1048
  set_bulk_update_value_map(value_map)
989
1049
 
990
1050
  try:
991
- result = super().bulk_update(objs, fields, **django_kwargs)
1051
+ result = super().bulk_update(objs, list(fields_set), **django_kwargs)
992
1052
  finally:
993
1053
  # Always clear after the internal update() path finishes
994
1054
  set_bulk_update_value_map(None)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.262"
3
+ version = "0.1.264"
4
4
  description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
5
5
  authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
6
6
  readme = "README.md"
@@ -14,7 +14,7 @@ packages = [
14
14
 
15
15
  [tool.poetry.dependencies]
16
16
  python = "^3.11"
17
- Django = ">=4.0"
17
+ django = "^5.2.0"
18
18
 
19
19
  [tool.poetry.group.dev.dependencies]
20
20
  pytest = "^7.4.0"