django-bulk-hooks 0.1.281__py3-none-any.whl → 0.2.1__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.
- django_bulk_hooks/__init__.py +57 -1
- django_bulk_hooks/changeset.py +230 -0
- django_bulk_hooks/conditions.py +49 -11
- django_bulk_hooks/constants.py +4 -0
- django_bulk_hooks/context.py +30 -43
- django_bulk_hooks/debug_utils.py +145 -0
- django_bulk_hooks/decorators.py +158 -103
- django_bulk_hooks/dispatcher.py +235 -0
- django_bulk_hooks/factory.py +565 -0
- django_bulk_hooks/handler.py +86 -159
- django_bulk_hooks/helpers.py +99 -0
- django_bulk_hooks/manager.py +25 -7
- django_bulk_hooks/models.py +39 -78
- django_bulk_hooks/operations/__init__.py +18 -0
- django_bulk_hooks/operations/analyzer.py +208 -0
- django_bulk_hooks/operations/bulk_executor.py +151 -0
- django_bulk_hooks/operations/coordinator.py +369 -0
- django_bulk_hooks/operations/mti_handler.py +103 -0
- django_bulk_hooks/queryset.py +113 -2159
- django_bulk_hooks/registry.py +279 -32
- {django_bulk_hooks-0.1.281.dist-info → django_bulk_hooks-0.2.1.dist-info}/METADATA +23 -16
- django_bulk_hooks-0.2.1.dist-info/RECORD +25 -0
- {django_bulk_hooks-0.1.281.dist-info → django_bulk_hooks-0.2.1.dist-info}/WHEEL +1 -1
- django_bulk_hooks/engine.py +0 -78
- django_bulk_hooks/priority.py +0 -16
- django_bulk_hooks-0.1.281.dist-info/RECORD +0 -17
- {django_bulk_hooks-0.1.281.dist-info → django_bulk_hooks-0.2.1.dist-info}/LICENSE +0 -0
django_bulk_hooks/decorators.py
CHANGED
|
@@ -29,141 +29,196 @@ def select_related(*related_fields):
|
|
|
29
29
|
- Works with instance methods (resolves `self`)
|
|
30
30
|
- Avoids replacing model instances
|
|
31
31
|
- Populates Django's relation cache to avoid extra queries
|
|
32
|
+
- Uses Django ORM __ notation for related field paths (e.g., 'parent__parent__value')
|
|
32
33
|
"""
|
|
33
34
|
|
|
34
35
|
def decorator(func):
|
|
35
36
|
sig = inspect.signature(func)
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
bound = sig.bind_partial(*args, **kwargs)
|
|
40
|
-
bound.apply_defaults()
|
|
41
|
-
|
|
42
|
-
if "new_records" not in bound.arguments:
|
|
38
|
+
def preload_related(records, *, model_cls=None):
|
|
39
|
+
if not isinstance(records, list):
|
|
43
40
|
raise TypeError(
|
|
44
|
-
"@
|
|
41
|
+
f"@select_related expects a list of model instances, got {type(records)}"
|
|
45
42
|
)
|
|
46
43
|
|
|
47
|
-
|
|
44
|
+
if not records:
|
|
45
|
+
return
|
|
48
46
|
|
|
49
|
-
if
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
if model_cls is None:
|
|
48
|
+
model_cls = records[0].__class__
|
|
49
|
+
|
|
50
|
+
# Validate field notation upfront
|
|
51
|
+
for field in related_fields:
|
|
52
|
+
if "." in field:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"Invalid field notation '{field}'. Use Django ORM __ notation (e.g., 'parent__field')"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
direct_relation_fields = {}
|
|
58
|
+
validated_fields = []
|
|
59
|
+
|
|
60
|
+
for field in related_fields:
|
|
61
|
+
if "__" in field:
|
|
62
|
+
validated_fields.append(field)
|
|
63
|
+
continue
|
|
53
64
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
model_cls = bound.arguments["model_cls"]
|
|
61
|
-
else:
|
|
62
|
-
model_cls = new_records[0].__class__
|
|
63
|
-
ids_to_fetch = []
|
|
64
|
-
for obj in new_records:
|
|
65
|
-
if obj.pk is None:
|
|
65
|
+
try:
|
|
66
|
+
if hasattr(model_cls, "_meta"):
|
|
67
|
+
relation_field = model_cls._meta.get_field(field)
|
|
68
|
+
else:
|
|
69
|
+
continue
|
|
70
|
+
except (FieldDoesNotExist, AttributeError):
|
|
66
71
|
continue
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
relation_field.is_relation
|
|
75
|
+
and not relation_field.many_to_many
|
|
76
|
+
and not relation_field.one_to_many
|
|
77
|
+
):
|
|
78
|
+
validated_fields.append(field)
|
|
79
|
+
direct_relation_fields[field] = relation_field
|
|
80
|
+
|
|
81
|
+
unsaved_related_ids_by_field = {
|
|
82
|
+
field: set() for field in direct_relation_fields.keys()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
saved_ids_to_fetch = []
|
|
86
|
+
for obj in records:
|
|
87
|
+
if obj.pk is not None:
|
|
88
|
+
needs_fetch = False
|
|
89
|
+
if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
|
|
90
|
+
try:
|
|
91
|
+
needs_fetch = any(
|
|
92
|
+
field not in obj._state.fields_cache
|
|
93
|
+
for field in related_fields
|
|
94
|
+
)
|
|
95
|
+
except (TypeError, AttributeError):
|
|
96
|
+
needs_fetch = True
|
|
97
|
+
else:
|
|
98
|
+
needs_fetch = True
|
|
99
|
+
|
|
100
|
+
if needs_fetch:
|
|
101
|
+
saved_ids_to_fetch.append(obj.pk)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
fields_cache = None
|
|
70
105
|
if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
):
|
|
76
|
-
ids_to_fetch.append(obj.pk)
|
|
77
|
-
except (TypeError, AttributeError):
|
|
78
|
-
# If _state.fields_cache is not iterable or accessible, always fetch
|
|
79
|
-
ids_to_fetch.append(obj.pk)
|
|
80
|
-
else:
|
|
81
|
-
# For Mock objects or objects without _state.fields_cache, always fetch
|
|
82
|
-
ids_to_fetch.append(obj.pk)
|
|
83
|
-
|
|
84
|
-
# Always validate fields for nested field errors, regardless of whether we need to fetch
|
|
85
|
-
# Note: We allow nested fields as Django's select_related supports them
|
|
86
|
-
|
|
87
|
-
fetched = {}
|
|
88
|
-
if ids_to_fetch:
|
|
89
|
-
# Validate fields before passing to select_related
|
|
90
|
-
validated_fields = []
|
|
91
|
-
for field in related_fields:
|
|
92
|
-
# For nested fields (containing __), let Django's select_related handle validation
|
|
93
|
-
if "__" in field:
|
|
94
|
-
validated_fields.append(field)
|
|
106
|
+
fields_cache = obj._state.fields_cache
|
|
107
|
+
|
|
108
|
+
for field_name, relation_field in direct_relation_fields.items():
|
|
109
|
+
if fields_cache is not None and field_name in fields_cache:
|
|
95
110
|
continue
|
|
96
111
|
|
|
97
112
|
try:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
f = model_cls._meta.get_field(field)
|
|
101
|
-
if not (
|
|
102
|
-
f.is_relation
|
|
103
|
-
and not f.many_to_many
|
|
104
|
-
and not f.one_to_many
|
|
105
|
-
):
|
|
106
|
-
continue
|
|
107
|
-
validated_fields.append(field)
|
|
108
|
-
else:
|
|
109
|
-
# For Mock objects, skip validation
|
|
110
|
-
continue
|
|
111
|
-
except (FieldDoesNotExist, AttributeError):
|
|
113
|
+
related_id = getattr(obj, relation_field.get_attname(), None)
|
|
114
|
+
except AttributeError:
|
|
112
115
|
continue
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
if related_id is not None:
|
|
118
|
+
unsaved_related_ids_by_field[field_name].add(related_id)
|
|
119
|
+
|
|
120
|
+
fetched_saved = {}
|
|
121
|
+
if saved_ids_to_fetch and validated_fields:
|
|
122
|
+
base_manager = getattr(model_cls, "_base_manager", None)
|
|
123
|
+
if base_manager is not None:
|
|
116
124
|
try:
|
|
117
|
-
|
|
125
|
+
fetched_saved = base_manager.select_related(
|
|
118
126
|
*validated_fields
|
|
119
|
-
).in_bulk(
|
|
127
|
+
).in_bulk(saved_ids_to_fetch)
|
|
120
128
|
except Exception:
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
fetched_saved = {}
|
|
130
|
+
|
|
131
|
+
fetched_unsaved_by_field = {
|
|
132
|
+
field: {} for field in direct_relation_fields.keys()
|
|
133
|
+
}
|
|
123
134
|
|
|
124
|
-
for
|
|
125
|
-
|
|
126
|
-
if not
|
|
135
|
+
for field_name, relation_field in direct_relation_fields.items():
|
|
136
|
+
related_ids = unsaved_related_ids_by_field[field_name]
|
|
137
|
+
if not related_ids:
|
|
127
138
|
continue
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
|
|
140
|
+
related_model = getattr(relation_field.remote_field, "model", None)
|
|
141
|
+
if related_model is None:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
manager = getattr(related_model, "_base_manager", None)
|
|
145
|
+
if manager is None:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
fetched_unsaved_by_field[field_name] = manager.in_bulk(related_ids)
|
|
150
|
+
except Exception:
|
|
151
|
+
fetched_unsaved_by_field[field_name] = {}
|
|
152
|
+
|
|
153
|
+
for obj in records:
|
|
154
|
+
fields_cache = None
|
|
155
|
+
if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
|
|
156
|
+
fields_cache = obj._state.fields_cache
|
|
157
|
+
|
|
158
|
+
if obj.pk is not None:
|
|
159
|
+
preloaded = fetched_saved.get(obj.pk)
|
|
160
|
+
if not preloaded:
|
|
136
161
|
continue
|
|
137
162
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
and not f.many_to_many
|
|
145
|
-
and not f.one_to_many
|
|
146
|
-
):
|
|
147
|
-
continue
|
|
148
|
-
else:
|
|
149
|
-
# For Mock objects, skip validation
|
|
163
|
+
for field in related_fields:
|
|
164
|
+
if fields_cache is not None and field in fields_cache:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
relation_field = direct_relation_fields.get(field)
|
|
168
|
+
if relation_field is None and "__" not in field:
|
|
150
169
|
continue
|
|
151
|
-
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
rel_obj = getattr(preloaded, field)
|
|
173
|
+
except AttributeError:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
setattr(obj, field, rel_obj)
|
|
177
|
+
if fields_cache is not None:
|
|
178
|
+
fields_cache[field] = rel_obj
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
for field_name, relation_field in direct_relation_fields.items():
|
|
182
|
+
if fields_cache is not None and field_name in fields_cache:
|
|
152
183
|
continue
|
|
153
184
|
|
|
154
185
|
try:
|
|
155
|
-
|
|
156
|
-
setattr(obj, field, rel_obj)
|
|
157
|
-
# Only set _state.fields_cache if it exists
|
|
158
|
-
if hasattr(obj, "_state") and hasattr(
|
|
159
|
-
obj._state, "fields_cache"
|
|
160
|
-
):
|
|
161
|
-
obj._state.fields_cache[field] = rel_obj
|
|
186
|
+
related_id = getattr(obj, relation_field.get_attname(), None)
|
|
162
187
|
except AttributeError:
|
|
163
|
-
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if related_id is None:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
rel_obj = fetched_unsaved_by_field[field_name].get(related_id)
|
|
194
|
+
if rel_obj is None:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
setattr(obj, field_name, rel_obj)
|
|
198
|
+
if fields_cache is not None:
|
|
199
|
+
fields_cache[field_name] = rel_obj
|
|
200
|
+
|
|
201
|
+
@wraps(func)
|
|
202
|
+
def wrapper(*args, **kwargs):
|
|
203
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
204
|
+
bound.apply_defaults()
|
|
205
|
+
|
|
206
|
+
if "new_records" not in bound.arguments:
|
|
207
|
+
raise TypeError(
|
|
208
|
+
"@preload_related requires a 'new_records' argument in the decorated function"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
new_records = bound.arguments["new_records"]
|
|
212
|
+
|
|
213
|
+
model_cls_override = bound.arguments.get("model_cls")
|
|
214
|
+
|
|
215
|
+
preload_related(new_records, model_cls=model_cls_override)
|
|
164
216
|
|
|
165
217
|
return func(*bound.args, **bound.kwargs)
|
|
166
218
|
|
|
219
|
+
wrapper._select_related_preload = preload_related
|
|
220
|
+
wrapper._select_related_fields = related_fields
|
|
221
|
+
|
|
167
222
|
return wrapper
|
|
168
223
|
|
|
169
224
|
return decorator
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HookDispatcher: Single execution path for all hooks.
|
|
3
|
+
|
|
4
|
+
Provides deterministic, priority-ordered hook execution,
|
|
5
|
+
similar to Salesforce's hook framework.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HookDispatcher:
|
|
15
|
+
"""
|
|
16
|
+
Single execution path for all hooks.
|
|
17
|
+
|
|
18
|
+
Responsibilities:
|
|
19
|
+
- Execute hooks in priority order
|
|
20
|
+
- Filter records based on conditions
|
|
21
|
+
- Provide ChangeSet context to hooks
|
|
22
|
+
- Fail-fast error propagation
|
|
23
|
+
- Manage complete operation lifecycle (VALIDATE, BEFORE, AFTER)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, registry):
|
|
27
|
+
"""
|
|
28
|
+
Initialize the dispatcher.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
registry: The hook registry (provides get_hooks method)
|
|
32
|
+
"""
|
|
33
|
+
self.registry = registry
|
|
34
|
+
|
|
35
|
+
def execute_operation_with_hooks(
|
|
36
|
+
self,
|
|
37
|
+
changeset,
|
|
38
|
+
operation,
|
|
39
|
+
event_prefix,
|
|
40
|
+
bypass_hooks=False,
|
|
41
|
+
bypass_validation=False,
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Execute operation with full hook lifecycle.
|
|
45
|
+
|
|
46
|
+
This is the high-level method that coordinates the complete lifecycle:
|
|
47
|
+
1. VALIDATE_{event}
|
|
48
|
+
2. BEFORE_{event}
|
|
49
|
+
3. Actual operation
|
|
50
|
+
4. AFTER_{event}
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
changeset: ChangeSet for the operation
|
|
54
|
+
operation: Callable that performs the actual DB operation
|
|
55
|
+
event_prefix: 'create', 'update', or 'delete'
|
|
56
|
+
bypass_hooks: Skip all hooks if True
|
|
57
|
+
bypass_validation: Skip validation hooks if True
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Result of operation
|
|
61
|
+
"""
|
|
62
|
+
if bypass_hooks:
|
|
63
|
+
return operation()
|
|
64
|
+
|
|
65
|
+
# VALIDATE phase
|
|
66
|
+
if not bypass_validation:
|
|
67
|
+
self.dispatch(changeset, f"validate_{event_prefix}", bypass_hooks=False)
|
|
68
|
+
|
|
69
|
+
# BEFORE phase
|
|
70
|
+
self.dispatch(changeset, f"before_{event_prefix}", bypass_hooks=False)
|
|
71
|
+
|
|
72
|
+
# Execute the actual operation
|
|
73
|
+
result = operation()
|
|
74
|
+
|
|
75
|
+
# AFTER phase - use result if operation returns modified data
|
|
76
|
+
if result and isinstance(result, list) and event_prefix == "create":
|
|
77
|
+
# For create, rebuild changeset with assigned PKs
|
|
78
|
+
from django_bulk_hooks.helpers import build_changeset_for_create
|
|
79
|
+
|
|
80
|
+
changeset = build_changeset_for_create(changeset.model_cls, result)
|
|
81
|
+
|
|
82
|
+
self.dispatch(changeset, f"after_{event_prefix}", bypass_hooks=False)
|
|
83
|
+
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
def dispatch(self, changeset, event, bypass_hooks=False):
|
|
87
|
+
"""
|
|
88
|
+
Dispatch hooks for a changeset with deterministic ordering.
|
|
89
|
+
|
|
90
|
+
This is the single execution path for ALL hooks in the system.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
changeset: ChangeSet instance with record changes
|
|
94
|
+
event: Event name (e.g., 'after_update', 'before_create')
|
|
95
|
+
bypass_hooks: If True, skip all hook execution
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
Exception: Any exception raised by a hook (fails fast)
|
|
99
|
+
RecursionError: If hooks create an infinite loop (Python's built-in limit)
|
|
100
|
+
"""
|
|
101
|
+
if bypass_hooks:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Get hooks sorted by priority (deterministic order)
|
|
105
|
+
hooks = self.registry.get_hooks(changeset.model_cls, event)
|
|
106
|
+
|
|
107
|
+
if not hooks:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
# Execute hooks in priority order
|
|
111
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
112
|
+
self._execute_hook(handler_cls, method_name, condition, changeset)
|
|
113
|
+
|
|
114
|
+
def _execute_hook(self, handler_cls, method_name, condition, changeset):
|
|
115
|
+
"""
|
|
116
|
+
Execute a single hook with condition checking.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
handler_cls: The hook handler class
|
|
120
|
+
method_name: Name of the method to call
|
|
121
|
+
condition: Optional condition to filter records
|
|
122
|
+
changeset: ChangeSet with all record changes
|
|
123
|
+
"""
|
|
124
|
+
# Filter records based on condition
|
|
125
|
+
if condition:
|
|
126
|
+
filtered_changes = [
|
|
127
|
+
change
|
|
128
|
+
for change in changeset.changes
|
|
129
|
+
if condition.check(change.new_record, change.old_record)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
if not filtered_changes:
|
|
133
|
+
# No records match condition, skip this hook
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
# Create filtered changeset
|
|
137
|
+
from django_bulk_hooks.changeset import ChangeSet
|
|
138
|
+
|
|
139
|
+
filtered_changeset = ChangeSet(
|
|
140
|
+
changeset.model_cls,
|
|
141
|
+
filtered_changes,
|
|
142
|
+
changeset.operation_type,
|
|
143
|
+
changeset.operation_meta,
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
# No condition, use full changeset
|
|
147
|
+
filtered_changeset = changeset
|
|
148
|
+
|
|
149
|
+
# Use DI factory to create handler instance
|
|
150
|
+
from django_bulk_hooks.factory import create_hook_instance
|
|
151
|
+
|
|
152
|
+
handler = create_hook_instance(handler_cls)
|
|
153
|
+
method = getattr(handler, method_name)
|
|
154
|
+
|
|
155
|
+
# Check if method has @select_related decorator
|
|
156
|
+
preload_func = getattr(method, "_select_related_preload", None)
|
|
157
|
+
if preload_func:
|
|
158
|
+
# Preload relationships to prevent N+1 queries
|
|
159
|
+
try:
|
|
160
|
+
model_cls_override = getattr(handler, "model_cls", None)
|
|
161
|
+
|
|
162
|
+
# Preload for new_records
|
|
163
|
+
if filtered_changeset.new_records:
|
|
164
|
+
logger.debug(
|
|
165
|
+
f"Preloading relationships for {len(filtered_changeset.new_records)} "
|
|
166
|
+
f"new_records for {handler_cls.__name__}.{method_name}"
|
|
167
|
+
)
|
|
168
|
+
preload_func(
|
|
169
|
+
filtered_changeset.new_records, model_cls=model_cls_override
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Also preload for old_records (for conditions that check previous values)
|
|
173
|
+
if filtered_changeset.old_records:
|
|
174
|
+
logger.debug(
|
|
175
|
+
f"Preloading relationships for {len(filtered_changeset.old_records)} "
|
|
176
|
+
f"old_records for {handler_cls.__name__}.{method_name}"
|
|
177
|
+
)
|
|
178
|
+
preload_func(
|
|
179
|
+
filtered_changeset.old_records, model_cls=model_cls_override
|
|
180
|
+
)
|
|
181
|
+
except Exception:
|
|
182
|
+
logger.debug(
|
|
183
|
+
"select_related preload failed for %s.%s",
|
|
184
|
+
handler_cls.__name__,
|
|
185
|
+
method_name,
|
|
186
|
+
exc_info=True,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Execute hook with ChangeSet
|
|
190
|
+
#
|
|
191
|
+
# ARCHITECTURE NOTE: Hook Contract
|
|
192
|
+
# ====================================
|
|
193
|
+
# All hooks must accept **kwargs for forward compatibility.
|
|
194
|
+
# We pass: changeset, new_records, old_records
|
|
195
|
+
#
|
|
196
|
+
# Old hooks that don't use changeset: def hook(self, new_records, old_records, **kwargs)
|
|
197
|
+
# New hooks that do use changeset: def hook(self, changeset, new_records, old_records, **kwargs)
|
|
198
|
+
#
|
|
199
|
+
# This is standard Python framework design (see Django signals, Flask hooks, etc.)
|
|
200
|
+
try:
|
|
201
|
+
method(
|
|
202
|
+
changeset=filtered_changeset,
|
|
203
|
+
new_records=filtered_changeset.new_records,
|
|
204
|
+
old_records=filtered_changeset.old_records,
|
|
205
|
+
)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
# Fail-fast: re-raise to rollback transaction
|
|
208
|
+
logger.error(
|
|
209
|
+
f"Hook {handler_cls.__name__}.{method_name} failed: {e}",
|
|
210
|
+
exc_info=True,
|
|
211
|
+
)
|
|
212
|
+
raise
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Global dispatcher instance
|
|
216
|
+
_dispatcher: Optional[HookDispatcher] = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_dispatcher():
|
|
220
|
+
"""
|
|
221
|
+
Get the global dispatcher instance.
|
|
222
|
+
|
|
223
|
+
Creates the dispatcher on first access (singleton pattern).
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
HookDispatcher instance
|
|
227
|
+
"""
|
|
228
|
+
global _dispatcher
|
|
229
|
+
if _dispatcher is None:
|
|
230
|
+
# Import here to avoid circular dependency
|
|
231
|
+
from django_bulk_hooks.registry import get_registry
|
|
232
|
+
|
|
233
|
+
# Create dispatcher with the registry instance
|
|
234
|
+
_dispatcher = HookDispatcher(get_registry())
|
|
235
|
+
return _dispatcher
|