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

@@ -485,19 +485,12 @@ class HookQuerySetMixin:
485
485
  but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
486
486
  passed through to the correct logic. For MTI, only a subset of options may be supported.
487
487
  """
488
- model_cls = self.model
489
-
490
- print(
491
- f"DEBUG: bulk_create called for {model_cls.__name__} with {len(objs)} objects"
492
- )
493
- print(
494
- f"DEBUG: update_conflicts={update_conflicts}, unique_fields={unique_fields}, update_fields={update_fields}"
495
- )
496
- logger.debug(
497
- f"bulk_create called for {model_cls.__name__} with {len(objs)} objects"
498
- )
499
- logger.debug(
500
- f"update_conflicts={update_conflicts}, unique_fields={unique_fields}, update_fields={update_fields}"
488
+ model_cls, ctx, originals = self._setup_bulk_operation(
489
+ objs, "bulk_create", require_pks=False,
490
+ bypass_hooks=bypass_hooks, bypass_validation=bypass_validation,
491
+ update_conflicts=update_conflicts,
492
+ unique_fields=unique_fields,
493
+ update_fields=update_fields
501
494
  )
502
495
 
503
496
  # When you bulk insert you don't get the primary keys back (if it's an
@@ -518,26 +511,13 @@ class HookQuerySetMixin:
518
511
  if not objs:
519
512
  return objs
520
513
 
521
- if any(not isinstance(obj, model_cls) for obj in objs):
522
- raise TypeError(
523
- f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
524
- )
514
+ self._validate_objects(objs, require_pks=False, operation_name="bulk_create")
525
515
 
526
516
  # Check for MTI - if we detect multi-table inheritance, we need special handling
527
- # This follows Django's approach: check that the parents share the same concrete model
528
- # with our model to detect the inheritance pattern ConcreteGrandParent ->
529
- # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
530
- # identify that case as involving multiple tables.
531
- is_mti = False
532
- for parent in model_cls._meta.all_parents:
533
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
534
- is_mti = True
535
- break
517
+ is_mti = self._is_multi_table_inheritance()
536
518
 
537
519
  # Fire hooks before DB ops
538
520
  if not bypass_hooks:
539
- ctx = HookContext(model_cls, bypass_hooks=False) # Pass bypass_hooks
540
-
541
521
  if update_conflicts and unique_fields:
542
522
  # For upsert operations, we need to determine which records will be created vs updated
543
523
  # Check which records already exist in the database based on unique fields
@@ -604,13 +584,7 @@ class HookQuerySetMixin:
604
584
 
605
585
  # Handle auto_now fields intelligently for upsert operations
606
586
  # Only set auto_now fields on records that will actually be created
607
- for obj in new_records:
608
- for field in model_cls._meta.local_fields:
609
- if hasattr(field, "auto_now") and field.auto_now:
610
- field.pre_save(obj, add=True)
611
- elif hasattr(field, "auto_now_add") and field.auto_now_add:
612
- if getattr(obj, field.name) is None:
613
- field.pre_save(obj, add=True)
587
+ self._handle_auto_now_fields(new_records, add=True)
614
588
 
615
589
  # For existing records, preserve their original auto_now values
616
590
  # We'll need to fetch them from the database to preserve the timestamps
@@ -737,19 +711,12 @@ class HookQuerySetMixin:
737
711
  else:
738
712
  # For regular create operations, run create hooks before DB ops
739
713
  # Handle auto_now fields normally for new records
740
- for obj in objs:
741
- for field in model_cls._meta.local_fields:
742
- if hasattr(field, "auto_now") and field.auto_now:
743
- field.pre_save(obj, add=True)
744
- elif hasattr(field, "auto_now_add") and field.auto_now_add:
745
- if getattr(obj, field.name) is None:
746
- field.pre_save(obj, add=True)
714
+ self._handle_auto_now_fields(objs, add=True)
747
715
 
748
716
  if not bypass_validation:
749
717
  engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
750
718
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
751
719
  else:
752
- ctx = HookContext(model_cls, bypass_hooks=True) # Pass bypass_hooks
753
720
  logger.debug("bulk_create bypassed hooks")
754
721
 
755
722
  # For MTI models, we need to handle them specially
@@ -978,11 +945,11 @@ class HookQuerySetMixin:
978
945
  if not objs:
979
946
  return []
980
947
 
981
- self._validate_objects(objs)
948
+ self._validate_objects(objs, require_pks=True, operation_name="bulk_update")
982
949
 
983
950
  changed_fields = self._detect_changed_fields(objs)
984
951
  is_mti = self._is_multi_table_inheritance()
985
- hook_context, originals = self._init_hook_context(bypass_hooks, objs)
952
+ hook_context, originals = self._init_hook_context(bypass_hooks, objs, "bulk_update")
986
953
 
987
954
  fields_set, auto_now_fields, custom_update_fields = self._prepare_update_fields(
988
955
  changed_fields
@@ -1128,10 +1095,14 @@ class HookQuerySetMixin:
1128
1095
  logger.debug("Built value_map for %d objects", len(value_map))
1129
1096
  return value_map
1130
1097
 
1131
- def _validate_objects(self, objs):
1098
+ def _validate_objects(self, objs, require_pks=False, operation_name="bulk_update"):
1132
1099
  """
1133
- Validate that all objects are instances of this queryset's model
1134
- and that they have primary keys (cannot bulk update unsaved objects).
1100
+ Validate that all objects are instances of this queryset's model.
1101
+
1102
+ Args:
1103
+ objs (list): Objects to validate
1104
+ require_pks (bool): Whether to validate that objects have primary keys
1105
+ operation_name (str): Name of the operation for error messages
1135
1106
  """
1136
1107
  model_cls = self.model
1137
1108
 
@@ -1141,27 +1112,34 @@ class HookQuerySetMixin:
1141
1112
  }
1142
1113
  if invalid_types:
1143
1114
  raise TypeError(
1144
- f"bulk_update expected instances of {model_cls.__name__}, "
1115
+ f"{operation_name} expected instances of {model_cls.__name__}, "
1145
1116
  f"but got {invalid_types}"
1146
1117
  )
1147
1118
 
1148
- # Primary key check
1149
- missing_pks = [obj for obj in objs if obj.pk is None]
1150
- if missing_pks:
1151
- raise ValueError(
1152
- f"bulk_update cannot operate on unsaved {model_cls.__name__} instances. "
1153
- f"{len(missing_pks)} object(s) have no primary key."
1154
- )
1119
+ # Primary key check (optional, for operations that require saved objects)
1120
+ if require_pks:
1121
+ missing_pks = [obj for obj in objs if obj.pk is None]
1122
+ if missing_pks:
1123
+ raise ValueError(
1124
+ f"{operation_name} cannot operate on unsaved {model_cls.__name__} instances. "
1125
+ f"{len(missing_pks)} object(s) have no primary key."
1126
+ )
1155
1127
 
1156
1128
  logger.debug(
1157
- "Validated %d %s objects for bulk_update",
1129
+ "Validated %d %s objects for %s",
1158
1130
  len(objs),
1159
1131
  model_cls.__name__,
1132
+ operation_name,
1160
1133
  )
1161
1134
 
1162
- def _init_hook_context(self, bypass_hooks: bool, objs):
1135
+ def _init_hook_context(self, bypass_hooks: bool, objs, operation_name="bulk_update"):
1163
1136
  """
1164
- Initialize the hook context for bulk_update.
1137
+ Initialize the hook context for bulk operations.
1138
+
1139
+ Args:
1140
+ bypass_hooks (bool): Whether to bypass hooks
1141
+ objs (list): List of objects being operated on
1142
+ operation_name (str): Name of the operation for logging
1165
1143
 
1166
1144
  Returns:
1167
1145
  (HookContext, list): The hook context and a placeholder list
@@ -1171,10 +1149,10 @@ class HookQuerySetMixin:
1171
1149
  model_cls = self.model
1172
1150
 
1173
1151
  if bypass_hooks:
1174
- logger.debug("bulk_update: hooks bypassed for %s", model_cls.__name__)
1152
+ logger.debug("%s: hooks bypassed for %s", operation_name, model_cls.__name__)
1175
1153
  ctx = HookContext(model_cls, bypass_hooks=True)
1176
1154
  else:
1177
- logger.debug("bulk_update: hooks enabled for %s", model_cls.__name__)
1155
+ logger.debug("%s: hooks enabled for %s", operation_name, model_cls.__name__)
1178
1156
  ctx = HookContext(model_cls, bypass_hooks=False)
1179
1157
 
1180
1158
  # Keep `originals` aligned with objs to support later hook execution.
@@ -1234,13 +1212,14 @@ class HookQuerySetMixin:
1234
1212
 
1235
1213
  return fields_set, auto_now_fields, custom_update_fields
1236
1214
 
1237
- def _apply_auto_now_fields(self, objs, auto_now_fields):
1215
+ def _apply_auto_now_fields(self, objs, auto_now_fields, add=False):
1238
1216
  """
1239
1217
  Apply the current timestamp to all auto_now fields on each object.
1240
1218
 
1241
1219
  Args:
1242
- objs (list[Model]): The model instances being updated.
1220
+ objs (list[Model]): The model instances being processed.
1243
1221
  auto_now_fields (list[str]): Field names that require auto_now behavior.
1222
+ add (bool): Whether this is for creation (add=True) or update (add=False).
1244
1223
  """
1245
1224
  if not auto_now_fields:
1246
1225
  return
@@ -1250,16 +1229,186 @@ class HookQuerySetMixin:
1250
1229
  current_time = timezone.now()
1251
1230
 
1252
1231
  logger.debug(
1253
- "Setting auto_now fields %s to %s for %d objects",
1232
+ "Setting auto_now fields %s to %s for %d objects (add=%s)",
1254
1233
  auto_now_fields,
1255
1234
  current_time,
1256
1235
  len(objs),
1236
+ add,
1257
1237
  )
1258
1238
 
1259
1239
  for obj in objs:
1260
1240
  for field_name in auto_now_fields:
1261
1241
  setattr(obj, field_name, current_time)
1262
1242
 
1243
+ def _handle_auto_now_fields(self, objs, add=False):
1244
+ """
1245
+ Handle auto_now and auto_now_add fields for objects.
1246
+
1247
+ Args:
1248
+ objs (list[Model]): The model instances being processed.
1249
+ add (bool): Whether this is for creation (add=True) or update (add=False).
1250
+
1251
+ Returns:
1252
+ list[str]: Names of auto_now fields that were handled.
1253
+ """
1254
+ model_cls = self.model
1255
+ handled_fields = []
1256
+
1257
+ for obj in objs:
1258
+ for field in model_cls._meta.local_fields:
1259
+ # Handle auto_now_add only during creation
1260
+ if add and hasattr(field, "auto_now_add") and field.auto_now_add:
1261
+ if getattr(obj, field.name) is None:
1262
+ field.pre_save(obj, add=True)
1263
+ handled_fields.append(field.name)
1264
+ # Handle auto_now during creation or update
1265
+ elif hasattr(field, "auto_now") and field.auto_now:
1266
+ field.pre_save(obj, add=add)
1267
+ handled_fields.append(field.name)
1268
+
1269
+ return list(set(handled_fields)) # Remove duplicates
1270
+
1271
+ def _execute_hooks_with_operation(self, operation_func, validate_hook, before_hook, after_hook, objs, originals=None, ctx=None, bypass_hooks=False, bypass_validation=False):
1272
+ """
1273
+ Execute the complete hook lifecycle around a database operation.
1274
+
1275
+ Args:
1276
+ operation_func (callable): The database operation to execute
1277
+ validate_hook: Hook constant for validation
1278
+ before_hook: Hook constant for before operation
1279
+ after_hook: Hook constant for after operation
1280
+ objs (list): Objects being operated on
1281
+ originals (list, optional): Original objects for comparison hooks
1282
+ ctx: Hook context
1283
+ bypass_hooks (bool): Whether to skip hooks
1284
+ bypass_validation (bool): Whether to skip validation hooks
1285
+
1286
+ Returns:
1287
+ The result of the database operation
1288
+ """
1289
+ model_cls = self.model
1290
+
1291
+ # Run validation hooks first (if not bypassed)
1292
+ if not bypass_validation and validate_hook:
1293
+ engine.run(model_cls, validate_hook, objs, ctx=ctx)
1294
+
1295
+ # Run before hooks (if not bypassed)
1296
+ if not bypass_hooks and before_hook:
1297
+ engine.run(model_cls, before_hook, objs, originals, ctx=ctx)
1298
+
1299
+ # Execute the database operation
1300
+ result = operation_func()
1301
+
1302
+ # Run after hooks (if not bypassed)
1303
+ if not bypass_hooks and after_hook:
1304
+ engine.run(model_cls, after_hook, objs, originals, ctx=ctx)
1305
+
1306
+ return result
1307
+
1308
+ def _log_bulk_operation_start(self, operation_name, objs, **kwargs):
1309
+ """
1310
+ Log the start of a bulk operation with consistent formatting.
1311
+
1312
+ Args:
1313
+ operation_name (str): Name of the operation (e.g., "bulk_create")
1314
+ objs (list): Objects being operated on
1315
+ **kwargs: Additional parameters to log
1316
+ """
1317
+ model_cls = self.model
1318
+
1319
+ # Build parameter string for additional kwargs
1320
+ param_str = ""
1321
+ if kwargs:
1322
+ param_parts = []
1323
+ for key, value in kwargs.items():
1324
+ if isinstance(value, (list, tuple)):
1325
+ param_parts.append(f"{key}={value}")
1326
+ else:
1327
+ param_parts.append(f"{key}={value}")
1328
+ param_str = f", {', '.join(param_parts)}"
1329
+
1330
+ # Use both print and logger for consistency with existing patterns
1331
+ print(f"DEBUG: {operation_name} called for {model_cls.__name__} with {len(objs)} objects{param_str}")
1332
+ logger.debug(f"{operation_name} called for {model_cls.__name__} with {len(objs)} objects{param_str}")
1333
+
1334
+ def _execute_delete_hooks_with_operation(self, operation_func, objs, ctx=None, bypass_hooks=False, bypass_validation=False):
1335
+ """
1336
+ Execute hooks for delete operations with special field caching logic.
1337
+
1338
+ Args:
1339
+ operation_func (callable): The delete operation to execute
1340
+ objs (list): Objects being deleted
1341
+ ctx: Hook context
1342
+ bypass_hooks (bool): Whether to skip hooks
1343
+ bypass_validation (bool): Whether to skip validation hooks
1344
+
1345
+ Returns:
1346
+ The result of the delete operation
1347
+ """
1348
+ model_cls = self.model
1349
+
1350
+ # Run validation hooks first (if not bypassed)
1351
+ if not bypass_validation:
1352
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
1353
+
1354
+ # Run before hooks (if not bypassed)
1355
+ if not bypass_hooks:
1356
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
1357
+
1358
+ # Before deletion, ensure all related fields are properly cached
1359
+ # to avoid DoesNotExist errors in AFTER_DELETE hooks
1360
+ for obj in objs:
1361
+ if obj.pk is not None:
1362
+ # Cache all foreign key relationships by accessing them
1363
+ for field in model_cls._meta.fields:
1364
+ if (
1365
+ field.is_relation
1366
+ and not field.many_to_many
1367
+ and not field.one_to_many
1368
+ ):
1369
+ try:
1370
+ # Access the related field to cache it before deletion
1371
+ getattr(obj, field.name)
1372
+ except Exception:
1373
+ # If we can't access the field (e.g., already deleted, no permission, etc.)
1374
+ # continue with other fields
1375
+ pass
1376
+
1377
+ # Execute the database operation
1378
+ result = operation_func()
1379
+
1380
+ # Run after hooks (if not bypassed)
1381
+ if not bypass_hooks:
1382
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
1383
+
1384
+ return result
1385
+
1386
+ def _setup_bulk_operation(self, objs, operation_name, require_pks=False, bypass_hooks=False, bypass_validation=False, **log_kwargs):
1387
+ """
1388
+ Common setup logic for bulk operations.
1389
+
1390
+ Args:
1391
+ objs (list): Objects to operate on
1392
+ operation_name (str): Name of the operation for logging and validation
1393
+ require_pks (bool): Whether objects must have primary keys
1394
+ bypass_hooks (bool): Whether to bypass hooks
1395
+ bypass_validation (bool): Whether to bypass validation
1396
+ **log_kwargs: Additional parameters to log
1397
+
1398
+ Returns:
1399
+ tuple: (model_cls, ctx, originals)
1400
+ """
1401
+ # Log operation start
1402
+ self._log_bulk_operation_start(operation_name, objs, **log_kwargs)
1403
+
1404
+ # Validate objects
1405
+ self._validate_objects(objs, require_pks=require_pks, operation_name=operation_name)
1406
+
1407
+ # Initialize hook context
1408
+ ctx, originals = self._init_hook_context(bypass_hooks, objs, operation_name)
1409
+
1410
+ return self.model, ctx, originals
1411
+
1263
1412
  def _is_multi_table_inheritance(self) -> bool:
1264
1413
  """
1265
1414
  Determine whether this model uses multi-table inheritance (MTI).
@@ -1271,7 +1420,7 @@ class HookQuerySetMixin:
1271
1420
  logger.debug(
1272
1421
  "%s detected as MTI model (parent: %s)",
1273
1422
  model_cls.__name__,
1274
- parent.__name__,
1423
+ getattr(parent, "__name__", str(parent)),
1275
1424
  )
1276
1425
  return True
1277
1426
 
@@ -1793,56 +1942,24 @@ class HookQuerySetMixin:
1793
1942
  if not objs:
1794
1943
  return 0
1795
1944
 
1796
- if any(not isinstance(obj, model_cls) for obj in objs):
1797
- raise TypeError(
1798
- f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
1799
- )
1800
-
1801
- logger.debug(
1802
- f"bulk_delete {model_cls.__name__} bypass_hooks={bypass_hooks} objs={len(objs)}"
1945
+ model_cls, ctx, _ = self._setup_bulk_operation(
1946
+ objs, "bulk_delete", require_pks=True,
1947
+ bypass_hooks=bypass_hooks, bypass_validation=bypass_validation
1803
1948
  )
1804
1949
 
1805
- # Fire hooks before DB ops
1806
- if not bypass_hooks:
1807
- ctx = HookContext(model_cls, bypass_hooks=False)
1808
- if not bypass_validation:
1809
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
1810
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
1811
- else:
1812
- ctx = HookContext(model_cls, bypass_hooks=True)
1813
- logger.debug("bulk_delete bypassed hooks")
1814
-
1815
- # Before deletion, ensure all related fields are properly cached
1816
- # to avoid DoesNotExist errors in AFTER_DELETE hooks
1817
- if not bypass_hooks:
1818
- for obj in objs:
1819
- if obj.pk is not None:
1820
- # Cache all foreign key relationships by accessing them
1821
- for field in model_cls._meta.fields:
1822
- if (
1823
- field.is_relation
1824
- and not field.many_to_many
1825
- and not field.one_to_many
1826
- ):
1827
- try:
1828
- # Access the related field to cache it before deletion
1829
- getattr(obj, field.name)
1830
- except Exception:
1831
- # If we can't access the field (e.g., already deleted, no permission, etc.)
1832
- # continue with other fields
1833
- pass
1834
-
1835
- # Use Django's standard delete() method on the queryset
1836
- pks = [obj.pk for obj in objs if obj.pk is not None]
1837
- if pks:
1838
- # Use the base manager to avoid recursion
1839
- result = self.model._base_manager.filter(pk__in=pks).delete()[0]
1840
- else:
1841
- result = 0
1950
+ # Execute the database operation with hooks
1951
+ def delete_operation():
1952
+ pks = [obj.pk for obj in objs if obj.pk is not None]
1953
+ if pks:
1954
+ # Use the base manager to avoid recursion
1955
+ return self.model._base_manager.filter(pk__in=pks).delete()[0]
1956
+ else:
1957
+ return 0
1842
1958
 
1843
- # Fire AFTER_DELETE hooks
1844
- if not bypass_hooks:
1845
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
1959
+ result = self._execute_delete_hooks_with_operation(
1960
+ delete_operation, objs, ctx=ctx,
1961
+ bypass_hooks=bypass_hooks, bypass_validation=bypass_validation
1962
+ )
1846
1963
 
1847
1964
  return result
1848
1965
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.267
3
+ Version: 0.1.268
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
@@ -9,9 +9,9 @@ django_bulk_hooks/handler.py,sha256=Bx-W6yyiciKMyy-BRxUt3CmRPCrX9_LhQgU-5LaJTjg,
9
9
  django_bulk_hooks/manager.py,sha256=3jNWL-EkvGScsliNc7mW-ozQCG6HyaEevI1u1BFS4AA,3836
10
10
  django_bulk_hooks/models.py,sha256=WtSfc4GBOG_oOt8n37cVvid0MtFIGze9JYKSixil2y0,4370
11
11
  django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
12
- django_bulk_hooks/queryset.py,sha256=xp8Xa2yxc278A11Voy0sx5eYn6O5p2y1nzuEB57q-Tg,84694
12
+ django_bulk_hooks/queryset.py,sha256=qgBwrlOeoK70QDFT5dAPoGttKCkkrAxhaIMs4qSI4A4,89278
13
13
  django_bulk_hooks/registry.py,sha256=GRUTGVQEO2sdkC9OaZ9Q3U7mM-3Ix83uTyvrlTtpatw,1317
14
- django_bulk_hooks-0.1.267.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
- django_bulk_hooks-0.1.267.dist-info/METADATA,sha256=WqAB7A5yKuD9PKX9bte-a3kk7MkhrUG8AQ_-xqoNJaE,9115
16
- django_bulk_hooks-0.1.267.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
- django_bulk_hooks-0.1.267.dist-info/RECORD,,
14
+ django_bulk_hooks-0.1.268.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
+ django_bulk_hooks-0.1.268.dist-info/METADATA,sha256=Sw7iTHbdn_v2TAEdrKSdazlC_wc6D-hK-w_PFrn9F7Y,9115
16
+ django_bulk_hooks-0.1.268.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
+ django_bulk_hooks-0.1.268.dist-info/RECORD,,