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

@@ -24,9 +24,11 @@ class BulkHookManager(models.Manager):
24
24
  # Check if this is our HookQuerySet or a different QuerySet
25
25
  if hasattr(qs, 'bulk_update') and 'bypass_hooks' in inspect.signature(qs.bulk_update).parameters:
26
26
  # Our HookQuerySet - pass all parameters
27
+ print(f"DEBUG: Using our HookQuerySet for {self.model}")
27
28
  return qs.bulk_update(objs, fields, bypass_hooks=bypass_hooks, bypass_validation=bypass_validation, **kwargs)
28
29
  else:
29
30
  # Different QuerySet (like queryable_properties) - only pass standard parameters
31
+ print(f"DEBUG: Using different QuerySet ({type(qs)}) for {self.model}, bypassing hooks")
30
32
  django_kwargs = {k: v for k, v in kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
31
33
  return qs.bulk_update(objs, fields, **django_kwargs)
32
34
 
@@ -52,6 +54,7 @@ class BulkHookManager(models.Manager):
52
54
  # Check if this is our HookQuerySet or a different QuerySet
53
55
  if hasattr(qs, 'bulk_create') and 'bypass_hooks' in inspect.signature(qs.bulk_create).parameters:
54
56
  # Our HookQuerySet - pass all parameters
57
+ print(f"DEBUG: Using our HookQuerySet for {self.model}")
55
58
  kwargs = {
56
59
  'batch_size': batch_size,
57
60
  'ignore_conflicts': ignore_conflicts,
@@ -62,6 +65,7 @@ class BulkHookManager(models.Manager):
62
65
  return qs.bulk_create(objs, bypass_hooks=bypass_hooks, bypass_validation=bypass_validation, **kwargs)
63
66
  else:
64
67
  # Different QuerySet - only pass standard parameters
68
+ print(f"DEBUG: Using different QuerySet ({type(qs)}) for {self.model}, bypassing hooks")
65
69
  kwargs = {
66
70
  'batch_size': batch_size,
67
71
  'ignore_conflicts': ignore_conflicts,
@@ -85,12 +89,14 @@ class BulkHookManager(models.Manager):
85
89
  # Check if this is our HookQuerySet or a different QuerySet
86
90
  if hasattr(qs, 'bulk_delete') and 'bypass_hooks' in inspect.signature(qs.bulk_delete).parameters:
87
91
  # Our HookQuerySet - pass all parameters
92
+ print(f"DEBUG: Using our HookQuerySet for {self.model}")
88
93
  kwargs = {
89
94
  'batch_size': batch_size,
90
95
  }
91
96
  return qs.bulk_delete(objs, bypass_hooks=bypass_hooks, bypass_validation=bypass_validation, **kwargs)
92
97
  else:
93
98
  # Different QuerySet - only pass standard parameters
99
+ print(f"DEBUG: Using different QuerySet ({type(qs)}) for {self.model}, bypassing hooks")
94
100
  kwargs = {
95
101
  'batch_size': batch_size,
96
102
  }
@@ -1,524 +1,540 @@
1
- from django.db import models, transaction
2
- from django.db.models import AutoField
3
-
4
- from django_bulk_hooks import engine
5
- from django_bulk_hooks.constants import (
6
- AFTER_CREATE,
7
- AFTER_DELETE,
8
- AFTER_UPDATE,
9
- BEFORE_CREATE,
10
- BEFORE_DELETE,
11
- BEFORE_UPDATE,
12
- VALIDATE_CREATE,
13
- VALIDATE_DELETE,
14
- VALIDATE_UPDATE,
15
- )
16
- from django_bulk_hooks.context import HookContext
17
-
18
-
19
- class HookQuerySet(models.QuerySet):
20
- CHUNK_SIZE = 200
21
-
22
- @transaction.atomic
23
- def delete(self):
24
- objs = list(self)
25
- if not objs:
26
- return 0
27
- return self.model.objects.bulk_delete(objs)
28
-
29
- @transaction.atomic
30
- def update(self, **kwargs):
31
- instances = list(self)
32
- if not instances:
33
- return 0
34
-
35
- model_cls = self.model
36
- pks = [obj.pk for obj in instances]
37
-
38
- # Load originals for hook comparison and ensure they match the order of instances
39
- original_map = {obj.pk: obj for obj in model_cls.objects.filter(pk__in=pks)}
40
- originals = [original_map.get(obj.pk) for obj in instances]
41
-
42
- # Apply field updates to instances
43
- for obj in instances:
44
- for field, value in kwargs.items():
45
- setattr(obj, field, value)
46
-
47
- # Run BEFORE_UPDATE hooks
48
- ctx = HookContext(model_cls)
49
- engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
50
-
51
- # Use Django's built-in update logic directly
52
- queryset = self.model.objects.filter(pk__in=pks)
53
- update_count = queryset.update(**kwargs)
54
-
55
- # Run AFTER_UPDATE hooks
56
- engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
57
-
58
- return update_count
59
-
60
- @transaction.atomic
61
- def bulk_create(
62
- self,
63
- objs,
64
- batch_size=None,
65
- ignore_conflicts=False,
66
- update_conflicts=False,
67
- update_fields=None,
68
- unique_fields=None,
69
- bypass_hooks=False,
70
- bypass_validation=False,
71
- ):
72
- """
73
- Insert each of the instances into the database. Behaves like Django's bulk_create,
74
- but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
75
- passed through to the correct logic. For MTI, only a subset of options may be supported.
76
- """
77
- model_cls = self.model
78
-
79
- # When you bulk insert you don't get the primary keys back (if it's an
80
- # autoincrement, except if can_return_rows_from_bulk_insert=True), so
81
- # you can't insert into the child tables which references this. There
82
- # are two workarounds:
83
- # 1) This could be implemented if you didn't have an autoincrement pk
84
- # 2) You could do it by doing O(n) normal inserts into the parent
85
- # tables to get the primary keys back and then doing a single bulk
86
- # insert into the childmost table.
87
- # We currently set the primary keys on the objects when using
88
- # PostgreSQL via the RETURNING ID clause. It should be possible for
89
- # Oracle as well, but the semantics for extracting the primary keys is
90
- # trickier so it's not done yet.
91
- if batch_size is not None and batch_size <= 0:
92
- raise ValueError("Batch size must be a positive integer.")
93
-
94
- # Check for MTI - if we detect multi-table inheritance, we need special handling
95
- # This follows Django's approach: check that the parents share the same concrete model
96
- # with our model to detect the inheritance pattern ConcreteGrandParent ->
97
- # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
98
- # identify that case as involving multiple tables.
99
- is_mti = False
100
- for parent in model_cls._meta.all_parents:
101
- if parent._meta.concrete_model is not model_cls._meta.concrete_model:
102
- is_mti = True
103
- break
104
-
105
- if not objs:
106
- return objs
107
-
108
- if any(not isinstance(obj, model_cls) for obj in objs):
109
- raise TypeError(
110
- f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
111
- )
112
-
113
- # Fire hooks before DB ops
114
- if not bypass_hooks:
115
- ctx = HookContext(model_cls)
116
- if not bypass_validation:
117
- engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
118
- engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
119
-
120
- # For MTI models, we need to handle them specially
121
- if is_mti:
122
- # Use our MTI-specific logic
123
- # Filter out custom parameters that Django's bulk_create doesn't accept
124
- mti_kwargs = {
125
- 'batch_size': batch_size,
126
- 'ignore_conflicts': ignore_conflicts,
127
- 'update_conflicts': update_conflicts,
128
- 'update_fields': update_fields,
129
- 'unique_fields': unique_fields,
130
- }
131
- # Remove custom hook kwargs if present in self.bulk_create signature
132
- result = self._mti_bulk_create(
133
- objs,
134
- **{k: v for k, v in mti_kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
135
- )
136
- else:
137
- # For single-table models, use Django's built-in bulk_create
138
- # but we need to call it on the base manager to avoid recursion
139
- # Filter out custom parameters that Django's bulk_create doesn't accept
140
-
141
- # Use Django's original QuerySet to avoid recursive calls
142
- from django.db.models import QuerySet
143
- original_qs = QuerySet(model_cls, using=self.db)
144
- result = original_qs.bulk_create(
145
- objs,
146
- batch_size=batch_size,
147
- ignore_conflicts=ignore_conflicts,
148
- update_conflicts=update_conflicts,
149
- update_fields=update_fields,
150
- unique_fields=unique_fields,
151
- )
152
-
153
-
154
-
155
- return result
156
-
157
- @transaction.atomic
158
- def bulk_update(self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs):
159
- """
160
- Bulk update objects in the database.
161
- """
162
- import inspect
163
- print(f"DEBUG: QuerySet.bulk_update called with:")
164
- print(f" - self: {type(self)}")
165
- print(f" - objs: {type(objs)}")
166
- print(f" - fields: {fields}")
167
- print(f" - bypass_hooks: {bypass_hooks}")
168
- print(f" - bypass_validation: {bypass_validation}")
169
- print(f" - kwargs: {kwargs}")
170
- print(f"DEBUG: Method signature: {inspect.signature(self.bulk_update)}")
171
-
172
- model_cls = self.model
173
-
174
- if any(not isinstance(obj, model_cls) for obj in objs):
175
- raise TypeError(
176
- f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
177
- )
178
-
179
- if not bypass_hooks:
180
- # Load originals for hook comparison and ensure they match the order of new instances
181
- original_map = {
182
- obj.pk: obj
183
- for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
184
- }
185
- originals = [original_map.get(obj.pk) for obj in objs]
186
-
187
- ctx = HookContext(model_cls)
188
-
189
- # Run validation hooks first
190
- if not bypass_validation:
191
- engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
192
-
193
- # Then run business logic hooks
194
- engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
195
-
196
- # Automatically detect fields that were modified during BEFORE_UPDATE hooks
197
- modified_fields = self._detect_modified_fields(objs, originals)
198
- if modified_fields:
199
- # Convert to set for efficient union operation
200
- fields_set = set(fields)
201
- fields_set.update(modified_fields)
202
- fields = list(fields_set)
203
-
204
- for i in range(0, len(objs), self.CHUNK_SIZE):
205
- chunk = objs[i : i + self.CHUNK_SIZE]
206
-
207
- # Call the base implementation to avoid re-triggering this method
208
- # Filter out custom parameters that Django's bulk_update doesn't accept
209
- django_kwargs = {k: v for k, v in kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
210
- super().bulk_update(chunk, fields, **django_kwargs)
211
-
212
- if not bypass_hooks:
213
- engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
214
-
215
- return objs
216
-
217
- @transaction.atomic
218
- def bulk_delete(
219
- self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
220
- ):
221
- if not objs:
222
- return []
223
-
224
- model_cls = self.model
225
-
226
- if any(not isinstance(obj, model_cls) for obj in objs):
227
- raise TypeError(
228
- f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
229
- )
230
-
231
- ctx = HookContext(model_cls)
232
-
233
- if not bypass_hooks:
234
- # Run validation hooks first
235
- if not bypass_validation:
236
- engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
237
-
238
- # Then run business logic hooks
239
- engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
240
-
241
- pks = [obj.pk for obj in objs if obj.pk is not None]
242
-
243
- # Use base manager for the actual deletion to prevent recursion
244
- # The hooks have already been fired above, so we don't need them again
245
- model_cls._base_manager.filter(pk__in=pks).delete()
246
-
247
- if not bypass_hooks:
248
- engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
249
-
250
- return objs
251
-
252
- def _detect_modified_fields(self, new_instances, original_instances):
253
- """
254
- Detect fields that were modified during BEFORE_UPDATE hooks by comparing
255
- new instances with their original values.
256
- """
257
- if not original_instances:
258
- return set()
259
-
260
- modified_fields = set()
261
-
262
- # Since original_instances is now ordered to match new_instances, we can zip them directly
263
- for new_instance, original in zip(new_instances, original_instances):
264
- if new_instance.pk is None or original is None:
265
- continue
266
-
267
- # Compare all fields to detect changes
268
- for field in new_instance._meta.fields:
269
- if field.name == "id":
270
- continue
271
-
272
- new_value = getattr(new_instance, field.name)
273
- original_value = getattr(original, field.name)
274
-
275
- # Handle different field types appropriately
276
- if field.is_relation:
277
- # For foreign keys, compare the pk values
278
- new_pk = new_value.pk if new_value else None
279
- original_pk = original_value.pk if original_value else None
280
- if new_pk != original_pk:
281
- modified_fields.add(field.name)
282
- else:
283
- # For regular fields, use direct comparison
284
- if new_value != original_value:
285
- modified_fields.add(field.name)
286
-
287
- return modified_fields
288
-
289
- def _get_inheritance_chain(self):
290
- """
291
- Get the complete inheritance chain from root parent to current model.
292
- Returns list of model classes in order: [RootParent, Parent, Child]
293
- """
294
- chain = []
295
- current_model = self.model
296
- while current_model:
297
- if not current_model._meta.proxy:
298
- chain.append(current_model)
299
- parents = [
300
- parent
301
- for parent in current_model._meta.parents.keys()
302
- if not parent._meta.proxy
303
- ]
304
- current_model = parents[0] if parents else None
305
- chain.reverse()
306
- return chain
307
-
308
- def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
309
- """
310
- Implements Django's suggested workaround #2 for MTI bulk_create:
311
- O(n) normal inserts into parent tables to get primary keys back,
312
- then single bulk insert into childmost table.
313
- Sets auto_now_add/auto_now fields for each model in the chain.
314
- """
315
- # Remove custom hook kwargs before passing to Django internals
316
- django_kwargs = {k: v for k, v in kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
317
- if inheritance_chain is None:
318
- inheritance_chain = self._get_inheritance_chain()
319
-
320
- # Safety check to prevent infinite recursion
321
- if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
322
- raise ValueError(
323
- "Inheritance chain too deep - possible infinite recursion detected"
324
- )
325
-
326
- batch_size = django_kwargs.get("batch_size") or len(objs)
327
- created_objects = []
328
- with transaction.atomic(using=self.db, savepoint=False):
329
- for i in range(0, len(objs), batch_size):
330
- batch = objs[i : i + batch_size]
331
- batch_result = self._process_mti_batch(
332
- batch, inheritance_chain, **django_kwargs
333
- )
334
- created_objects.extend(batch_result)
335
- return created_objects
336
-
337
- def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
338
- """
339
- Process a single batch of objects through the inheritance chain.
340
- Implements Django's suggested workaround #2: O(n) normal inserts into parent
341
- tables to get primary keys back, then single bulk insert into childmost table.
342
- """
343
- # For MTI, we need to save parent objects first to get PKs
344
- # Then we can use Django's bulk_create for the child objects
345
- parent_objects_map = {}
346
-
347
- # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
348
- # Get bypass_hooks from kwargs
349
- bypass_hooks = kwargs.get('bypass_hooks', False)
350
- bypass_validation = kwargs.get('bypass_validation', False)
351
-
352
- for obj in batch:
353
- parent_instances = {}
354
- current_parent = None
355
- for model_class in inheritance_chain[:-1]:
356
- parent_obj = self._create_parent_instance(
357
- obj, model_class, current_parent
358
- )
359
-
360
- # Fire parent hooks if not bypassed
361
- if not bypass_hooks:
362
- ctx = HookContext(model_class)
363
- if not bypass_validation:
364
- engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
365
- engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
366
-
367
- # Use Django's base manager to create the object and get PKs back
368
- # This bypasses hooks and the MTI exception
369
- field_values = {
370
- field.name: getattr(parent_obj, field.name)
371
- for field in model_class._meta.local_fields
372
- if hasattr(parent_obj, field.name) and getattr(parent_obj, field.name) is not None
373
- }
374
- created_obj = model_class._base_manager.using(self.db).create(**field_values)
375
-
376
- # Update the parent_obj with the created object's PK
377
- parent_obj.pk = created_obj.pk
378
- parent_obj._state.adding = False
379
- parent_obj._state.db = self.db
380
-
381
- # Fire AFTER_CREATE hooks for parent
382
- if not bypass_hooks:
383
- engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
384
-
385
- parent_instances[model_class] = parent_obj
386
- current_parent = parent_obj
387
- parent_objects_map[id(obj)] = parent_instances
388
-
389
- # Step 2: Create all child objects and do single bulk insert into childmost table
390
- child_model = inheritance_chain[-1]
391
- all_child_objects = []
392
- for obj in batch:
393
- child_obj = self._create_child_instance(
394
- obj, child_model, parent_objects_map.get(id(obj), {})
395
- )
396
- all_child_objects.append(child_obj)
397
-
398
- # Step 2.5: Use Django's internal bulk_create infrastructure
399
- if all_child_objects:
400
- # Get the base manager's queryset
401
- base_qs = child_model._base_manager.using(self.db)
402
-
403
- # Use Django's exact approach: call _prepare_for_bulk_create then partition
404
- base_qs._prepare_for_bulk_create(all_child_objects)
405
-
406
- # Implement our own partition since itertools.partition might not be available
407
- objs_without_pk, objs_with_pk = [], []
408
- for obj in all_child_objects:
409
- if obj._is_pk_set():
410
- objs_with_pk.append(obj)
411
- else:
412
- objs_without_pk.append(obj)
413
-
414
- # Use Django's internal _batched_insert method
415
- opts = child_model._meta
416
- # For child models in MTI, we need to include the foreign key to the parent
417
- # but exclude the primary key since it's inherited
418
-
419
- # Include all local fields except generated ones
420
- # We need to include the foreign key to the parent (business_ptr)
421
- fields = [f for f in opts.local_fields if not f.generated]
422
-
423
- with transaction.atomic(using=self.db, savepoint=False):
424
- if objs_with_pk:
425
- returned_columns = base_qs._batched_insert(
426
- objs_with_pk,
427
- fields,
428
- batch_size=len(objs_with_pk), # Use actual batch size
429
- )
430
- for obj_with_pk, results in zip(objs_with_pk, returned_columns):
431
- for result, field in zip(results, opts.db_returning_fields):
432
- if field != opts.pk:
433
- setattr(obj_with_pk, field.attname, result)
434
- for obj_with_pk in objs_with_pk:
435
- obj_with_pk._state.adding = False
436
- obj_with_pk._state.db = self.db
437
-
438
- if objs_without_pk:
439
- # For objects without PK, we still need to exclude primary key fields
440
- fields = [f for f in fields if not isinstance(f, AutoField) and not f.primary_key]
441
- returned_columns = base_qs._batched_insert(
442
- objs_without_pk,
443
- fields,
444
- batch_size=len(objs_without_pk), # Use actual batch size
445
- )
446
- for obj_without_pk, results in zip(objs_without_pk, returned_columns):
447
- for result, field in zip(results, opts.db_returning_fields):
448
- setattr(obj_without_pk, field.attname, result)
449
- obj_without_pk._state.adding = False
450
- obj_without_pk._state.db = self.db
451
-
452
- # Step 3: Update original objects with generated PKs and state
453
- pk_field_name = child_model._meta.pk.name
454
- for orig_obj, child_obj in zip(batch, all_child_objects):
455
- child_pk = getattr(child_obj, pk_field_name)
456
- setattr(orig_obj, pk_field_name, child_pk)
457
- orig_obj._state.adding = False
458
- orig_obj._state.db = self.db
459
-
460
- return batch
461
-
462
- def _create_parent_instance(self, source_obj, parent_model, current_parent):
463
- parent_obj = parent_model()
464
- for field in parent_model._meta.local_fields:
465
- # Only copy if the field exists on the source and is not None
466
- if hasattr(source_obj, field.name):
467
- value = getattr(source_obj, field.name, None)
468
- if value is not None:
469
- setattr(parent_obj, field.name, value)
470
- if current_parent is not None:
471
- for field in parent_model._meta.local_fields:
472
- if (
473
- hasattr(field, "remote_field")
474
- and field.remote_field
475
- and field.remote_field.model == current_parent.__class__
476
- ):
477
- setattr(parent_obj, field.name, current_parent)
478
- break
479
-
480
- # Handle auto_now_add and auto_now fields like Django does
481
- for field in parent_model._meta.local_fields:
482
- if hasattr(field, "auto_now_add") and field.auto_now_add:
483
- # Ensure auto_now_add fields are properly set
484
- if getattr(parent_obj, field.name) is None:
485
- field.pre_save(parent_obj, add=True)
486
- # Explicitly set the value to ensure it's not None
487
- setattr(parent_obj, field.name, field.value_from_object(parent_obj))
488
- elif hasattr(field, "auto_now") and field.auto_now:
489
- field.pre_save(parent_obj, add=True)
490
-
491
- return parent_obj
492
-
493
- def _create_child_instance(self, source_obj, child_model, parent_instances):
494
- child_obj = child_model()
495
- # Only copy fields that exist in the child model's local fields
496
- for field in child_model._meta.local_fields:
497
- if isinstance(field, AutoField):
498
- continue
499
- if hasattr(source_obj, field.name):
500
- value = getattr(source_obj, field.name, None)
501
- if value is not None:
502
- setattr(child_obj, field.name, value)
503
-
504
- # Set parent links for MTI
505
- for parent_model, parent_instance in parent_instances.items():
506
- parent_link = child_model._meta.get_ancestor_link(parent_model)
507
- if parent_link:
508
- # Set both the foreign key value (the ID) and the object reference
509
- # This follows Django's pattern in _set_pk_val
510
- setattr(child_obj, parent_link.attname, parent_instance.pk) # Set the foreign key value
511
- setattr(child_obj, parent_link.name, parent_instance) # Set the object reference
512
-
513
- # Handle auto_now_add and auto_now fields like Django does
514
- for field in child_model._meta.local_fields:
515
- if hasattr(field, "auto_now_add") and field.auto_now_add:
516
- # Ensure auto_now_add fields are properly set
517
- if getattr(child_obj, field.name) is None:
518
- field.pre_save(child_obj, add=True)
519
- # Explicitly set the value to ensure it's not None
520
- setattr(child_obj, field.name, field.value_from_object(child_obj))
521
- elif hasattr(field, "auto_now") and field.auto_now:
522
- field.pre_save(child_obj, add=True)
523
-
524
- return child_obj
1
+ from django.db import models, transaction
2
+ from django.db.models import AutoField
3
+
4
+ from django_bulk_hooks import engine
5
+ from django_bulk_hooks.constants import (
6
+ AFTER_CREATE,
7
+ AFTER_DELETE,
8
+ AFTER_UPDATE,
9
+ BEFORE_CREATE,
10
+ BEFORE_DELETE,
11
+ BEFORE_UPDATE,
12
+ VALIDATE_CREATE,
13
+ VALIDATE_DELETE,
14
+ VALIDATE_UPDATE,
15
+ )
16
+ from django_bulk_hooks.context import HookContext
17
+
18
+
19
+ class HookQuerySet(models.QuerySet):
20
+ CHUNK_SIZE = 200
21
+
22
+ @transaction.atomic
23
+ def delete(self):
24
+ objs = list(self)
25
+ if not objs:
26
+ return 0
27
+ return self.model.objects.bulk_delete(objs)
28
+
29
+ @transaction.atomic
30
+ def update(self, **kwargs):
31
+ instances = list(self)
32
+ if not instances:
33
+ return 0
34
+
35
+ model_cls = self.model
36
+ pks = [obj.pk for obj in instances]
37
+
38
+ # Load originals for hook comparison and ensure they match the order of instances
39
+ original_map = {obj.pk: obj for obj in model_cls.objects.filter(pk__in=pks)}
40
+ originals = [original_map.get(obj.pk) for obj in instances]
41
+
42
+ # Apply field updates to instances
43
+ for obj in instances:
44
+ for field, value in kwargs.items():
45
+ setattr(obj, field, value)
46
+
47
+ # Run BEFORE_UPDATE hooks
48
+ ctx = HookContext(model_cls)
49
+ engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
50
+
51
+ # Use Django's built-in update logic directly
52
+ queryset = self.model.objects.filter(pk__in=pks)
53
+ update_count = queryset.update(**kwargs)
54
+
55
+ # Run AFTER_UPDATE hooks
56
+ engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
57
+
58
+ return update_count
59
+
60
+ @transaction.atomic
61
+ def bulk_create(
62
+ self,
63
+ objs,
64
+ batch_size=None,
65
+ ignore_conflicts=False,
66
+ update_conflicts=False,
67
+ update_fields=None,
68
+ unique_fields=None,
69
+ bypass_hooks=False,
70
+ bypass_validation=False,
71
+ ):
72
+ """
73
+ Insert each of the instances into the database. Behaves like Django's bulk_create,
74
+ but supports multi-table inheritance (MTI) models and hooks. All arguments are supported and
75
+ passed through to the correct logic. For MTI, only a subset of options may be supported.
76
+ """
77
+ model_cls = self.model
78
+
79
+ # When you bulk insert you don't get the primary keys back (if it's an
80
+ # autoincrement, except if can_return_rows_from_bulk_insert=True), so
81
+ # you can't insert into the child tables which references this. There
82
+ # are two workarounds:
83
+ # 1) This could be implemented if you didn't have an autoincrement pk
84
+ # 2) You could do it by doing O(n) normal inserts into the parent
85
+ # tables to get the primary keys back and then doing a single bulk
86
+ # insert into the childmost table.
87
+ # We currently set the primary keys on the objects when using
88
+ # PostgreSQL via the RETURNING ID clause. It should be possible for
89
+ # Oracle as well, but the semantics for extracting the primary keys is
90
+ # trickier so it's not done yet.
91
+ if batch_size is not None and batch_size <= 0:
92
+ raise ValueError("Batch size must be a positive integer.")
93
+
94
+ # Check for MTI - if we detect multi-table inheritance, we need special handling
95
+ # This follows Django's approach: check that the parents share the same concrete model
96
+ # with our model to detect the inheritance pattern ConcreteGrandParent ->
97
+ # MultiTableParent -> ProxyChild. Simply checking self.model._meta.proxy would not
98
+ # identify that case as involving multiple tables.
99
+ is_mti = False
100
+ for parent in model_cls._meta.all_parents:
101
+ if parent._meta.concrete_model is not model_cls._meta.concrete_model:
102
+ is_mti = True
103
+ break
104
+
105
+ if not objs:
106
+ return objs
107
+
108
+ if any(not isinstance(obj, model_cls) for obj in objs):
109
+ raise TypeError(
110
+ f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
111
+ )
112
+
113
+ # Fire hooks before DB ops
114
+ if not bypass_hooks:
115
+ print(f"DEBUG: Firing BEFORE_CREATE hooks for {model_cls}")
116
+ ctx = HookContext(model_cls)
117
+ if not bypass_validation:
118
+ engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
119
+ engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
120
+ else:
121
+ print(f"DEBUG: Skipping hooks due to bypass_hooks=True for {model_cls}")
122
+
123
+ # For MTI models, we need to handle them specially
124
+ if is_mti:
125
+ # Use our MTI-specific logic
126
+ # Filter out custom parameters that Django's bulk_create doesn't accept
127
+ mti_kwargs = {
128
+ 'batch_size': batch_size,
129
+ 'ignore_conflicts': ignore_conflicts,
130
+ 'update_conflicts': update_conflicts,
131
+ 'update_fields': update_fields,
132
+ 'unique_fields': unique_fields,
133
+ }
134
+ # Remove custom hook kwargs if present in self.bulk_create signature
135
+ result = self._mti_bulk_create(
136
+ objs,
137
+ **{k: v for k, v in mti_kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
138
+ )
139
+ else:
140
+ # For single-table models, use Django's built-in bulk_create
141
+ # but we need to call it on the base manager to avoid recursion
142
+ # Filter out custom parameters that Django's bulk_create doesn't accept
143
+
144
+ # Use Django's original QuerySet to avoid recursive calls
145
+ from django.db.models import QuerySet
146
+ original_qs = QuerySet(model_cls, using=self.db)
147
+ result = original_qs.bulk_create(
148
+ objs,
149
+ batch_size=batch_size,
150
+ ignore_conflicts=ignore_conflicts,
151
+ update_conflicts=update_conflicts,
152
+ update_fields=update_fields,
153
+ unique_fields=unique_fields,
154
+ )
155
+
156
+ # Fire AFTER_CREATE hooks
157
+ if not bypass_hooks:
158
+ print(f"DEBUG: Firing AFTER_CREATE hooks for {model_cls}")
159
+ engine.run(model_cls, AFTER_CREATE, objs, ctx=ctx)
160
+ else:
161
+ print(f"DEBUG: Skipping AFTER_CREATE hooks due to bypass_hooks=True for {model_cls}")
162
+
163
+ return result
164
+
165
+ @transaction.atomic
166
+ def bulk_update(self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs):
167
+ """
168
+ Bulk update objects in the database.
169
+ """
170
+ import inspect
171
+ print(f"DEBUG: QuerySet.bulk_update called with:")
172
+ print(f" - self: {type(self)}")
173
+ print(f" - objs: {type(objs)}")
174
+ print(f" - fields: {fields}")
175
+ print(f" - bypass_hooks: {bypass_hooks}")
176
+ print(f" - bypass_validation: {bypass_validation}")
177
+ print(f" - kwargs: {kwargs}")
178
+ print(f"DEBUG: Method signature: {inspect.signature(self.bulk_update)}")
179
+
180
+ model_cls = self.model
181
+ print(f"DEBUG: Model class: {model_cls}")
182
+ print(f"DEBUG: bypass_hooks value: {bypass_hooks}")
183
+
184
+ if not objs:
185
+ return []
186
+
187
+ if any(not isinstance(obj, model_cls) for obj in objs):
188
+ raise TypeError(
189
+ f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
190
+ )
191
+
192
+ if not bypass_hooks:
193
+ # Load originals for hook comparison and ensure they match the order of new instances
194
+ original_map = {
195
+ obj.pk: obj
196
+ for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
197
+ }
198
+ originals = [original_map.get(obj.pk) for obj in objs]
199
+
200
+ ctx = HookContext(model_cls)
201
+
202
+ # Run validation hooks first
203
+ if not bypass_validation:
204
+ engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
205
+
206
+ # Then run business logic hooks
207
+ engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
208
+
209
+ # Automatically detect fields that were modified during BEFORE_UPDATE hooks
210
+ modified_fields = self._detect_modified_fields(objs, originals)
211
+ if modified_fields:
212
+ # Convert to set for efficient union operation
213
+ fields_set = set(fields)
214
+ fields_set.update(modified_fields)
215
+ fields = list(fields_set)
216
+
217
+ for i in range(0, len(objs), self.CHUNK_SIZE):
218
+ chunk = objs[i : i + self.CHUNK_SIZE]
219
+
220
+ # Call the base implementation to avoid re-triggering this method
221
+ # Filter out custom parameters that Django's bulk_update doesn't accept
222
+ django_kwargs = {k: v for k, v in kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
223
+ super().bulk_update(chunk, fields, **django_kwargs)
224
+
225
+ if not bypass_hooks:
226
+ print(f"DEBUG: Firing AFTER_UPDATE hooks for {model_cls}")
227
+ engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
228
+ else:
229
+ print(f"DEBUG: Skipping AFTER_UPDATE hooks due to bypass_hooks=True for {model_cls}")
230
+
231
+ return objs
232
+
233
+ @transaction.atomic
234
+ def bulk_delete(
235
+ self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
236
+ ):
237
+ if not objs:
238
+ return []
239
+
240
+ model_cls = self.model
241
+
242
+ if any(not isinstance(obj, model_cls) for obj in objs):
243
+ raise TypeError(
244
+ f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
245
+ )
246
+
247
+ ctx = HookContext(model_cls)
248
+
249
+ if not bypass_hooks:
250
+ # Run validation hooks first
251
+ if not bypass_validation:
252
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
253
+
254
+ # Then run business logic hooks
255
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
256
+
257
+ pks = [obj.pk for obj in objs if obj.pk is not None]
258
+
259
+ # Use base manager for the actual deletion to prevent recursion
260
+ # The hooks have already been fired above, so we don't need them again
261
+ model_cls._base_manager.filter(pk__in=pks).delete()
262
+
263
+ if not bypass_hooks:
264
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
265
+
266
+ return objs
267
+
268
+ def _detect_modified_fields(self, new_instances, original_instances):
269
+ """
270
+ Detect fields that were modified during BEFORE_UPDATE hooks by comparing
271
+ new instances with their original values.
272
+ """
273
+ if not original_instances:
274
+ return set()
275
+
276
+ modified_fields = set()
277
+
278
+ # Since original_instances is now ordered to match new_instances, we can zip them directly
279
+ for new_instance, original in zip(new_instances, original_instances):
280
+ if new_instance.pk is None or original is None:
281
+ continue
282
+
283
+ # Compare all fields to detect changes
284
+ for field in new_instance._meta.fields:
285
+ if field.name == "id":
286
+ continue
287
+
288
+ new_value = getattr(new_instance, field.name)
289
+ original_value = getattr(original, field.name)
290
+
291
+ # Handle different field types appropriately
292
+ if field.is_relation:
293
+ # For foreign keys, compare the pk values
294
+ new_pk = new_value.pk if new_value else None
295
+ original_pk = original_value.pk if original_value else None
296
+ if new_pk != original_pk:
297
+ modified_fields.add(field.name)
298
+ else:
299
+ # For regular fields, use direct comparison
300
+ if new_value != original_value:
301
+ modified_fields.add(field.name)
302
+
303
+ return modified_fields
304
+
305
+ def _get_inheritance_chain(self):
306
+ """
307
+ Get the complete inheritance chain from root parent to current model.
308
+ Returns list of model classes in order: [RootParent, Parent, Child]
309
+ """
310
+ chain = []
311
+ current_model = self.model
312
+ while current_model:
313
+ if not current_model._meta.proxy:
314
+ chain.append(current_model)
315
+ parents = [
316
+ parent
317
+ for parent in current_model._meta.parents.keys()
318
+ if not parent._meta.proxy
319
+ ]
320
+ current_model = parents[0] if parents else None
321
+ chain.reverse()
322
+ return chain
323
+
324
+ def _mti_bulk_create(self, objs, inheritance_chain=None, **kwargs):
325
+ """
326
+ Implements Django's suggested workaround #2 for MTI bulk_create:
327
+ O(n) normal inserts into parent tables to get primary keys back,
328
+ then single bulk insert into childmost table.
329
+ Sets auto_now_add/auto_now fields for each model in the chain.
330
+ """
331
+ # Remove custom hook kwargs before passing to Django internals
332
+ django_kwargs = {k: v for k, v in kwargs.items() if k not in ['bypass_hooks', 'bypass_validation']}
333
+ if inheritance_chain is None:
334
+ inheritance_chain = self._get_inheritance_chain()
335
+
336
+ # Safety check to prevent infinite recursion
337
+ if len(inheritance_chain) > 10: # Arbitrary limit to prevent infinite loops
338
+ raise ValueError(
339
+ "Inheritance chain too deep - possible infinite recursion detected"
340
+ )
341
+
342
+ batch_size = django_kwargs.get("batch_size") or len(objs)
343
+ created_objects = []
344
+ with transaction.atomic(using=self.db, savepoint=False):
345
+ for i in range(0, len(objs), batch_size):
346
+ batch = objs[i : i + batch_size]
347
+ batch_result = self._process_mti_batch(
348
+ batch, inheritance_chain, **django_kwargs
349
+ )
350
+ created_objects.extend(batch_result)
351
+ return created_objects
352
+
353
+ def _process_mti_batch(self, batch, inheritance_chain, **kwargs):
354
+ """
355
+ Process a single batch of objects through the inheritance chain.
356
+ Implements Django's suggested workaround #2: O(n) normal inserts into parent
357
+ tables to get primary keys back, then single bulk insert into childmost table.
358
+ """
359
+ # For MTI, we need to save parent objects first to get PKs
360
+ # Then we can use Django's bulk_create for the child objects
361
+ parent_objects_map = {}
362
+
363
+ # Step 1: Do O(n) normal inserts into parent tables to get primary keys back
364
+ # Get bypass_hooks from kwargs
365
+ bypass_hooks = kwargs.get('bypass_hooks', False)
366
+ bypass_validation = kwargs.get('bypass_validation', False)
367
+
368
+ for obj in batch:
369
+ parent_instances = {}
370
+ current_parent = None
371
+ for model_class in inheritance_chain[:-1]:
372
+ parent_obj = self._create_parent_instance(
373
+ obj, model_class, current_parent
374
+ )
375
+
376
+ # Fire parent hooks if not bypassed
377
+ if not bypass_hooks:
378
+ ctx = HookContext(model_class)
379
+ if not bypass_validation:
380
+ engine.run(model_class, VALIDATE_CREATE, [parent_obj], ctx=ctx)
381
+ engine.run(model_class, BEFORE_CREATE, [parent_obj], ctx=ctx)
382
+
383
+ # Use Django's base manager to create the object and get PKs back
384
+ # This bypasses hooks and the MTI exception
385
+ field_values = {
386
+ field.name: getattr(parent_obj, field.name)
387
+ for field in model_class._meta.local_fields
388
+ if hasattr(parent_obj, field.name) and getattr(parent_obj, field.name) is not None
389
+ }
390
+ created_obj = model_class._base_manager.using(self.db).create(**field_values)
391
+
392
+ # Update the parent_obj with the created object's PK
393
+ parent_obj.pk = created_obj.pk
394
+ parent_obj._state.adding = False
395
+ parent_obj._state.db = self.db
396
+
397
+ # Fire AFTER_CREATE hooks for parent
398
+ if not bypass_hooks:
399
+ engine.run(model_class, AFTER_CREATE, [parent_obj], ctx=ctx)
400
+
401
+ parent_instances[model_class] = parent_obj
402
+ current_parent = parent_obj
403
+ parent_objects_map[id(obj)] = parent_instances
404
+
405
+ # Step 2: Create all child objects and do single bulk insert into childmost table
406
+ child_model = inheritance_chain[-1]
407
+ all_child_objects = []
408
+ for obj in batch:
409
+ child_obj = self._create_child_instance(
410
+ obj, child_model, parent_objects_map.get(id(obj), {})
411
+ )
412
+ all_child_objects.append(child_obj)
413
+
414
+ # Step 2.5: Use Django's internal bulk_create infrastructure
415
+ if all_child_objects:
416
+ # Get the base manager's queryset
417
+ base_qs = child_model._base_manager.using(self.db)
418
+
419
+ # Use Django's exact approach: call _prepare_for_bulk_create then partition
420
+ base_qs._prepare_for_bulk_create(all_child_objects)
421
+
422
+ # Implement our own partition since itertools.partition might not be available
423
+ objs_without_pk, objs_with_pk = [], []
424
+ for obj in all_child_objects:
425
+ if obj._is_pk_set():
426
+ objs_with_pk.append(obj)
427
+ else:
428
+ objs_without_pk.append(obj)
429
+
430
+ # Use Django's internal _batched_insert method
431
+ opts = child_model._meta
432
+ # For child models in MTI, we need to include the foreign key to the parent
433
+ # but exclude the primary key since it's inherited
434
+
435
+ # Include all local fields except generated ones
436
+ # We need to include the foreign key to the parent (business_ptr)
437
+ fields = [f for f in opts.local_fields if not f.generated]
438
+
439
+ with transaction.atomic(using=self.db, savepoint=False):
440
+ if objs_with_pk:
441
+ returned_columns = base_qs._batched_insert(
442
+ objs_with_pk,
443
+ fields,
444
+ batch_size=len(objs_with_pk), # Use actual batch size
445
+ )
446
+ for obj_with_pk, results in zip(objs_with_pk, returned_columns):
447
+ for result, field in zip(results, opts.db_returning_fields):
448
+ if field != opts.pk:
449
+ setattr(obj_with_pk, field.attname, result)
450
+ for obj_with_pk in objs_with_pk:
451
+ obj_with_pk._state.adding = False
452
+ obj_with_pk._state.db = self.db
453
+
454
+ if objs_without_pk:
455
+ # For objects without PK, we still need to exclude primary key fields
456
+ fields = [f for f in fields if not isinstance(f, AutoField) and not f.primary_key]
457
+ returned_columns = base_qs._batched_insert(
458
+ objs_without_pk,
459
+ fields,
460
+ batch_size=len(objs_without_pk), # Use actual batch size
461
+ )
462
+ for obj_without_pk, results in zip(objs_without_pk, returned_columns):
463
+ for result, field in zip(results, opts.db_returning_fields):
464
+ setattr(obj_without_pk, field.attname, result)
465
+ obj_without_pk._state.adding = False
466
+ obj_without_pk._state.db = self.db
467
+
468
+ # Step 3: Update original objects with generated PKs and state
469
+ pk_field_name = child_model._meta.pk.name
470
+ for orig_obj, child_obj in zip(batch, all_child_objects):
471
+ child_pk = getattr(child_obj, pk_field_name)
472
+ setattr(orig_obj, pk_field_name, child_pk)
473
+ orig_obj._state.adding = False
474
+ orig_obj._state.db = self.db
475
+
476
+ return batch
477
+
478
+ def _create_parent_instance(self, source_obj, parent_model, current_parent):
479
+ parent_obj = parent_model()
480
+ for field in parent_model._meta.local_fields:
481
+ # Only copy if the field exists on the source and is not None
482
+ if hasattr(source_obj, field.name):
483
+ value = getattr(source_obj, field.name, None)
484
+ if value is not None:
485
+ setattr(parent_obj, field.name, value)
486
+ if current_parent is not None:
487
+ for field in parent_model._meta.local_fields:
488
+ if (
489
+ hasattr(field, "remote_field")
490
+ and field.remote_field
491
+ and field.remote_field.model == current_parent.__class__
492
+ ):
493
+ setattr(parent_obj, field.name, current_parent)
494
+ break
495
+
496
+ # Handle auto_now_add and auto_now fields like Django does
497
+ for field in parent_model._meta.local_fields:
498
+ if hasattr(field, "auto_now_add") and field.auto_now_add:
499
+ # Ensure auto_now_add fields are properly set
500
+ if getattr(parent_obj, field.name) is None:
501
+ field.pre_save(parent_obj, add=True)
502
+ # Explicitly set the value to ensure it's not None
503
+ setattr(parent_obj, field.name, field.value_from_object(parent_obj))
504
+ elif hasattr(field, "auto_now") and field.auto_now:
505
+ field.pre_save(parent_obj, add=True)
506
+
507
+ return parent_obj
508
+
509
+ def _create_child_instance(self, source_obj, child_model, parent_instances):
510
+ child_obj = child_model()
511
+ # Only copy fields that exist in the child model's local fields
512
+ for field in child_model._meta.local_fields:
513
+ if isinstance(field, AutoField):
514
+ continue
515
+ if hasattr(source_obj, field.name):
516
+ value = getattr(source_obj, field.name, None)
517
+ if value is not None:
518
+ setattr(child_obj, field.name, value)
519
+
520
+ # Set parent links for MTI
521
+ for parent_model, parent_instance in parent_instances.items():
522
+ parent_link = child_model._meta.get_ancestor_link(parent_model)
523
+ if parent_link:
524
+ # Set both the foreign key value (the ID) and the object reference
525
+ # This follows Django's pattern in _set_pk_val
526
+ setattr(child_obj, parent_link.attname, parent_instance.pk) # Set the foreign key value
527
+ setattr(child_obj, parent_link.name, parent_instance) # Set the object reference
528
+
529
+ # Handle auto_now_add and auto_now fields like Django does
530
+ for field in child_model._meta.local_fields:
531
+ if hasattr(field, "auto_now_add") and field.auto_now_add:
532
+ # Ensure auto_now_add fields are properly set
533
+ if getattr(child_obj, field.name) is None:
534
+ field.pre_save(child_obj, add=True)
535
+ # Explicitly set the value to ensure it's not None
536
+ setattr(child_obj, field.name, field.value_from_object(child_obj))
537
+ elif hasattr(field, "auto_now") and field.auto_now:
538
+ field.pre_save(child_obj, add=True)
539
+
540
+ return child_obj
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.162
3
+ Version: 0.1.164
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
@@ -6,12 +6,12 @@ django_bulk_hooks/decorators.py,sha256=tckDcxtOzKCbgvS9QydgeIAWTFDEl-ch3_Q--ruEG
6
6
  django_bulk_hooks/engine.py,sha256=3HbgV12JRYIy9IlygHPxZiHnFXj7EwzLyTuJNQeVIoI,1402
7
7
  django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
8
  django_bulk_hooks/handler.py,sha256=xZt8iNdYF-ACz-MnKMY0co6scWINU5V5wC1lyDn844k,4854
9
- django_bulk_hooks/manager.py,sha256=7wQv2V1SgfjshFSqwDdIuWU3NrRKQ4y-3mpV47JTZXc,5035
9
+ django_bulk_hooks/manager.py,sha256=ipOr2PMGycW0LpWsZ0asNRTPKVNpp3D081ibuY__cj0,5551
10
10
  django_bulk_hooks/models.py,sha256=5PBjBoGlHwAE5b4yaZ5kDjt5UtHfQp6pDrwB2XPs2tk,3850
11
11
  django_bulk_hooks/priority.py,sha256=HG_2D35nga68lBCZmSXTcplXrjFoRgZFRDOy4ROKonY,376
12
- django_bulk_hooks/queryset.py,sha256=PlT0ygU8Hrb6w1XESxEuAnKgmUuq-K_bFgHg3LBSogA,23274
12
+ django_bulk_hooks/queryset.py,sha256=qMMr_IgOrWqV5dYoEqOSKjJcxgepcovdjAn77QL5bs8,23574
13
13
  django_bulk_hooks/registry.py,sha256=-mQBizJ06nz_tajZBinViKx_uP2Tbc1tIpTEMv7lwKA,705
14
- django_bulk_hooks-0.1.162.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
- django_bulk_hooks-0.1.162.dist-info/METADATA,sha256=XzFCAvWbGpuNeG5yCe1YSQHqKtY77r_gnE5G8oZPhQY,6951
16
- django_bulk_hooks-0.1.162.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
- django_bulk_hooks-0.1.162.dist-info/RECORD,,
14
+ django_bulk_hooks-0.1.164.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
15
+ django_bulk_hooks-0.1.164.dist-info/METADATA,sha256=ctAhRwnzonbXKWblwj0eU6agetmVEhVcmvj2TqtQls0,6951
16
+ django_bulk_hooks-0.1.164.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
17
+ django_bulk_hooks-0.1.164.dist-info/RECORD,,