django-bulk-hooks 0.1.50__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.50/LICENSE +21 -0
- django_bulk_hooks-0.1.50/PKG-INFO +102 -0
- django_bulk_hooks-0.1.50/README.md +82 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/__init__.py +3 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/conditions.py +158 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/constants.py +6 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/context.py +4 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/decorators.py +93 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/engine.py +63 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/enums.py +17 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/handler.py +208 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/manager.py +130 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/models.py +25 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/priority.py +16 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/queryset.py +27 -0
- django_bulk_hooks-0.1.50/django_bulk_hooks/registry.py +20 -0
- django_bulk_hooks-0.1.50/pyproject.toml +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 konradbeck
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: django-bulk-hooks
|
|
3
|
+
Version: 0.1.50
|
|
4
|
+
Summary: Lifecycle-style hooks for Django bulk operations like bulk_create and bulk_update.
|
|
5
|
+
Home-page: https://github.com/AugendLimited/django-bulk-hooks
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: django,bulk,hooks
|
|
8
|
+
Author: Konrad Beck
|
|
9
|
+
Author-email: konrad.beck@merchantcapital.co.za
|
|
10
|
+
Requires-Python: >=3.11,<4.0
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Dist: Django (>=4.0)
|
|
17
|
+
Project-URL: Repository, https://github.com/AugendLimited/django-bulk-hooks
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# django-bulk-hooks
|
|
22
|
+
|
|
23
|
+
⚡ Salesforce-style hooks hooks for Django bulk operations.
|
|
24
|
+
|
|
25
|
+
`django-bulk-hooks` brings a declarative, trigger-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety.
|
|
26
|
+
|
|
27
|
+
## ✨ Features
|
|
28
|
+
|
|
29
|
+
- Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
|
|
30
|
+
- BEFORE/AFTER hooks for create, update, delete
|
|
31
|
+
- Salesforce-style semantics with full batch support
|
|
32
|
+
- Lifecycle-aware manager that wraps Django’s `bulk_` operations
|
|
33
|
+
- Hook chaining, trigger deduplication, and atomicity
|
|
34
|
+
- Class-based hook handlers with DI support
|
|
35
|
+
|
|
36
|
+
## 🚀 Quickstart
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install django-bulk-hooks
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Define Your Model
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from django.db import models
|
|
46
|
+
from django_bulk_hooks.manager import BulkLifecycleManager
|
|
47
|
+
|
|
48
|
+
class Account(models.Model):
|
|
49
|
+
balance = models.DecimalField(max_digits=10, decimal_places=2)
|
|
50
|
+
objects = BulkLifecycleManager()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Create a Trigger Handler
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from django_bulk_hooks import hook, AFTER_UPDATE, TriggerHandler
|
|
57
|
+
from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
58
|
+
from .models import Account
|
|
59
|
+
|
|
60
|
+
class AccountTriggerHandler(TriggerHandler):
|
|
61
|
+
@hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
62
|
+
def log_balance_change(self, new_objs):
|
|
63
|
+
print("Accounts updated:", [a.pk for a in new_objs])
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 🛠 Supported Lifecycle Events
|
|
67
|
+
|
|
68
|
+
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
69
|
+
- `BEFORE_UPDATE`, `AFTER_UPDATE`
|
|
70
|
+
- `BEFORE_DELETE`, `AFTER_DELETE`
|
|
71
|
+
|
|
72
|
+
## 🧠 Why?
|
|
73
|
+
|
|
74
|
+
Django’s `bulk_` methods bypass signals and `save()`. This package fills that gap with:
|
|
75
|
+
|
|
76
|
+
- Triggers that behave consistently across creates/updates/deletes
|
|
77
|
+
- Scalable performance via chunking (default 200)
|
|
78
|
+
- Support for `@hook` decorators and centralized trigger classes
|
|
79
|
+
|
|
80
|
+
## 📦 Usage in Views / Commands
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# Calls AFTER_UPDATE hooks automatically
|
|
84
|
+
Account.objects.bulk_update(accounts, ['balance'])
|
|
85
|
+
|
|
86
|
+
# Triggers BEFORE_CREATE and AFTER_CREATE
|
|
87
|
+
Account.objects.bulk_create(accounts)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 🧩 Integration with Queryable Properties
|
|
91
|
+
|
|
92
|
+
You can extend from `BulkLifecycleManager` to support formula fields or property querying.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
class MyManager(BulkLifecycleManager, QueryablePropertiesManager):
|
|
96
|
+
pass
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## 📝 License
|
|
100
|
+
|
|
101
|
+
MIT © 2024 Augend / Konrad Beck
|
|
102
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
|
|
2
|
+
# django-bulk-hooks
|
|
3
|
+
|
|
4
|
+
⚡ Salesforce-style hooks hooks for Django bulk operations.
|
|
5
|
+
|
|
6
|
+
`django-bulk-hooks` brings a declarative, trigger-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety.
|
|
7
|
+
|
|
8
|
+
## ✨ Features
|
|
9
|
+
|
|
10
|
+
- Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
|
|
11
|
+
- BEFORE/AFTER hooks for create, update, delete
|
|
12
|
+
- Salesforce-style semantics with full batch support
|
|
13
|
+
- Lifecycle-aware manager that wraps Django’s `bulk_` operations
|
|
14
|
+
- Hook chaining, trigger deduplication, and atomicity
|
|
15
|
+
- Class-based hook handlers with DI support
|
|
16
|
+
|
|
17
|
+
## 🚀 Quickstart
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install django-bulk-hooks
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Define Your Model
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from django.db import models
|
|
27
|
+
from django_bulk_hooks.manager import BulkLifecycleManager
|
|
28
|
+
|
|
29
|
+
class Account(models.Model):
|
|
30
|
+
balance = models.DecimalField(max_digits=10, decimal_places=2)
|
|
31
|
+
objects = BulkLifecycleManager()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Create a Trigger Handler
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from django_bulk_hooks import hook, AFTER_UPDATE, TriggerHandler
|
|
38
|
+
from django_bulk_hooks.conditions import WhenFieldHasChanged
|
|
39
|
+
from .models import Account
|
|
40
|
+
|
|
41
|
+
class AccountTriggerHandler(TriggerHandler):
|
|
42
|
+
@hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
|
|
43
|
+
def log_balance_change(self, new_objs):
|
|
44
|
+
print("Accounts updated:", [a.pk for a in new_objs])
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 🛠 Supported Lifecycle Events
|
|
48
|
+
|
|
49
|
+
- `BEFORE_CREATE`, `AFTER_CREATE`
|
|
50
|
+
- `BEFORE_UPDATE`, `AFTER_UPDATE`
|
|
51
|
+
- `BEFORE_DELETE`, `AFTER_DELETE`
|
|
52
|
+
|
|
53
|
+
## 🧠 Why?
|
|
54
|
+
|
|
55
|
+
Django’s `bulk_` methods bypass signals and `save()`. This package fills that gap with:
|
|
56
|
+
|
|
57
|
+
- Triggers that behave consistently across creates/updates/deletes
|
|
58
|
+
- Scalable performance via chunking (default 200)
|
|
59
|
+
- Support for `@hook` decorators and centralized trigger classes
|
|
60
|
+
|
|
61
|
+
## 📦 Usage in Views / Commands
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# Calls AFTER_UPDATE hooks automatically
|
|
65
|
+
Account.objects.bulk_update(accounts, ['balance'])
|
|
66
|
+
|
|
67
|
+
# Triggers BEFORE_CREATE and AFTER_CREATE
|
|
68
|
+
Account.objects.bulk_create(accounts)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 🧩 Integration with Queryable Properties
|
|
72
|
+
|
|
73
|
+
You can extend from `BulkLifecycleManager` to support formula fields or property querying.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
class MyManager(BulkLifecycleManager, QueryablePropertiesManager):
|
|
77
|
+
pass
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 📝 License
|
|
81
|
+
|
|
82
|
+
MIT © 2024 Augend / Konrad Beck
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
logger = logging.getLogger(__name__)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def resolve_dotted_attr(instance, dotted_path):
|
|
7
|
+
"""
|
|
8
|
+
Recursively resolve a dotted attribute path, e.g., "type.category".
|
|
9
|
+
"""
|
|
10
|
+
for attr in dotted_path.split("."):
|
|
11
|
+
if instance is None:
|
|
12
|
+
return None
|
|
13
|
+
instance = getattr(instance, attr, None)
|
|
14
|
+
return instance
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HookCondition:
|
|
18
|
+
def check(self, instance, original_instance=None):
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
def __call__(self, instance, original_instance=None):
|
|
22
|
+
return self.check(instance, original_instance)
|
|
23
|
+
|
|
24
|
+
def __and__(self, other):
|
|
25
|
+
return AndCondition(self, other)
|
|
26
|
+
|
|
27
|
+
def __or__(self, other):
|
|
28
|
+
return OrCondition(self, other)
|
|
29
|
+
|
|
30
|
+
def __invert__(self):
|
|
31
|
+
return NotCondition(self)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WhenFieldValueIsNot(HookCondition):
|
|
35
|
+
def __init__(self, field, value, only_on_change=False):
|
|
36
|
+
self.field = field
|
|
37
|
+
self.value = value
|
|
38
|
+
self.only_on_change = only_on_change
|
|
39
|
+
|
|
40
|
+
def check(self, instance, original_instance=None):
|
|
41
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
42
|
+
logger.debug(
|
|
43
|
+
"%s current=%r, original=%r",
|
|
44
|
+
self.field,
|
|
45
|
+
current,
|
|
46
|
+
resolve_dotted_attr(original_instance, self.field) if original_instance else None,
|
|
47
|
+
)
|
|
48
|
+
if self.only_on_change:
|
|
49
|
+
if original_instance is None:
|
|
50
|
+
return False
|
|
51
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
52
|
+
return previous == self.value and current != self.value
|
|
53
|
+
else:
|
|
54
|
+
return current != self.value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WhenFieldValueIs(HookCondition):
|
|
58
|
+
def __init__(self, field, value, only_on_change=False):
|
|
59
|
+
self.field = field
|
|
60
|
+
self.value = value
|
|
61
|
+
self.only_on_change = only_on_change
|
|
62
|
+
|
|
63
|
+
def check(self, instance, original_instance=None):
|
|
64
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
65
|
+
logger.debug(
|
|
66
|
+
"%s current=%r, original=%r",
|
|
67
|
+
self.field,
|
|
68
|
+
current,
|
|
69
|
+
resolve_dotted_attr(original_instance, self.field) if original_instance else None,
|
|
70
|
+
)
|
|
71
|
+
if self.only_on_change:
|
|
72
|
+
if original_instance is None:
|
|
73
|
+
return False
|
|
74
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
75
|
+
return previous != self.value and current == self.value
|
|
76
|
+
else:
|
|
77
|
+
return current == self.value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class WhenFieldHasChanged(HookCondition):
|
|
81
|
+
def __init__(self, field, has_changed=True):
|
|
82
|
+
self.field = field
|
|
83
|
+
self.has_changed = has_changed
|
|
84
|
+
|
|
85
|
+
def check(self, instance, original_instance=None):
|
|
86
|
+
if not original_instance:
|
|
87
|
+
return False
|
|
88
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
89
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
90
|
+
return (current != previous) == self.has_changed
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class WhenFieldValueWas(HookCondition):
|
|
94
|
+
def __init__(self, field, value, only_on_change=False):
|
|
95
|
+
"""
|
|
96
|
+
Check if a field's original value was `value`.
|
|
97
|
+
If only_on_change is True, only return True when the field has changed away from that value.
|
|
98
|
+
"""
|
|
99
|
+
self.field = field
|
|
100
|
+
self.value = value
|
|
101
|
+
self.only_on_change = only_on_change
|
|
102
|
+
|
|
103
|
+
def check(self, instance, original_instance=None):
|
|
104
|
+
if original_instance is None:
|
|
105
|
+
return False
|
|
106
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
107
|
+
if self.only_on_change:
|
|
108
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
109
|
+
return previous == self.value and current != self.value
|
|
110
|
+
else:
|
|
111
|
+
return previous == self.value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class WhenFieldValueChangesTo(HookCondition):
|
|
115
|
+
def __init__(self, field, value):
|
|
116
|
+
"""
|
|
117
|
+
Check if a field's value has changed to `value`.
|
|
118
|
+
Only returns True when original value != value and current value == value.
|
|
119
|
+
"""
|
|
120
|
+
self.field = field
|
|
121
|
+
self.value = value
|
|
122
|
+
|
|
123
|
+
def check(self, instance, original_instance=None):
|
|
124
|
+
if original_instance is None:
|
|
125
|
+
return False
|
|
126
|
+
previous = resolve_dotted_attr(original_instance, self.field)
|
|
127
|
+
current = resolve_dotted_attr(instance, self.field)
|
|
128
|
+
return previous != self.value and current == self.value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AndCondition(HookCondition):
|
|
132
|
+
def __init__(self, cond1, cond2):
|
|
133
|
+
self.cond1 = cond1
|
|
134
|
+
self.cond2 = cond2
|
|
135
|
+
|
|
136
|
+
def check(self, instance, original_instance=None):
|
|
137
|
+
return self.cond1.check(instance, original_instance) and self.cond2.check(
|
|
138
|
+
instance, original_instance
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class OrCondition(HookCondition):
|
|
143
|
+
def __init__(self, cond1, cond2):
|
|
144
|
+
self.cond1 = cond1
|
|
145
|
+
self.cond2 = cond2
|
|
146
|
+
|
|
147
|
+
def check(self, instance, original_instance=None):
|
|
148
|
+
return self.cond1.check(instance, original_instance) or self.cond2.check(
|
|
149
|
+
instance, original_instance
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class NotCondition(HookCondition):
|
|
154
|
+
def __init__(self, cond):
|
|
155
|
+
self.cond = cond
|
|
156
|
+
|
|
157
|
+
def check(self, instance, original_instance=None):
|
|
158
|
+
return not self.cond.check(instance, original_instance)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from functools import wraps
|
|
3
|
+
|
|
4
|
+
from django.core.exceptions import FieldDoesNotExist
|
|
5
|
+
from django_bulk_hooks.enums import DEFAULT_PRIORITY
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
|
|
9
|
+
"""
|
|
10
|
+
Decorator to annotate a method with multiple hooks hook registrations.
|
|
11
|
+
If no priority is provided, uses Priority.NORMAL (50).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def decorator(fn):
|
|
15
|
+
if not hasattr(fn, "hooks_hooks"):
|
|
16
|
+
fn.hooks_hooks = []
|
|
17
|
+
fn.hooks_hooks.append((model, event, condition, priority))
|
|
18
|
+
return fn
|
|
19
|
+
|
|
20
|
+
return decorator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def select_related(*related_fields):
|
|
24
|
+
"""
|
|
25
|
+
Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
|
|
26
|
+
|
|
27
|
+
- Works with instance methods (resolves `self`)
|
|
28
|
+
- Avoids replacing model instances
|
|
29
|
+
- Populates Django's relation cache to avoid extra queries
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def decorator(func):
|
|
33
|
+
sig = inspect.signature(func)
|
|
34
|
+
|
|
35
|
+
@wraps(func)
|
|
36
|
+
def wrapper(*args, **kwargs):
|
|
37
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
38
|
+
bound.apply_defaults()
|
|
39
|
+
|
|
40
|
+
if "new_records" not in bound.arguments:
|
|
41
|
+
raise TypeError(
|
|
42
|
+
"@preload_related requires a 'new_records' argument in the decorated function"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
new_records = bound.arguments["new_records"]
|
|
46
|
+
|
|
47
|
+
if not isinstance(new_records, list):
|
|
48
|
+
raise TypeError(
|
|
49
|
+
f"@preload_related expects a list of model instances, got {type(new_records)}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if not new_records:
|
|
53
|
+
return func(*args, **kwargs)
|
|
54
|
+
|
|
55
|
+
# In-place preload
|
|
56
|
+
model_cls = new_records[0].__class__
|
|
57
|
+
ids = [obj.pk for obj in new_records if obj.pk is not None]
|
|
58
|
+
if not ids:
|
|
59
|
+
return func(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
fetched = model_cls.objects.select_related(*related_fields).in_bulk(ids)
|
|
62
|
+
|
|
63
|
+
for obj in new_records:
|
|
64
|
+
preloaded = fetched.get(obj.pk)
|
|
65
|
+
if not preloaded:
|
|
66
|
+
continue
|
|
67
|
+
for field in related_fields:
|
|
68
|
+
if "." in field:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"@preload_related does not support nested fields like '{field}'"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
f = model_cls._meta.get_field(field)
|
|
75
|
+
if not (
|
|
76
|
+
f.is_relation and not f.many_to_many and not f.one_to_many
|
|
77
|
+
):
|
|
78
|
+
continue
|
|
79
|
+
except FieldDoesNotExist:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
rel_obj = getattr(preloaded, field)
|
|
84
|
+
setattr(obj, field, rel_obj)
|
|
85
|
+
obj._state.fields_cache[field] = rel_obj
|
|
86
|
+
except AttributeError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
return func(*bound.args, **bound.kwargs)
|
|
90
|
+
|
|
91
|
+
return wrapper
|
|
92
|
+
|
|
93
|
+
return decorator
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django_bulk_hooks.registry import get_hooks
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run(model_cls, event, new_instances, original_instances=None, ctx=None):
|
|
9
|
+
hooks = get_hooks(model_cls, event)
|
|
10
|
+
|
|
11
|
+
logger.debug(
|
|
12
|
+
"bulk_hooks.run: model=%s, event=%s, #new=%d, #original=%d",
|
|
13
|
+
model_cls.__name__,
|
|
14
|
+
event,
|
|
15
|
+
len(new_instances),
|
|
16
|
+
len(original_instances or []),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
20
|
+
handler_instance = handler_cls()
|
|
21
|
+
func = getattr(handler_instance, method_name)
|
|
22
|
+
|
|
23
|
+
logger.debug(
|
|
24
|
+
"Executing hook %s for %s.%s with priority=%s",
|
|
25
|
+
func.__name__,
|
|
26
|
+
model_cls.__name__,
|
|
27
|
+
event,
|
|
28
|
+
priority,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
to_process_new = []
|
|
32
|
+
to_process_old = []
|
|
33
|
+
|
|
34
|
+
for new, original in zip(
|
|
35
|
+
new_instances,
|
|
36
|
+
original_instances or [None] * len(new_instances),
|
|
37
|
+
strict=True,
|
|
38
|
+
):
|
|
39
|
+
logger.debug(
|
|
40
|
+
" considering instance: new=%r, original=%r",
|
|
41
|
+
new,
|
|
42
|
+
original,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if not condition or condition.check(new, original):
|
|
46
|
+
to_process_new.append(new)
|
|
47
|
+
to_process_old.append(original)
|
|
48
|
+
logger.debug(" -> will process (passed condition)")
|
|
49
|
+
else:
|
|
50
|
+
logger.debug(" -> skipped (condition returned False)")
|
|
51
|
+
|
|
52
|
+
if to_process_new:
|
|
53
|
+
logger.info(
|
|
54
|
+
"Calling %s on %d instance(s): %r",
|
|
55
|
+
func.__name__,
|
|
56
|
+
len(to_process_new),
|
|
57
|
+
to_process_new,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Call the function with direct arguments
|
|
61
|
+
func(to_process_new, to_process_old if any(to_process_old) else None)
|
|
62
|
+
else:
|
|
63
|
+
logger.debug("No instances to process for hook %s", func.__name__)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Priority(IntEnum):
|
|
5
|
+
"""
|
|
6
|
+
Named priorities for django-bulk-hooks hooks.
|
|
7
|
+
Replaces module-level constants with a clean IntEnum.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
HIGHEST = 0 # runs first
|
|
11
|
+
HIGH = 25 # runs early
|
|
12
|
+
NORMAL = 50 # default ordering
|
|
13
|
+
LOW = 75 # runs late
|
|
14
|
+
LOWEST = 100 # runs last
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_PRIORITY = Priority.NORMAL
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.db import transaction
|
|
4
|
+
from django_bulk_hooks.conditions import HookCondition
|
|
5
|
+
from django_bulk_hooks.registry import get_hooks, register_hook
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TriggerHandlerMeta(type):
|
|
11
|
+
_registered = set()
|
|
12
|
+
|
|
13
|
+
def __new__(mcs, name, bases, namespace):
|
|
14
|
+
cls = super().__new__(mcs, name, bases, namespace)
|
|
15
|
+
for method_name, method in namespace.items():
|
|
16
|
+
if hasattr(method, "hooks_hooks"):
|
|
17
|
+
for model_cls, event, condition, priority in method.hooks_hooks:
|
|
18
|
+
key = (model_cls, event, cls, method_name)
|
|
19
|
+
if key in TriggerHandlerMeta._registered:
|
|
20
|
+
logger.debug(
|
|
21
|
+
"Skipping duplicate registration for %s.%s on %s.%s",
|
|
22
|
+
cls.__name__,
|
|
23
|
+
method_name,
|
|
24
|
+
model_cls.__name__,
|
|
25
|
+
event,
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
register_hook(
|
|
29
|
+
model=model_cls,
|
|
30
|
+
event=event,
|
|
31
|
+
handler_cls=cls,
|
|
32
|
+
method_name=method_name,
|
|
33
|
+
condition=condition,
|
|
34
|
+
priority=priority,
|
|
35
|
+
)
|
|
36
|
+
TriggerHandlerMeta._registered.add(key)
|
|
37
|
+
logger.debug(
|
|
38
|
+
"Registered hook %s.%s → %s.%s (cond=%r, prio=%s)",
|
|
39
|
+
model_cls.__name__,
|
|
40
|
+
event,
|
|
41
|
+
cls.__name__,
|
|
42
|
+
method_name,
|
|
43
|
+
condition,
|
|
44
|
+
priority,
|
|
45
|
+
)
|
|
46
|
+
return cls
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TriggerHandler(metaclass=TriggerHandlerMeta):
|
|
50
|
+
@classmethod
|
|
51
|
+
def handle(
|
|
52
|
+
cls,
|
|
53
|
+
event: str,
|
|
54
|
+
model: type,
|
|
55
|
+
*,
|
|
56
|
+
new_records: list = None,
|
|
57
|
+
old_records: list = None,
|
|
58
|
+
**kwargs,
|
|
59
|
+
) -> None:
|
|
60
|
+
# Prepare hook list and log names
|
|
61
|
+
hooks = get_hooks(model, event)
|
|
62
|
+
|
|
63
|
+
# Sort hooks by priority (ascending: lower number = higher priority)
|
|
64
|
+
hooks = sorted(hooks, key=lambda x: x[3])
|
|
65
|
+
|
|
66
|
+
hook_names = [f"{h.__name__}.{m}" for h, m, _, _ in hooks]
|
|
67
|
+
logger.debug(
|
|
68
|
+
"Found %d hooks for %s.%s: %s",
|
|
69
|
+
len(hooks),
|
|
70
|
+
model.__name__,
|
|
71
|
+
event,
|
|
72
|
+
hook_names,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _process():
|
|
76
|
+
# Ensure new_records is a list
|
|
77
|
+
new_records_local = new_records or []
|
|
78
|
+
|
|
79
|
+
# Normalize old_records: ensure list and pad with None
|
|
80
|
+
old_records_local = list(old_records) if old_records else []
|
|
81
|
+
if len(old_records_local) < len(new_records_local):
|
|
82
|
+
old_records_local += [None] * (
|
|
83
|
+
len(new_records_local) - len(old_records_local)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
logger.debug(
|
|
87
|
+
"ℹ️ bulk_hooks.handle() start: model=%s event=%s new_count=%d old_count=%d",
|
|
88
|
+
model.__name__,
|
|
89
|
+
event,
|
|
90
|
+
len(new_records_local),
|
|
91
|
+
len(old_records_local),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
for handler_cls, method_name, condition, priority in hooks:
|
|
95
|
+
logger.debug(
|
|
96
|
+
"→ evaluating hook %s.%s (cond=%r, prio=%s)",
|
|
97
|
+
handler_cls.__name__,
|
|
98
|
+
method_name,
|
|
99
|
+
condition,
|
|
100
|
+
priority,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Evaluate condition
|
|
104
|
+
passed = True
|
|
105
|
+
if condition is not None:
|
|
106
|
+
if isinstance(condition, HookCondition):
|
|
107
|
+
cond_info = getattr(condition, "__dict__", str(condition))
|
|
108
|
+
logger.debug(
|
|
109
|
+
" [cond-info] %s.%s → %r",
|
|
110
|
+
handler_cls.__name__,
|
|
111
|
+
method_name,
|
|
112
|
+
cond_info,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
checks = []
|
|
116
|
+
for new, old in zip(new_records_local, old_records_local):
|
|
117
|
+
field_name = getattr(condition, "field", None) or getattr(
|
|
118
|
+
condition, "field_name", None
|
|
119
|
+
)
|
|
120
|
+
if field_name:
|
|
121
|
+
actual_val = getattr(new, field_name, None)
|
|
122
|
+
expected = getattr(condition, "value", None) or getattr(
|
|
123
|
+
condition, "value", None
|
|
124
|
+
)
|
|
125
|
+
logger.debug(
|
|
126
|
+
" [field-lookup] %s.%s → field=%r actual=%r expected=%r",
|
|
127
|
+
handler_cls.__name__,
|
|
128
|
+
method_name,
|
|
129
|
+
field_name,
|
|
130
|
+
actual_val,
|
|
131
|
+
expected,
|
|
132
|
+
)
|
|
133
|
+
result = condition.check(new, old)
|
|
134
|
+
checks.append(result)
|
|
135
|
+
logger.debug(
|
|
136
|
+
" [cond-check] %s.%s → new=%r old=%r => %s",
|
|
137
|
+
handler_cls.__name__,
|
|
138
|
+
method_name,
|
|
139
|
+
new,
|
|
140
|
+
old,
|
|
141
|
+
result,
|
|
142
|
+
)
|
|
143
|
+
passed = any(checks)
|
|
144
|
+
logger.debug(
|
|
145
|
+
" [cond-summary] %s.%s any-passed=%s",
|
|
146
|
+
handler_cls.__name__,
|
|
147
|
+
method_name,
|
|
148
|
+
passed,
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
# Legacy callable conditions
|
|
152
|
+
passed = condition(
|
|
153
|
+
new_records=new_records_local,
|
|
154
|
+
old_records=old_records_local,
|
|
155
|
+
)
|
|
156
|
+
logger.debug(
|
|
157
|
+
" [legacy-cond] %s.%s → full-list => %s",
|
|
158
|
+
handler_cls.__name__,
|
|
159
|
+
method_name,
|
|
160
|
+
passed,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if not passed:
|
|
164
|
+
logger.debug(
|
|
165
|
+
"↳ skipping %s.%s (condition not met)",
|
|
166
|
+
handler_cls.__name__,
|
|
167
|
+
method_name,
|
|
168
|
+
)
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# Instantiate & invoke handler method
|
|
172
|
+
handler = handler_cls()
|
|
173
|
+
method = getattr(handler, method_name)
|
|
174
|
+
logger.info(
|
|
175
|
+
"✨ invoking %s.%s on %d record(s)",
|
|
176
|
+
handler_cls.__name__,
|
|
177
|
+
method_name,
|
|
178
|
+
len(new_records_local),
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
method(
|
|
182
|
+
new_records=new_records_local,
|
|
183
|
+
old_records=old_records_local,
|
|
184
|
+
**kwargs,
|
|
185
|
+
)
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.exception(
|
|
188
|
+
"❌ exception in %s.%s",
|
|
189
|
+
handler_cls.__name__,
|
|
190
|
+
method_name,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
logger.debug(
|
|
194
|
+
"✔️ bulk_hooks.handle() complete for %s.%s",
|
|
195
|
+
model.__name__,
|
|
196
|
+
event,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Defer if in atomic block and event is after_*
|
|
200
|
+
conn = transaction.get_connection()
|
|
201
|
+
if conn.in_atomic_block and event.startswith("after_"):
|
|
202
|
+
logger.debug(
|
|
203
|
+
"Deferring hook execution until after transaction commit for event '%s'",
|
|
204
|
+
event,
|
|
205
|
+
)
|
|
206
|
+
transaction.on_commit(_process)
|
|
207
|
+
else:
|
|
208
|
+
_process()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from django.db import models, transaction
|
|
2
|
+
from django_bulk_hooks import engine
|
|
3
|
+
from django_bulk_hooks.constants import (
|
|
4
|
+
AFTER_CREATE,
|
|
5
|
+
AFTER_DELETE,
|
|
6
|
+
AFTER_UPDATE,
|
|
7
|
+
BEFORE_CREATE,
|
|
8
|
+
BEFORE_DELETE,
|
|
9
|
+
BEFORE_UPDATE,
|
|
10
|
+
)
|
|
11
|
+
from django_bulk_hooks.context import TriggerContext
|
|
12
|
+
from django_bulk_hooks.queryset import LifecycleQuerySet
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BulkLifecycleManager(models.Manager):
|
|
16
|
+
CHUNK_SIZE = 200
|
|
17
|
+
|
|
18
|
+
def get_queryset(self):
|
|
19
|
+
return LifecycleQuerySet(self.model, using=self._db)
|
|
20
|
+
|
|
21
|
+
@transaction.atomic
|
|
22
|
+
def bulk_update(self, objs, fields, batch_size=None, bypass_hooks=False):
|
|
23
|
+
if not objs:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
model_cls = self.model
|
|
27
|
+
|
|
28
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
29
|
+
raise TypeError(
|
|
30
|
+
f"bulk_update expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if not bypass_hooks:
|
|
34
|
+
originals = list(model_cls.objects.filter(pk__in=[obj.pk for obj in objs]))
|
|
35
|
+
ctx = TriggerContext(model_cls)
|
|
36
|
+
engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
|
|
37
|
+
|
|
38
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
39
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
40
|
+
super().bulk_update(chunk, fields, batch_size=batch_size)
|
|
41
|
+
|
|
42
|
+
if not bypass_hooks:
|
|
43
|
+
engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
|
|
44
|
+
|
|
45
|
+
return objs
|
|
46
|
+
|
|
47
|
+
@transaction.atomic
|
|
48
|
+
def bulk_create(
|
|
49
|
+
self, objs, batch_size=None, ignore_conflicts=False, bypass_hooks=False
|
|
50
|
+
):
|
|
51
|
+
model_cls = self.model
|
|
52
|
+
|
|
53
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
54
|
+
raise TypeError(
|
|
55
|
+
f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
result = []
|
|
59
|
+
|
|
60
|
+
if not bypass_hooks:
|
|
61
|
+
ctx = TriggerContext(model_cls)
|
|
62
|
+
engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
|
|
63
|
+
|
|
64
|
+
for i in range(0, len(objs), self.CHUNK_SIZE):
|
|
65
|
+
chunk = objs[i : i + self.CHUNK_SIZE]
|
|
66
|
+
result.extend(
|
|
67
|
+
super().bulk_create(
|
|
68
|
+
chunk, batch_size=batch_size, ignore_conflicts=ignore_conflicts
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if not bypass_hooks:
|
|
73
|
+
engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
|
|
74
|
+
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
@transaction.atomic
|
|
78
|
+
def bulk_delete(self, objs, batch_size=None, bypass_hooks=False):
|
|
79
|
+
if not objs:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
model_cls = self.model
|
|
83
|
+
|
|
84
|
+
if any(not isinstance(obj, model_cls) for obj in objs):
|
|
85
|
+
raise TypeError(
|
|
86
|
+
f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
ctx = TriggerContext(model_cls)
|
|
90
|
+
|
|
91
|
+
if not bypass_hooks:
|
|
92
|
+
engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
|
|
93
|
+
|
|
94
|
+
pks = [obj.pk for obj in objs if obj.pk is not None]
|
|
95
|
+
model_cls.objects.filter(pk__in=pks).delete()
|
|
96
|
+
|
|
97
|
+
if not bypass_hooks:
|
|
98
|
+
engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
|
|
99
|
+
|
|
100
|
+
return objs
|
|
101
|
+
|
|
102
|
+
@transaction.atomic
|
|
103
|
+
def update(self, **kwargs):
|
|
104
|
+
objs = list(self.all())
|
|
105
|
+
if not objs:
|
|
106
|
+
return 0
|
|
107
|
+
for key, value in kwargs.items():
|
|
108
|
+
for obj in objs:
|
|
109
|
+
setattr(obj, key, value)
|
|
110
|
+
self.bulk_update(objs, fields=list(kwargs.keys()))
|
|
111
|
+
return len(objs)
|
|
112
|
+
|
|
113
|
+
@transaction.atomic
|
|
114
|
+
def delete(self):
|
|
115
|
+
objs = list(self.all())
|
|
116
|
+
if not objs:
|
|
117
|
+
return 0
|
|
118
|
+
self.model.objects.bulk_delete(objs)
|
|
119
|
+
return len(objs)
|
|
120
|
+
|
|
121
|
+
@transaction.atomic
|
|
122
|
+
def save(self, obj):
|
|
123
|
+
if obj.pk:
|
|
124
|
+
self.bulk_update(
|
|
125
|
+
[obj],
|
|
126
|
+
fields=[field.name for field in obj._meta.fields if field.name != "id"],
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
self.bulk_create([obj])
|
|
130
|
+
return obj
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from django.db import models, transaction
|
|
2
|
+
from django_bulk_hooks.manager import BulkLifecycleManager
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LifecycleModelMixin(models.Model):
|
|
6
|
+
objects = BulkLifecycleManager()
|
|
7
|
+
|
|
8
|
+
class Meta:
|
|
9
|
+
abstract = True
|
|
10
|
+
|
|
11
|
+
def delete(self, *args, **kwargs):
|
|
12
|
+
self.before_delete()
|
|
13
|
+
|
|
14
|
+
with transaction.atomic():
|
|
15
|
+
result = super().delete(*args, **kwargs)
|
|
16
|
+
|
|
17
|
+
self.after_delete()
|
|
18
|
+
|
|
19
|
+
return result
|
|
20
|
+
|
|
21
|
+
def before_delete(self):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def after_delete(self):
|
|
25
|
+
pass
|
|
@@ -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
|
+
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
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from django.db import models, transaction
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LifecycleQuerySet(models.QuerySet):
|
|
5
|
+
@transaction.atomic
|
|
6
|
+
def delete(self):
|
|
7
|
+
objs = list(self)
|
|
8
|
+
if not objs:
|
|
9
|
+
return 0
|
|
10
|
+
return self.model.objects.bulk_delete(objs)
|
|
11
|
+
|
|
12
|
+
@transaction.atomic
|
|
13
|
+
def update(self, **kwargs):
|
|
14
|
+
instances = list(self)
|
|
15
|
+
if not instances:
|
|
16
|
+
return 0
|
|
17
|
+
|
|
18
|
+
for obj in instances:
|
|
19
|
+
for field, value in kwargs.items():
|
|
20
|
+
setattr(obj, field, value)
|
|
21
|
+
|
|
22
|
+
self.model.objects.bulk_update(
|
|
23
|
+
instances,
|
|
24
|
+
fields=list(kwargs.keys()),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return len(instances)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Union
|
|
3
|
+
|
|
4
|
+
from django_bulk_hooks.priority import Priority
|
|
5
|
+
|
|
6
|
+
_hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_hook(
|
|
10
|
+
model, event, handler_cls, method_name, condition, priority: Union[int, Priority]
|
|
11
|
+
):
|
|
12
|
+
key = (model, event)
|
|
13
|
+
hooks = _hooks.setdefault(key, [])
|
|
14
|
+
hooks.append((handler_cls, method_name, condition, priority))
|
|
15
|
+
# keep sorted by priority
|
|
16
|
+
hooks.sort(key=lambda x: x[3])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_hooks(model, event):
|
|
20
|
+
return _hooks.get((model, event), [])
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "django-bulk-hooks"
|
|
3
|
+
version = "0.1.50"
|
|
4
|
+
description = "Lifecycle-style hooks for Django bulk operations like bulk_create and bulk_update."
|
|
5
|
+
authors = ["Konrad Beck <konrad.beck@merchantcapital.co.za>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
homepage = "https://github.com/AugendLimited/django-bulk-hooks"
|
|
9
|
+
repository = "https://github.com/AugendLimited/django-bulk-hooks"
|
|
10
|
+
keywords = ["django", "bulk", "hooks"]
|
|
11
|
+
packages = [
|
|
12
|
+
{ include = "django_bulk_hooks" }
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.poetry.dependencies]
|
|
16
|
+
python = "^3.11"
|
|
17
|
+
Django = ">=4.0"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|