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

@@ -1,62 +1,4 @@
1
- from django_bulk_hooks.conditions import (
2
- ChangesTo,
3
- HasChanged,
4
- IsBlank,
5
- IsEqual,
6
- IsGreaterThan,
7
- IsGreaterThanOrEqual,
8
- IsLessThan,
9
- IsLessThanOrEqual,
10
- IsNotEqual,
11
- LambdaCondition,
12
- WasEqual,
13
- is_field_set,
14
- safe_get_related_attr,
15
- safe_get_related_object,
16
- )
17
- from django_bulk_hooks.constants import (
18
- AFTER_CREATE,
19
- AFTER_DELETE,
20
- AFTER_UPDATE,
21
- BEFORE_CREATE,
22
- BEFORE_DELETE,
23
- BEFORE_UPDATE,
24
- VALIDATE_CREATE,
25
- VALIDATE_DELETE,
26
- VALIDATE_UPDATE,
27
- )
28
- from django_bulk_hooks.decorators import hook, select_related
29
- from django_bulk_hooks.enums import Priority
30
- from django_bulk_hooks.handler import HookHandler
31
- from django_bulk_hooks.models import HookModelMixin
1
+ from django_bulk_hooks.handler import Hook
2
+ from django_bulk_hooks.manager import BulkHookManager
32
3
 
33
- __all__ = [
34
- "HookHandler",
35
- "HookModelMixin",
36
- "BEFORE_CREATE",
37
- "AFTER_CREATE",
38
- "BEFORE_UPDATE",
39
- "AFTER_UPDATE",
40
- "BEFORE_DELETE",
41
- "AFTER_DELETE",
42
- "VALIDATE_CREATE",
43
- "VALIDATE_UPDATE",
44
- "VALIDATE_DELETE",
45
- "safe_get_related_object",
46
- "safe_get_related_attr",
47
- "is_field_set",
48
- "Priority",
49
- "hook",
50
- "select_related",
51
- "ChangesTo",
52
- "HasChanged",
53
- "IsEqual",
54
- "IsNotEqual",
55
- "WasEqual",
56
- "IsBlank",
57
- "IsGreaterThan",
58
- "IsLessThan",
59
- "IsGreaterThanOrEqual",
60
- "IsLessThanOrEqual",
61
- "LambdaCondition",
62
- ]
4
+ __all__ = ["BulkHookManager", "Hook"]
@@ -1,93 +1,12 @@
1
- from django.db import models
2
-
3
-
4
- def safe_get_related_object(instance, field_name):
5
- """
6
- Safely get a related object without raising RelatedObjectDoesNotExist.
7
- Returns None if the foreign key field is None or the related object doesn't exist.
8
- """
9
- if not hasattr(instance, field_name):
10
- return None
11
-
12
- # Get the foreign key field
13
- try:
14
- field = instance._meta.get_field(field_name)
15
- if not field.is_relation or field.many_to_many or field.one_to_many:
16
- return getattr(instance, field_name, None)
17
- except models.FieldDoesNotExist:
18
- return getattr(instance, field_name, None)
19
-
20
- # Check if the foreign key field is None
21
- fk_field_name = f"{field_name}_id"
22
- if hasattr(instance, fk_field_name) and getattr(instance, fk_field_name) is None:
23
- return None
24
-
25
- # Try to get the related object, but catch RelatedObjectDoesNotExist
26
- try:
27
- return getattr(instance, field_name)
28
- except field.related_model.RelatedObjectDoesNotExist:
29
- return None
30
-
31
-
32
- def safe_get_related_attr(instance, field_name, attr_name=None):
33
- """
34
- Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
35
-
36
- This is particularly useful in hooks where objects might not have their related
37
- fields populated yet (e.g., during bulk_create operations or on unsaved objects).
38
-
39
- Args:
40
- instance: The model instance
41
- field_name: The foreign key field name
42
- attr_name: Optional attribute name to access on the related object
43
-
44
- Returns:
45
- The related object, the attribute value, or None if not available
46
-
47
- Example:
48
- # Instead of: loan_transaction.status.name (which might fail)
49
- # Use: safe_get_related_attr(loan_transaction, 'status', 'name')
50
-
51
- status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
52
- if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
53
- # Process the transaction
54
- pass
55
- """
56
- # For unsaved objects, check the foreign key ID field first
57
- if instance.pk is None:
58
- fk_field_name = f"{field_name}_id"
59
- if hasattr(instance, fk_field_name):
60
- fk_value = getattr(instance, fk_field_name, None)
61
- if fk_value is None:
62
- return None
63
- # If we have an ID but the object isn't loaded, try to load it
64
- try:
65
- field = instance._meta.get_field(field_name)
66
- if hasattr(field, "related_model"):
67
- related_obj = field.related_model.objects.get(id=fk_value)
68
- if attr_name is None:
69
- return related_obj
70
- return getattr(related_obj, attr_name, None)
71
- except (field.related_model.DoesNotExist, AttributeError):
72
- return None
73
-
74
- # For saved objects or when the above doesn't work, use the original method
75
- related_obj = safe_get_related_object(instance, field_name)
76
- if related_obj is None:
77
- return None
78
- if attr_name is None:
79
- return related_obj
80
- return getattr(related_obj, attr_name, None)
81
-
82
-
83
- def is_field_set(instance, field_name):
1
+ def resolve_dotted_attr(instance, dotted_path):
84
2
  """
85
- Check if a field has been set on a model instance.
86
-
87
- This is useful for checking if a field has been explicitly set,
88
- even if it's been set to None.
3
+ Recursively resolve a dotted attribute path, e.g., "type.category".
89
4
  """
90
- return hasattr(instance, field_name)
5
+ for attr in dotted_path.split("."):
6
+ if instance is None:
7
+ return None
8
+ instance = getattr(instance, attr, None)
9
+ return instance
91
10
 
92
11
 
93
12
  class HookCondition:
@@ -106,253 +25,157 @@ class HookCondition:
106
25
  def __invert__(self):
107
26
  return NotCondition(self)
108
27
 
109
- def get_required_fields(self):
110
- """
111
- Returns a set of field names that this condition needs to evaluate.
112
- Override in subclasses to specify required fields.
113
- """
114
- return set()
115
-
116
-
117
- class IsBlank(HookCondition):
118
- """
119
- Condition that checks if a field is blank (None or empty string).
120
- """
121
-
122
- def __init__(self, field_name):
123
- self.field_name = field_name
124
-
125
- def check(self, instance, original_instance=None):
126
- value = getattr(instance, self.field_name, None)
127
- return value is None or value == ""
128
-
129
- def get_required_fields(self):
130
- return {self.field_name}
131
28
 
132
-
133
- class AndCondition(HookCondition):
134
- def __init__(self, *conditions):
135
- self.conditions = conditions
136
-
137
- def check(self, instance, original_instance=None):
138
- return all(c.check(instance, original_instance) for c in self.conditions)
139
-
140
- def get_required_fields(self):
141
- fields = set()
142
- for condition in self.conditions:
143
- fields.update(condition.get_required_fields())
144
- return fields
145
-
146
-
147
- class OrCondition(HookCondition):
148
- def __init__(self, *conditions):
149
- self.conditions = conditions
150
-
151
- def check(self, instance, original_instance=None):
152
- return any(c.check(instance, original_instance) for c in self.conditions)
153
-
154
- def get_required_fields(self):
155
- fields = set()
156
- for condition in self.conditions:
157
- fields.update(condition.get_required_fields())
158
- return fields
159
-
160
-
161
- class NotCondition(HookCondition):
162
- def __init__(self, condition):
163
- self.condition = condition
29
+ class IsNotEqual(HookCondition):
30
+ def __init__(self, field, value, only_on_change=False):
31
+ self.field = field
32
+ self.value = value
33
+ self.only_on_change = only_on_change
164
34
 
165
35
  def check(self, instance, original_instance=None):
166
- return not self.condition.check(instance, original_instance)
167
-
168
- def get_required_fields(self):
169
- return self.condition.get_required_fields()
36
+ current = resolve_dotted_attr(instance, self.field)
37
+ if self.only_on_change:
38
+ if original_instance is None:
39
+ return False
40
+ previous = resolve_dotted_attr(original_instance, self.field)
41
+ return previous == self.value and current != self.value
42
+ else:
43
+ return current != self.value
170
44
 
171
45
 
172
46
  class IsEqual(HookCondition):
173
- def __init__(self, field_name, value):
174
- self.field_name = field_name
47
+ def __init__(self, field, value, only_on_change=False):
48
+ self.field = field
175
49
  self.value = value
50
+ self.only_on_change = only_on_change
176
51
 
177
52
  def check(self, instance, original_instance=None):
178
- return getattr(instance, self.field_name, None) == self.value
53
+ current = resolve_dotted_attr(instance, self.field)
54
+ if self.only_on_change:
55
+ if original_instance is None:
56
+ return False
57
+ previous = resolve_dotted_attr(original_instance, self.field)
58
+ return previous != self.value and current == self.value
59
+ else:
60
+ return current == self.value
179
61
 
180
- def get_required_fields(self):
181
- return {self.field_name}
182
62
 
183
-
184
- class WasEqual(HookCondition):
185
- def __init__(self, field_name, value):
186
- self.field_name = field_name
187
- self.value = value
63
+ class HasChanged(HookCondition):
64
+ def __init__(self, field, has_changed=True):
65
+ self.field = field
66
+ self.has_changed = has_changed
188
67
 
189
68
  def check(self, instance, original_instance=None):
190
- if original_instance is None:
69
+ if not original_instance:
191
70
  return False
192
- return getattr(original_instance, self.field_name, None) == self.value
193
-
194
- def get_required_fields(self):
195
- return {self.field_name}
71
+ current = resolve_dotted_attr(instance, self.field)
72
+ previous = resolve_dotted_attr(original_instance, self.field)
73
+ return (current != previous) == self.has_changed
196
74
 
197
75
 
198
- class HasChanged(HookCondition):
199
- def __init__(self, field_name, has_changed=True):
76
+ class WasEqual(HookCondition):
77
+ def __init__(self, field, value, only_on_change=False):
200
78
  """
201
- Check if a field's value has changed or remained the same.
202
-
203
- Args:
204
- field_name: The field name to check
205
- has_changed: If True (default), condition passes when field has changed.
206
- If False, condition passes when field has remained the same.
207
- This is useful for:
208
- - Detecting stable/unchanged fields
209
- - Validating field immutability
210
- - Ensuring critical fields remain constant
211
- - State machine validations
79
+ Check if a field's original value was `value`.
80
+ If only_on_change is True, only return True when the field has changed away from that value.
212
81
  """
213
- self.field_name = field_name
214
- self.has_changed = has_changed
82
+ self.field = field
83
+ self.value = value
84
+ self.only_on_change = only_on_change
215
85
 
216
86
  def check(self, instance, original_instance=None):
217
87
  if original_instance is None:
218
- # For new instances:
219
- # - If we're checking for changes (has_changed=True), return True since it's a new record
220
- # - If we're checking for stability (has_changed=False), return False since it's technically changed from nothing
221
- return self.has_changed
222
-
223
- current_value = getattr(instance, self.field_name, None)
224
- original_value = getattr(original_instance, self.field_name, None)
225
- return (current_value != original_value) == self.has_changed
226
-
227
- def get_required_fields(self):
228
- return {self.field_name}
88
+ return False
89
+ previous = resolve_dotted_attr(original_instance, self.field)
90
+ if self.only_on_change:
91
+ current = resolve_dotted_attr(instance, self.field)
92
+ return previous == self.value and current != self.value
93
+ else:
94
+ return previous == self.value
229
95
 
230
96
 
231
97
  class ChangesTo(HookCondition):
232
- def __init__(self, field_name, value):
233
- self.field_name = field_name
98
+ def __init__(self, field, value):
99
+ """
100
+ Check if a field's value has changed to `value`.
101
+ Only returns True when original value != value and current value == value.
102
+ """
103
+ self.field = field
234
104
  self.value = value
235
105
 
236
106
  def check(self, instance, original_instance=None):
237
107
  if original_instance is None:
238
- return getattr(instance, self.field_name, None) == self.value
239
- return getattr(instance, self.field_name, None) == self.value and getattr(
240
- instance, self.field_name, None
241
- ) != getattr(original_instance, self.field_name, None)
242
-
243
- def get_required_fields(self):
244
- return {self.field_name}
245
-
246
-
247
- class IsNotEqual(HookCondition):
248
- def __init__(self, field_name, value):
249
- self.field_name = field_name
250
- self.value = value
251
-
252
- def check(self, instance, original_instance=None):
253
- return getattr(instance, self.field_name, None) != self.value
254
-
255
- def get_required_fields(self):
256
- return {self.field_name}
108
+ return False
109
+ previous = resolve_dotted_attr(original_instance, self.field)
110
+ current = resolve_dotted_attr(instance, self.field)
111
+ return previous != self.value and current == self.value
257
112
 
258
113
 
259
114
  class IsGreaterThan(HookCondition):
260
- def __init__(self, field_name, value):
261
- self.field_name = field_name
115
+ def __init__(self, field, value):
116
+ self.field = field
262
117
  self.value = value
263
118
 
264
119
  def check(self, instance, original_instance=None):
265
- field_value = getattr(instance, self.field_name, None)
266
- if field_value is None:
267
- return False
268
- return field_value > self.value
269
-
270
- def get_required_fields(self):
271
- return {self.field_name}
120
+ current = resolve_dotted_attr(instance, self.field)
121
+ return current is not None and current > self.value
272
122
 
273
123
 
274
- class IsLessThan(HookCondition):
275
- def __init__(self, field_name, value):
276
- self.field_name = field_name
124
+ class IsGreaterThanOrEqual(HookCondition):
125
+ def __init__(self, field, value):
126
+ self.field = field
277
127
  self.value = value
278
128
 
279
129
  def check(self, instance, original_instance=None):
280
- field_value = getattr(instance, self.field_name, None)
281
- if field_value is None:
282
- return False
283
- return field_value < self.value
130
+ current = resolve_dotted_attr(instance, self.field)
131
+ return current is not None and current >= self.value
284
132
 
285
- def get_required_fields(self):
286
- return {self.field_name}
287
133
 
288
-
289
- class IsGreaterThanOrEqual(HookCondition):
290
- def __init__(self, field_name, value):
291
- self.field_name = field_name
134
+ class IsLessThan(HookCondition):
135
+ def __init__(self, field, value):
136
+ self.field = field
292
137
  self.value = value
293
138
 
294
139
  def check(self, instance, original_instance=None):
295
- field_value = getattr(instance, self.field_name, None)
296
- if field_value is None:
297
- return False
298
- return field_value >= self.value
299
-
300
- def get_required_fields(self):
301
- return {self.field_name}
140
+ current = resolve_dotted_attr(instance, self.field)
141
+ return current is not None and current < self.value
302
142
 
303
143
 
304
144
  class IsLessThanOrEqual(HookCondition):
305
- def __init__(self, field_name, value):
306
- self.field_name = field_name
145
+ def __init__(self, field, value):
146
+ self.field = field
307
147
  self.value = value
308
148
 
309
149
  def check(self, instance, original_instance=None):
310
- field_value = getattr(instance, self.field_name, None)
311
- if field_value is None:
312
- return False
313
- return field_value <= self.value
150
+ current = resolve_dotted_attr(instance, self.field)
151
+ return current is not None and current <= self.value
314
152
 
315
- def get_required_fields(self):
316
- return {self.field_name}
317
153
 
154
+ class AndCondition(HookCondition):
155
+ def __init__(self, cond1, cond2):
156
+ self.cond1 = cond1
157
+ self.cond2 = cond2
318
158
 
319
- class LambdaCondition(HookCondition):
320
- """
321
- A condition that uses a lambda function or any callable.
159
+ def check(self, instance, original_instance=None):
160
+ return self.cond1.check(instance, original_instance) and self.cond2.check(
161
+ instance, original_instance
162
+ )
322
163
 
323
- This makes it easy to create custom conditions inline without defining
324
- a full class.
325
164
 
326
- Example:
327
- # Simple lambda condition
328
- condition = LambdaCondition(lambda instance: instance.price > 100)
165
+ class OrCondition(HookCondition):
166
+ def __init__(self, cond1, cond2):
167
+ self.cond1 = cond1
168
+ self.cond2 = cond2
329
169
 
330
- # Lambda with original instance
331
- condition = LambdaCondition(
332
- lambda instance, original: instance.price > original.price
170
+ def check(self, instance, original_instance=None):
171
+ return self.cond1.check(instance, original_instance) or self.cond2.check(
172
+ instance, original_instance
333
173
  )
334
174
 
335
- # Using in a hook
336
- @hook(Product, "after_update", condition=LambdaCondition(
337
- lambda instance: instance.price > 100 and instance.is_active
338
- ))
339
- def handle_expensive_active_products(self, new_records, old_records):
340
- pass
341
- """
342
-
343
- def __init__(self, func, required_fields=None):
344
- """
345
- Initialize with a callable function.
346
175
 
347
- Args:
348
- func: A callable that takes (instance, original_instance=None) and returns bool
349
- required_fields: Optional set of field names this condition depends on
350
- """
351
- self.func = func
352
- self._required_fields = required_fields or set()
176
+ class NotCondition(HookCondition):
177
+ def __init__(self, cond):
178
+ self.cond = cond
353
179
 
354
180
  def check(self, instance, original_instance=None):
355
- return self.func(instance, original_instance)
356
-
357
- def get_required_fields(self):
358
- return self._required_fields
181
+ return not self.cond.check(instance, original_instance)
@@ -4,7 +4,6 @@ from functools import wraps
4
4
  from django.core.exceptions import FieldDoesNotExist
5
5
  from django_bulk_hooks.enums import DEFAULT_PRIORITY
6
6
  from django_bulk_hooks.registry import register_hook
7
- from django_bulk_hooks.engine import safe_get_related_object
8
7
 
9
8
 
10
9
  def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
@@ -24,22 +23,83 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
24
23
 
25
24
  def select_related(*related_fields):
26
25
  """
27
- Decorator that marks a hook method to preload related fields.
28
-
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.
26
+ Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
32
27
 
33
28
  - Works with instance methods (resolves `self`)
34
29
  - Avoids replacing model instances
35
30
  - Populates Django's relation cache to avoid extra queries
36
- - Provides bulk loading for performance optimization
37
31
  """
38
32
 
39
33
  def decorator(func):
40
- # Store the related fields on the function for later access
41
- func._select_related_fields = related_fields
42
- return func
34
+ sig = inspect.signature(func)
35
+
36
+ @wraps(func)
37
+ def wrapper(*args, **kwargs):
38
+ bound = sig.bind_partial(*args, **kwargs)
39
+ bound.apply_defaults()
40
+
41
+ if "new_records" not in bound.arguments:
42
+ raise TypeError(
43
+ "@preload_related requires a 'new_records' argument in the decorated function"
44
+ )
45
+
46
+ new_records = bound.arguments["new_records"]
47
+
48
+ if not isinstance(new_records, list):
49
+ raise TypeError(
50
+ f"@preload_related expects a list of model instances, got {type(new_records)}"
51
+ )
52
+
53
+ if not new_records:
54
+ return func(*args, **kwargs)
55
+
56
+ # Determine which instances actually need preloading
57
+ model_cls = new_records[0].__class__
58
+ ids_to_fetch = []
59
+ for obj in new_records:
60
+ if obj.pk is None:
61
+ continue
62
+ # if any related field is not already cached on the instance,
63
+ # mark it for fetching
64
+ if any(field not in obj._state.fields_cache for field in related_fields):
65
+ ids_to_fetch.append(obj.pk)
66
+
67
+ fetched = {}
68
+ if ids_to_fetch:
69
+ fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids_to_fetch)
70
+
71
+ for obj in new_records:
72
+ preloaded = fetched.get(obj.pk)
73
+ if not preloaded:
74
+ continue
75
+ for field in related_fields:
76
+ if field in obj._state.fields_cache:
77
+ # don't override values that were explicitly set or already loaded
78
+ continue
79
+ if "." in field:
80
+ raise ValueError(
81
+ f"@preload_related does not support nested fields like '{field}'"
82
+ )
83
+
84
+ try:
85
+ f = model_cls._meta.get_field(field)
86
+ if not (
87
+ f.is_relation and not f.many_to_many and not f.one_to_many
88
+ ):
89
+ continue
90
+ except FieldDoesNotExist:
91
+ continue
92
+
93
+ try:
94
+ rel_obj = getattr(preloaded, field)
95
+ setattr(obj, field, rel_obj)
96
+ obj._state.fields_cache[field] = rel_obj
97
+ except AttributeError:
98
+ pass
99
+
100
+ return func(*bound.args, **bound.kwargs)
101
+
102
+ return wrapper
43
103
 
44
104
  return decorator
45
105