django-bulk-hooks 0.1.98__py3-none-any.whl → 0.1.101__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,11 +24,11 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
24
24
 
25
25
  def select_related(*related_fields):
26
26
  """
27
- Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
27
+ Decorator that marks a hook method to preload related fields.
28
28
 
29
- This decorator provides bulk loading for performance when you explicitly need it.
30
- If you don't use this decorator, the framework will automatically detect and load
31
- foreign keys only when conditions need them, preserving standard Django behavior.
29
+ This decorator works in conjunction with the hook system to ensure that
30
+ related fields are bulk-loaded before the hook logic runs, preventing
31
+ queries in loops.
32
32
 
33
33
  - Works with instance methods (resolves `self`)
34
34
  - Avoids replacing model instances
@@ -37,114 +37,9 @@ def select_related(*related_fields):
37
37
  """
38
38
 
39
39
  def decorator(func):
40
- sig = inspect.signature(func)
41
-
42
- @wraps(func)
43
- def wrapper(*args, **kwargs):
44
- bound = sig.bind_partial(*args, **kwargs)
45
- bound.apply_defaults()
46
-
47
- if "new_records" not in bound.arguments:
48
- raise TypeError(
49
- "@select_related requires a 'new_records' argument in the decorated function"
50
- )
51
-
52
- new_records = bound.arguments["new_records"]
53
-
54
- if not isinstance(new_records, list):
55
- raise TypeError(
56
- f"@select_related expects a list of model instances, got {type(new_records)}"
57
- )
58
-
59
- if not new_records:
60
- return func(*args, **kwargs)
61
-
62
- # Determine which instances actually need preloading
63
- model_cls = new_records[0].__class__
64
- ids_to_fetch = []
65
- instances_without_pk = []
66
-
67
- for obj in new_records:
68
- if obj.pk is None:
69
- # For objects without PKs (BEFORE_CREATE), check if foreign key fields are already set
70
- instances_without_pk.append(obj)
71
- continue
72
-
73
- # if any related field is not already cached on the instance,
74
- # mark it for fetching
75
- if any(field not in obj._state.fields_cache for field in related_fields):
76
- ids_to_fetch.append(obj.pk)
77
-
78
- # Load foreign keys for objects with PKs
79
- fetched = {}
80
- if ids_to_fetch:
81
- fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids_to_fetch)
82
-
83
- # Apply loaded foreign keys to objects with PKs
84
- for obj in new_records:
85
- if obj.pk is None:
86
- continue
87
-
88
- preloaded = fetched.get(obj.pk)
89
- if not preloaded:
90
- continue
91
- for field in related_fields:
92
- if field in obj._state.fields_cache:
93
- # don't override values that were explicitly set or already loaded
94
- continue
95
- if "." in field:
96
- raise ValueError(
97
- f"@select_related does not support nested fields like '{field}'"
98
- )
99
-
100
- try:
101
- f = model_cls._meta.get_field(field)
102
- if not (
103
- f.is_relation and not f.many_to_many and not f.one_to_many
104
- ):
105
- continue
106
- except FieldDoesNotExist:
107
- continue
108
-
109
- try:
110
- rel_obj = getattr(preloaded, field)
111
- setattr(obj, field, rel_obj)
112
- obj._state.fields_cache[field] = rel_obj
113
- except AttributeError:
114
- pass
115
-
116
- # For objects without PKs, ensure foreign key fields are properly set in the cache
117
- # This prevents RelatedObjectDoesNotExist when accessing foreign keys
118
- for obj in instances_without_pk:
119
- for field in related_fields:
120
- if "." in field:
121
- raise ValueError(
122
- f"@select_related does not support nested fields like '{field}'"
123
- )
124
-
125
- try:
126
- f = model_cls._meta.get_field(field)
127
- if not (
128
- f.is_relation and not f.many_to_many and not f.one_to_many
129
- ):
130
- continue
131
- except FieldDoesNotExist:
132
- continue
133
-
134
- # Check if the foreign key field is set
135
- fk_field_name = f"{field}_id"
136
- if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
137
- # The foreign key ID is set, so we can try to get the related object safely
138
- rel_obj = safe_get_related_object(obj, field)
139
- if rel_obj is not None:
140
- # Ensure it's cached to prevent future queries
141
- if not hasattr(obj._state, 'fields_cache'):
142
- obj._state.fields_cache = {}
143
- obj._state.fields_cache[field] = rel_obj
144
-
145
- return func(*bound.args, **bound.kwargs)
146
-
147
- return wrapper
40
+ # Store the related fields on the function for later access
41
+ func._select_related_fields = related_fields
42
+ return func
148
43
 
149
44
  return decorator
150
45
 
@@ -42,7 +42,7 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
42
42
  original_instances = [None] * len(new_instances)
43
43
 
44
44
  # Process all hooks
45
- for handler_cls, method_name, condition, priority in hooks:
45
+ for handler_cls, method_name, condition, priority, select_related_fields in hooks:
46
46
  # Get or create handler instance from cache
47
47
  handler_key = (handler_cls, method_name)
48
48
  if handler_key not in _handler_cache:
@@ -52,13 +52,19 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
52
52
  else:
53
53
  handler_instance, func = _handler_cache[handler_key]
54
54
 
55
+ # Apply select_related if specified
56
+ if select_related_fields:
57
+ new_instances_with_related = _apply_select_related(new_instances, select_related_fields)
58
+ else:
59
+ new_instances_with_related = new_instances
60
+
55
61
  # Filter instances based on condition
56
62
  if condition:
57
63
  to_process_new = []
58
64
  to_process_old = []
59
65
 
60
66
  logger.debug(f"Checking condition {condition.__class__.__name__} for {len(new_instances)} instances")
61
- for new, original in zip(new_instances, original_instances, strict=True):
67
+ for new, original in zip(new_instances_with_related, original_instances, strict=True):
62
68
  logger.debug(f"Checking instance {new.__class__.__name__}(pk={new.pk})")
63
69
  try:
64
70
  matches = condition.check(new, original)
@@ -79,4 +85,54 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
79
85
  else:
80
86
  # No condition, process all instances
81
87
  logger.debug("No condition, processing all instances")
82
- func(new_records=new_instances, old_records=original_instances if any(original_instances) else None)
88
+ func(new_records=new_instances_with_related, old_records=original_instances if any(original_instances) else None)
89
+
90
+
91
+ def _apply_select_related(instances, related_fields):
92
+ """
93
+ Apply select_related to instances to prevent queries in loops.
94
+ This function bulk loads related objects and caches them on the instances.
95
+ """
96
+ if not instances:
97
+ return instances
98
+
99
+ # Separate instances with and without PKs
100
+ instances_with_pk = [obj for obj in instances if obj.pk is not None]
101
+ instances_without_pk = [obj for obj in instances if obj.pk is None]
102
+
103
+ # Bulk load related objects for instances with PKs
104
+ if instances_with_pk:
105
+ model_cls = instances_with_pk[0].__class__
106
+ pks = [obj.pk for obj in instances_with_pk]
107
+
108
+ # Bulk fetch with select_related
109
+ fetched_instances = model_cls.objects.select_related(*related_fields).in_bulk(pks)
110
+
111
+ # Apply cached related objects to original instances
112
+ for obj in instances_with_pk:
113
+ fetched_obj = fetched_instances.get(obj.pk)
114
+ if fetched_obj:
115
+ for field in related_fields:
116
+ if field not in obj._state.fields_cache:
117
+ try:
118
+ rel_obj = getattr(fetched_obj, field)
119
+ setattr(obj, field, rel_obj)
120
+ obj._state.fields_cache[field] = rel_obj
121
+ except AttributeError:
122
+ pass
123
+
124
+ # Handle instances without PKs (e.g., BEFORE_CREATE)
125
+ for obj in instances_without_pk:
126
+ for field in related_fields:
127
+ # Check if the foreign key field is set
128
+ fk_field_name = f"{field}_id"
129
+ if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
130
+ # The foreign key ID is set, so we can try to get the related object safely
131
+ rel_obj = safe_get_related_object(obj, field)
132
+ if rel_obj is not None:
133
+ # Ensure it's cached to prevent future queries
134
+ if not hasattr(obj._state, 'fields_cache'):
135
+ obj._state.fields_cache = {}
136
+ obj._state.fields_cache[field] = rel_obj
137
+
138
+ return instances
@@ -75,6 +75,11 @@ class HookMeta(type):
75
75
  for model_cls, event, condition, priority in method.hooks_hooks:
76
76
  key = (model_cls, event, cls, method_name)
77
77
  if key not in HookMeta._registered:
78
+ # Check if the method has been decorated with select_related
79
+ select_related_fields = getattr(
80
+ method, "_select_related_fields", None
81
+ )
82
+
78
83
  register_hook(
79
84
  model=model_cls,
80
85
  event=event,
@@ -82,6 +87,7 @@ class HookMeta(type):
82
87
  method_name=method_name,
83
88
  condition=condition,
84
89
  priority=priority,
90
+ select_related_fields=select_related_fields,
85
91
  )
86
92
  HookMeta._registered.add(key)
87
93
  return cls
@@ -132,10 +138,17 @@ class HookHandler(metaclass=HookMeta):
132
138
  if len(old_local) < len(new_local):
133
139
  old_local += [None] * (len(new_local) - len(old_local))
134
140
 
135
- for handler_cls, method_name, condition, priority in hooks:
141
+ for handler_cls, method_name, condition, priority, select_related_fields in hooks:
142
+ # Apply select_related if specified to prevent queries in loops
143
+ if select_related_fields:
144
+ from django_bulk_hooks.engine import _apply_select_related
145
+ new_local_with_related = _apply_select_related(new_local, select_related_fields)
146
+ else:
147
+ new_local_with_related = new_local
148
+
136
149
  if condition is not None:
137
150
  checks = [
138
- condition.check(n, o) for n, o in zip(new_local, old_local)
151
+ condition.check(n, o) for n, o in zip(new_local_with_related, old_local)
139
152
  ]
140
153
  if not any(checks):
141
154
  continue
@@ -157,7 +170,7 @@ class HookHandler(metaclass=HookMeta):
157
170
  try:
158
171
  method(
159
172
  old_records=old_local,
160
- new_records=new_local,
173
+ new_records=new_local_with_related,
161
174
  **kwargs,
162
175
  )
163
176
  except Exception:
@@ -1,4 +1,8 @@
1
+ import contextlib
2
+ from functools import wraps
3
+
1
4
  from django.db import models, transaction
5
+ from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
2
6
 
3
7
  from django_bulk_hooks.constants import (
4
8
  AFTER_CREATE,
@@ -14,9 +18,6 @@ from django_bulk_hooks.constants import (
14
18
  from django_bulk_hooks.context import HookContext
15
19
  from django_bulk_hooks.engine import run
16
20
  from django_bulk_hooks.manager import BulkHookManager
17
- from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
18
- from functools import wraps
19
- import contextlib
20
21
 
21
22
 
22
23
  @contextlib.contextmanager
@@ -26,7 +27,7 @@ def patch_foreign_key_behavior():
26
27
  RelatedObjectDoesNotExist when accessing an unset foreign key field.
27
28
  """
28
29
  original_get = ForwardManyToOneDescriptor.__get__
29
-
30
+
30
31
  @wraps(original_get)
31
32
  def safe_get(self, instance, cls=None):
32
33
  if instance is None:
@@ -35,7 +36,7 @@ def patch_foreign_key_behavior():
35
36
  return original_get(self, instance, cls)
36
37
  except self.RelatedObjectDoesNotExist:
37
38
  return None
38
-
39
+
39
40
  # Patch the descriptor
40
41
  ForwardManyToOneDescriptor.__get__ = safe_get
41
42
  try:
@@ -63,7 +64,7 @@ class HookModelMixin(models.Model):
63
64
  # Skip hook validation during admin form validation
64
65
  # This prevents RelatedObjectDoesNotExist errors when Django hasn't
65
66
  # fully set up the object's relationships yet
66
- if hasattr(self, '_state') and getattr(self._state, 'validating', False):
67
+ if hasattr(self, "_state") and getattr(self._state, "validating", False):
67
68
  return
68
69
 
69
70
  # Determine if this is a create or update operation
@@ -80,7 +81,9 @@ class HookModelMixin(models.Model):
80
81
  old_instance = self.__class__.objects.get(pk=self.pk)
81
82
  ctx = HookContext(self.__class__)
82
83
  with patch_foreign_key_behavior():
83
- run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
84
+ run(
85
+ self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
86
+ )
84
87
  except self.__class__.DoesNotExist:
85
88
  # If the old instance doesn't exist, treat as create
86
89
  ctx = HookContext(self.__class__)
@@ -91,27 +94,40 @@ class HookModelMixin(models.Model):
91
94
  is_create = self.pk is None
92
95
  ctx = HookContext(self.__class__)
93
96
 
94
- # Let Django save first to handle form validation
95
- super().save(*args, **kwargs)
96
-
97
- # Then run our hooks with the validated data
97
+ # Run BEFORE hooks before saving to allow field modifications
98
98
  with patch_foreign_key_behavior():
99
99
  if is_create:
100
100
  # For create operations
101
101
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
102
102
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
103
- run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
104
103
  else:
105
104
  # For update operations
106
105
  try:
107
106
  old_instance = self.__class__.objects.get(pk=self.pk)
108
- run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
107
+ run(
108
+ self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
109
+ )
109
110
  run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
110
- run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
111
111
  except self.__class__.DoesNotExist:
112
112
  # If the old instance doesn't exist, treat as create
113
113
  run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
114
114
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
115
+
116
+ # Now let Django save with any modifications from BEFORE hooks
117
+ super().save(*args, **kwargs)
118
+
119
+ # Then run AFTER hooks
120
+ with patch_foreign_key_behavior():
121
+ if is_create:
122
+ # For create operations
123
+ run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
124
+ else:
125
+ # For update operations
126
+ try:
127
+ old_instance = self.__class__.objects.get(pk=self.pk)
128
+ run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
129
+ except self.__class__.DoesNotExist:
130
+ # If the old instance doesn't exist, treat as create
115
131
  run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
116
132
 
117
133
  return self
@@ -125,5 +141,5 @@ class HookModelMixin(models.Model):
125
141
  run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
126
142
  result = super().delete(*args, **kwargs)
127
143
  run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
128
-
144
+
129
145
  return result
@@ -3,15 +3,15 @@ from typing import Union
3
3
 
4
4
  from django_bulk_hooks.enums import Priority
5
5
 
6
- _hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
6
+ _hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int, tuple]]] = {}
7
7
 
8
8
 
9
9
  def register_hook(
10
- model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
10
+ model, event, handler_cls, method_name, condition, priority: Union[int, Priority], select_related_fields=None
11
11
  ):
12
12
  key = (model, event)
13
13
  hooks = _hooks.setdefault(key, [])
14
- hooks.append((handler_cls, method_name, condition, priority))
14
+ hooks.append((handler_cls, method_name, condition, priority, select_related_fields))
15
15
  # keep sorted by priority
16
16
  hooks.sort(key=lambda x: x[3])
17
17
 
@@ -0,0 +1,295 @@
1
+ Metadata-Version: 2.1
2
+ Name: django-bulk-hooks
3
+ Version: 0.1.101
4
+ Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
+ Home-page: https://github.com/AugendLimited/django-bulk-hooks
6
+ License: MIT
7
+ Keywords: django,bulk,hooks
8
+ Author: Konrad Beck
9
+ Author-email: konrad.beck@merchantcapital.co.za
10
+ Requires-Python: >=3.11,<4.0
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Dist: Django (>=4.0)
17
+ Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
+ Description-Content-Type: text/markdown
19
+
20
+
21
+ # django-bulk-hooks
22
+
23
+ ⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
24
+
25
+ `django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
26
+
27
+ ## ✨ Features
28
+
29
+ - Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
30
+ - BEFORE/AFTER hooks for create, update, delete
31
+ - Hook-aware manager that wraps Django's `bulk_` operations
32
+ - **NEW**: `HookModelMixin` for individual model lifecycle events
33
+ - Hook chaining, hook deduplication, and atomicity
34
+ - Class-based hook handlers with DI support
35
+ - Support for both bulk and individual model operations
36
+ - **NEW**: Safe handling of related objects to prevent `RelatedObjectDoesNotExist` errors
37
+ - **NEW**: `@select_related` decorator to prevent queries in loops
38
+
39
+ ## 🚀 Quickstart
40
+
41
+ ```bash
42
+ pip install django-bulk-hooks
43
+ ```
44
+
45
+ ### Define Your Model
46
+
47
+ ```python
48
+ from django.db import models
49
+ from django_bulk_hooks.models import HookModelMixin
50
+
51
+ class Account(HookModelMixin):
52
+ balance = models.DecimalField(max_digits=10, decimal_places=2)
53
+ # The HookModelMixin automatically provides BulkHookManager
54
+ ```
55
+
56
+ ### Create a Hook Handler
57
+
58
+ ```python
59
+ from django_bulk_hooks import hook, AFTER_UPDATE, select_related
60
+ from django_bulk_hooks.conditions import WhenFieldHasChanged
61
+ from .models import Account
62
+
63
+ class AccountHandler:
64
+ @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
65
+ @select_related("user") # Preload user to prevent queries in loops
66
+ def notify_balance_change(self, new_records, old_records):
67
+ for account in new_records:
68
+ # This won't cause a query since user is preloaded
69
+ user_email = account.user.email
70
+ self.send_notification(user_email, account.balance)
71
+ ```
72
+
73
+ ## 🔧 Using `@select_related` to Prevent Queries in Loops
74
+
75
+ The `@select_related` decorator is essential when your hook logic needs to access related objects. Without it, you might end up with N+1 query problems.
76
+
77
+ ### ❌ Without `@select_related` (causes queries in loops)
78
+
79
+ ```python
80
+ @hook(AFTER_CREATE, model=LoanAccount)
81
+ def process_accounts(self, new_records, old_records):
82
+ for account in new_records:
83
+ # ❌ This causes a query for each account!
84
+ status_name = account.status.name
85
+ if status_name == "ACTIVE":
86
+ self.activate_account(account)
87
+ ```
88
+
89
+ ### ✅ With `@select_related` (bulk loads related objects)
90
+
91
+ ```python
92
+ @hook(AFTER_CREATE, model=LoanAccount)
93
+ @select_related("status") # Bulk load status objects
94
+ def process_accounts(self, new_records, old_records):
95
+ for account in new_records:
96
+ # ✅ No query here - status is preloaded
97
+ status_name = account.status.name
98
+ if status_name == "ACTIVE":
99
+ self.activate_account(account)
100
+ ```
101
+
102
+ ### Multiple Related Fields
103
+
104
+ ```python
105
+ @hook(AFTER_UPDATE, model=Transaction)
106
+ @select_related("account", "category", "status")
107
+ def process_transactions(self, new_records, old_records):
108
+ for transaction in new_records:
109
+ # All related objects are preloaded - no queries in loops
110
+ account_name = transaction.account.name
111
+ category_type = transaction.category.type
112
+ status_name = transaction.status.name
113
+
114
+ if status_name == "COMPLETE":
115
+ self.process_complete_transaction(transaction)
116
+ ```
117
+
118
+ ### Your Original Example (Fixed)
119
+
120
+ ```python
121
+ @hook(BEFORE_CREATE, model=LoanAccount, condition=IsEqual("status.name", value=Status.ACTIVE.value))
122
+ @hook(
123
+ BEFORE_UPDATE,
124
+ model=LoanAccount,
125
+ condition=HasChanged("status", has_changed=True) & IsEqual("status.name", value=Status.ACTIVE.value),
126
+ priority=Priority.HIGH,
127
+ )
128
+ @select_related("status") # This ensures status is preloaded
129
+ def _set_activated_date(self, old_records: list[LoanAccount], new_records: list[LoanAccount], **kwargs) -> None:
130
+ logger.info(f"Setting activated date for {new_records}")
131
+ # No queries in loops - status objects are preloaded
132
+ self._loan_account_service.set_activated_date(new_records)
133
+ ```
134
+
135
+ ## 🛡️ Safe Handling of Related Objects
136
+
137
+ Use the `safe_get_related_attr` utility function to safely access related object attributes:
138
+
139
+ ```python
140
+ from django_bulk_hooks.conditions import safe_get_related_attr
141
+
142
+ # ✅ SAFE: Use safe_get_related_attr to handle None values
143
+ @hook(AFTER_CREATE, model=Transaction)
144
+ def process_transaction(self, new_records, old_records):
145
+ for transaction in new_records:
146
+ # Safely get the status name, returns None if status doesn't exist
147
+ status_name = safe_get_related_attr(transaction, 'status', 'name')
148
+
149
+ if status_name == "COMPLETE":
150
+ # Process the transaction
151
+ pass
152
+ elif status_name is None:
153
+ # Handle case where status is not set
154
+ print(f"Transaction {transaction.id} has no status")
155
+ ```
156
+
157
+ ### Complete Example
158
+
159
+ ```python
160
+ from django.db import models
161
+ from django_bulk_hooks import hook, select_related
162
+ from django_bulk_hooks.conditions import safe_get_related_attr
163
+
164
+ class Status(models.Model):
165
+ name = models.CharField(max_length=50)
166
+
167
+ class Transaction(HookModelMixin, models.Model):
168
+ amount = models.DecimalField(max_digits=10, decimal_places=2)
169
+ status = models.ForeignKey(Status, on_delete=models.CASCADE, null=True, blank=True)
170
+ category = models.ForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True)
171
+
172
+ class TransactionHandler:
173
+ @hook(Transaction, "before_create")
174
+ def set_default_status(self, new_records, old_records=None):
175
+ """Set default status for new transactions."""
176
+ default_status = Status.objects.filter(name="PENDING").first()
177
+ for transaction in new_records:
178
+ if transaction.status is None:
179
+ transaction.status = default_status
180
+
181
+ @hook(Transaction, "after_create")
182
+ @select_related("status", "category") # Preload related objects
183
+ def process_transactions(self, new_records, old_records=None):
184
+ """Process transactions based on their status."""
185
+ for transaction in new_records:
186
+ # ✅ SAFE: Get status name safely (no queries in loops)
187
+ status_name = safe_get_related_attr(transaction, 'status', 'name')
188
+
189
+ if status_name == "COMPLETE":
190
+ self._process_complete_transaction(transaction)
191
+ elif status_name == "FAILED":
192
+ self._process_failed_transaction(transaction)
193
+ elif status_name is None:
194
+ print(f"Transaction {transaction.id} has no status")
195
+
196
+ # ✅ SAFE: Check for related object existence (no queries in loops)
197
+ category = safe_get_related_attr(transaction, 'category')
198
+ if category:
199
+ print(f"Transaction {transaction.id} belongs to category: {category.name}")
200
+
201
+ def _process_complete_transaction(self, transaction):
202
+ # Process complete transaction logic
203
+ pass
204
+
205
+ def _process_failed_transaction(self, transaction):
206
+ # Process failed transaction logic
207
+ pass
208
+ ```
209
+
210
+ ### Best Practices for Related Objects
211
+
212
+ 1. **Always use `@select_related`** when accessing related object attributes in hooks
213
+ 2. **Use `safe_get_related_attr`** for safe access to related object attributes
214
+ 3. **Set default values in `BEFORE_CREATE` hooks** to ensure related objects exist
215
+ 4. **Handle None cases explicitly** to avoid unexpected behavior
216
+ 5. **Use bulk operations efficiently** by fetching related objects once and reusing them
217
+
218
+ ## 🔍 Performance Tips
219
+
220
+ ### Monitor Query Count
221
+
222
+ ```python
223
+ from django.db import connection, reset_queries
224
+
225
+ # Before your bulk operation
226
+ reset_queries()
227
+
228
+ # Your bulk operation
229
+ accounts = Account.objects.bulk_create(account_list)
230
+
231
+ # After your bulk operation
232
+ print(f"Total queries: {len(connection.queries)}")
233
+ ```
234
+
235
+ ### Use `@select_related` Strategically
236
+
237
+ ```python
238
+ # Only select_related fields you actually use
239
+ @select_related("status") # Good - only what you need
240
+ @select_related("status", "category", "user", "account") # Only if you use all of them
241
+ ```
242
+
243
+ ### Avoid Nested Loops with Related Objects
244
+
245
+ ```python
246
+ # ❌ Bad - nested loops with related objects
247
+ @hook(AFTER_CREATE, model=Order)
248
+ def process_orders(self, new_records, old_records):
249
+ for order in new_records:
250
+ for item in order.items.all(): # This causes queries!
251
+ process_item(item)
252
+
253
+ # ✅ Good - use prefetch_related for many-to-many/one-to-many
254
+ @hook(AFTER_CREATE, model=Order)
255
+ @select_related("customer")
256
+ def process_orders(self, new_records, old_records):
257
+ # Prefetch items for all orders at once
258
+ from django.db.models import Prefetch
259
+ orders_with_items = Order.objects.prefetch_related(
260
+ Prefetch('items', queryset=Item.objects.select_related('product'))
261
+ ).filter(id__in=[order.id for order in new_records])
262
+
263
+ for order in orders_with_items:
264
+ for item in order.items.all(): # No queries here
265
+ process_item(item)
266
+ ```
267
+
268
+ ## 📚 API Reference
269
+
270
+ ### Decorators
271
+
272
+ - `@hook(event, model, condition=None, priority=DEFAULT_PRIORITY)` - Register a hook
273
+ - `@select_related(*fields)` - Preload related fields to prevent queries in loops
274
+
275
+ ### Conditions
276
+
277
+ - `IsEqual(field, value)` - Check if field equals value
278
+ - `HasChanged(field, has_changed=True)` - Check if field has changed
279
+ - `safe_get_related_attr(instance, field, attr=None)` - Safely get related object attributes
280
+
281
+ ### Events
282
+
283
+ - `BEFORE_CREATE`, `AFTER_CREATE`
284
+ - `BEFORE_UPDATE`, `AFTER_UPDATE`
285
+ - `BEFORE_DELETE`, `AFTER_DELETE`
286
+ - `VALIDATE_CREATE`, `VALIDATE_UPDATE`, `VALIDATE_DELETE`
287
+
288
+ ## 🤝 Contributing
289
+
290
+ Contributions are welcome! Please feel free to submit a Pull Request.
291
+
292
+ ## 📄 License
293
+
294
+ This project is licensed under the MIT License - see the LICENSE file for details.
295
+
@@ -0,0 +1,16 @@
1
+ django_bulk_hooks/__init__.py,sha256=EAWve4HjrrIuPbl8uc1s1ISDM3RPDtwCvTOPRwFpX8w,1392
2
+ django_bulk_hooks/conditions.py,sha256=wDtY90Kv3xjWx8HEA4aAjva8fDDaYegJhn0Eu6G0F60,12150
3
+ django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
4
+ django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
5
+ django_bulk_hooks/decorators.py,sha256=_bTcC4zJiRjpJIMBhjZbuTsqeR0y4GAQ70EJvp8Q0wU,2517
6
+ django_bulk_hooks/engine.py,sha256=T3vIrYDRfGLr6GQfDwwlQQKpEGzsCfCca012DfPl7Z4,6137
7
+ django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
+ django_bulk_hooks/handler.py,sha256=1viPTjT9U-5rUPETOtyGHp_UaSPNxVoSYhbBwIHigy8,6076
9
+ django_bulk_hooks/manager.py,sha256=DcVosEA4RS79KSYgw3Z14_a9Sd8CfxNNc5F3eSb8xc0,11459
10
+ django_bulk_hooks/models.py,sha256=U5nCxingZS2sznDjgW8fWo93SisA03WKcGpxxApqhuM,5519
11
+ django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
12
+ django_bulk_hooks/registry.py,sha256=MY-JOuDphsxay9GHqpZGY_NHGGkvqaH_8RW5kiStDuI,741
13
+ django_bulk_hooks-0.1.101.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
14
+ django_bulk_hooks-0.1.101.dist-info/METADATA,sha256=UgcHNzW2TURJUzwXHW4bcnDeaPUMh9ySdEl8FnIK8fY,10700
15
+ django_bulk_hooks-0.1.101.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
16
+ django_bulk_hooks-0.1.101.dist-info/RECORD,,
@@ -1,391 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: django-bulk-hooks
3
- Version: 0.1.98
4
- Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
- Home-page: https://github.com/AugendLimited/django-bulk-hooks
6
- License: MIT
7
- Keywords: django,bulk,hooks
8
- Author: Konrad Beck
9
- Author-email: konrad.beck@merchantcapital.co.za
10
- Requires-Python: >=3.11,<4.0
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Requires-Dist: Django (>=4.0)
17
- Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
18
- Description-Content-Type: text/markdown
19
-
20
-
21
- # django-bulk-hooks
22
-
23
- ⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
24
-
25
- `django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
26
-
27
- ## ✨ Features
28
-
29
- - Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
30
- - BEFORE/AFTER hooks for create, update, delete
31
- - Hook-aware manager that wraps Django's `bulk_` operations
32
- - **NEW**: `HookModelMixin` for individual model lifecycle events
33
- - Hook chaining, hook deduplication, and atomicity
34
- - Class-based hook handlers with DI support
35
- - Support for both bulk and individual model operations
36
- - **NEW**: Safe handling of related objects to prevent `RelatedObjectDoesNotExist` errors
37
-
38
- ## 🚀 Quickstart
39
-
40
- ```bash
41
- pip install django-bulk-hooks
42
- ```
43
-
44
- ### Define Your Model
45
-
46
- ```python
47
- from django.db import models
48
- from django_bulk_hooks.models import HookModelMixin
49
-
50
- class Account(HookModelMixin):
51
- balance = models.DecimalField(max_digits=10, decimal_places=2)
52
- # The HookModelMixin automatically provides BulkHookManager
53
- ```
54
-
55
- ### Create a Hook Handler
56
-
57
- ```python
58
- from django_bulk_hooks import hook, AFTER_UPDATE, Hook
59
- from django_bulk_hooks.conditions import WhenFieldHasChanged
60
- from .models import Account
61
-
62
- class AccountHooks(HookHandler):
63
- @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
64
- def log_balance_change(self, new_records, old_records):
65
- print("Accounts updated:", [a.pk for a in new_records])
66
-
67
- @hook(BEFORE_CREATE, model=Account)
68
- def before_create(self, new_records, old_records):
69
- for account in new_records:
70
- if account.balance < 0:
71
- raise ValueError("Account cannot have negative balance")
72
-
73
- @hook(AFTER_DELETE, model=Account)
74
- def after_delete(self, new_records, old_records):
75
- print("Accounts deleted:", [a.pk for a in old_records])
76
- ```
77
-
78
- ### Advanced Hook Usage
79
-
80
- ```python
81
- class AdvancedAccountHooks(HookHandler):
82
- @hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
83
- def validate_balance_change(self, new_records, old_records):
84
- for new_account, old_account in zip(new_records, old_records):
85
- if new_account.balance < 0 and old_account.balance >= 0:
86
- raise ValueError("Cannot set negative balance")
87
-
88
- @hook(AFTER_CREATE, model=Account)
89
- def send_welcome_email(self, new_records, old_records):
90
- for account in new_records:
91
- # Send welcome email logic here
92
- pass
93
- ```
94
-
95
- ## 🔒 Safely Handling Related Objects
96
-
97
- One of the most common issues when working with hooks is the `RelatedObjectDoesNotExist` exception. This occurs when you try to access a related object that doesn't exist or hasn't been saved yet.
98
-
99
- ### The Problem
100
-
101
- ```python
102
- # ❌ DANGEROUS: This can raise RelatedObjectDoesNotExist
103
- @hook(AFTER_CREATE, model=Transaction)
104
- def process_transaction(self, new_records, old_records):
105
- for transaction in new_records:
106
- # This will fail if transaction.status is None or doesn't exist
107
- if transaction.status.name == "COMPLETE":
108
- # Process the transaction
109
- pass
110
- ```
111
-
112
- ### The Solution
113
-
114
- Use the `safe_get_related_attr` utility function to safely access related object attributes:
115
-
116
- ```python
117
- from django_bulk_hooks.conditions import safe_get_related_attr
118
-
119
- # ✅ SAFE: Use safe_get_related_attr to handle None values
120
- @hook(AFTER_CREATE, model=Transaction)
121
- def process_transaction(self, new_records, old_records):
122
- for transaction in new_records:
123
- # Safely get the status name, returns None if status doesn't exist
124
- status_name = safe_get_related_attr(transaction, 'status', 'name')
125
-
126
- if status_name == "COMPLETE":
127
- # Process the transaction
128
- pass
129
- elif status_name is None:
130
- # Handle case where status is not set
131
- print(f"Transaction {transaction.id} has no status")
132
- ```
133
-
134
- ### Complete Example
135
-
136
- ```python
137
- from django.db import models
138
- from django_bulk_hooks import hook
139
- from django_bulk_hooks.conditions import safe_get_related_attr
140
-
141
- class Status(models.Model):
142
- name = models.CharField(max_length=50)
143
-
144
- class Transaction(HookModelMixin, models.Model):
145
- amount = models.DecimalField(max_digits=10, decimal_places=2)
146
- status = models.ForeignKey(Status, on_delete=models.CASCADE, null=True, blank=True)
147
- category = models.ForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True)
148
-
149
- class TransactionHandler:
150
- @hook(Transaction, "before_create")
151
- def set_default_status(self, new_records, old_records=None):
152
- """Set default status for new transactions."""
153
- default_status = Status.objects.filter(name="PENDING").first()
154
- for transaction in new_records:
155
- if transaction.status is None:
156
- transaction.status = default_status
157
-
158
- @hook(Transaction, "after_create")
159
- def process_transactions(self, new_records, old_records=None):
160
- """Process transactions based on their status."""
161
- for transaction in new_records:
162
- # ✅ SAFE: Get status name safely
163
- status_name = safe_get_related_attr(transaction, 'status', 'name')
164
-
165
- if status_name == "COMPLETE":
166
- self._process_complete_transaction(transaction)
167
- elif status_name == "FAILED":
168
- self._process_failed_transaction(transaction)
169
- elif status_name is None:
170
- print(f"Transaction {transaction.id} has no status")
171
-
172
- # ✅ SAFE: Check for related object existence
173
- category = safe_get_related_attr(transaction, 'category')
174
- if category:
175
- print(f"Transaction {transaction.id} belongs to category: {category.name}")
176
-
177
- def _process_complete_transaction(self, transaction):
178
- # Process complete transaction logic
179
- pass
180
-
181
- def _process_failed_transaction(self, transaction):
182
- # Process failed transaction logic
183
- pass
184
- ```
185
-
186
- ### Best Practices for Related Objects
187
-
188
- 1. **Always use `safe_get_related_attr`** when accessing related object attributes in hooks
189
- 2. **Set default values in `BEFORE_CREATE` hooks** to ensure related objects exist
190
- 3. **Handle None cases explicitly** to avoid unexpected behavior
191
- 4. **Use bulk operations efficiently** by fetching related objects once and reusing them
192
-
193
- ```python
194
- class EfficientTransactionHandler:
195
- @hook(Transaction, "before_create")
196
- def prepare_transactions(self, new_records, old_records=None):
197
- """Efficiently prepare transactions for bulk creation."""
198
- # Get default objects once to avoid multiple queries
199
- default_status = Status.objects.filter(name="PENDING").first()
200
- default_category = Category.objects.filter(name="GENERAL").first()
201
-
202
- for transaction in new_records:
203
- if transaction.status is None:
204
- transaction.status = default_status
205
- if transaction.category is None:
206
- transaction.category = default_category
207
-
208
- @hook(Transaction, "after_create")
209
- def post_creation_processing(self, new_records, old_records=None):
210
- """Process transactions after creation."""
211
- # Group by status for efficient processing
212
- transactions_by_status = {}
213
-
214
- for transaction in new_records:
215
- status_name = safe_get_related_attr(transaction, 'status', 'name')
216
- if status_name not in transactions_by_status:
217
- transactions_by_status[status_name] = []
218
- transactions_by_status[status_name].append(transaction)
219
-
220
- # Process each group
221
- for status_name, transactions in transactions_by_status.items():
222
- if status_name == "COMPLETE":
223
- self._batch_process_complete(transactions)
224
- elif status_name == "FAILED":
225
- self._batch_process_failed(transactions)
226
- ```
227
-
228
- This approach ensures your hooks are robust and won't fail due to missing related objects, while also being efficient with database queries.
229
-
230
- ## 🎯 Lambda Conditions and Anonymous Functions
231
-
232
- `django-bulk-hooks` supports using anonymous functions (lambda functions) and custom callables as conditions, giving you maximum flexibility for complex filtering logic.
233
-
234
- ### Using LambdaCondition
235
-
236
- The `LambdaCondition` class allows you to use lambda functions or any callable as a condition:
237
-
238
- ```python
239
- from django_bulk_hooks import LambdaCondition
240
-
241
- class ProductHandler:
242
- # Simple lambda condition
243
- @hook(Product, "after_create", condition=LambdaCondition(
244
- lambda instance: instance.price > 100
245
- ))
246
- def handle_expensive_products(self, new_records, old_records):
247
- """Handle products with price > 100"""
248
- for product in new_records:
249
- print(f"Expensive product: {product.name}")
250
-
251
- # Lambda with multiple conditions
252
- @hook(Product, "after_update", condition=LambdaCondition(
253
- lambda instance: instance.price > 50 and instance.is_active and instance.stock_quantity > 0
254
- ))
255
- def handle_available_expensive_products(self, new_records, old_records):
256
- """Handle active products with price > 50 and stock > 0"""
257
- for product in new_records:
258
- print(f"Available expensive product: {product.name}")
259
-
260
- # Lambda comparing with original instance
261
- @hook(Product, "after_update", condition=LambdaCondition(
262
- lambda instance, original: original and instance.price > original.price * 1.5
263
- ))
264
- def handle_significant_price_increases(self, new_records, old_records):
265
- """Handle products with >50% price increase"""
266
- for new_product, old_product in zip(new_records, old_records):
267
- if old_product:
268
- increase = ((new_product.price - old_product.price) / old_product.price) * 100
269
- print(f"Significant price increase: {new_product.name} +{increase:.1f}%")
270
- ```
271
-
272
- ### Combining Lambda Conditions with Built-in Conditions
273
-
274
- You can combine lambda conditions with built-in conditions using the `&` (AND) and `|` (OR) operators:
275
-
276
- ```python
277
- from django_bulk_hooks.conditions import HasChanged, IsEqual
278
-
279
- class AdvancedProductHandler:
280
- # Combine lambda with built-in conditions
281
- @hook(Product, "after_update", condition=(
282
- HasChanged("price") &
283
- LambdaCondition(lambda instance: instance.price > 100)
284
- ))
285
- def handle_expensive_price_changes(self, new_records, old_records):
286
- """Handle when expensive products have price changes"""
287
- for new_product, old_product in zip(new_records, old_records):
288
- print(f"Expensive product price changed: {new_product.name}")
289
-
290
- # Complex combined conditions
291
- @hook(Order, "after_update", condition=(
292
- LambdaCondition(lambda instance: instance.status == 'completed') &
293
- LambdaCondition(lambda instance, original: original and instance.total_amount > original.total_amount)
294
- ))
295
- def handle_completed_orders_with_increased_amount(self, new_records, old_records):
296
- """Handle completed orders that had amount increases"""
297
- for new_order, old_order in zip(new_records, old_records):
298
- if old_order:
299
- increase = new_order.total_amount - old_order.total_amount
300
- print(f"Completed order with amount increase: {new_order.customer_name} +${increase}")
301
- ```
302
-
303
- ### Custom Condition Classes
304
-
305
- For reusable logic, you can create custom condition classes:
306
-
307
- ```python
308
- from django_bulk_hooks.conditions import HookCondition
309
-
310
- class IsPremiumProduct(HookCondition):
311
- def check(self, instance, original_instance=None):
312
- return (
313
- instance.price > 200 and
314
- instance.rating >= 4.0 and
315
- instance.is_active
316
- )
317
-
318
- def get_required_fields(self):
319
- return {'price', 'rating', 'is_active'}
320
-
321
- class ProductHandler:
322
- @hook(Product, "after_create", condition=IsPremiumProduct())
323
- def handle_premium_products(self, new_records, old_records):
324
- """Handle premium products"""
325
- for product in new_records:
326
- print(f"Premium product: {product.name}")
327
- ```
328
-
329
- ### Lambda Conditions with Required Fields
330
-
331
- For optimization, you can specify which fields your lambda condition depends on:
332
-
333
- ```python
334
- class OptimizedProductHandler:
335
- @hook(Product, "after_update", condition=LambdaCondition(
336
- lambda instance: instance.price > 100 and instance.category == 'electronics',
337
- required_fields={'price', 'category'}
338
- ))
339
- def handle_expensive_electronics(self, new_records, old_records):
340
- """Handle expensive electronics products"""
341
- for product in new_records:
342
- print(f"Expensive electronics: {product.name}")
343
- ```
344
-
345
- ### Best Practices for Lambda Conditions
346
-
347
- 1. **Keep lambdas simple** - Complex logic should be moved to custom condition classes
348
- 2. **Handle None values** - Always check for None before performing operations
349
- 3. **Specify required fields** - This helps with query optimization
350
- 4. **Use descriptive names** - Make your lambda conditions self-documenting
351
- 5. **Test thoroughly** - Lambda conditions can be harder to debug than named functions
352
-
353
- ```python
354
- # ✅ GOOD: Simple, clear lambda
355
- condition = LambdaCondition(lambda instance: instance.price > 100)
356
-
357
- # ✅ GOOD: Handles None values
358
- condition = LambdaCondition(
359
- lambda instance: instance.price is not None and instance.price > 100
360
- )
361
-
362
- # ❌ AVOID: Complex logic in lambda
363
- condition = LambdaCondition(
364
- lambda instance: (
365
- instance.price > 100 and
366
- instance.category in ['electronics', 'computers'] and
367
- instance.stock_quantity > 0 and
368
- instance.rating >= 4.0 and
369
- instance.is_active and
370
- instance.created_at > datetime.now() - timedelta(days=30)
371
- )
372
- )
373
-
374
- # ✅ BETTER: Use custom condition class for complex logic
375
- class IsRecentExpensiveElectronics(HookCondition):
376
- def check(self, instance, original_instance=None):
377
- return (
378
- instance.price > 100 and
379
- instance.category in ['electronics', 'computers'] and
380
- instance.stock_quantity > 0 and
381
- instance.rating >= 4.0 and
382
- instance.is_active and
383
- instance.created_at > datetime.now() - timedelta(days=30)
384
- )
385
-
386
- def get_required_fields(self):
387
- return {'price', 'category', 'stock_quantity', 'rating', 'is_active', 'created_at'}
388
- ```
389
-
390
- ## 🔧 Best Practices for Related Objects
391
-
@@ -1,16 +0,0 @@
1
- django_bulk_hooks/__init__.py,sha256=EAWve4HjrrIuPbl8uc1s1ISDM3RPDtwCvTOPRwFpX8w,1392
2
- django_bulk_hooks/conditions.py,sha256=wDtY90Kv3xjWx8HEA4aAjva8fDDaYegJhn0Eu6G0F60,12150
3
- django_bulk_hooks/constants.py,sha256=3x1H1fSUUNo0DZONN7GUVDuySZctTR-jtByBHmAIX5w,303
4
- django_bulk_hooks/context.py,sha256=HVDT73uSzvgrOR6mdXTvsBm3hLOgBU8ant_mB7VlFuM,380
5
- django_bulk_hooks/decorators.py,sha256=zstmb27dKcOHu3Atg7cauewCTzPvUmq03mzVKJRi56o,7230
6
- django_bulk_hooks/engine.py,sha256=qSBvBel3pBWVE92ZzgKjMn2ceQpA7FMws2uMXes1MkA,3626
7
- django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
8
- django_bulk_hooks/handler.py,sha256=tdDolHAJ_Nd7-RT4s9HRyLtM1UWGjRjP1Y_U6Af32Gg,5325
9
- django_bulk_hooks/manager.py,sha256=DcVosEA4RS79KSYgw3Z14_a9Sd8CfxNNc5F3eSb8xc0,11459
10
- django_bulk_hooks/models.py,sha256=a9XoGgIG4Sfi_kvGnPBbG2DlvgZDz6Qck4VG-DGqFT0,4981
11
- django_bulk_hooks/queryset.py,sha256=7lLqhZ-XOYsZ1I3Loxi4Nhz79M8HlTYE413AW8nyeDI,1330
12
- django_bulk_hooks/registry.py,sha256=Vh78exKYcdZhM27120kQm-iXGOjd_kf9ZUYBZ8eQ2V0,683
13
- django_bulk_hooks-0.1.98.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
14
- django_bulk_hooks-0.1.98.dist-info/METADATA,sha256=KLEuzqcEjYRTaH3rhRfoyQIGkbNQwhA0KL1HniVfHzM,15414
15
- django_bulk_hooks-0.1.98.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
16
- django_bulk_hooks-0.1.98.dist-info/RECORD,,