django-bulk-hooks 0.1.226__tar.gz → 0.1.228__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.
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/PKG-INFO +32 -16
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/README.md +29 -13
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/engine.py +30 -15
- django_bulk_hooks-0.1.228/django_bulk_hooks/enums.py +3 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/handler.py +35 -13
- django_bulk_hooks-0.1.228/django_bulk_hooks/priority.py +16 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/queryset.py +197 -170
- django_bulk_hooks-0.1.228/django_bulk_hooks/registry.py +91 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/pyproject.toml +1 -1
- django_bulk_hooks-0.1.226/django_bulk_hooks/enums.py +0 -17
- django_bulk_hooks-0.1.226/django_bulk_hooks/priority.py +0 -16
- django_bulk_hooks-0.1.226/django_bulk_hooks/registry.py +0 -34
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/LICENSE +0 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/conditions.py +0 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/decorators.py +0 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/manager.py +0 -0
- {django_bulk_hooks-0.1.226 → django_bulk_hooks-0.1.228}/django_bulk_hooks/models.py +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: django-bulk-hooks
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.228
|
|
4
4
|
Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
|
|
5
|
-
Home-page: https://github.com/AugendLimited/django-bulk-hooks
|
|
6
5
|
License: MIT
|
|
7
6
|
Keywords: django,bulk,hooks
|
|
8
7
|
Author: Konrad Beck
|
|
@@ -14,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
15
|
Requires-Dist: Django (>=4.0)
|
|
16
|
+
Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
|
|
17
17
|
Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
@@ -59,21 +59,37 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
|
59
59
|
from .models import Account
|
|
60
60
|
|
|
61
61
|
class AccountHooks(Hook):
|
|
62
|
-
@hook(AFTER_UPDATE,
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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)
|
|
75
85
|
```
|
|
76
86
|
|
|
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
|
+
|
|
77
93
|
## 🛠 Supported Hook Events
|
|
78
94
|
|
|
79
95
|
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
@@ -40,21 +40,37 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
|
40
40
|
from .models import Account
|
|
41
41
|
|
|
42
42
|
class AccountHooks(Hook):
|
|
43
|
-
@hook(AFTER_UPDATE,
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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)
|
|
56
66
|
```
|
|
57
67
|
|
|
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
|
+
|
|
58
74
|
## 🛠 Supported Hook Events
|
|
59
75
|
|
|
60
76
|
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
@@ -10,6 +10,13 @@ logger = logging.getLogger(__name__)
|
|
|
10
10
|
def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
11
11
|
"""
|
|
12
12
|
Run hooks for a given model, event, and records.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
model_cls: The Django model class
|
|
16
|
+
event: The hook event (e.g., 'before_create', 'after_update')
|
|
17
|
+
new_records: List of new/updated records
|
|
18
|
+
old_records: List of original records (for comparison)
|
|
19
|
+
ctx: Optional hook context
|
|
13
20
|
"""
|
|
14
21
|
if not new_records:
|
|
15
22
|
return
|
|
@@ -20,14 +27,11 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
20
27
|
if not hooks:
|
|
21
28
|
return
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
stack = traceback.format_stack()
|
|
26
|
-
logger.debug(f"engine.run {model_cls.__name__}.{event} {len(new_records)} records")
|
|
30
|
+
logger.debug(f"Running {len(hooks)} hooks for {model_cls.__name__}.{event} ({len(new_records)} records)")
|
|
27
31
|
|
|
28
32
|
# Check if we're in a bypass context
|
|
29
33
|
if ctx and hasattr(ctx, 'bypass_hooks') and ctx.bypass_hooks:
|
|
30
|
-
logger.debug("
|
|
34
|
+
logger.debug("Hook execution bypassed")
|
|
31
35
|
return
|
|
32
36
|
|
|
33
37
|
# For BEFORE_* events, run model.clean() first for validation
|
|
@@ -39,11 +43,17 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
39
43
|
logger.error("Validation failed for %s: %s", instance, e)
|
|
40
44
|
raise
|
|
41
45
|
|
|
42
|
-
# Process hooks
|
|
46
|
+
# Process hooks in priority order (highest priority first)
|
|
47
|
+
# Registry now sorts by priority (highest first)
|
|
43
48
|
for handler_cls, method_name, condition, priority in hooks:
|
|
44
|
-
logger.debug(f"Processing {handler_cls.__name__}.{method_name}")
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
logger.debug(f"Processing {handler_cls.__name__}.{method_name} (priority: {priority})")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
handler_instance = handler_cls()
|
|
53
|
+
func = getattr(handler_instance, method_name)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.error(f"Failed to instantiate {handler_cls.__name__}: {e}")
|
|
56
|
+
continue
|
|
47
57
|
|
|
48
58
|
to_process_new = []
|
|
49
59
|
to_process_old = []
|
|
@@ -57,18 +67,23 @@ def run(model_cls, event, new_records, old_records=None, ctx=None):
|
|
|
57
67
|
to_process_new.append(new)
|
|
58
68
|
to_process_old.append(original)
|
|
59
69
|
else:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
try:
|
|
71
|
+
condition_result = condition.check(new, original)
|
|
72
|
+
if condition_result:
|
|
73
|
+
to_process_new.append(new)
|
|
74
|
+
to_process_old.append(original)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(f"Condition check failed for {handler_cls.__name__}.{method_name}: {e}")
|
|
77
|
+
continue
|
|
64
78
|
|
|
65
79
|
if to_process_new:
|
|
66
80
|
logger.debug(f"Executing {handler_cls.__name__}.{method_name} for {len(to_process_new)} records")
|
|
67
81
|
try:
|
|
68
82
|
func(
|
|
69
83
|
new_records=to_process_new,
|
|
70
|
-
old_records=to_process_old if any(to_process_old) else None,
|
|
84
|
+
old_records=to_process_old if any(x is not None for x in to_process_old) else None,
|
|
71
85
|
)
|
|
72
86
|
except Exception as e:
|
|
73
|
-
logger.
|
|
87
|
+
logger.error(f"Hook execution failed in {handler_cls.__name__}.{method_name}: {e}")
|
|
88
|
+
# Re-raise the exception to ensure proper error handling
|
|
74
89
|
raise
|
|
@@ -61,28 +61,44 @@ class HookContextState:
|
|
|
61
61
|
return hook_vars.model
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
Hook = HookContextState()
|
|
65
|
-
|
|
66
|
-
|
|
67
64
|
class HookMeta(type):
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
"""Metaclass that automatically registers hooks when Hook classes are defined."""
|
|
66
|
+
|
|
70
67
|
def __new__(mcs, name, bases, namespace):
|
|
71
68
|
cls = super().__new__(mcs, name, bases, namespace)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
|
|
70
|
+
# Register hooks for this class, including inherited methods
|
|
71
|
+
# We need to check all methods in the MRO to handle inheritance
|
|
72
|
+
for attr_name in dir(cls):
|
|
73
|
+
if attr_name.startswith('_'):
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
attr = getattr(cls, attr_name)
|
|
78
|
+
if callable(attr) and hasattr(attr, "hooks_hooks"):
|
|
79
|
+
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
|
+
# Register the hook
|
|
77
84
|
register_hook(
|
|
78
85
|
model=model_cls,
|
|
79
86
|
event=event,
|
|
80
87
|
handler_cls=cls,
|
|
81
|
-
method_name=
|
|
88
|
+
method_name=attr_name,
|
|
82
89
|
condition=condition,
|
|
83
90
|
priority=priority,
|
|
84
91
|
)
|
|
85
|
-
|
|
92
|
+
|
|
93
|
+
logger.debug(
|
|
94
|
+
f"Registered hook {cls.__name__}.{attr_name} "
|
|
95
|
+
f"for {model_cls.__name__}.{event} with priority {priority}"
|
|
96
|
+
)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
# Skip attributes that can't be accessed
|
|
99
|
+
logger.debug(f"Skipping attribute {attr_name}: {e}")
|
|
100
|
+
continue
|
|
101
|
+
|
|
86
102
|
return cls
|
|
87
103
|
|
|
88
104
|
|
|
@@ -123,7 +139,7 @@ class Hook(metaclass=HookMeta):
|
|
|
123
139
|
hook_vars.event = event
|
|
124
140
|
hook_vars.model = model
|
|
125
141
|
|
|
126
|
-
hooks =
|
|
142
|
+
hooks = get_hooks(model, event)
|
|
127
143
|
|
|
128
144
|
def _execute():
|
|
129
145
|
new_local = new_records or []
|
|
@@ -152,6 +168,8 @@ class Hook(metaclass=HookMeta):
|
|
|
152
168
|
logger.exception(
|
|
153
169
|
"Error in hook %s.%s", handler_cls.__name__, method_name
|
|
154
170
|
)
|
|
171
|
+
# Re-raise the exception to ensure proper error handling
|
|
172
|
+
raise
|
|
155
173
|
|
|
156
174
|
conn = transaction.get_connection()
|
|
157
175
|
try:
|
|
@@ -165,3 +183,7 @@ class Hook(metaclass=HookMeta):
|
|
|
165
183
|
hook_vars.event = None
|
|
166
184
|
hook_vars.model = None
|
|
167
185
|
hook_vars.depth -= 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Create a global Hook instance for context access
|
|
189
|
+
HookContext = HookContextState()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Priority(IntEnum):
|
|
5
|
+
"""
|
|
6
|
+
Named priorities for django-bulk-hooks hooks.
|
|
7
|
+
|
|
8
|
+
Higher values run earlier (higher priority).
|
|
9
|
+
Hooks are sorted in descending order.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
LOWEST = 0 # runs last
|
|
13
|
+
LOW = 25 # runs later
|
|
14
|
+
NORMAL = 50 # default ordering
|
|
15
|
+
HIGH = 75 # runs early
|
|
16
|
+
HIGHEST = 100 # runs first
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
3
|
from django.db import models, transaction
|
|
4
|
-
from django.db.models import AutoField, Case,
|
|
5
|
-
|
|
4
|
+
from django.db.models import AutoField, Case, Value, When
|
|
6
5
|
from django_bulk_hooks import engine
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger(__name__)
|
|
9
6
|
from django_bulk_hooks.constants import (
|
|
10
7
|
AFTER_CREATE,
|
|
11
8
|
AFTER_DELETE,
|
|
@@ -19,6 +16,8 @@ from django_bulk_hooks.constants import (
|
|
|
19
16
|
)
|
|
20
17
|
from django_bulk_hooks.context import HookContext
|
|
21
18
|
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
22
21
|
|
|
23
22
|
class HookQuerySetMixin:
|
|
24
23
|
"""
|
|
@@ -28,11 +27,23 @@ class HookQuerySetMixin:
|
|
|
28
27
|
|
|
29
28
|
@transaction.atomic
|
|
30
29
|
def delete(self):
|
|
30
|
+
"""
|
|
31
|
+
Delete objects from the database with complete hook support.
|
|
32
|
+
|
|
33
|
+
This method runs the complete hook cycle:
|
|
34
|
+
VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
|
|
35
|
+
"""
|
|
31
36
|
objs = list(self)
|
|
32
37
|
if not objs:
|
|
33
38
|
return 0
|
|
34
39
|
|
|
35
40
|
model_cls = self.model
|
|
41
|
+
|
|
42
|
+
# Validate that all objects have primary keys
|
|
43
|
+
for obj in objs:
|
|
44
|
+
if obj.pk is None:
|
|
45
|
+
raise ValueError("Cannot delete objects without primary keys")
|
|
46
|
+
|
|
36
47
|
ctx = HookContext(model_cls)
|
|
37
48
|
|
|
38
49
|
# Run validation hooks first
|
|
@@ -51,6 +62,17 @@ class HookQuerySetMixin:
|
|
|
51
62
|
|
|
52
63
|
@transaction.atomic
|
|
53
64
|
def update(self, **kwargs):
|
|
65
|
+
"""
|
|
66
|
+
Update objects with field values and run complete hook cycle.
|
|
67
|
+
|
|
68
|
+
This method runs the complete hook cycle for all updates:
|
|
69
|
+
VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
|
|
70
|
+
|
|
71
|
+
Supports both simple field updates and complex expressions (Subquery, Case, etc.).
|
|
72
|
+
"""
|
|
73
|
+
# Extract custom parameters
|
|
74
|
+
bypass_hooks = kwargs.pop('bypass_hooks', False)
|
|
75
|
+
|
|
54
76
|
instances = list(self)
|
|
55
77
|
if not instances:
|
|
56
78
|
return 0
|
|
@@ -58,109 +80,77 @@ class HookQuerySetMixin:
|
|
|
58
80
|
model_cls = self.model
|
|
59
81
|
pks = [obj.pk for obj in instances]
|
|
60
82
|
|
|
61
|
-
# Load originals for hook comparison
|
|
62
|
-
# Use the base manager to avoid recursion
|
|
83
|
+
# Load originals for hook comparison
|
|
63
84
|
original_map = {
|
|
64
85
|
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
65
86
|
}
|
|
66
87
|
originals = [original_map.get(obj.pk) for obj in instances]
|
|
67
88
|
|
|
68
|
-
# Check if any of the update values are complex database expressions
|
|
89
|
+
# Check if any of the update values are complex database expressions
|
|
69
90
|
has_subquery = any(
|
|
70
|
-
(hasattr(value, "query") and hasattr(value, "
|
|
71
|
-
or hasattr(
|
|
72
|
-
value, "resolve_expression"
|
|
73
|
-
) # This catches Case, F expressions, etc.
|
|
91
|
+
(hasattr(value, "query") and hasattr(value.query, "model"))
|
|
92
|
+
or (hasattr(value, "get_source_expressions") and value.get_source_expressions())
|
|
74
93
|
for value in kwargs.values()
|
|
75
94
|
)
|
|
76
|
-
|
|
77
|
-
# Also check if any of the instances have complex expressions in their attributes
|
|
78
|
-
# This can happen when bulk_update creates Case expressions and applies them to instances
|
|
79
|
-
if not has_subquery and instances:
|
|
80
|
-
for instance in instances:
|
|
81
|
-
for field_name in kwargs.keys():
|
|
82
|
-
if hasattr(instance, field_name):
|
|
83
|
-
field_value = getattr(instance, field_name)
|
|
84
|
-
if hasattr(field_value, "resolve_expression"):
|
|
85
|
-
has_subquery = True
|
|
86
|
-
break
|
|
87
|
-
if has_subquery:
|
|
88
|
-
break
|
|
89
|
-
|
|
90
|
-
# Check if we're in a bulk operation context to prevent double hook execution
|
|
91
|
-
from django_bulk_hooks.context import get_bypass_hooks
|
|
92
|
-
|
|
93
|
-
current_bypass_hooks = get_bypass_hooks()
|
|
94
|
-
|
|
95
|
-
# Apply field updates to instances for all cases (needed for hook inspection)
|
|
96
|
-
for obj in instances:
|
|
97
|
-
for field, value in kwargs.items():
|
|
98
|
-
# For subquery fields, set the original Subquery object temporarily
|
|
99
|
-
# We'll resolve it after database update if needed
|
|
100
|
-
setattr(obj, field, value)
|
|
101
|
-
|
|
102
|
-
# If we're in a bulk operation context, skip hooks to prevent double execution
|
|
103
|
-
if current_bypass_hooks:
|
|
104
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
105
|
-
# For bulk operations without hooks, execute update
|
|
106
|
-
update_count = super().update(**kwargs)
|
|
107
|
-
else:
|
|
108
|
-
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
109
95
|
|
|
110
|
-
|
|
96
|
+
# Run hooks only if not bypassed
|
|
97
|
+
if not bypass_hooks:
|
|
98
|
+
ctx = HookContext(model_cls)
|
|
99
|
+
# Run VALIDATE_UPDATE hooks
|
|
100
|
+
engine.run(model_cls, VALIDATE_UPDATE, instances, originals, ctx=ctx)
|
|
101
|
+
|
|
102
|
+
# For subqueries, we need to compute the values and apply them to instances
|
|
103
|
+
# before running BEFORE_UPDATE hooks
|
|
111
104
|
if has_subquery:
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
# Execute the database update first to compute subquery values
|
|
125
|
-
update_count = super().update(**kwargs)
|
|
126
|
-
|
|
127
|
-
# Refresh instances to get computed subquery values BEFORE running BEFORE hooks
|
|
128
|
-
# Use the model's default manager to ensure queryable properties are properly handled
|
|
129
|
-
refreshed_instances = {
|
|
130
|
-
obj.pk: obj for obj in model_cls.objects.filter(pk__in=pks)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
# Update instances in memory with computed values
|
|
134
|
-
for instance in instances:
|
|
135
|
-
if instance.pk in refreshed_instances:
|
|
136
|
-
refreshed_instance = refreshed_instances[instance.pk]
|
|
137
|
-
# Update all fields except primary key with the computed values
|
|
138
|
-
for field in model_cls._meta.fields:
|
|
139
|
-
if field.name != "id":
|
|
140
|
-
setattr(
|
|
141
|
-
instance,
|
|
142
|
-
field.name,
|
|
143
|
-
getattr(refreshed_instance, field.name),
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
# Now run BEFORE_UPDATE hooks with resolved values
|
|
147
|
-
# Note: This is a trade-off - BEFORE hooks run after DB update for subquery cases
|
|
148
|
-
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
105
|
+
# Create a temporary update to compute the values
|
|
106
|
+
# We'll use a subquery to compute values without actually updating
|
|
107
|
+
for field_name, value in kwargs.items():
|
|
108
|
+
if (hasattr(value, "query") and hasattr(value.query, "model")) or \
|
|
109
|
+
(hasattr(value, "get_source_expressions") and value.get_source_expressions()):
|
|
110
|
+
# This is a complex expression - compute it for each instance
|
|
111
|
+
for instance in instances:
|
|
112
|
+
# Create a single-instance queryset to compute the value
|
|
113
|
+
single_qs = model_cls._base_manager.filter(pk=instance.pk)
|
|
114
|
+
computed_values = single_qs.annotate(computed_field=value).values_list('computed_field', flat=True)
|
|
115
|
+
if computed_values:
|
|
116
|
+
setattr(instance, field_name, computed_values[0])
|
|
149
117
|
else:
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
118
|
+
# For simple updates, apply the values directly
|
|
119
|
+
for obj in instances:
|
|
120
|
+
for field, value in kwargs.items():
|
|
121
|
+
setattr(obj, field, value)
|
|
122
|
+
|
|
123
|
+
# Run BEFORE_UPDATE hooks with updated instances
|
|
124
|
+
engine.run(model_cls, BEFORE_UPDATE, instances, originals, ctx=ctx)
|
|
125
|
+
|
|
126
|
+
if has_subquery:
|
|
127
|
+
# For complex expressions, use Django's native update
|
|
128
|
+
# This handles Subquery, Case, F expressions, etc. correctly
|
|
129
|
+
result = super().update(**kwargs)
|
|
130
|
+
|
|
131
|
+
# After updating with complex expressions, we need to reload the instances
|
|
132
|
+
# to get the computed values for the AFTER_UPDATE hooks
|
|
133
|
+
if not bypass_hooks:
|
|
134
|
+
# Reload instances to get computed values
|
|
135
|
+
updated_instances = list(model_cls._base_manager.filter(pk__in=pks))
|
|
136
|
+
# Maintain the original order
|
|
137
|
+
updated_map = {obj.pk: obj for obj in updated_instances}
|
|
138
|
+
instances = [updated_map.get(obj.pk, obj) for obj in instances]
|
|
139
|
+
else:
|
|
140
|
+
# For simple field updates, instances have already been updated in the hook section
|
|
141
|
+
# Perform database update using Django's native bulk_update
|
|
142
|
+
# We use the base manager to avoid recursion
|
|
143
|
+
base_manager = model_cls._base_manager
|
|
144
|
+
fields_to_update = list(kwargs.keys())
|
|
145
|
+
base_manager.bulk_update(instances, fields_to_update)
|
|
146
|
+
result = len(instances)
|
|
147
|
+
|
|
148
|
+
# Run AFTER_UPDATE hooks only if not bypassed
|
|
149
|
+
if not bypass_hooks:
|
|
150
|
+
ctx = HookContext(model_cls)
|
|
161
151
|
engine.run(model_cls, AFTER_UPDATE, instances, originals, ctx=ctx)
|
|
162
|
-
|
|
163
|
-
return
|
|
152
|
+
|
|
153
|
+
return result
|
|
164
154
|
|
|
165
155
|
@transaction.atomic
|
|
166
156
|
def bulk_create(
|
|
@@ -175,35 +165,32 @@ class HookQuerySetMixin:
|
|
|
175
165
|
bypass_validation=False,
|
|
176
166
|
):
|
|
177
167
|
"""
|
|
178
|
-
Insert each of the instances into the database
|
|
179
|
-
|
|
180
|
-
|
|
168
|
+
Insert each of the instances into the database with complete hook support.
|
|
169
|
+
|
|
170
|
+
This method runs the complete hook cycle:
|
|
171
|
+
VALIDATE_CREATE → BEFORE_CREATE → DB create → AFTER_CREATE
|
|
172
|
+
|
|
173
|
+
Behaves like Django's bulk_create but supports multi-table inheritance (MTI)
|
|
174
|
+
models and hooks. All arguments are supported and passed through to the correct logic.
|
|
181
175
|
"""
|
|
182
176
|
model_cls = self.model
|
|
183
177
|
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# are two workarounds:
|
|
188
|
-
# 1) This could be implemented if you didn't have an autoincrement pk
|
|
189
|
-
# 2) You could do it by doing O(n) normal inserts into the parent
|
|
190
|
-
# tables to get the primary keys back and then doing a single bulk
|
|
191
|
-
# insert into the childmost table.
|
|
192
|
-
# We currently set the primary keys on the objects when using
|
|
193
|
-
# PostgreSQL via the RETURNING ID clause. It should be possible for
|
|
194
|
-
# Oracle as well, but the semantics for extracting the primary keys is
|
|
195
|
-
# trickier so it's not done yet.
|
|
196
|
-
if batch_size is not None and batch_size <= 0:
|
|
197
|
-
raise ValueError("Batch size must be a positive integer.")
|
|
178
|
+
# Validate inputs
|
|
179
|
+
if not isinstance(objs, (list, tuple)):
|
|
180
|
+
raise TypeError("objs must be a list or tuple")
|
|
198
181
|
|
|
199
182
|
if not objs:
|
|
200
183
|
return objs
|
|
201
184
|
|
|
202
185
|
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
203
186
|
raise TypeError(
|
|
204
|
-
f"bulk_create expected instances of {model_cls.__name__},
|
|
187
|
+
f"bulk_create expected instances of {model_cls.__name__}, "
|
|
188
|
+
f"but got {set(type(obj).__name__ for obj in objs)}"
|
|
205
189
|
)
|
|
206
190
|
|
|
191
|
+
if batch_size is not None and batch_size <= 0:
|
|
192
|
+
raise ValueError("batch_size must be a positive integer.")
|
|
193
|
+
|
|
207
194
|
# Check for MTI - if we detect multi-table inheritance, we need special handling
|
|
208
195
|
# This follows Django's approach: check that the parents share the same concrete model
|
|
209
196
|
# with our model to detect the inheritance pattern ConcreteGrandParent ->
|
|
@@ -217,12 +204,12 @@ class HookQuerySetMixin:
|
|
|
217
204
|
|
|
218
205
|
# Fire hooks before DB ops
|
|
219
206
|
if not bypass_hooks:
|
|
220
|
-
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
207
|
+
ctx = HookContext(model_cls, bypass_hooks=False)
|
|
221
208
|
if not bypass_validation:
|
|
222
209
|
engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
|
|
223
210
|
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
224
211
|
else:
|
|
225
|
-
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
212
|
+
ctx = HookContext(model_cls, bypass_hooks=True)
|
|
226
213
|
logger.debug("bulk_create bypassed hooks")
|
|
227
214
|
|
|
228
215
|
# For MTI models, we need to handle them specially
|
|
@@ -266,76 +253,116 @@ class HookQuerySetMixin:
|
|
|
266
253
|
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
267
254
|
):
|
|
268
255
|
"""
|
|
269
|
-
Bulk update objects in the database with
|
|
256
|
+
Bulk update objects in the database with complete hook support.
|
|
257
|
+
|
|
258
|
+
This method always runs the complete hook cycle:
|
|
259
|
+
VALIDATE_UPDATE → BEFORE_UPDATE → DB update → AFTER_UPDATE
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
objs: List of model instances to update
|
|
263
|
+
fields: List of field names to update
|
|
264
|
+
bypass_hooks: DEPRECATED - kept for backward compatibility only
|
|
265
|
+
bypass_validation: DEPRECATED - kept for backward compatibility only
|
|
266
|
+
**kwargs: Additional arguments passed to Django's bulk_update
|
|
270
267
|
"""
|
|
271
268
|
model_cls = self.model
|
|
272
269
|
|
|
273
270
|
if not objs:
|
|
274
271
|
return []
|
|
275
272
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
273
|
+
# Validate inputs
|
|
274
|
+
if not isinstance(objs, (list, tuple)):
|
|
275
|
+
raise TypeError("objs must be a list or tuple")
|
|
280
276
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
)
|
|
277
|
+
if not isinstance(fields, (list, tuple)):
|
|
278
|
+
raise TypeError("fields must be a list or tuple")
|
|
284
279
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
break
|
|
280
|
+
if not objs:
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
if not fields:
|
|
284
|
+
raise ValueError("fields cannot be empty")
|
|
291
285
|
|
|
286
|
+
# Validate that all objects are instances of the model
|
|
287
|
+
for obj in objs:
|
|
288
|
+
if not isinstance(obj, model_cls):
|
|
289
|
+
raise TypeError(
|
|
290
|
+
f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
|
|
291
|
+
)
|
|
292
|
+
if obj.pk is None:
|
|
293
|
+
raise ValueError("All objects must have a primary key")
|
|
294
|
+
|
|
295
|
+
# Load originals for hook comparison
|
|
296
|
+
pks = [obj.pk for obj in objs]
|
|
297
|
+
original_map = {
|
|
298
|
+
obj.pk: obj for obj in model_cls._base_manager.filter(pk__in=pks)
|
|
299
|
+
}
|
|
300
|
+
originals = [original_map.get(obj.pk) for obj in objs]
|
|
301
|
+
|
|
302
|
+
# Run VALIDATE_UPDATE hooks
|
|
303
|
+
if not bypass_validation:
|
|
304
|
+
ctx = HookContext(model_cls)
|
|
305
|
+
engine.run(
|
|
306
|
+
model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Run BEFORE_UPDATE hooks
|
|
292
310
|
if not bypass_hooks:
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
# Handle auto_now fields like Django's update_or_create does
|
|
304
|
-
fields_set = set(fields)
|
|
305
|
-
pk_fields = model_cls._meta.pk_fields
|
|
306
|
-
for field in model_cls._meta.local_concrete_fields:
|
|
307
|
-
# Only add auto_now fields (like updated_at) that aren't already in the fields list
|
|
308
|
-
# Don't include auto_now_add fields (like created_at) as they should only be set on creation
|
|
309
|
-
if hasattr(field, "auto_now") and field.auto_now:
|
|
310
|
-
if field.name not in fields_set and field.name not in pk_fields:
|
|
311
|
-
fields_set.add(field.name)
|
|
312
|
-
if field.name != field.attname:
|
|
313
|
-
fields_set.add(field.attname)
|
|
314
|
-
fields = list(fields_set)
|
|
315
|
-
|
|
316
|
-
# Handle MTI models differently
|
|
317
|
-
if is_mti:
|
|
318
|
-
result = self._mti_bulk_update(objs, fields, **kwargs)
|
|
319
|
-
else:
|
|
320
|
-
# For single-table models, use Django's built-in bulk_update
|
|
321
|
-
django_kwargs = {
|
|
322
|
-
k: v
|
|
323
|
-
for k, v in kwargs.items()
|
|
324
|
-
if k not in ["bypass_hooks", "bypass_validation"]
|
|
325
|
-
}
|
|
326
|
-
logger.debug("Calling Django bulk_update")
|
|
327
|
-
result = super().bulk_update(objs, fields, **django_kwargs)
|
|
328
|
-
logger.debug(f"Django bulk_update done: {result}")
|
|
311
|
+
ctx = HookContext(model_cls)
|
|
312
|
+
engine.run(
|
|
313
|
+
model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Perform database update using Django's native bulk_update
|
|
317
|
+
# We use the base manager to avoid recursion
|
|
318
|
+
base_manager = model_cls._base_manager
|
|
319
|
+
result = base_manager.bulk_update(objs, fields, **kwargs)
|
|
329
320
|
|
|
330
|
-
#
|
|
331
|
-
# The update() method will handle all hook execution based on thread-local state
|
|
321
|
+
# Run AFTER_UPDATE hooks
|
|
332
322
|
if not bypass_hooks:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
logger.debug("bulk_update: hooks bypassed")
|
|
323
|
+
ctx = HookContext(model_cls)
|
|
324
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
336
325
|
|
|
337
326
|
return result
|
|
338
327
|
|
|
328
|
+
@transaction.atomic
|
|
329
|
+
def bulk_delete(self, objs, **kwargs):
|
|
330
|
+
"""
|
|
331
|
+
Delete the given objects from the database with complete hook support.
|
|
332
|
+
|
|
333
|
+
This method runs the complete hook cycle:
|
|
334
|
+
VALIDATE_DELETE → BEFORE_DELETE → DB delete → AFTER_DELETE
|
|
335
|
+
|
|
336
|
+
This is a convenience method that provides a bulk_delete interface
|
|
337
|
+
similar to bulk_create and bulk_update.
|
|
338
|
+
"""
|
|
339
|
+
model_cls = self.model
|
|
340
|
+
|
|
341
|
+
# Extract custom kwargs
|
|
342
|
+
bypass_hooks = kwargs.pop("bypass_hooks", False)
|
|
343
|
+
|
|
344
|
+
# Validate inputs
|
|
345
|
+
if not isinstance(objs, (list, tuple)):
|
|
346
|
+
raise TypeError("objs must be a list or tuple")
|
|
347
|
+
|
|
348
|
+
if not objs:
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
# Validate that all objects are instances of the model
|
|
352
|
+
for obj in objs:
|
|
353
|
+
if not isinstance(obj, model_cls):
|
|
354
|
+
raise TypeError(
|
|
355
|
+
f"Expected instances of {model_cls.__name__}, got {type(obj).__name__}"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Get the pks to delete
|
|
359
|
+
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
360
|
+
if not pks:
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
# Use the delete() method which already has hook support
|
|
364
|
+
return self.filter(pk__in=pks).delete()
|
|
365
|
+
|
|
339
366
|
def _detect_modified_fields(self, new_instances, original_instances):
|
|
340
367
|
"""
|
|
341
368
|
Detect fields that were modified during BEFORE_UPDATE hooks by comparing
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
from django_bulk_hooks.priority import Priority
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
_hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_hook(
|
|
13
|
+
model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
|
|
14
|
+
):
|
|
15
|
+
"""
|
|
16
|
+
Register a hook for a specific model and event.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
model: The Django model class
|
|
20
|
+
event: The hook event (e.g., 'before_create', 'after_update')
|
|
21
|
+
handler_cls: The hook handler class
|
|
22
|
+
method_name: The method name in the handler class
|
|
23
|
+
condition: Optional condition for when the hook should run
|
|
24
|
+
priority: Hook execution priority (higher numbers execute first)
|
|
25
|
+
"""
|
|
26
|
+
if not model or not event or not handler_cls or not method_name:
|
|
27
|
+
logger.warning("Invalid hook registration parameters")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
key = (model, event)
|
|
31
|
+
hooks = _hooks.setdefault(key, [])
|
|
32
|
+
|
|
33
|
+
# Check for duplicate registrations
|
|
34
|
+
existing = [h for h in hooks if h[0] == handler_cls and h[1] == method_name]
|
|
35
|
+
if existing:
|
|
36
|
+
logger.warning(
|
|
37
|
+
f"Hook {handler_cls.__name__}.{method_name} already registered "
|
|
38
|
+
f"for {model.__name__}.{event}"
|
|
39
|
+
)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Add the hook
|
|
43
|
+
hooks.append((handler_cls, method_name, condition, priority))
|
|
44
|
+
|
|
45
|
+
# Sort by priority (highest numbers execute first)
|
|
46
|
+
hooks.sort(key=lambda x: x[3], reverse=True)
|
|
47
|
+
|
|
48
|
+
logger.debug(
|
|
49
|
+
f"Registered {handler_cls.__name__}.{method_name} "
|
|
50
|
+
f"for {model.__name__}.{event} with priority {priority}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_hooks(model, event):
|
|
55
|
+
"""
|
|
56
|
+
Get all registered hooks for a specific model and event.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
model: The Django model class
|
|
60
|
+
event: The hook event
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of (handler_cls, method_name, condition, priority) tuples
|
|
64
|
+
"""
|
|
65
|
+
if not model or not event:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
key = (model, event)
|
|
69
|
+
hooks = _hooks.get(key, [])
|
|
70
|
+
|
|
71
|
+
# Log hook discovery for debugging
|
|
72
|
+
if hooks:
|
|
73
|
+
logger.debug(f"Found {len(hooks)} hooks for {model.__name__}.{event}")
|
|
74
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
75
|
+
logger.debug(f" - {handler_cls.__name__}.{method_name} (priority: {priority})")
|
|
76
|
+
else:
|
|
77
|
+
logger.debug(f"No hooks found for {model.__name__}.{event}")
|
|
78
|
+
|
|
79
|
+
return hooks
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def list_all_hooks():
|
|
83
|
+
"""Debug function to list all registered hooks."""
|
|
84
|
+
return _hooks
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def clear_hooks():
|
|
88
|
+
"""Clear all registered hooks (mainly for testing)."""
|
|
89
|
+
global _hooks
|
|
90
|
+
_hooks.clear()
|
|
91
|
+
logger.debug("All hooks cleared")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "django-bulk-hooks"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.228"
|
|
4
4
|
description = "Hook-style hooks for Django bulk operations like bulk_create and bulk_update."
|
|
5
5
|
authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -1,17 +0,0 @@
|
|
|
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
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
from enum import IntEnum
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class Priority(IntEnum):
|
|
5
|
-
"""
|
|
6
|
-
Named priorities for django-bulk-hooks hooks.
|
|
7
|
-
|
|
8
|
-
Lower values run earlier (higher priority).
|
|
9
|
-
Hooks are sorted in ascending order.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
HIGHEST = 0 # runs first
|
|
13
|
-
HIGH = 25 # runs early
|
|
14
|
-
NORMAL = 50 # default ordering
|
|
15
|
-
LOW = 75 # runs later
|
|
16
|
-
LOWEST = 100 # runs last
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from collections.abc import Callable
|
|
3
|
-
from typing import Union
|
|
4
|
-
|
|
5
|
-
from django_bulk_hooks.priority import Priority
|
|
6
|
-
|
|
7
|
-
logger = logging.getLogger(__name__)
|
|
8
|
-
|
|
9
|
-
_hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def register_hook(
|
|
13
|
-
model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
|
|
14
|
-
):
|
|
15
|
-
key = (model, event)
|
|
16
|
-
hooks = _hooks.setdefault(key, [])
|
|
17
|
-
hooks.append((handler_cls, method_name, condition, priority))
|
|
18
|
-
# keep sorted by priority
|
|
19
|
-
hooks.sort(key=lambda x: x[3])
|
|
20
|
-
logger.debug(f"Registered {handler_cls.__name__}.{method_name} for {model.__name__}.{event}")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def get_hooks(model, event):
|
|
24
|
-
key = (model, event)
|
|
25
|
-
hooks = _hooks.get(key, [])
|
|
26
|
-
# Only log when hooks are found or for specific events to reduce noise
|
|
27
|
-
if hooks or event in ['after_update', 'before_update', 'after_create', 'before_create']:
|
|
28
|
-
logger.debug(f"get_hooks {model.__name__}.{event} found {len(hooks)} hooks")
|
|
29
|
-
return hooks
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def list_all_hooks():
|
|
33
|
-
"""Debug function to list all registered hooks"""
|
|
34
|
-
return _hooks
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|