django-bulk-hooks 0.1.228__tar.gz → 0.1.230__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.
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/PKG-INFO +1 -1
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/django_bulk_hooks/conditions.py +33 -30
- django_bulk_hooks-0.1.230/django_bulk_hooks/context.py +81 -0
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/django_bulk_hooks/decorators.py +100 -8
- django_bulk_hooks-0.1.230/django_bulk_hooks/engine.py +127 -0
- django_bulk_hooks-0.1.230/django_bulk_hooks/enums.py +14 -0
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/django_bulk_hooks/handler.py +1 -10
- django_bulk_hooks-0.1.230/django_bulk_hooks/manager.py +135 -0
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/django_bulk_hooks/models.py +42 -11
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/django_bulk_hooks/queryset.py +164 -90
- django_bulk_hooks-0.1.230/django_bulk_hooks/registry.py +136 -0
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.228/django_bulk_hooks/context.py +0 -53
- django_bulk_hooks-0.1.228/django_bulk_hooks/engine.py +0 -89
- django_bulk_hooks-0.1.228/django_bulk_hooks/enums.py +0 -3
- django_bulk_hooks-0.1.228/django_bulk_hooks/manager.py +0 -113
- django_bulk_hooks-0.1.228/django_bulk_hooks/registry.py +0 -91
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/LICENSE +0 -0
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/README.md +0 -0
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.228 → django_bulk_hooks-0.1.230}/django_bulk_hooks/priority.py +0 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from typing import Any, Optional
|
|
2
3
|
|
|
3
4
|
logger = logging.getLogger(__name__)
|
|
4
5
|
|
|
5
6
|
|
|
6
|
-
def resolve_dotted_attr(instance, dotted_path):
|
|
7
|
+
def resolve_dotted_attr(instance: Any, dotted_path: str) -> Any:
|
|
7
8
|
"""
|
|
8
9
|
Recursively resolve a dotted attribute path, e.g., "type.category".
|
|
10
|
+
|
|
11
|
+
Returns None if any intermediate value is None or missing.
|
|
9
12
|
"""
|
|
10
13
|
for attr in dotted_path.split("."):
|
|
11
14
|
if instance is None:
|
|
@@ -15,29 +18,29 @@ def resolve_dotted_attr(instance, dotted_path):
|
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
class HookCondition:
|
|
18
|
-
def check(self, instance, original_instance=None):
|
|
21
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
19
22
|
raise NotImplementedError
|
|
20
23
|
|
|
21
|
-
def __call__(self, instance, original_instance=None):
|
|
24
|
+
def __call__(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
22
25
|
return self.check(instance, original_instance)
|
|
23
26
|
|
|
24
|
-
def __and__(self, other):
|
|
27
|
+
def __and__(self, other: "HookCondition") -> "HookCondition":
|
|
25
28
|
return AndCondition(self, other)
|
|
26
29
|
|
|
27
|
-
def __or__(self, other):
|
|
30
|
+
def __or__(self, other: "HookCondition") -> "HookCondition":
|
|
28
31
|
return OrCondition(self, other)
|
|
29
32
|
|
|
30
|
-
def __invert__(self):
|
|
33
|
+
def __invert__(self) -> "HookCondition":
|
|
31
34
|
return NotCondition(self)
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
class IsNotEqual(HookCondition):
|
|
35
|
-
def __init__(self, field, value, only_on_change=False):
|
|
38
|
+
def __init__(self, field: str, value: Any, only_on_change: bool = False):
|
|
36
39
|
self.field = field
|
|
37
40
|
self.value = value
|
|
38
41
|
self.only_on_change = only_on_change
|
|
39
42
|
|
|
40
|
-
def check(self, instance, original_instance=None):
|
|
43
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
41
44
|
current = resolve_dotted_attr(instance, self.field)
|
|
42
45
|
if self.only_on_change:
|
|
43
46
|
if original_instance is None:
|
|
@@ -49,12 +52,12 @@ class IsNotEqual(HookCondition):
|
|
|
49
52
|
|
|
50
53
|
|
|
51
54
|
class IsEqual(HookCondition):
|
|
52
|
-
def __init__(self, field, value, only_on_change=False):
|
|
55
|
+
def __init__(self, field: str, value: Any, only_on_change: bool = False):
|
|
53
56
|
self.field = field
|
|
54
57
|
self.value = value
|
|
55
58
|
self.only_on_change = only_on_change
|
|
56
59
|
|
|
57
|
-
def check(self, instance, original_instance=None):
|
|
60
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
58
61
|
current = resolve_dotted_attr(instance, self.field)
|
|
59
62
|
if self.only_on_change:
|
|
60
63
|
if original_instance is None:
|
|
@@ -66,11 +69,11 @@ class IsEqual(HookCondition):
|
|
|
66
69
|
|
|
67
70
|
|
|
68
71
|
class HasChanged(HookCondition):
|
|
69
|
-
def __init__(self, field, has_changed=True):
|
|
72
|
+
def __init__(self, field: str, has_changed: bool = True):
|
|
70
73
|
self.field = field
|
|
71
74
|
self.has_changed = has_changed
|
|
72
75
|
|
|
73
|
-
def check(self, instance, original_instance=None):
|
|
76
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
74
77
|
if not original_instance:
|
|
75
78
|
return False
|
|
76
79
|
|
|
@@ -85,7 +88,7 @@ class HasChanged(HookCondition):
|
|
|
85
88
|
|
|
86
89
|
|
|
87
90
|
class WasEqual(HookCondition):
|
|
88
|
-
def __init__(self, field, value, only_on_change=False):
|
|
91
|
+
def __init__(self, field: str, value: Any, only_on_change: bool = False):
|
|
89
92
|
"""
|
|
90
93
|
Check if a field's original value was `value`.
|
|
91
94
|
If only_on_change is True, only return True when the field has changed away from that value.
|
|
@@ -94,7 +97,7 @@ class WasEqual(HookCondition):
|
|
|
94
97
|
self.value = value
|
|
95
98
|
self.only_on_change = only_on_change
|
|
96
99
|
|
|
97
|
-
def check(self, instance, original_instance=None):
|
|
100
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
98
101
|
if original_instance is None:
|
|
99
102
|
return False
|
|
100
103
|
previous = resolve_dotted_attr(original_instance, self.field)
|
|
@@ -106,7 +109,7 @@ class WasEqual(HookCondition):
|
|
|
106
109
|
|
|
107
110
|
|
|
108
111
|
class ChangesTo(HookCondition):
|
|
109
|
-
def __init__(self, field, value):
|
|
112
|
+
def __init__(self, field: str, value: Any):
|
|
110
113
|
"""
|
|
111
114
|
Check if a field's value has changed to `value`.
|
|
112
115
|
Only returns True when original value != value and current value == value.
|
|
@@ -114,7 +117,7 @@ class ChangesTo(HookCondition):
|
|
|
114
117
|
self.field = field
|
|
115
118
|
self.value = value
|
|
116
119
|
|
|
117
|
-
def check(self, instance, original_instance=None):
|
|
120
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
118
121
|
if original_instance is None:
|
|
119
122
|
return False
|
|
120
123
|
previous = resolve_dotted_attr(original_instance, self.field)
|
|
@@ -123,70 +126,70 @@ class ChangesTo(HookCondition):
|
|
|
123
126
|
|
|
124
127
|
|
|
125
128
|
class IsGreaterThan(HookCondition):
|
|
126
|
-
def __init__(self, field, value):
|
|
129
|
+
def __init__(self, field: str, value: Any):
|
|
127
130
|
self.field = field
|
|
128
131
|
self.value = value
|
|
129
132
|
|
|
130
|
-
def check(self, instance, original_instance=None):
|
|
133
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
131
134
|
current = resolve_dotted_attr(instance, self.field)
|
|
132
135
|
return current is not None and current > self.value
|
|
133
136
|
|
|
134
137
|
|
|
135
138
|
class IsGreaterThanOrEqual(HookCondition):
|
|
136
|
-
def __init__(self, field, value):
|
|
139
|
+
def __init__(self, field: str, value: Any):
|
|
137
140
|
self.field = field
|
|
138
141
|
self.value = value
|
|
139
142
|
|
|
140
|
-
def check(self, instance, original_instance=None):
|
|
143
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
141
144
|
current = resolve_dotted_attr(instance, self.field)
|
|
142
145
|
return current is not None and current >= self.value
|
|
143
146
|
|
|
144
147
|
|
|
145
148
|
class IsLessThan(HookCondition):
|
|
146
|
-
def __init__(self, field, value):
|
|
149
|
+
def __init__(self, field: str, value: Any):
|
|
147
150
|
self.field = field
|
|
148
151
|
self.value = value
|
|
149
152
|
|
|
150
|
-
def check(self, instance, original_instance=None):
|
|
153
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
151
154
|
current = resolve_dotted_attr(instance, self.field)
|
|
152
155
|
return current is not None and current < self.value
|
|
153
156
|
|
|
154
157
|
|
|
155
158
|
class IsLessThanOrEqual(HookCondition):
|
|
156
|
-
def __init__(self, field, value):
|
|
159
|
+
def __init__(self, field: str, value: Any):
|
|
157
160
|
self.field = field
|
|
158
161
|
self.value = value
|
|
159
162
|
|
|
160
|
-
def check(self, instance, original_instance=None):
|
|
163
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
161
164
|
current = resolve_dotted_attr(instance, self.field)
|
|
162
165
|
return current is not None and current <= self.value
|
|
163
166
|
|
|
164
167
|
|
|
165
168
|
class AndCondition(HookCondition):
|
|
166
|
-
def __init__(self, cond1, cond2):
|
|
169
|
+
def __init__(self, cond1: HookCondition, cond2: HookCondition):
|
|
167
170
|
self.cond1 = cond1
|
|
168
171
|
self.cond2 = cond2
|
|
169
172
|
|
|
170
|
-
def check(self, instance, original_instance=None):
|
|
173
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
171
174
|
return self.cond1.check(instance, original_instance) and self.cond2.check(
|
|
172
175
|
instance, original_instance
|
|
173
176
|
)
|
|
174
177
|
|
|
175
178
|
|
|
176
179
|
class OrCondition(HookCondition):
|
|
177
|
-
def __init__(self, cond1, cond2):
|
|
180
|
+
def __init__(self, cond1: HookCondition, cond2: HookCondition):
|
|
178
181
|
self.cond1 = cond1
|
|
179
182
|
self.cond2 = cond2
|
|
180
183
|
|
|
181
|
-
def check(self, instance, original_instance=None):
|
|
184
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
182
185
|
return self.cond1.check(instance, original_instance) or self.cond2.check(
|
|
183
186
|
instance, original_instance
|
|
184
187
|
)
|
|
185
188
|
|
|
186
189
|
|
|
187
190
|
class NotCondition(HookCondition):
|
|
188
|
-
def __init__(self, cond):
|
|
191
|
+
def __init__(self, cond: HookCondition):
|
|
189
192
|
self.cond = cond
|
|
190
193
|
|
|
191
|
-
def check(self, instance, original_instance=None):
|
|
194
|
+
def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
|
|
192
195
|
return not self.cond.check(instance, original_instance)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import Deque, Optional
|
|
3
|
+
from django_bulk_hooks.handler import hook_vars, get_hook_queue as _handler_get_hook_queue
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_hook_context = threading.local()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_hook_queue() -> Deque:
|
|
10
|
+
"""
|
|
11
|
+
Return the per-thread hook execution queue used by the handler.
|
|
12
|
+
|
|
13
|
+
This proxies to the centralized queue in the handler module to avoid
|
|
14
|
+
divergent queues.
|
|
15
|
+
"""
|
|
16
|
+
return _handler_get_hook_queue()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def set_bypass_hooks(bypass_hooks: bool) -> None:
|
|
20
|
+
"""Set the current bypass_hooks state for the current thread."""
|
|
21
|
+
_hook_context.bypass_hooks = bypass_hooks
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_bypass_hooks() -> bool:
|
|
25
|
+
"""Get the current bypass_hooks state for the current thread."""
|
|
26
|
+
return getattr(_hook_context, 'bypass_hooks', False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HookContext:
|
|
30
|
+
"""
|
|
31
|
+
Hook execution context helper.
|
|
32
|
+
|
|
33
|
+
- Carries the model and bypass_hooks flag.
|
|
34
|
+
- On construction sets thread-local bypass state;
|
|
35
|
+
when used as a context manager, restores previous state on exit.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, model: type, bypass_hooks: bool = False):
|
|
39
|
+
self.model: type = model
|
|
40
|
+
self.bypass_hooks: bool = bypass_hooks
|
|
41
|
+
self._prev_bypass: Optional[bool] = None
|
|
42
|
+
# Preserve legacy behavior: set bypass state at construction
|
|
43
|
+
set_bypass_hooks(bypass_hooks)
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> "HookContext":
|
|
46
|
+
self._prev_bypass = get_bypass_hooks()
|
|
47
|
+
set_bypass_hooks(self.bypass_hooks)
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
51
|
+
# Restore previous bypass state
|
|
52
|
+
if self._prev_bypass is not None:
|
|
53
|
+
set_bypass_hooks(self._prev_bypass)
|
|
54
|
+
# Do not suppress exceptions
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_executing(self) -> bool:
|
|
59
|
+
"""
|
|
60
|
+
Check if we're currently in a hook execution context.
|
|
61
|
+
Similar to Salesforce's Trigger.isExecuting.
|
|
62
|
+
Use this to prevent infinite recursion in hooks.
|
|
63
|
+
"""
|
|
64
|
+
return hasattr(hook_vars, 'event') and hook_vars.event is not None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def current_event(self) -> Optional[str]:
|
|
68
|
+
"""
|
|
69
|
+
Get the current hook event being executed.
|
|
70
|
+
"""
|
|
71
|
+
return getattr(hook_vars, 'event', None)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def execution_depth(self) -> int:
|
|
75
|
+
"""
|
|
76
|
+
Get the current execution depth to detect deep recursion.
|
|
77
|
+
"""
|
|
78
|
+
return getattr(hook_vars, 'depth', 0)
|
|
79
|
+
|
|
80
|
+
def __repr__(self) -> str:
|
|
81
|
+
return f"HookContext(model={getattr(self.model, '__name__', self.model)!r}, bypass_hooks={self.bypass_hooks})"
|
|
@@ -1,18 +1,28 @@
|
|
|
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
|
+
|
|
1
10
|
import inspect
|
|
2
11
|
from functools import wraps
|
|
12
|
+
from typing import Any, Callable, Optional
|
|
3
13
|
|
|
4
14
|
from django.core.exceptions import FieldDoesNotExist
|
|
5
15
|
from django_bulk_hooks.enums import DEFAULT_PRIORITY
|
|
6
16
|
from django_bulk_hooks.registry import register_hook
|
|
7
17
|
|
|
8
18
|
|
|
9
|
-
def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
|
|
19
|
+
def hook(event: str, *, model: type, condition: Optional[Callable] = None, priority: int = DEFAULT_PRIORITY):
|
|
10
20
|
"""
|
|
11
21
|
Decorator to annotate a method with multiple hooks hook registrations.
|
|
12
22
|
If no priority is provided, uses Priority.NORMAL (50).
|
|
13
23
|
"""
|
|
14
24
|
|
|
15
|
-
def decorator(fn):
|
|
25
|
+
def decorator(fn: Callable):
|
|
16
26
|
if not hasattr(fn, "hooks_hooks"):
|
|
17
27
|
fn.hooks_hooks = []
|
|
18
28
|
fn.hooks_hooks.append((model, event, condition, priority))
|
|
@@ -21,7 +31,7 @@ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
|
|
|
21
31
|
return decorator
|
|
22
32
|
|
|
23
33
|
|
|
24
|
-
def select_related(*related_fields):
|
|
34
|
+
def select_related(*related_fields: str):
|
|
25
35
|
"""
|
|
26
36
|
Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
|
|
27
37
|
|
|
@@ -30,11 +40,14 @@ def select_related(*related_fields):
|
|
|
30
40
|
- Populates Django's relation cache to avoid extra queries
|
|
31
41
|
"""
|
|
32
42
|
|
|
33
|
-
def decorator(func):
|
|
43
|
+
def decorator(func: Callable):
|
|
44
|
+
# No-op if no fields specified
|
|
45
|
+
if not related_fields:
|
|
46
|
+
return func
|
|
34
47
|
sig = inspect.signature(func)
|
|
35
48
|
|
|
36
49
|
@wraps(func)
|
|
37
|
-
def wrapper(*args, **kwargs):
|
|
50
|
+
def wrapper(*args: Any, **kwargs: Any):
|
|
38
51
|
bound = sig.bind_partial(*args, **kwargs)
|
|
39
52
|
bound.apply_defaults()
|
|
40
53
|
|
|
@@ -105,7 +118,7 @@ def select_related(*related_fields):
|
|
|
105
118
|
return decorator
|
|
106
119
|
|
|
107
120
|
|
|
108
|
-
def bulk_hook(model_cls, event, when=None, priority=None):
|
|
121
|
+
def bulk_hook(model_cls: type, event: str, when: Optional[Callable] = None, priority: Optional[int] = None):
|
|
109
122
|
"""
|
|
110
123
|
Decorator to register a bulk hook for a model.
|
|
111
124
|
|
|
@@ -115,13 +128,13 @@ def bulk_hook(model_cls, event, when=None, priority=None):
|
|
|
115
128
|
when: Optional condition for when the hook should run
|
|
116
129
|
priority: Optional priority for hook execution order
|
|
117
130
|
"""
|
|
118
|
-
def decorator(func):
|
|
131
|
+
def decorator(func: Callable):
|
|
119
132
|
# Create a simple handler class for the function
|
|
120
133
|
class FunctionHandler:
|
|
121
134
|
def __init__(self):
|
|
122
135
|
self.func = func
|
|
123
136
|
|
|
124
|
-
def handle(self, new_instances, original_instances):
|
|
137
|
+
def handle(self, new_instances: list, original_instances: Optional[list]):
|
|
125
138
|
return self.func(new_instances, original_instances)
|
|
126
139
|
|
|
127
140
|
# Register the hook using the registry
|
|
@@ -135,3 +148,82 @@ def bulk_hook(model_cls, event, when=None, priority=None):
|
|
|
135
148
|
)
|
|
136
149
|
return func
|
|
137
150
|
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,127 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.core.exceptions import ValidationError
|
|
4
|
+
from django.db import transaction
|
|
5
|
+
|
|
6
|
+
from django_bulk_hooks.registry import get_hooks
|
|
7
|
+
from django_bulk_hooks.handler import hook_vars
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
13
|
+
"""
|
|
14
|
+
Run hooks for a given model, event, and records.
|
|
15
|
+
|
|
16
|
+
Production-grade executor:
|
|
17
|
+
- Honors bypass_hooks via ctx
|
|
18
|
+
- Runs model.clean() before BEFORE_* events for validation
|
|
19
|
+
- Executes hooks in registry priority order with condition filtering
|
|
20
|
+
- Exposes thread-local context via hook_vars during execution
|
|
21
|
+
- AFTER_* timing is configurable via settings.BULK_HOOKS_AFTER_ON_COMMIT (default: False)
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
model_cls: The Django model class
|
|
25
|
+
event: The hook event (e.g., 'before_create', 'after_update')
|
|
26
|
+
new_records: List of new/updated records
|
|
27
|
+
old_records: List of original records (for comparison)
|
|
28
|
+
ctx: Optional hook context
|
|
29
|
+
"""
|
|
30
|
+
if not new_records:
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
hooks = get_hooks(model_cls, event)
|
|
34
|
+
if not hooks:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
logger.debug(
|
|
38
|
+
f"Running {len(hooks)} hooks for {model_cls.__name__}.{event} ({len(new_records)} records)"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Check if we're in a bypass context
|
|
42
|
+
if ctx and hasattr(ctx, "bypass_hooks") and ctx.bypass_hooks:
|
|
43
|
+
logger.debug("Hook execution bypassed")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# For BEFORE_* events, run model.clean() first for validation
|
|
47
|
+
if event.startswith("before_"):
|
|
48
|
+
for instance in new_records:
|
|
49
|
+
try:
|
|
50
|
+
instance.clean()
|
|
51
|
+
except ValidationError as e:
|
|
52
|
+
logger.error("Validation failed for %s: %s", instance, e)
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
def _execute():
|
|
56
|
+
hook_vars.depth += 1
|
|
57
|
+
hook_vars.new = new_records
|
|
58
|
+
hook_vars.old = old_records
|
|
59
|
+
hook_vars.event = event
|
|
60
|
+
hook_vars.model = model_cls
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
# Align old_records length with new_records for safe zipping
|
|
64
|
+
local_old = old_records or []
|
|
65
|
+
if len(local_old) < len(new_records):
|
|
66
|
+
local_old = local_old + [None] * (len(new_records) - len(local_old))
|
|
67
|
+
|
|
68
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
69
|
+
try:
|
|
70
|
+
handler_instance = handler_cls()
|
|
71
|
+
func = getattr(handler_instance, method_name)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error(
|
|
74
|
+
"Failed to instantiate %s.%s: %s",
|
|
75
|
+
handler_cls.__name__,
|
|
76
|
+
method_name,
|
|
77
|
+
e,
|
|
78
|
+
)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Condition filtering per record
|
|
82
|
+
to_process_new = []
|
|
83
|
+
to_process_old = []
|
|
84
|
+
for new_obj, old_obj in zip(new_records, local_old, strict=True):
|
|
85
|
+
if not condition:
|
|
86
|
+
to_process_new.append(new_obj)
|
|
87
|
+
to_process_old.append(old_obj)
|
|
88
|
+
else:
|
|
89
|
+
try:
|
|
90
|
+
if condition.check(new_obj, old_obj):
|
|
91
|
+
to_process_new.append(new_obj)
|
|
92
|
+
to_process_old.append(old_obj)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.error(
|
|
95
|
+
"Condition failed for %s.%s: %s",
|
|
96
|
+
handler_cls.__name__,
|
|
97
|
+
method_name,
|
|
98
|
+
e,
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if not to_process_new:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
func(
|
|
107
|
+
new_records=to_process_new,
|
|
108
|
+
old_records=to_process_old
|
|
109
|
+
if any(x is not None for x in to_process_old)
|
|
110
|
+
else None,
|
|
111
|
+
)
|
|
112
|
+
except Exception:
|
|
113
|
+
logger.exception(
|
|
114
|
+
"Error in hook %s.%s", handler_cls.__name__, method_name
|
|
115
|
+
)
|
|
116
|
+
# Re-raise to ensure proper transactional behavior
|
|
117
|
+
raise
|
|
118
|
+
finally:
|
|
119
|
+
hook_vars.new = None
|
|
120
|
+
hook_vars.old = None
|
|
121
|
+
hook_vars.event = None
|
|
122
|
+
hook_vars.model = None
|
|
123
|
+
hook_vars.depth -= 1
|
|
124
|
+
|
|
125
|
+
# Execute immediately so AFTER_* runs within the transaction.
|
|
126
|
+
# If a hook raises, the transaction is rolled back (Salesforce-style).
|
|
127
|
+
_execute()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compatibility layer for priority-related exports.
|
|
3
|
+
|
|
4
|
+
This module re-exports the canonical Priority enum and a DEFAULT_PRIORITY
|
|
5
|
+
constant so existing imports like `from django_bulk_hooks.enums import ...`
|
|
6
|
+
continue to work without churn. Prefer importing from `priority` in new code.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from django_bulk_hooks.priority import Priority
|
|
10
|
+
|
|
11
|
+
# Default priority used when none is specified by the hook decorator
|
|
12
|
+
DEFAULT_PRIORITY = Priority.NORMAL
|
|
13
|
+
|
|
14
|
+
__all__ = ["Priority", "DEFAULT_PRIORITY"]
|
|
@@ -2,8 +2,6 @@ import logging
|
|
|
2
2
|
import threading
|
|
3
3
|
from collections import deque
|
|
4
4
|
|
|
5
|
-
from django.db import transaction
|
|
6
|
-
|
|
7
5
|
from django_bulk_hooks.registry import get_hooks, register_hook
|
|
8
6
|
|
|
9
7
|
logger = logging.getLogger(__name__)
|
|
@@ -77,9 +75,6 @@ class HookMeta(type):
|
|
|
77
75
|
attr = getattr(cls, attr_name)
|
|
78
76
|
if callable(attr) and hasattr(attr, "hooks_hooks"):
|
|
79
77
|
for model_cls, event, condition, priority in attr.hooks_hooks:
|
|
80
|
-
# Create a unique key for this hook registration
|
|
81
|
-
key = (model_cls, event, cls, attr_name)
|
|
82
|
-
|
|
83
78
|
# Register the hook
|
|
84
79
|
register_hook(
|
|
85
80
|
model=model_cls,
|
|
@@ -171,12 +166,8 @@ class Hook(metaclass=HookMeta):
|
|
|
171
166
|
# Re-raise the exception to ensure proper error handling
|
|
172
167
|
raise
|
|
173
168
|
|
|
174
|
-
conn = transaction.get_connection()
|
|
175
169
|
try:
|
|
176
|
-
|
|
177
|
-
transaction.on_commit(_execute)
|
|
178
|
-
else:
|
|
179
|
-
_execute()
|
|
170
|
+
_execute()
|
|
180
171
|
finally:
|
|
181
172
|
hook_vars.new = None
|
|
182
173
|
hook_vars.old = None
|