django-bulk-hooks 0.1.230__tar.gz → 0.1.232__tar.gz

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.

Files changed (23) hide show
  1. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/PKG-INFO +14 -30
  2. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/README.md +13 -29
  3. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/django_bulk_hooks/conditions.py +30 -33
  4. django_bulk_hooks-0.1.232/django_bulk_hooks/context.py +53 -0
  5. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/django_bulk_hooks/decorators.py +8 -100
  6. django_bulk_hooks-0.1.232/django_bulk_hooks/engine.py +74 -0
  7. django_bulk_hooks-0.1.232/django_bulk_hooks/enums.py +17 -0
  8. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/django_bulk_hooks/handler.py +20 -33
  9. django_bulk_hooks-0.1.232/django_bulk_hooks/manager.py +113 -0
  10. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/django_bulk_hooks/models.py +11 -45
  11. django_bulk_hooks-0.1.232/django_bulk_hooks/priority.py +16 -0
  12. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/django_bulk_hooks/queryset.py +166 -308
  13. django_bulk_hooks-0.1.232/django_bulk_hooks/registry.py +34 -0
  14. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/pyproject.toml +1 -1
  15. django_bulk_hooks-0.1.230/django_bulk_hooks/context.py +0 -81
  16. django_bulk_hooks-0.1.230/django_bulk_hooks/engine.py +0 -127
  17. django_bulk_hooks-0.1.230/django_bulk_hooks/enums.py +0 -14
  18. django_bulk_hooks-0.1.230/django_bulk_hooks/manager.py +0 -135
  19. django_bulk_hooks-0.1.230/django_bulk_hooks/priority.py +0 -16
  20. django_bulk_hooks-0.1.230/django_bulk_hooks/registry.py +0 -136
  21. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/LICENSE +0 -0
  22. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/django_bulk_hooks/__init__.py +0 -0
  23. {django_bulk_hooks-0.1.230 → django_bulk_hooks-0.1.232}/django_bulk_hooks/constants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.230
3
+ Version: 0.1.232
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -59,37 +59,21 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
59
59
  from .models import Account
60
60
 
61
61
  class AccountHooks(Hook):
62
- @hook(AFTER_UPDATE, condition=WhenFieldHasChanged('balance'))
63
- def _notify_balance_change(self, new_records, old_records, **kwargs):
64
- for new_record, old_record in zip(new_records, old_records):
65
- if old_record and new_record.balance != old_record.balance:
66
- print(f"Balance changed from {old_record.balance} to {new_record.balance}")
67
- ```
68
-
69
- ### Bulk Operations with Hooks
70
-
71
- ```python
72
- # For complete hook execution, use the update() method
73
- accounts = Account.objects.filter(active=True)
74
- accounts.update(balance=1000) # Runs all hooks automatically
75
-
76
- # For bulk operations with hooks
77
- accounts = Account.objects.filter(active=True)
78
- instances = list(accounts)
79
-
80
- # bulk_update now runs complete hook cycle by default
81
- accounts.bulk_update(instances, ['balance']) # Runs VALIDATE → BEFORE → DB update → AFTER
82
-
83
- # To skip hooks (for performance or when called from update())
84
- accounts.bulk_update(instances, ['balance'], bypass_hooks=True)
62
+ @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
63
+ def log_balance_change(self, new_records, old_records):
64
+ print("Accounts updated:", [a.pk for a in new_records])
65
+
66
+ @hook(BEFORE_CREATE, model=Account)
67
+ def before_create(self, new_records, old_records):
68
+ for account in new_records:
69
+ if account.balance < 0:
70
+ raise ValueError("Account cannot have negative balance")
71
+
72
+ @hook(AFTER_DELETE, model=Account)
73
+ def after_delete(self, new_records, old_records):
74
+ print("Accounts deleted:", [a.pk for a in old_records])
85
75
  ```
86
76
 
87
- ### Understanding Hook Execution
88
-
89
- - **`update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
90
- - **`bulk_update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
91
- - **`bypass_hooks=True`**: Skips all hooks for performance or to prevent double execution
92
-
93
77
  ## 🛠 Supported Hook Events
94
78
 
95
79
  - `BEFORE_CREATE`, `AFTER_CREATE`
@@ -40,37 +40,21 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
40
40
  from .models import Account
41
41
 
42
42
  class AccountHooks(Hook):
43
- @hook(AFTER_UPDATE, condition=WhenFieldHasChanged('balance'))
44
- def _notify_balance_change(self, new_records, old_records, **kwargs):
45
- for new_record, old_record in zip(new_records, old_records):
46
- if old_record and new_record.balance != old_record.balance:
47
- print(f"Balance changed from {old_record.balance} to {new_record.balance}")
48
- ```
49
-
50
- ### Bulk Operations with Hooks
51
-
52
- ```python
53
- # For complete hook execution, use the update() method
54
- accounts = Account.objects.filter(active=True)
55
- accounts.update(balance=1000) # Runs all hooks automatically
56
-
57
- # For bulk operations with hooks
58
- accounts = Account.objects.filter(active=True)
59
- instances = list(accounts)
60
-
61
- # bulk_update now runs complete hook cycle by default
62
- accounts.bulk_update(instances, ['balance']) # Runs VALIDATE → BEFORE → DB update → AFTER
63
-
64
- # To skip hooks (for performance or when called from update())
65
- accounts.bulk_update(instances, ['balance'], bypass_hooks=True)
43
+ @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
44
+ def log_balance_change(self, new_records, old_records):
45
+ print("Accounts updated:", [a.pk for a in new_records])
46
+
47
+ @hook(BEFORE_CREATE, model=Account)
48
+ def before_create(self, new_records, old_records):
49
+ for account in new_records:
50
+ if account.balance < 0:
51
+ raise ValueError("Account cannot have negative balance")
52
+
53
+ @hook(AFTER_DELETE, model=Account)
54
+ def after_delete(self, new_records, old_records):
55
+ print("Accounts deleted:", [a.pk for a in old_records])
66
56
  ```
67
57
 
68
- ### Understanding Hook Execution
69
-
70
- - **`update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
71
- - **`bulk_update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
72
- - **`bypass_hooks=True`**: Skips all hooks for performance or to prevent double execution
73
-
74
58
  ## 🛠 Supported Hook Events
75
59
 
76
60
  - `BEFORE_CREATE`, `AFTER_CREATE`
@@ -1,14 +1,11 @@
1
1
  import logging
2
- from typing import Any, Optional
3
2
 
4
3
  logger = logging.getLogger(__name__)
5
4
 
6
5
 
7
- def resolve_dotted_attr(instance: Any, dotted_path: str) -> Any:
6
+ def resolve_dotted_attr(instance, dotted_path):
8
7
  """
9
8
  Recursively resolve a dotted attribute path, e.g., "type.category".
10
-
11
- Returns None if any intermediate value is None or missing.
12
9
  """
13
10
  for attr in dotted_path.split("."):
14
11
  if instance is None:
@@ -18,29 +15,29 @@ def resolve_dotted_attr(instance: Any, dotted_path: str) -> Any:
18
15
 
19
16
 
20
17
  class HookCondition:
21
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
18
+ def check(self, instance, original_instance=None):
22
19
  raise NotImplementedError
23
20
 
24
- def __call__(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
21
+ def __call__(self, instance, original_instance=None):
25
22
  return self.check(instance, original_instance)
26
23
 
27
- def __and__(self, other: "HookCondition") -> "HookCondition":
24
+ def __and__(self, other):
28
25
  return AndCondition(self, other)
29
26
 
30
- def __or__(self, other: "HookCondition") -> "HookCondition":
27
+ def __or__(self, other):
31
28
  return OrCondition(self, other)
32
29
 
33
- def __invert__(self) -> "HookCondition":
30
+ def __invert__(self):
34
31
  return NotCondition(self)
35
32
 
36
33
 
37
34
  class IsNotEqual(HookCondition):
38
- def __init__(self, field: str, value: Any, only_on_change: bool = False):
35
+ def __init__(self, field, value, only_on_change=False):
39
36
  self.field = field
40
37
  self.value = value
41
38
  self.only_on_change = only_on_change
42
39
 
43
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
40
+ def check(self, instance, original_instance=None):
44
41
  current = resolve_dotted_attr(instance, self.field)
45
42
  if self.only_on_change:
46
43
  if original_instance is None:
@@ -52,12 +49,12 @@ class IsNotEqual(HookCondition):
52
49
 
53
50
 
54
51
  class IsEqual(HookCondition):
55
- def __init__(self, field: str, value: Any, only_on_change: bool = False):
52
+ def __init__(self, field, value, only_on_change=False):
56
53
  self.field = field
57
54
  self.value = value
58
55
  self.only_on_change = only_on_change
59
56
 
60
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
57
+ def check(self, instance, original_instance=None):
61
58
  current = resolve_dotted_attr(instance, self.field)
62
59
  if self.only_on_change:
63
60
  if original_instance is None:
@@ -69,11 +66,11 @@ class IsEqual(HookCondition):
69
66
 
70
67
 
71
68
  class HasChanged(HookCondition):
72
- def __init__(self, field: str, has_changed: bool = True):
69
+ def __init__(self, field, has_changed=True):
73
70
  self.field = field
74
71
  self.has_changed = has_changed
75
72
 
76
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
73
+ def check(self, instance, original_instance=None):
77
74
  if not original_instance:
78
75
  return False
79
76
 
@@ -88,7 +85,7 @@ class HasChanged(HookCondition):
88
85
 
89
86
 
90
87
  class WasEqual(HookCondition):
91
- def __init__(self, field: str, value: Any, only_on_change: bool = False):
88
+ def __init__(self, field, value, only_on_change=False):
92
89
  """
93
90
  Check if a field's original value was `value`.
94
91
  If only_on_change is True, only return True when the field has changed away from that value.
@@ -97,7 +94,7 @@ class WasEqual(HookCondition):
97
94
  self.value = value
98
95
  self.only_on_change = only_on_change
99
96
 
100
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
97
+ def check(self, instance, original_instance=None):
101
98
  if original_instance is None:
102
99
  return False
103
100
  previous = resolve_dotted_attr(original_instance, self.field)
@@ -109,7 +106,7 @@ class WasEqual(HookCondition):
109
106
 
110
107
 
111
108
  class ChangesTo(HookCondition):
112
- def __init__(self, field: str, value: Any):
109
+ def __init__(self, field, value):
113
110
  """
114
111
  Check if a field's value has changed to `value`.
115
112
  Only returns True when original value != value and current value == value.
@@ -117,7 +114,7 @@ class ChangesTo(HookCondition):
117
114
  self.field = field
118
115
  self.value = value
119
116
 
120
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
117
+ def check(self, instance, original_instance=None):
121
118
  if original_instance is None:
122
119
  return False
123
120
  previous = resolve_dotted_attr(original_instance, self.field)
@@ -126,70 +123,70 @@ class ChangesTo(HookCondition):
126
123
 
127
124
 
128
125
  class IsGreaterThan(HookCondition):
129
- def __init__(self, field: str, value: Any):
126
+ def __init__(self, field, value):
130
127
  self.field = field
131
128
  self.value = value
132
129
 
133
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
130
+ def check(self, instance, original_instance=None):
134
131
  current = resolve_dotted_attr(instance, self.field)
135
132
  return current is not None and current > self.value
136
133
 
137
134
 
138
135
  class IsGreaterThanOrEqual(HookCondition):
139
- def __init__(self, field: str, value: Any):
136
+ def __init__(self, field, value):
140
137
  self.field = field
141
138
  self.value = value
142
139
 
143
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
140
+ def check(self, instance, original_instance=None):
144
141
  current = resolve_dotted_attr(instance, self.field)
145
142
  return current is not None and current >= self.value
146
143
 
147
144
 
148
145
  class IsLessThan(HookCondition):
149
- def __init__(self, field: str, value: Any):
146
+ def __init__(self, field, value):
150
147
  self.field = field
151
148
  self.value = value
152
149
 
153
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
150
+ def check(self, instance, original_instance=None):
154
151
  current = resolve_dotted_attr(instance, self.field)
155
152
  return current is not None and current < self.value
156
153
 
157
154
 
158
155
  class IsLessThanOrEqual(HookCondition):
159
- def __init__(self, field: str, value: Any):
156
+ def __init__(self, field, value):
160
157
  self.field = field
161
158
  self.value = value
162
159
 
163
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
160
+ def check(self, instance, original_instance=None):
164
161
  current = resolve_dotted_attr(instance, self.field)
165
162
  return current is not None and current <= self.value
166
163
 
167
164
 
168
165
  class AndCondition(HookCondition):
169
- def __init__(self, cond1: HookCondition, cond2: HookCondition):
166
+ def __init__(self, cond1, cond2):
170
167
  self.cond1 = cond1
171
168
  self.cond2 = cond2
172
169
 
173
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
170
+ def check(self, instance, original_instance=None):
174
171
  return self.cond1.check(instance, original_instance) and self.cond2.check(
175
172
  instance, original_instance
176
173
  )
177
174
 
178
175
 
179
176
  class OrCondition(HookCondition):
180
- def __init__(self, cond1: HookCondition, cond2: HookCondition):
177
+ def __init__(self, cond1, cond2):
181
178
  self.cond1 = cond1
182
179
  self.cond2 = cond2
183
180
 
184
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
181
+ def check(self, instance, original_instance=None):
185
182
  return self.cond1.check(instance, original_instance) or self.cond2.check(
186
183
  instance, original_instance
187
184
  )
188
185
 
189
186
 
190
187
  class NotCondition(HookCondition):
191
- def __init__(self, cond: HookCondition):
188
+ def __init__(self, cond):
192
189
  self.cond = cond
193
190
 
194
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
191
+ def check(self, instance, original_instance=None):
195
192
  return not self.cond.check(instance, original_instance)
@@ -0,0 +1,53 @@
1
+ import threading
2
+ from collections import deque
3
+ from django_bulk_hooks.handler import hook_vars
4
+
5
+
6
+ _hook_context = threading.local()
7
+
8
+
9
+ def get_hook_queue():
10
+ if not hasattr(_hook_context, "queue"):
11
+ _hook_context.queue = deque()
12
+ return _hook_context.queue
13
+
14
+
15
+ def set_bypass_hooks(bypass_hooks):
16
+ """Set the current bypass_hooks state for the current thread."""
17
+ _hook_context.bypass_hooks = bypass_hooks
18
+
19
+
20
+ def get_bypass_hooks():
21
+ """Get the current bypass_hooks state for the current thread."""
22
+ return getattr(_hook_context, 'bypass_hooks', False)
23
+
24
+
25
+ class HookContext:
26
+ def __init__(self, model, bypass_hooks=False):
27
+ self.model = model
28
+ self.bypass_hooks = bypass_hooks
29
+ # Set the thread-local bypass state when creating a context
30
+ set_bypass_hooks(bypass_hooks)
31
+
32
+ @property
33
+ def is_executing(self):
34
+ """
35
+ Check if we're currently in a hook execution context.
36
+ Similar to Salesforce's Trigger.isExecuting.
37
+ Use this to prevent infinite recursion in hooks.
38
+ """
39
+ return hasattr(hook_vars, 'event') and hook_vars.event is not None
40
+
41
+ @property
42
+ def current_event(self):
43
+ """
44
+ Get the current hook event being executed.
45
+ """
46
+ return getattr(hook_vars, 'event', None)
47
+
48
+ @property
49
+ def execution_depth(self):
50
+ """
51
+ Get the current execution depth to detect deep recursion.
52
+ """
53
+ return getattr(hook_vars, 'depth', 0)
@@ -1,28 +1,18 @@
1
- """
2
- Decorators for defining and optimizing hook handlers.
3
-
4
- Notes:
5
- - Hook registration occurs at import time; importing modules that define Hook
6
- subclasses or use @hook will register handlers in the global registry.
7
- - The preload helpers below are safe, in-place optimizations to avoid N+1s.
8
- """
9
-
10
1
  import inspect
11
2
  from functools import wraps
12
- from typing import Any, Callable, Optional
13
3
 
14
4
  from django.core.exceptions import FieldDoesNotExist
15
5
  from django_bulk_hooks.enums import DEFAULT_PRIORITY
16
6
  from django_bulk_hooks.registry import register_hook
17
7
 
18
8
 
19
- def hook(event: str, *, model: type, condition: Optional[Callable] = None, priority: int = DEFAULT_PRIORITY):
9
+ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
20
10
  """
21
11
  Decorator to annotate a method with multiple hooks hook registrations.
22
12
  If no priority is provided, uses Priority.NORMAL (50).
23
13
  """
24
14
 
25
- def decorator(fn: Callable):
15
+ def decorator(fn):
26
16
  if not hasattr(fn, "hooks_hooks"):
27
17
  fn.hooks_hooks = []
28
18
  fn.hooks_hooks.append((model, event, condition, priority))
@@ -31,7 +21,7 @@ def hook(event: str, *, model: type, condition: Optional[Callable] = None, prior
31
21
  return decorator
32
22
 
33
23
 
34
- def select_related(*related_fields: str):
24
+ def select_related(*related_fields):
35
25
  """
36
26
  Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
37
27
 
@@ -40,14 +30,11 @@ def select_related(*related_fields: str):
40
30
  - Populates Django's relation cache to avoid extra queries
41
31
  """
42
32
 
43
- def decorator(func: Callable):
44
- # No-op if no fields specified
45
- if not related_fields:
46
- return func
33
+ def decorator(func):
47
34
  sig = inspect.signature(func)
48
35
 
49
36
  @wraps(func)
50
- def wrapper(*args: Any, **kwargs: Any):
37
+ def wrapper(*args, **kwargs):
51
38
  bound = sig.bind_partial(*args, **kwargs)
52
39
  bound.apply_defaults()
53
40
 
@@ -118,7 +105,7 @@ def select_related(*related_fields: str):
118
105
  return decorator
119
106
 
120
107
 
121
- def bulk_hook(model_cls: type, event: str, when: Optional[Callable] = None, priority: Optional[int] = None):
108
+ def bulk_hook(model_cls, event, when=None, priority=None):
122
109
  """
123
110
  Decorator to register a bulk hook for a model.
124
111
 
@@ -128,13 +115,13 @@ def bulk_hook(model_cls: type, event: str, when: Optional[Callable] = None, prio
128
115
  when: Optional condition for when the hook should run
129
116
  priority: Optional priority for hook execution order
130
117
  """
131
- def decorator(func: Callable):
118
+ def decorator(func):
132
119
  # Create a simple handler class for the function
133
120
  class FunctionHandler:
134
121
  def __init__(self):
135
122
  self.func = func
136
123
 
137
- def handle(self, new_instances: list, original_instances: Optional[list]):
124
+ def handle(self, new_instances, original_instances):
138
125
  return self.func(new_instances, original_instances)
139
126
 
140
127
  # Register the hook using the registry
@@ -148,82 +135,3 @@ def bulk_hook(model_cls: type, event: str, when: Optional[Callable] = None, prio
148
135
  )
149
136
  return func
150
137
  return decorator
151
-
152
-
153
- def prefetch_related(*related_fields: str):
154
- """
155
- Decorator that prefetches related collections on `new_records` in-place,
156
- populating Django's prefetch cache to avoid extra queries in hooks.
157
-
158
- - Supports many-to-many and one-to-many relationships
159
- - Preserves instance identity; does not replace objects
160
- - Uses the base manager to avoid recursive hook triggering
161
- """
162
-
163
- def decorator(func: Callable):
164
- # No-op if no fields specified
165
- if not related_fields:
166
- return func
167
-
168
- sig = inspect.signature(func)
169
-
170
- @wraps(func)
171
- def wrapper(*args: Any, **kwargs: Any):
172
- bound = sig.bind_partial(*args, **kwargs)
173
- bound.apply_defaults()
174
-
175
- if "new_records" not in bound.arguments:
176
- raise TypeError(
177
- "@prefetch_related requires a 'new_records' argument in the decorated function"
178
- )
179
-
180
- new_records = bound.arguments["new_records"]
181
-
182
- if not isinstance(new_records, list):
183
- raise TypeError(
184
- f"@prefetch_related expects a list of model instances, got {type(new_records)}"
185
- )
186
-
187
- if not new_records:
188
- return func(*args, **kwargs)
189
-
190
- model_cls = new_records[0].__class__
191
- ids_to_fetch = [obj.pk for obj in new_records if getattr(obj, "pk", None)]
192
-
193
- if ids_to_fetch:
194
- # Validate fields (no dotted notation)
195
- for field in related_fields:
196
- if "." in field:
197
- raise ValueError(
198
- f"@prefetch_related does not support nested fields like '{field}'"
199
- )
200
-
201
- fetched_map = {
202
- obj.pk: obj
203
- for obj in (
204
- model_cls._base_manager.filter(pk__in=ids_to_fetch)
205
- .prefetch_related(*related_fields)
206
- )
207
- }
208
-
209
- for obj in new_records:
210
- preloaded = fetched_map.get(obj.pk)
211
- if preloaded is None:
212
- continue
213
- # Copy prefetch cache entries from the preloaded instance
214
- src_cache = getattr(preloaded, "_prefetched_objects_cache", {})
215
- if not src_cache:
216
- continue
217
- dst_cache = getattr(obj, "_prefetched_objects_cache", None)
218
- if dst_cache is None:
219
- obj._prefetched_objects_cache = {}
220
- dst_cache = obj._prefetched_objects_cache
221
- for field in related_fields:
222
- if field in src_cache and field not in dst_cache:
223
- dst_cache[field] = src_cache[field]
224
-
225
- return func(*bound.args, **bound.kwargs)
226
-
227
- return wrapper
228
-
229
- return decorator
@@ -0,0 +1,74 @@
1
+ import logging
2
+
3
+ from django.core.exceptions import ValidationError
4
+
5
+ from django_bulk_hooks.registry import get_hooks
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def run(model_cls, event, new_records, old_records=None, ctx=None):
11
+ """
12
+ Run hooks for a given model, event, and records.
13
+ """
14
+ if not new_records:
15
+ return
16
+
17
+ # Get hooks for this model and event
18
+ hooks = get_hooks(model_cls, event)
19
+
20
+ if not hooks:
21
+ return
22
+
23
+ import traceback
24
+
25
+ stack = traceback.format_stack()
26
+ logger.debug(f"engine.run {model_cls.__name__}.{event} {len(new_records)} records")
27
+
28
+ # Check if we're in a bypass context
29
+ if ctx and hasattr(ctx, 'bypass_hooks') and ctx.bypass_hooks:
30
+ logger.debug("engine.run bypassed")
31
+ return
32
+
33
+ # For BEFORE_* events, run model.clean() first for validation
34
+ if event.startswith("before_"):
35
+ for instance in new_records:
36
+ try:
37
+ instance.clean()
38
+ except ValidationError as e:
39
+ logger.error("Validation failed for %s: %s", instance, e)
40
+ raise
41
+
42
+ # Process hooks
43
+ for handler_cls, method_name, condition, priority in hooks:
44
+ logger.debug(f"Processing {handler_cls.__name__}.{method_name}")
45
+ handler_instance = handler_cls()
46
+ func = getattr(handler_instance, method_name)
47
+
48
+ to_process_new = []
49
+ to_process_old = []
50
+
51
+ for new, original in zip(
52
+ new_records,
53
+ old_records or [None] * len(new_records),
54
+ strict=True,
55
+ ):
56
+ if not condition:
57
+ to_process_new.append(new)
58
+ to_process_old.append(original)
59
+ else:
60
+ condition_result = condition.check(new, original)
61
+ if condition_result:
62
+ to_process_new.append(new)
63
+ to_process_old.append(original)
64
+
65
+ if to_process_new:
66
+ logger.debug(f"Executing {handler_cls.__name__}.{method_name} for {len(to_process_new)} records")
67
+ try:
68
+ func(
69
+ new_records=to_process_new,
70
+ old_records=to_process_old if any(to_process_old) else None,
71
+ )
72
+ except Exception as e:
73
+ logger.debug(f"Hook execution failed: {e}")
74
+ raise
@@ -0,0 +1,17 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ class Priority(IntEnum):
5
+ """
6
+ Named priorities for django-bulk-hooks hooks.
7
+ Replaces module-level constants with a clean IntEnum.
8
+ """
9
+
10
+ HIGHEST = 0 # runs first
11
+ HIGH = 25 # runs early
12
+ NORMAL = 50 # default ordering
13
+ LOW = 75 # runs late
14
+ LOWEST = 100 # runs last
15
+
16
+
17
+ DEFAULT_PRIORITY = Priority.NORMAL