django-bulk-hooks 0.1.101__py3-none-any.whl → 0.1.102__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.
@@ -1,19 +1,3 @@
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
1
  from django_bulk_hooks.constants import (
18
2
  AFTER_CREATE,
19
3
  AFTER_DELETE,
@@ -25,10 +9,20 @@ from django_bulk_hooks.constants import (
25
9
  VALIDATE_DELETE,
26
10
  VALIDATE_UPDATE,
27
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
+ )
28
22
  from django_bulk_hooks.decorators import hook, select_related
29
- from django_bulk_hooks.enums import Priority
30
23
  from django_bulk_hooks.handler import HookHandler
31
24
  from django_bulk_hooks.models import HookModelMixin
25
+ from django_bulk_hooks.enums import Priority
32
26
 
33
27
  __all__ = [
34
28
  "HookHandler",
@@ -53,10 +47,4 @@ __all__ = [
53
47
  "IsEqual",
54
48
  "IsNotEqual",
55
49
  "WasEqual",
56
- "IsBlank",
57
- "IsGreaterThan",
58
- "IsLessThan",
59
- "IsGreaterThanOrEqual",
60
- "IsLessThanOrEqual",
61
- "LambdaCondition",
62
50
  ]
@@ -8,7 +8,7 @@ def safe_get_related_object(instance, field_name):
8
8
  """
9
9
  if not hasattr(instance, field_name):
10
10
  return None
11
-
11
+
12
12
  # Get the foreign key field
13
13
  try:
14
14
  field = instance._meta.get_field(field_name)
@@ -16,12 +16,12 @@ def safe_get_related_object(instance, field_name):
16
16
  return getattr(instance, field_name, None)
17
17
  except models.FieldDoesNotExist:
18
18
  return getattr(instance, field_name, None)
19
-
19
+
20
20
  # Check if the foreign key field is None
21
21
  fk_field_name = f"{field_name}_id"
22
22
  if hasattr(instance, fk_field_name) and getattr(instance, fk_field_name) is None:
23
23
  return None
24
-
24
+
25
25
  # Try to get the related object, but catch RelatedObjectDoesNotExist
26
26
  try:
27
27
  return getattr(instance, field_name)
@@ -29,25 +29,49 @@ def safe_get_related_object(instance, field_name):
29
29
  return None
30
30
 
31
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
+
32
56
  def safe_get_related_attr(instance, field_name, attr_name=None):
33
57
  """
34
58
  Safely get a related object or its attribute without raising RelatedObjectDoesNotExist.
35
-
59
+
36
60
  This is particularly useful in hooks where objects might not have their related
37
61
  fields populated yet (e.g., during bulk_create operations or on unsaved objects).
38
-
62
+
39
63
  Args:
40
64
  instance: The model instance
41
65
  field_name: The foreign key field name
42
66
  attr_name: Optional attribute name to access on the related object
43
-
67
+
44
68
  Returns:
45
69
  The related object, the attribute value, or None if not available
46
-
70
+
47
71
  Example:
48
72
  # Instead of: loan_transaction.status.name (which might fail)
49
73
  # Use: safe_get_related_attr(loan_transaction, 'status', 'name')
50
-
74
+
51
75
  status_name = safe_get_related_attr(loan_transaction, 'status', 'name')
52
76
  if status_name in {Status.COMPLETE.value, Status.FAILED.value}:
53
77
  # Process the transaction
@@ -63,31 +87,96 @@ def safe_get_related_attr(instance, field_name, attr_name=None):
63
87
  # If we have an ID but the object isn't loaded, try to load it
64
88
  try:
65
89
  field = instance._meta.get_field(field_name)
66
- if hasattr(field, "related_model"):
90
+ if hasattr(field, 'related_model'):
67
91
  related_obj = field.related_model.objects.get(id=fk_value)
68
92
  if attr_name is None:
69
93
  return related_obj
70
94
  return getattr(related_obj, attr_name, None)
71
95
  except (field.related_model.DoesNotExist, AttributeError):
72
96
  return None
73
-
97
+
74
98
  # For saved objects or when the above doesn't work, use the original method
75
99
  related_obj = safe_get_related_object(instance, field_name)
76
100
  if related_obj is None:
77
101
  return None
102
+
78
103
  if attr_name is None:
79
104
  return related_obj
105
+
80
106
  return getattr(related_obj, attr_name, None)
81
107
 
82
108
 
83
- def is_field_set(instance, field_name):
109
+ def safe_get_related_attr_with_fallback(instance, field_name, attr_name=None, fallback_value=None):
84
110
  """
85
- Check if a field has been set on a model instance.
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
+
86
149
 
87
- This is useful for checking if a field has been explicitly set,
88
- even if it's been set to None.
150
+ def resolve_dotted_attr(instance, dotted_path):
151
+ """
152
+ 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.
89
154
  """
90
- return hasattr(instance, field_name)
155
+ if instance is None:
156
+ return None
157
+
158
+ current = instance
159
+ for attr in dotted_path.split("."):
160
+ if current is None:
161
+ 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
91
180
 
92
181
 
93
182
  class HookCondition:
@@ -114,94 +203,54 @@ class HookCondition:
114
203
  return set()
115
204
 
116
205
 
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
-
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
206
+ class IsEqual(HookCondition):
207
+ def __init__(self, field, value):
208
+ self.field = field
209
+ self.value = value
164
210
 
165
211
  def check(self, instance, original_instance=None):
166
- return not self.condition.check(instance, original_instance)
212
+ current_value = resolve_dotted_attr(instance, self.field)
213
+ return current_value == self.value
167
214
 
168
215
  def get_required_fields(self):
169
- return self.condition.get_required_fields()
216
+ return {self.field.split('.')[0]}
170
217
 
171
218
 
172
- class IsEqual(HookCondition):
173
- def __init__(self, field_name, value):
174
- self.field_name = field_name
219
+ class IsNotEqual(HookCondition):
220
+ def __init__(self, field, value):
221
+ self.field = field
175
222
  self.value = value
176
223
 
177
224
  def check(self, instance, original_instance=None):
178
- return getattr(instance, self.field_name, None) == self.value
225
+ current_value = resolve_dotted_attr(instance, self.field)
226
+ return current_value != self.value
179
227
 
180
228
  def get_required_fields(self):
181
- return {self.field_name}
229
+ return {self.field.split('.')[0]}
182
230
 
183
231
 
184
232
  class WasEqual(HookCondition):
185
- def __init__(self, field_name, value):
186
- self.field_name = field_name
233
+ def __init__(self, field, value):
234
+ self.field = field
187
235
  self.value = value
188
236
 
189
237
  def check(self, instance, original_instance=None):
190
238
  if original_instance is None:
191
239
  return False
192
- return getattr(original_instance, self.field_name, None) == self.value
240
+ original_value = resolve_dotted_attr(original_instance, self.field)
241
+ return original_value == self.value
193
242
 
194
243
  def get_required_fields(self):
195
- return {self.field_name}
244
+ return {self.field.split('.')[0]}
196
245
 
197
246
 
198
247
  class HasChanged(HookCondition):
199
- def __init__(self, field_name, has_changed=True):
248
+ def __init__(self, field, has_changed=True):
200
249
  """
201
250
  Check if a field's value has changed or remained the same.
202
-
251
+
203
252
  Args:
204
- field_name: The field name to check
253
+ field: The field name to check
205
254
  has_changed: If True (default), condition passes when field has changed.
206
255
  If False, condition passes when field has remained the same.
207
256
  This is useful for:
@@ -210,149 +259,118 @@ class HasChanged(HookCondition):
210
259
  - Ensuring critical fields remain constant
211
260
  - State machine validations
212
261
  """
213
- self.field_name = field_name
262
+ self.field = field
214
263
  self.has_changed = has_changed
215
264
 
216
265
  def check(self, instance, original_instance=None):
217
266
  if original_instance is None:
218
267
  # 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)
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)
225
274
  return (current_value != original_value) == self.has_changed
226
275
 
227
276
  def get_required_fields(self):
228
- return {self.field_name}
277
+ return {self.field.split('.')[0]}
229
278
 
230
279
 
231
280
  class ChangesTo(HookCondition):
232
- def __init__(self, field_name, value):
233
- self.field_name = field_name
281
+ def __init__(self, field, value):
282
+ self.field = field
234
283
  self.value = value
235
284
 
236
285
  def check(self, instance, original_instance=None):
237
286
  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)
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
242
293
 
243
294
  def get_required_fields(self):
244
- return {self.field_name}
295
+ return {self.field.split('.')[0]}
245
296
 
246
297
 
247
- class IsNotEqual(HookCondition):
248
- def __init__(self, field_name, value):
249
- self.field_name = field_name
298
+ class IsGreaterThan(HookCondition):
299
+ def __init__(self, field, value):
300
+ self.field = field
250
301
  self.value = value
251
302
 
252
303
  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}
304
+ current = resolve_dotted_attr(instance, self.field)
305
+ return current is not None and current > self.value
257
306
 
258
307
 
259
- class IsGreaterThan(HookCondition):
260
- def __init__(self, field_name, value):
261
- self.field_name = field_name
308
+ class IsGreaterThanOrEqual(HookCondition):
309
+ def __init__(self, field, value):
310
+ self.field = field
262
311
  self.value = value
263
312
 
264
313
  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}
314
+ current = resolve_dotted_attr(instance, self.field)
315
+ return current is not None and current >= self.value
272
316
 
273
317
 
274
318
  class IsLessThan(HookCondition):
275
- def __init__(self, field_name, value):
276
- self.field_name = field_name
319
+ def __init__(self, field, value):
320
+ self.field = field
277
321
  self.value = value
278
322
 
279
323
  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
284
-
285
- def get_required_fields(self):
286
- return {self.field_name}
324
+ current = resolve_dotted_attr(instance, self.field)
325
+ return current is not None and current < self.value
287
326
 
288
327
 
289
- class IsGreaterThanOrEqual(HookCondition):
290
- def __init__(self, field_name, value):
291
- self.field_name = field_name
328
+ class IsLessThanOrEqual(HookCondition):
329
+ def __init__(self, field, value):
330
+ self.field = field
292
331
  self.value = value
293
332
 
294
333
  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
334
+ current = resolve_dotted_attr(instance, self.field)
335
+ return current is not None and current <= self.value
299
336
 
300
- def get_required_fields(self):
301
- return {self.field_name}
302
337
 
303
-
304
- class IsLessThanOrEqual(HookCondition):
305
- def __init__(self, field_name, value):
306
- self.field_name = field_name
307
- self.value = value
338
+ class AndCondition(HookCondition):
339
+ def __init__(self, condition1, condition2):
340
+ self.condition1 = condition1
341
+ self.condition2 = condition2
308
342
 
309
343
  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
344
+ return (
345
+ self.condition1.check(instance, original_instance)
346
+ and self.condition2.check(instance, original_instance)
347
+ )
314
348
 
315
349
  def get_required_fields(self):
316
- return {self.field_name}
350
+ return self.condition1.get_required_fields() | self.condition2.get_required_fields()
317
351
 
318
352
 
319
- class LambdaCondition(HookCondition):
320
- """
321
- A condition that uses a lambda function or any callable.
322
-
323
- This makes it easy to create custom conditions inline without defining
324
- a full class.
325
-
326
- Example:
327
- # Simple lambda condition
328
- condition = LambdaCondition(lambda instance: instance.price > 100)
353
+ class OrCondition(HookCondition):
354
+ def __init__(self, condition1, condition2):
355
+ self.condition1 = condition1
356
+ self.condition2 = condition2
329
357
 
330
- # Lambda with original instance
331
- condition = LambdaCondition(
332
- lambda instance, original: instance.price > original.price
358
+ def check(self, instance, original_instance=None):
359
+ return (
360
+ self.condition1.check(instance, original_instance)
361
+ or self.condition2.check(instance, original_instance)
333
362
  )
334
363
 
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
- """
364
+ def get_required_fields(self):
365
+ return self.condition1.get_required_fields() | self.condition2.get_required_fields()
342
366
 
343
- def __init__(self, func, required_fields=None):
344
- """
345
- Initialize with a callable function.
346
367
 
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()
368
+ class NotCondition(HookCondition):
369
+ def __init__(self, condition):
370
+ self.condition = condition
353
371
 
354
372
  def check(self, instance, original_instance=None):
355
- return self.func(instance, original_instance)
373
+ return not self.condition.check(instance, original_instance)
356
374
 
357
375
  def get_required_fields(self):
358
- return self._required_fields
376
+ return self.condition.get_required_fields()