django-bulk-hooks 0.1.280__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 -2129
- django_bulk_hooks/registry.py +279 -32
- {django_bulk_hooks-0.1.280.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.280.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.280.dist-info/RECORD +0 -17
- {django_bulk_hooks-0.1.280.dist-info → django_bulk_hooks-0.2.1.dist-info}/LICENSE +0 -0
django_bulk_hooks/handler.py
CHANGED
|
@@ -1,75 +1,68 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import threading
|
|
3
|
-
from collections import deque
|
|
4
2
|
|
|
5
|
-
from
|
|
6
|
-
|
|
7
|
-
from django_bulk_hooks.registry import get_hooks, register_hook
|
|
3
|
+
from django_bulk_hooks.registry import register_hook
|
|
8
4
|
|
|
9
5
|
logger = logging.getLogger(__name__)
|
|
10
6
|
|
|
11
7
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
self.event = None
|
|
18
|
-
self.model = None
|
|
19
|
-
self.depth = 0
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
hook_vars = HookVars()
|
|
23
|
-
|
|
24
|
-
# Hook queue per thread
|
|
25
|
-
_hook_context = threading.local()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def get_hook_queue():
|
|
29
|
-
if not hasattr(_hook_context, "queue"):
|
|
30
|
-
_hook_context.queue = deque()
|
|
31
|
-
return _hook_context.queue
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class HookContextState:
|
|
35
|
-
@property
|
|
36
|
-
def is_before(self):
|
|
37
|
-
return hook_vars.event.startswith("before_") if hook_vars.event else False
|
|
38
|
-
|
|
39
|
-
@property
|
|
40
|
-
def is_after(self):
|
|
41
|
-
return hook_vars.event.startswith("after_") if hook_vars.event else False
|
|
42
|
-
|
|
43
|
-
@property
|
|
44
|
-
def is_create(self):
|
|
45
|
-
return "create" in hook_vars.event if hook_vars.event else False
|
|
46
|
-
|
|
47
|
-
@property
|
|
48
|
-
def is_update(self):
|
|
49
|
-
return "update" in hook_vars.event if hook_vars.event else False
|
|
50
|
-
|
|
51
|
-
@property
|
|
52
|
-
def new(self):
|
|
53
|
-
return hook_vars.new
|
|
54
|
-
|
|
55
|
-
@property
|
|
56
|
-
def old(self):
|
|
57
|
-
return hook_vars.old
|
|
8
|
+
class HookMeta(type):
|
|
9
|
+
_registered = set()
|
|
10
|
+
_class_hook_map: dict[
|
|
11
|
+
type, set[tuple]
|
|
12
|
+
] = {} # Track which hooks belong to which class
|
|
58
13
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
14
|
+
def __new__(mcs, name, bases, namespace):
|
|
15
|
+
cls = super().__new__(mcs, name, bases, namespace)
|
|
16
|
+
mcs._register_hooks_for_class(cls)
|
|
17
|
+
return cls
|
|
62
18
|
|
|
19
|
+
@classmethod
|
|
20
|
+
def _register_hooks_for_class(mcs, cls):
|
|
21
|
+
"""
|
|
22
|
+
Register hooks for a given class following OOP inheritance semantics.
|
|
23
|
+
|
|
24
|
+
- Child classes inherit all parent hook methods
|
|
25
|
+
- Child overrides replace parent implementations (not add to them)
|
|
26
|
+
- Child can add new hook methods
|
|
27
|
+
"""
|
|
28
|
+
from django_bulk_hooks.registry import register_hook, unregister_hook
|
|
29
|
+
|
|
30
|
+
# Step 1: Unregister ALL hooks from parent classes in the MRO
|
|
31
|
+
# This ensures only the most-derived class owns the active hooks,
|
|
32
|
+
# providing true OOP semantics (overrides replace, others are inherited once).
|
|
33
|
+
for base in cls.__mro__[1:]: # Skip cls itself, start from first parent
|
|
34
|
+
if not isinstance(base, HookMeta):
|
|
35
|
+
continue
|
|
63
36
|
|
|
64
|
-
|
|
37
|
+
if base in mcs._class_hook_map:
|
|
38
|
+
for model_cls, event, base_cls, method_name in list(
|
|
39
|
+
mcs._class_hook_map[base]
|
|
40
|
+
):
|
|
41
|
+
key = (model_cls, event, base_cls, method_name)
|
|
42
|
+
if key in HookMeta._registered:
|
|
43
|
+
unregister_hook(model_cls, event, base_cls, method_name)
|
|
44
|
+
HookMeta._registered.discard(key)
|
|
45
|
+
logger.debug(
|
|
46
|
+
f"Unregistered base hook: {base_cls.__name__}.{method_name} "
|
|
47
|
+
f"(superseded by {cls.__name__})"
|
|
48
|
+
)
|
|
65
49
|
|
|
50
|
+
# Step 2: Register all hook methods on this class (including inherited ones)
|
|
51
|
+
# Walk the MRO to find ALL methods with hook decorators
|
|
52
|
+
all_hook_methods = {}
|
|
53
|
+
for klass in reversed(cls.__mro__): # Start from most base class
|
|
54
|
+
if not isinstance(klass, HookMeta):
|
|
55
|
+
continue
|
|
56
|
+
for method_name, method in klass.__dict__.items():
|
|
57
|
+
if hasattr(method, "hooks_hooks"):
|
|
58
|
+
# Store with method name as key - child methods will override parent
|
|
59
|
+
all_hook_methods[method_name] = method
|
|
66
60
|
|
|
67
|
-
class
|
|
68
|
-
|
|
61
|
+
# Step 3: Register all hook methods with THIS class as the handler
|
|
62
|
+
if cls not in mcs._class_hook_map:
|
|
63
|
+
mcs._class_hook_map[cls] = set()
|
|
69
64
|
|
|
70
|
-
|
|
71
|
-
cls = super().__new__(mcs, name, bases, namespace)
|
|
72
|
-
for method_name, method in namespace.items():
|
|
65
|
+
for method_name, method in all_hook_methods.items():
|
|
73
66
|
if hasattr(method, "hooks_hooks"):
|
|
74
67
|
for model_cls, event, condition, priority in method.hooks_hooks:
|
|
75
68
|
key = (model_cls, event, cls, method_name)
|
|
@@ -83,106 +76,40 @@ class HookMeta(type):
|
|
|
83
76
|
priority=priority,
|
|
84
77
|
)
|
|
85
78
|
HookMeta._registered.add(key)
|
|
86
|
-
|
|
79
|
+
mcs._class_hook_map[cls].add(key)
|
|
80
|
+
logger.debug(
|
|
81
|
+
f"Registered hook: {cls.__name__}.{method_name} "
|
|
82
|
+
f"for {model_cls.__name__}.{event}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def re_register_all_hooks(mcs):
|
|
87
|
+
"""Re-register all hooks for all existing Hook classes."""
|
|
88
|
+
# Clear the registered set and class hook map so we can re-register
|
|
89
|
+
HookMeta._registered.clear()
|
|
90
|
+
mcs._class_hook_map.clear()
|
|
91
|
+
|
|
92
|
+
# Find all Hook classes and re-register their hooks
|
|
93
|
+
import gc
|
|
94
|
+
|
|
95
|
+
registered_classes = set()
|
|
96
|
+
for obj in gc.get_objects():
|
|
97
|
+
if isinstance(obj, type) and isinstance(obj, HookMeta):
|
|
98
|
+
if obj not in registered_classes:
|
|
99
|
+
registered_classes.add(obj)
|
|
100
|
+
mcs._register_hooks_for_class(obj)
|
|
87
101
|
|
|
88
102
|
|
|
89
103
|
class Hook(metaclass=HookMeta):
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
cls,
|
|
93
|
-
event: str,
|
|
94
|
-
model: type,
|
|
95
|
-
*,
|
|
96
|
-
new_records: list = None,
|
|
97
|
-
old_records: list = None,
|
|
98
|
-
**kwargs,
|
|
99
|
-
) -> None:
|
|
100
|
-
queue = get_hook_queue()
|
|
101
|
-
queue.append((cls, event, model, new_records, old_records, kwargs))
|
|
102
|
-
logger.debug(f"Added item to queue: {event}, depth: {hook_vars.depth}")
|
|
103
|
-
|
|
104
|
-
# If we're already processing hooks (depth > 0), don't process the queue
|
|
105
|
-
# The outermost call will process the entire queue
|
|
106
|
-
if hook_vars.depth > 0:
|
|
107
|
-
logger.debug(f"Depth > 0, returning without processing queue")
|
|
108
|
-
return
|
|
109
|
-
|
|
110
|
-
# Process the entire queue
|
|
111
|
-
logger.debug(f"Processing queue with {len(queue)} items")
|
|
112
|
-
while queue:
|
|
113
|
-
item = queue.popleft()
|
|
114
|
-
if len(item) == 6:
|
|
115
|
-
cls_, event_, model_, new_, old_, kw_ = item
|
|
116
|
-
logger.debug(f"Processing queue item: {event_}")
|
|
117
|
-
# Call _process on the Hook class, not the calling class
|
|
118
|
-
Hook._process(event_, model_, new_, old_, **kw_)
|
|
119
|
-
else:
|
|
120
|
-
logger.warning(f"Invalid queue item format: {item}")
|
|
121
|
-
continue
|
|
104
|
+
"""
|
|
105
|
+
Base class for hook handlers.
|
|
122
106
|
|
|
123
|
-
@
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
hook_vars.depth += 1
|
|
133
|
-
hook_vars.new = new_records
|
|
134
|
-
hook_vars.old = old_records
|
|
135
|
-
hook_vars.event = event
|
|
136
|
-
hook_vars.model = model
|
|
137
|
-
|
|
138
|
-
hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
|
|
139
|
-
logger.debug(f"Found {len(hooks)} hooks for {event}")
|
|
140
|
-
|
|
141
|
-
def _execute():
|
|
142
|
-
logger.debug(f"Executing {len(hooks)} hooks for {event}")
|
|
143
|
-
new_local = new_records or []
|
|
144
|
-
old_local = old_records or []
|
|
145
|
-
if len(old_local) < len(new_local):
|
|
146
|
-
old_local += [None] * (len(new_local) - len(old_local))
|
|
147
|
-
|
|
148
|
-
for handler_cls, method_name, condition, priority in hooks:
|
|
149
|
-
logger.debug(f"Processing hook {handler_cls.__name__}.{method_name}")
|
|
150
|
-
if condition is not None:
|
|
151
|
-
checks = [
|
|
152
|
-
condition.check(n, o) for n, o in zip(new_local, old_local)
|
|
153
|
-
]
|
|
154
|
-
if not any(checks):
|
|
155
|
-
logger.debug(f"Condition failed for {handler_cls.__name__}.{method_name}")
|
|
156
|
-
continue
|
|
157
|
-
|
|
158
|
-
handler = handler_cls()
|
|
159
|
-
method = getattr(handler, method_name)
|
|
160
|
-
logger.debug(f"Executing {handler_cls.__name__}.{method_name}")
|
|
161
|
-
|
|
162
|
-
try:
|
|
163
|
-
method(
|
|
164
|
-
new_records=new_local,
|
|
165
|
-
old_records=old_local,
|
|
166
|
-
**kwargs,
|
|
167
|
-
)
|
|
168
|
-
logger.debug(f"Successfully executed {handler_cls.__name__}.{method_name}")
|
|
169
|
-
except Exception:
|
|
170
|
-
logger.exception(
|
|
171
|
-
"Error in hook %s.%s", handler_cls.__name__, method_name
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
conn = transaction.get_connection()
|
|
175
|
-
logger.debug(f"Transaction in_atomic_block: {conn.in_atomic_block}, event: {event}")
|
|
176
|
-
try:
|
|
177
|
-
if conn.in_atomic_block and event.startswith("after_"):
|
|
178
|
-
logger.debug(f"Deferring {event} to on_commit")
|
|
179
|
-
transaction.on_commit(_execute)
|
|
180
|
-
else:
|
|
181
|
-
logger.debug(f"Executing {event} immediately")
|
|
182
|
-
_execute()
|
|
183
|
-
finally:
|
|
184
|
-
hook_vars.new = None
|
|
185
|
-
hook_vars.old = None
|
|
186
|
-
hook_vars.event = None
|
|
187
|
-
hook_vars.model = None
|
|
188
|
-
hook_vars.depth -= 1
|
|
107
|
+
Hooks are registered via the @hook decorator and executed by
|
|
108
|
+
the HookDispatcher. This class serves as a base for all hook
|
|
109
|
+
handlers and uses HookMeta for automatic registration.
|
|
110
|
+
|
|
111
|
+
All hook execution logic has been moved to HookDispatcher for
|
|
112
|
+
a single, consistent execution path.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
pass
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper functions for building ChangeSets from operation contexts.
|
|
3
|
+
|
|
4
|
+
These functions eliminate duplication across queryset.py, bulk_operations.py,
|
|
5
|
+
and models.py by providing reusable ChangeSet builders.
|
|
6
|
+
|
|
7
|
+
NOTE: These helpers are pure changeset builders - they don't fetch data.
|
|
8
|
+
Data fetching is the responsibility of ModelAnalyzer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from django_bulk_hooks.changeset import ChangeSet, RecordChange
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_changeset_for_update(
|
|
15
|
+
model_cls, instances, update_kwargs, old_records_map=None, **meta
|
|
16
|
+
):
|
|
17
|
+
"""
|
|
18
|
+
Build ChangeSet for update operations.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
model_cls: Django model class
|
|
22
|
+
instances: List of instances being updated
|
|
23
|
+
update_kwargs: Dict of fields being updated
|
|
24
|
+
old_records_map: Optional dict of {pk: old_instance}. If None, no old records.
|
|
25
|
+
**meta: Additional metadata (e.g., has_subquery=True, lock_records=False)
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
ChangeSet instance ready for dispatcher
|
|
29
|
+
"""
|
|
30
|
+
if old_records_map is None:
|
|
31
|
+
old_records_map = {}
|
|
32
|
+
|
|
33
|
+
changes = [
|
|
34
|
+
RecordChange(
|
|
35
|
+
new, old_records_map.get(new.pk), changed_fields=list(update_kwargs.keys())
|
|
36
|
+
)
|
|
37
|
+
for new in instances
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
operation_meta = {"update_kwargs": update_kwargs}
|
|
41
|
+
operation_meta.update(meta)
|
|
42
|
+
|
|
43
|
+
return ChangeSet(model_cls, changes, "update", operation_meta)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_changeset_for_create(model_cls, instances, **meta):
|
|
47
|
+
"""
|
|
48
|
+
Build ChangeSet for create operations.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
model_cls: Django model class
|
|
52
|
+
instances: List of instances being created
|
|
53
|
+
**meta: Additional metadata (e.g., batch_size=1000)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
ChangeSet instance ready for dispatcher
|
|
57
|
+
"""
|
|
58
|
+
changes = [RecordChange(new, None) for new in instances]
|
|
59
|
+
return ChangeSet(model_cls, changes, "create", meta)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_changeset_for_delete(model_cls, instances, **meta):
|
|
63
|
+
"""
|
|
64
|
+
Build ChangeSet for delete operations.
|
|
65
|
+
|
|
66
|
+
For delete, the "new_record" is the object being deleted (current state),
|
|
67
|
+
and old_record is also the same (or None). This matches Salesforce behavior
|
|
68
|
+
where Hook.new contains the records being deleted.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
model_cls: Django model class
|
|
72
|
+
instances: List of instances being deleted
|
|
73
|
+
**meta: Additional metadata
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
ChangeSet instance ready for dispatcher
|
|
77
|
+
"""
|
|
78
|
+
changes = [
|
|
79
|
+
RecordChange(obj, obj) # new_record and old_record are the same for delete
|
|
80
|
+
for obj in instances
|
|
81
|
+
]
|
|
82
|
+
return ChangeSet(model_cls, changes, "delete", meta)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def dispatch_hooks_for_operation(changeset, event, bypass_hooks=False):
|
|
86
|
+
"""
|
|
87
|
+
Dispatch hooks for an operation using the dispatcher.
|
|
88
|
+
|
|
89
|
+
This is a convenience function that wraps the dispatcher call.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
changeset: ChangeSet instance
|
|
93
|
+
event: Event name (e.g., 'before_update', 'after_create')
|
|
94
|
+
bypass_hooks: If True, skip hook execution
|
|
95
|
+
"""
|
|
96
|
+
from django_bulk_hooks.dispatcher import get_dispatcher
|
|
97
|
+
|
|
98
|
+
dispatcher = get_dispatcher()
|
|
99
|
+
dispatcher.dispatch(changeset, event, bypass_hooks=bypass_hooks)
|
django_bulk_hooks/manager.py
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
from django.db import models
|
|
2
2
|
|
|
3
|
-
from django_bulk_hooks.queryset import HookQuerySet
|
|
3
|
+
from django_bulk_hooks.queryset import HookQuerySet
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class BulkHookManager(models.Manager):
|
|
7
|
+
"""
|
|
8
|
+
Manager that provides hook-aware bulk operations.
|
|
9
|
+
|
|
10
|
+
This is a simple facade that returns HookQuerySet,
|
|
11
|
+
delegating all bulk operations to it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
7
14
|
def get_queryset(self):
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
"""
|
|
16
|
+
Return a HookQuerySet for this manager.
|
|
17
|
+
|
|
18
|
+
This ensures all bulk operations go through the coordinator.
|
|
19
|
+
"""
|
|
10
20
|
base_queryset = super().get_queryset()
|
|
11
21
|
|
|
12
|
-
# If the base queryset already
|
|
13
|
-
if isinstance(base_queryset,
|
|
22
|
+
# If the base queryset is already a HookQuerySet, return it as-is
|
|
23
|
+
if isinstance(base_queryset, HookQuerySet):
|
|
14
24
|
return base_queryset
|
|
15
25
|
|
|
16
26
|
# Otherwise, create a new HookQuerySet with the same parameters
|
|
17
|
-
# This is much simpler and avoids dynamic class creation issues
|
|
18
27
|
return HookQuerySet(
|
|
19
28
|
model=base_queryset.model,
|
|
20
29
|
query=base_queryset.query,
|
|
@@ -50,7 +59,14 @@ class BulkHookManager(models.Manager):
|
|
|
50
59
|
**kwargs,
|
|
51
60
|
)
|
|
52
61
|
|
|
53
|
-
def bulk_update(
|
|
62
|
+
def bulk_update(
|
|
63
|
+
self,
|
|
64
|
+
objs,
|
|
65
|
+
fields=None,
|
|
66
|
+
bypass_hooks=False,
|
|
67
|
+
bypass_validation=False,
|
|
68
|
+
**kwargs,
|
|
69
|
+
):
|
|
54
70
|
"""
|
|
55
71
|
Delegate to QuerySet's bulk_update implementation.
|
|
56
72
|
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
@@ -59,6 +75,8 @@ class BulkHookManager(models.Manager):
|
|
|
59
75
|
are not supported by bulk_update and will be ignored with a warning.
|
|
60
76
|
These parameters are only available in bulk_create for UPSERT operations.
|
|
61
77
|
"""
|
|
78
|
+
if fields is not None:
|
|
79
|
+
kwargs["fields"] = fields
|
|
62
80
|
return self.get_queryset().bulk_update(
|
|
63
81
|
objs,
|
|
64
82
|
bypass_hooks=bypass_hooks,
|
django_bulk_hooks/models.py
CHANGED
|
@@ -1,19 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
|
|
2
3
|
from django.db import models
|
|
3
4
|
|
|
4
|
-
from django_bulk_hooks.constants import (
|
|
5
|
-
AFTER_CREATE,
|
|
6
|
-
AFTER_DELETE,
|
|
7
|
-
AFTER_UPDATE,
|
|
8
|
-
BEFORE_CREATE,
|
|
9
|
-
BEFORE_DELETE,
|
|
10
|
-
BEFORE_UPDATE,
|
|
11
|
-
VALIDATE_CREATE,
|
|
12
|
-
VALIDATE_DELETE,
|
|
13
|
-
VALIDATE_UPDATE,
|
|
14
|
-
)
|
|
15
|
-
from django_bulk_hooks.context import HookContext
|
|
16
|
-
from django_bulk_hooks.engine import run
|
|
17
5
|
from django_bulk_hooks.manager import BulkHookManager
|
|
18
6
|
|
|
19
7
|
logger = logging.getLogger(__name__)
|
|
@@ -27,9 +15,9 @@ class HookModelMixin(models.Model):
|
|
|
27
15
|
|
|
28
16
|
def clean(self, bypass_hooks=False):
|
|
29
17
|
"""
|
|
30
|
-
Override clean() to
|
|
18
|
+
Override clean() to hook validation hooks.
|
|
31
19
|
This ensures that when Django calls clean() (like in admin forms),
|
|
32
|
-
it
|
|
20
|
+
it hooks the VALIDATE_* hooks for validation only.
|
|
33
21
|
"""
|
|
34
22
|
super().clean()
|
|
35
23
|
|
|
@@ -37,79 +25,52 @@ class HookModelMixin(models.Model):
|
|
|
37
25
|
if bypass_hooks:
|
|
38
26
|
return
|
|
39
27
|
|
|
40
|
-
#
|
|
28
|
+
# Delegate to coordinator (consistent with save/delete)
|
|
41
29
|
is_create = self.pk is None
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
ctx = HookContext(self.__class__)
|
|
46
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
47
|
-
else:
|
|
48
|
-
# For update operations, run VALIDATE_UPDATE hooks for validation
|
|
49
|
-
try:
|
|
50
|
-
# Use _base_manager to avoid triggering hooks recursively
|
|
51
|
-
old_instance = self.__class__._base_manager.get(pk=self.pk)
|
|
52
|
-
ctx = HookContext(self.__class__)
|
|
53
|
-
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
54
|
-
except self.__class__.DoesNotExist:
|
|
55
|
-
# If the old instance doesn't exist, treat as create
|
|
56
|
-
ctx = HookContext(self.__class__)
|
|
57
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
30
|
+
self.__class__.objects.get_queryset().coordinator.clean(
|
|
31
|
+
[self], is_create=is_create
|
|
32
|
+
)
|
|
58
33
|
|
|
59
34
|
def save(self, *args, bypass_hooks=False, **kwargs):
|
|
60
|
-
|
|
35
|
+
"""
|
|
36
|
+
Save the model instance.
|
|
37
|
+
|
|
38
|
+
Delegates to bulk_create/bulk_update which handle all hook logic
|
|
39
|
+
including MTI parent hooks.
|
|
40
|
+
"""
|
|
61
41
|
if bypass_hooks:
|
|
62
|
-
|
|
63
|
-
return
|
|
42
|
+
# Use super().save() to call Django's default save without our hook logic
|
|
43
|
+
return super().save(*args, **kwargs)
|
|
64
44
|
|
|
65
45
|
is_create = self.pk is None
|
|
66
46
|
|
|
67
47
|
if is_create:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
72
|
-
|
|
73
|
-
super().save(*args, **kwargs)
|
|
74
|
-
|
|
75
|
-
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
48
|
+
# Delegate to bulk_create which handles all hook logic
|
|
49
|
+
result = self.__class__.objects.bulk_create([self])
|
|
50
|
+
return result[0] if result else self
|
|
76
51
|
else:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
except self.__class__.DoesNotExist:
|
|
89
|
-
# If the old instance doesn't exist, treat as create
|
|
90
|
-
ctx = HookContext(self.__class__)
|
|
91
|
-
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
92
|
-
|
|
93
|
-
super().save(*args, **kwargs)
|
|
94
|
-
|
|
95
|
-
run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
|
|
96
|
-
|
|
97
|
-
return self
|
|
52
|
+
# Delegate to bulk_update which handles all hook logic
|
|
53
|
+
update_fields = kwargs.get("update_fields")
|
|
54
|
+
if update_fields is None:
|
|
55
|
+
# Update all non-auto fields
|
|
56
|
+
update_fields = [
|
|
57
|
+
f.name
|
|
58
|
+
for f in self.__class__._meta.fields
|
|
59
|
+
if not f.auto_created and f.name != "id"
|
|
60
|
+
]
|
|
61
|
+
self.__class__.objects.bulk_update([self], update_fields)
|
|
62
|
+
return self
|
|
98
63
|
|
|
99
64
|
def delete(self, *args, bypass_hooks=False, **kwargs):
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return self.__class__._base_manager.delete(self, *args, **kwargs)
|
|
103
|
-
|
|
104
|
-
ctx = HookContext(self.__class__)
|
|
105
|
-
|
|
106
|
-
# Run validation hooks first
|
|
107
|
-
run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
|
|
108
|
-
|
|
109
|
-
# Then run business logic hooks
|
|
110
|
-
run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
|
|
65
|
+
"""
|
|
66
|
+
Delete the model instance.
|
|
111
67
|
|
|
112
|
-
|
|
68
|
+
Delegates to bulk_delete which handles all hook logic
|
|
69
|
+
including MTI parent hooks.
|
|
70
|
+
"""
|
|
71
|
+
if bypass_hooks:
|
|
72
|
+
# Use super().delete() to call Django's default delete without our hook logic
|
|
73
|
+
return super().delete(*args, **kwargs)
|
|
113
74
|
|
|
114
|
-
|
|
115
|
-
return
|
|
75
|
+
# Delegate to bulk_delete (handles both MTI and non-MTI)
|
|
76
|
+
return self.__class__.objects.filter(pk=self.pk).delete()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Operations module for django-bulk-hooks.
|
|
3
|
+
|
|
4
|
+
This module contains all services for bulk operations following
|
|
5
|
+
a clean, service-based architecture.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django_bulk_hooks.operations.coordinator import BulkOperationCoordinator
|
|
9
|
+
from django_bulk_hooks.operations.analyzer import ModelAnalyzer
|
|
10
|
+
from django_bulk_hooks.operations.bulk_executor import BulkExecutor
|
|
11
|
+
from django_bulk_hooks.operations.mti_handler import MTIHandler
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
'BulkOperationCoordinator',
|
|
15
|
+
'ModelAnalyzer',
|
|
16
|
+
'BulkExecutor',
|
|
17
|
+
'MTIHandler',
|
|
18
|
+
]
|