django-bulk-hooks 0.1.102__py3-none-any.whl → 0.1.104__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,50 +1,4 @@
1
- from django_bulk_hooks.constants import (
2
- AFTER_CREATE,
3
- AFTER_DELETE,
4
- AFTER_UPDATE,
5
- BEFORE_CREATE,
6
- BEFORE_DELETE,
7
- BEFORE_UPDATE,
8
- VALIDATE_CREATE,
9
- VALIDATE_DELETE,
10
- VALIDATE_UPDATE,
11
- )
12
- from django_bulk_hooks.conditions import (
13
- ChangesTo,
14
- HasChanged,
15
- IsEqual,
16
- IsNotEqual,
17
- WasEqual,
18
- safe_get_related_object,
19
- safe_get_related_attr,
20
- is_field_set,
21
- )
22
- from django_bulk_hooks.decorators import hook, select_related
23
- from django_bulk_hooks.handler import HookHandler
24
- from django_bulk_hooks.models import HookModelMixin
25
- from django_bulk_hooks.enums import Priority
1
+ from django_bulk_hooks.handler import Hook
2
+ from django_bulk_hooks.manager import BulkHookManager
26
3
 
27
- __all__ = [
28
- "HookHandler",
29
- "HookModelMixin",
30
- "BEFORE_CREATE",
31
- "AFTER_CREATE",
32
- "BEFORE_UPDATE",
33
- "AFTER_UPDATE",
34
- "BEFORE_DELETE",
35
- "AFTER_DELETE",
36
- "VALIDATE_CREATE",
37
- "VALIDATE_UPDATE",
38
- "VALIDATE_DELETE",
39
- "safe_get_related_object",
40
- "safe_get_related_attr",
41
- "is_field_set",
42
- "Priority",
43
- "hook",
44
- "select_related",
45
- "ChangesTo",
46
- "HasChanged",
47
- "IsEqual",
48
- "IsNotEqual",
49
- "WasEqual",
50
- ]
4
+ __all__ = ["BulkHookManager", "Hook"]
@@ -1,182 +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 is_field_set(instance, field_name):
33
- """
34
- Check if a foreign key field is set without raising RelatedObjectDoesNotExist.
35
-
36
- Args:
37
- instance: The model instance
38
- field_name: The foreign key field name
39
-
40
- Returns:
41
- True if the field is set, False otherwise
42
- """
43
- # Check the foreign key ID field first
44
- fk_field_name = f"{field_name}_id"
45
- if hasattr(instance, fk_field_name):
46
- fk_value = getattr(instance, fk_field_name, None)
47
- return fk_value is not None
48
-
49
- # Fallback to checking the field directly
50
- try:
51
- return getattr(instance, field_name) is not None
52
- except Exception:
53
- return False
54
-
55
-
56
- def safe_get_related_attr(instance, field_name, attr_name=None):
57
- """
58
- Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
59
-
60
- This is particularly useful in hooks where objects might not have their related
61
- fields populated yet (e.g., during bulk_create operations or on unsaved objects).
62
-
63
- Args:
64
- instance: The model instance
65
- field_name: The foreign key field name
66
- attr_name: Optional attribute name to access on the related object
67
-
68
- Returns:
69
- The related object, the attribute value, or None if not available
70
-
71
- Example:
72
- # Instead of: loan_transaction.status.name (which might fail)
73
- # Use: safe_get_related_attr(loan_transaction, 'status', 'name')
74
-
75
- status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
76
- if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
77
- # Process the transaction
78
- pass
79
- """
80
- # For unsaved objects, check the foreign key ID field first
81
- if instance.pk is None:
82
- fk_field_name = f"{field_name}_id"
83
- if hasattr(instance, fk_field_name):
84
- fk_value = getattr(instance, fk_field_name, None)
85
- if fk_value is None:
86
- return None
87
- # If we have an ID but the object isn't loaded, try to load it
88
- try:
89
- field = instance._meta.get_field(field_name)
90
- if hasattr(field, 'related_model'):
91
- related_obj = field.related_model.objects.get(id=fk_value)
92
- if attr_name is None:
93
- return related_obj
94
- return getattr(related_obj, attr_name, None)
95
- except (field.related_model.DoesNotExist, AttributeError):
96
- return None
97
-
98
- # For saved objects or when the above doesn't work, use the original method
99
- related_obj = safe_get_related_object(instance, field_name)
100
- if related_obj is None:
101
- return None
102
-
103
- if attr_name is None:
104
- return related_obj
105
-
106
- return getattr(related_obj, attr_name, None)
107
-
108
-
109
- def safe_get_related_attr_with_fallback(instance, field_name, attr_name=None, fallback_value=None):
110
- """
111
- Enhanced version of safe_get_related_attr that provides fallback handling.
112
-
113
- This function is especially useful for bulk operations where related objects
114
- might not be fully loaded or might not exist yet.
115
-
116
- Args:
117
- instance: The model instance
118
- field_name: The foreign key field name
119
- attr_name: Optional attribute name to access on the related object
120
- fallback_value: Value to return if the related object or attribute doesn't exist
121
-
122
- Returns:
123
- The related object, the attribute value, or fallback_value if not available
124
- """
125
- # First try the standard safe access
126
- result = safe_get_related_attr(instance, field_name, attr_name)
127
- if result is not None:
128
- return result
129
-
130
- # If that fails, try to get the foreign key ID and fetch the object directly
131
- fk_field_name = f"{field_name}_id"
132
- if hasattr(instance, fk_field_name):
133
- fk_id = getattr(instance, fk_field_name)
134
- if fk_id is not None:
135
- try:
136
- # Get the field to determine the related model
137
- field = instance._meta.get_field(field_name)
138
- if field.is_relation and not field.many_to_many and not field.one_to_many:
139
- # Try to fetch the related object directly
140
- related_obj = field.related_model.objects.get(pk=fk_id)
141
- if attr_name is None:
142
- return related_obj
143
- return getattr(related_obj, attr_name, fallback_value)
144
- except (field.related_model.DoesNotExist, AttributeError):
145
- pass
146
-
147
- return fallback_value
148
-
149
-
150
1
  def resolve_dotted_attr(instance, dotted_path):
151
2
  """
152
3
  Recursively resolve a dotted attribute path, e.g., "type.category".
153
- This function is designed to work with pre-loaded foreign keys to avoid queries.
154
4
  """
155
- if instance is None:
156
- return None
157
-
158
- current = instance
159
5
  for attr in dotted_path.split("."):
160
- if current is None:
6
+ if instance is None:
161
7
  return None
162
-
163
- # Check if this is a foreign key that might trigger a query
164
- if hasattr(current, '_meta') and hasattr(current._meta, 'get_field'):
165
- try:
166
- field = current._meta.get_field(attr)
167
- if field.is_relation and not field.many_to_many and not field.one_to_many:
168
- # For foreign keys, use safe access to prevent RelatedObjectDoesNotExist
169
- current = safe_get_related_object(current, attr)
170
- else:
171
- current = getattr(current, attr, None)
172
- except Exception:
173
- # If field lookup fails, fall back to regular attribute access
174
- current = getattr(current, attr, None)
175
- else:
176
- # Not a model instance, use regular attribute access
177
- current = getattr(current, attr, None)
178
-
179
- return current
8
+ instance = getattr(instance, attr, None)
9
+ return instance
180
10
 
181
11
 
182
12
  class HookCondition:
@@ -195,104 +25,90 @@ class HookCondition:
195
25
  def __invert__(self):
196
26
  return NotCondition(self)
197
27
 
198
- def get_required_fields(self):
199
- """
200
- Returns a set of field names that this condition needs to evaluate.
201
- Override in subclasses to specify required fields.
202
- """
203
- return set()
204
-
205
28
 
206
- class IsEqual(HookCondition):
207
- def __init__(self, field, value):
29
+ class IsNotEqual(HookCondition):
30
+ def __init__(self, field, value, only_on_change=False):
208
31
  self.field = field
209
32
  self.value = value
33
+ self.only_on_change = only_on_change
210
34
 
211
35
  def check(self, instance, original_instance=None):
212
- current_value = resolve_dotted_attr(instance, self.field)
213
- return current_value == self.value
214
-
215
- def get_required_fields(self):
216
- return {self.field.split('.')[0]}
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
217
44
 
218
45
 
219
- class IsNotEqual(HookCondition):
220
- def __init__(self, field, value):
46
+ class IsEqual(HookCondition):
47
+ def __init__(self, field, value, only_on_change=False):
221
48
  self.field = field
222
49
  self.value = value
50
+ self.only_on_change = only_on_change
223
51
 
224
52
  def check(self, instance, original_instance=None):
225
- current_value = resolve_dotted_attr(instance, self.field)
226
- return current_value != self.value
227
-
228
- def get_required_fields(self):
229
- return {self.field.split('.')[0]}
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
230
61
 
231
62
 
232
- class WasEqual(HookCondition):
233
- def __init__(self, field, value):
63
+ class HasChanged(HookCondition):
64
+ def __init__(self, field, has_changed=True):
234
65
  self.field = field
235
- self.value = value
66
+ self.has_changed = has_changed
236
67
 
237
68
  def check(self, instance, original_instance=None):
238
- if original_instance is None:
69
+ if not original_instance:
239
70
  return False
240
- original_value = resolve_dotted_attr(original_instance, self.field)
241
- return original_value == self.value
242
-
243
- def get_required_fields(self):
244
- return {self.field.split('.')[0]}
71
+ current = resolve_dotted_attr(instance, self.field)
72
+ previous = resolve_dotted_attr(original_instance, self.field)
73
+ return (current != previous) == self.has_changed
245
74
 
246
75
 
247
- class HasChanged(HookCondition):
248
- def __init__(self, field, has_changed=True):
76
+ class WasEqual(HookCondition):
77
+ def __init__(self, field, value, only_on_change=False):
249
78
  """
250
- Check if a field's value has changed or remained the same.
251
-
252
- Args:
253
- field: The field name to check
254
- has_changed: If True (default), condition passes when field has changed.
255
- If False, condition passes when field has remained the same.
256
- This is useful for:
257
- - Detecting stable/unchanged fields
258
- - Validating field immutability
259
- - Ensuring critical fields remain constant
260
- - 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.
261
81
  """
262
82
  self.field = field
263
- self.has_changed = has_changed
83
+ self.value = value
84
+ self.only_on_change = only_on_change
264
85
 
265
86
  def check(self, instance, original_instance=None):
266
87
  if original_instance is None:
267
- # For new instances:
268
- # - If we're checking for changes (has_changed=True), return False since there's no change yet
269
- # - If we're checking for stability (has_changed=False), return True since it's technically unchanged
270
- return not self.has_changed
271
-
272
- current_value = resolve_dotted_attr(instance, self.field)
273
- original_value = resolve_dotted_attr(original_instance, self.field)
274
- return (current_value != original_value) == self.has_changed
275
-
276
- def get_required_fields(self):
277
- return {self.field.split('.')[0]}
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
278
95
 
279
96
 
280
97
  class ChangesTo(HookCondition):
281
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
+ """
282
103
  self.field = field
283
104
  self.value = value
284
105
 
285
106
  def check(self, instance, original_instance=None):
286
107
  if original_instance is None:
287
- current_value = resolve_dotted_attr(instance, self.field)
288
- return current_value == self.value
289
-
290
- current_value = resolve_dotted_attr(instance, self.field)
291
- original_value = resolve_dotted_attr(original_instance, self.field)
292
- return current_value == self.value and current_value != original_value
293
-
294
- def get_required_fields(self):
295
- return {self.field.split('.')[0]}
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
296
112
 
297
113
 
298
114
  class IsGreaterThan(HookCondition):
@@ -336,41 +152,30 @@ class IsLessThanOrEqual(HookCondition):
336
152
 
337
153
 
338
154
  class AndCondition(HookCondition):
339
- def __init__(self, condition1, condition2):
340
- self.condition1 = condition1
341
- self.condition2 = condition2
155
+ def __init__(self, cond1, cond2):
156
+ self.cond1 = cond1
157
+ self.cond2 = cond2
342
158
 
343
159
  def check(self, instance, original_instance=None):
344
- return (
345
- self.condition1.check(instance, original_instance)
346
- and self.condition2.check(instance, original_instance)
160
+ return self.cond1.check(instance, original_instance) and self.cond2.check(
161
+ instance, original_instance
347
162
  )
348
163
 
349
- def get_required_fields(self):
350
- return self.condition1.get_required_fields() | self.condition2.get_required_fields()
351
-
352
164
 
353
165
  class OrCondition(HookCondition):
354
- def __init__(self, condition1, condition2):
355
- self.condition1 = condition1
356
- self.condition2 = condition2
166
+ def __init__(self, cond1, cond2):
167
+ self.cond1 = cond1
168
+ self.cond2 = cond2
357
169
 
358
170
  def check(self, instance, original_instance=None):
359
- return (
360
- self.condition1.check(instance, original_instance)
361
- or self.condition2.check(instance, original_instance)
171
+ return self.cond1.check(instance, original_instance) or self.cond2.check(
172
+ instance, original_instance
362
173
  )
363
174
 
364
- def get_required_fields(self):
365
- return self.condition1.get_required_fields() | self.condition2.get_required_fields()
366
-
367
175
 
368
176
  class NotCondition(HookCondition):
369
- def __init__(self, condition):
370
- self.condition = condition
177
+ def __init__(self, cond):
178
+ self.cond = cond
371
179
 
372
180
  def check(self, instance, original_instance=None):
373
- return not self.condition.check(instance, original_instance)
374
-
375
- def get_required_fields(self):
376
- return self.condition.get_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):
@@ -25,15 +24,10 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
25
24
  def select_related(*related_fields):
26
25
  """
27
26
  Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
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.
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):
@@ -46,14 +40,14 @@ def select_related(*related_fields):
46
40
 
47
41
  if "new_records" not in bound.arguments:
48
42
  raise TypeError(
49
- "@select_related requires a 'new_records' argument in the decorated function"
43
+ "@preload_related requires a 'new_records' argument in the decorated function"
50
44
  )
51
45
 
52
46
  new_records = bound.arguments["new_records"]
53
47
 
54
48
  if not isinstance(new_records, list):
55
49
  raise TypeError(
56
- f"@select_related expects a list of model instances, got {type(new_records)}"
50
+ f"@preload_related expects a list of model instances, got {type(new_records)}"
57
51
  )
58
52
 
59
53
  if not new_records:
@@ -62,29 +56,19 @@ def select_related(*related_fields):
62
56
  # Determine which instances actually need preloading
63
57
  model_cls = new_records[0].__class__
64
58
  ids_to_fetch = []
65
- instances_without_pk = []
66
-
67
59
  for obj in new_records:
68
60
  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
61
  continue
72
-
73
62
  # if any related field is not already cached on the instance,
74
63
  # mark it for fetching
75
64
  if any(field not in obj._state.fields_cache for field in related_fields):
76
65
  ids_to_fetch.append(obj.pk)
77
66
 
78
- # Load foreign keys for objects with PKs
79
67
  fetched = {}
80
68
  if ids_to_fetch:
81
69
  fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids_to_fetch)
82
70
 
83
- # Apply loaded foreign keys to objects with PKs
84
71
  for obj in new_records:
85
- if obj.pk is None:
86
- continue
87
-
88
72
  preloaded = fetched.get(obj.pk)
89
73
  if not preloaded:
90
74
  continue
@@ -94,7 +78,7 @@ def select_related(*related_fields):
94
78
  continue
95
79
  if "." in field:
96
80
  raise ValueError(
97
- f"@select_related does not support nested fields like '{field}'"
81
+ f"@preload_related does not support nested fields like '{field}'"
98
82
  )
99
83
 
100
84
  try:
@@ -112,35 +96,6 @@ def select_related(*related_fields):
112
96
  obj._state.fields_cache[field] = rel_obj
113
97
  except AttributeError:
114
98
  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
99
 
145
100
  return func(*bound.args, **bound.kwargs)
146
101
 
@@ -1,19 +1,13 @@
1
1
  import logging
2
2
 
3
3
  from django.core.exceptions import ValidationError
4
- from django.db import models
4
+
5
5
  from django_bulk_hooks.registry import get_hooks
6
- from django_bulk_hooks.conditions import safe_get_related_object, safe_get_related_attr
7
6
 
8
7
  logger = logging.getLogger(__name__)
9
8
 
10
9
 
11
- # Cache for hook handlers to avoid creating them repeatedly
12
- _handler_cache = {}
13
-
14
10
  def run(model_cls, event, new_instances, original_instances=None, ctx=None):
15
- # Get hooks from cache or fetch them
16
- cache_key = (model_cls, event)
17
11
  hooks = get_hooks(model_cls, event)
18
12
 
19
13
  if not hooks:
@@ -27,42 +21,20 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
27
21
  except ValidationError as e:
28
22
  logger.error("Validation failed for %s: %s", instance, e)
29
23
  raise
30
- except Exception as e:
31
- # Handle RelatedObjectDoesNotExist and other exceptions that might occur
32
- # when accessing foreign key fields on unsaved objects
33
- if "RelatedObjectDoesNotExist" in str(type(e).__name__):
34
- logger.debug("Skipping validation for unsaved object with unset foreign keys: %s", e)
35
- continue
36
- else:
37
- logger.error("Unexpected error during validation for %s: %s", instance, e)
38
- raise
39
24
 
40
- # Pre-create None list for originals if needed
41
- if original_instances is None:
42
- original_instances = [None] * len(new_instances)
43
-
44
- # Process all hooks
45
25
  for handler_cls, method_name, condition, priority in hooks:
46
- # Get or create handler instance from cache
47
- handler_key = (handler_cls, method_name)
48
- if handler_key not in _handler_cache:
49
- handler_instance = handler_cls()
50
- func = getattr(handler_instance, method_name)
51
- _handler_cache[handler_key] = (handler_instance, func)
52
- else:
53
- handler_instance, func = _handler_cache[handler_key]
54
-
55
- # If no condition, process all instances at once
56
- if not condition:
57
- func(new_records=new_instances, old_records=original_instances if any(original_instances) else None)
58
- continue
26
+ handler_instance = handler_cls()
27
+ func = getattr(handler_instance, method_name)
59
28
 
60
- # For conditional hooks, filter instances first
61
29
  to_process_new = []
62
30
  to_process_old = []
63
31
 
64
- for new, original in zip(new_instances, original_instances, strict=True):
65
- if condition.check(new, original):
32
+ for new, original in zip(
33
+ new_instances,
34
+ original_instances or [None] * len(new_instances),
35
+ strict=True,
36
+ ):
37
+ if not condition or condition.check(new, original):
66
38
  to_process_new.append(new)
67
39
  to_process_old.append(original)
68
40
 
@@ -86,7 +86,7 @@ class HookMeta(type):
86
86
  return cls
87
87
 
88
88
 
89
- class HookHandler(metaclass=HookMeta):
89
+ class Hook(metaclass=HookMeta):
90
90
  @classmethod
91
91
  def handle(
92
92
  cls,