django-bulk-hooks 0.1.230__py3-none-any.whl → 0.1.232__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/conditions.py +30 -33
- django_bulk_hooks/context.py +15 -43
- django_bulk_hooks/decorators.py +8 -100
- django_bulk_hooks/engine.py +41 -94
- django_bulk_hooks/enums.py +13 -10
- django_bulk_hooks/handler.py +20 -33
- django_bulk_hooks/manager.py +101 -123
- django_bulk_hooks/models.py +11 -45
- django_bulk_hooks/priority.py +6 -6
- django_bulk_hooks/queryset.py +166 -308
- django_bulk_hooks/registry.py +24 -126
- {django_bulk_hooks-0.1.230.dist-info → django_bulk_hooks-0.1.232.dist-info}/METADATA +14 -30
- django_bulk_hooks-0.1.232.dist-info/RECORD +17 -0
- django_bulk_hooks-0.1.230.dist-info/RECORD +0 -17
- {django_bulk_hooks-0.1.230.dist-info → django_bulk_hooks-0.1.232.dist-info}/LICENSE +0 -0
- {django_bulk_hooks-0.1.230.dist-info → django_bulk_hooks-0.1.232.dist-info}/WHEEL +0 -0
django_bulk_hooks/handler.py
CHANGED
|
@@ -2,6 +2,8 @@ import logging
|
|
|
2
2
|
import threading
|
|
3
3
|
from collections import deque
|
|
4
4
|
|
|
5
|
+
from django.db import transaction
|
|
6
|
+
|
|
5
7
|
from django_bulk_hooks.registry import get_hooks, register_hook
|
|
6
8
|
|
|
7
9
|
logger = logging.getLogger(__name__)
|
|
@@ -59,41 +61,28 @@ class HookContextState:
|
|
|
59
61
|
return hook_vars.model
|
|
60
62
|
|
|
61
63
|
|
|
64
|
+
Hook = HookContextState()
|
|
65
|
+
|
|
66
|
+
|
|
62
67
|
class HookMeta(type):
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
_registered = set()
|
|
69
|
+
|
|
65
70
|
def __new__(mcs, name, bases, namespace):
|
|
66
71
|
cls = super().__new__(mcs, name, bases, namespace)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
continue
|
|
73
|
-
|
|
74
|
-
try:
|
|
75
|
-
attr = getattr(cls, attr_name)
|
|
76
|
-
if callable(attr) and hasattr(attr, "hooks_hooks"):
|
|
77
|
-
for model_cls, event, condition, priority in attr.hooks_hooks:
|
|
78
|
-
# Register the hook
|
|
72
|
+
for method_name, method in namespace.items():
|
|
73
|
+
if hasattr(method, "hooks_hooks"):
|
|
74
|
+
for model_cls, event, condition, priority in method.hooks_hooks:
|
|
75
|
+
key = (model_cls, event, cls, method_name)
|
|
76
|
+
if key not in HookMeta._registered:
|
|
79
77
|
register_hook(
|
|
80
78
|
model=model_cls,
|
|
81
79
|
event=event,
|
|
82
80
|
handler_cls=cls,
|
|
83
|
-
method_name=
|
|
81
|
+
method_name=method_name,
|
|
84
82
|
condition=condition,
|
|
85
83
|
priority=priority,
|
|
86
84
|
)
|
|
87
|
-
|
|
88
|
-
logger.debug(
|
|
89
|
-
f"Registered hook {cls.__name__}.{attr_name} "
|
|
90
|
-
f"for {model_cls.__name__}.{event} with priority {priority}"
|
|
91
|
-
)
|
|
92
|
-
except Exception as e:
|
|
93
|
-
# Skip attributes that can't be accessed
|
|
94
|
-
logger.debug(f"Skipping attribute {attr_name}: {e}")
|
|
95
|
-
continue
|
|
96
|
-
|
|
85
|
+
HookMeta._registered.add(key)
|
|
97
86
|
return cls
|
|
98
87
|
|
|
99
88
|
|
|
@@ -134,7 +123,7 @@ class Hook(metaclass=HookMeta):
|
|
|
134
123
|
hook_vars.event = event
|
|
135
124
|
hook_vars.model = model
|
|
136
125
|
|
|
137
|
-
hooks = get_hooks(model, event)
|
|
126
|
+
hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
|
|
138
127
|
|
|
139
128
|
def _execute():
|
|
140
129
|
new_local = new_records or []
|
|
@@ -163,18 +152,16 @@ class Hook(metaclass=HookMeta):
|
|
|
163
152
|
logger.exception(
|
|
164
153
|
"Error in hook %s.%s", handler_cls.__name__, method_name
|
|
165
154
|
)
|
|
166
|
-
# Re-raise the exception to ensure proper error handling
|
|
167
|
-
raise
|
|
168
155
|
|
|
156
|
+
conn = transaction.get_connection()
|
|
169
157
|
try:
|
|
170
|
-
|
|
158
|
+
if conn.in_atomic_block and event.startswith("after_"):
|
|
159
|
+
transaction.on_commit(_execute)
|
|
160
|
+
else:
|
|
161
|
+
_execute()
|
|
171
162
|
finally:
|
|
172
163
|
hook_vars.new = None
|
|
173
164
|
hook_vars.old = None
|
|
174
165
|
hook_vars.event = None
|
|
175
166
|
hook_vars.model = None
|
|
176
167
|
hook_vars.depth -= 1
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
# Create a global Hook instance for context access
|
|
180
|
-
HookContext = HookContextState()
|
django_bulk_hooks/manager.py
CHANGED
|
@@ -1,135 +1,113 @@
|
|
|
1
|
-
from typing import Iterable, Sequence, Any
|
|
2
1
|
from django.db import models
|
|
3
2
|
|
|
4
3
|
from django_bulk_hooks.queryset import HookQuerySet, HookQuerySetMixin
|
|
5
4
|
|
|
6
5
|
|
|
7
6
|
class BulkHookManager(models.Manager):
|
|
8
|
-
|
|
7
|
+
def get_queryset(self):
|
|
8
|
+
# Use super().get_queryset() to let Django and MRO build the queryset
|
|
9
|
+
# This ensures cooperation with other managers
|
|
10
|
+
base_queryset = super().get_queryset()
|
|
11
|
+
|
|
12
|
+
# If the base queryset already has hook functionality, return it as-is
|
|
13
|
+
if isinstance(base_queryset, HookQuerySetMixin):
|
|
14
|
+
return base_queryset
|
|
15
|
+
|
|
16
|
+
# Otherwise, create a new HookQuerySet with the same parameters
|
|
17
|
+
# This is much simpler and avoids dynamic class creation issues
|
|
18
|
+
return HookQuerySet(
|
|
19
|
+
model=base_queryset.model,
|
|
20
|
+
query=base_queryset.query,
|
|
21
|
+
using=base_queryset._db,
|
|
22
|
+
hints=base_queryset._hints
|
|
23
|
+
)
|
|
9
24
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
25
|
+
def bulk_create(
|
|
26
|
+
self,
|
|
27
|
+
objs,
|
|
28
|
+
batch_size=None,
|
|
29
|
+
ignore_conflicts=False,
|
|
30
|
+
update_conflicts=False,
|
|
31
|
+
update_fields=None,
|
|
32
|
+
unique_fields=None,
|
|
33
|
+
bypass_hooks=False,
|
|
34
|
+
bypass_validation=False,
|
|
35
|
+
**kwargs,
|
|
36
|
+
):
|
|
37
|
+
"""
|
|
38
|
+
Delegate to QuerySet's bulk_create implementation.
|
|
39
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
40
|
+
"""
|
|
41
|
+
return self.get_queryset().bulk_create(
|
|
42
|
+
objs,
|
|
43
|
+
bypass_hooks=bypass_hooks,
|
|
44
|
+
bypass_validation=bypass_validation,
|
|
45
|
+
batch_size=batch_size,
|
|
46
|
+
ignore_conflicts=ignore_conflicts,
|
|
47
|
+
update_conflicts=update_conflicts,
|
|
48
|
+
update_fields=update_fields,
|
|
49
|
+
unique_fields=unique_fields,
|
|
50
|
+
**kwargs,
|
|
51
|
+
)
|
|
14
52
|
|
|
15
|
-
|
|
16
|
-
|
|
53
|
+
def bulk_update(
|
|
54
|
+
self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
|
|
55
|
+
):
|
|
56
|
+
"""
|
|
57
|
+
Delegate to QuerySet's bulk_update implementation.
|
|
58
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
59
|
+
"""
|
|
60
|
+
return self.get_queryset().bulk_update(
|
|
61
|
+
objs,
|
|
62
|
+
fields,
|
|
63
|
+
bypass_hooks=bypass_hooks,
|
|
64
|
+
bypass_validation=bypass_validation,
|
|
65
|
+
**kwargs,
|
|
66
|
+
)
|
|
17
67
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
68
|
+
def bulk_delete(
|
|
69
|
+
self,
|
|
70
|
+
objs,
|
|
71
|
+
batch_size=None,
|
|
72
|
+
bypass_hooks=False,
|
|
73
|
+
bypass_validation=False,
|
|
74
|
+
**kwargs,
|
|
75
|
+
):
|
|
76
|
+
"""
|
|
77
|
+
Delegate to QuerySet's bulk_delete implementation.
|
|
78
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
79
|
+
"""
|
|
80
|
+
return self.get_queryset().bulk_delete(
|
|
81
|
+
objs,
|
|
82
|
+
bypass_hooks=bypass_hooks,
|
|
83
|
+
bypass_validation=bypass_validation,
|
|
84
|
+
batch_size=batch_size,
|
|
85
|
+
**kwargs,
|
|
86
|
+
)
|
|
21
87
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
88
|
+
def delete(self):
|
|
89
|
+
"""
|
|
90
|
+
Delegate to QuerySet's delete implementation.
|
|
91
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
92
|
+
"""
|
|
93
|
+
return self.get_queryset().delete()
|
|
25
94
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
composed_cls = type(composed_name, (HookQuerySetMixin, base_cls), {})
|
|
33
|
-
self._qs_compose_cache[base_cls] = composed_cls
|
|
95
|
+
def update(self, **kwargs):
|
|
96
|
+
"""
|
|
97
|
+
Delegate to QuerySet's update implementation.
|
|
98
|
+
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
99
|
+
"""
|
|
100
|
+
return self.get_queryset().update(**kwargs)
|
|
34
101
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
update_conflicts: bool = False,
|
|
48
|
-
update_fields: Sequence[str] | None = None,
|
|
49
|
-
unique_fields: Sequence[str] | None = None,
|
|
50
|
-
bypass_hooks: bool = False,
|
|
51
|
-
bypass_validation: bool = False,
|
|
52
|
-
**kwargs: Any,
|
|
53
|
-
) -> list[models.Model]:
|
|
54
|
-
"""
|
|
55
|
-
Delegate to QuerySet's bulk_create implementation.
|
|
56
|
-
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
57
|
-
"""
|
|
58
|
-
return self.get_queryset().bulk_create(
|
|
59
|
-
objs,
|
|
60
|
-
bypass_hooks=bypass_hooks,
|
|
61
|
-
bypass_validation=bypass_validation,
|
|
62
|
-
batch_size=batch_size,
|
|
63
|
-
ignore_conflicts=ignore_conflicts,
|
|
64
|
-
update_conflicts=update_conflicts,
|
|
65
|
-
update_fields=update_fields,
|
|
66
|
-
unique_fields=unique_fields,
|
|
67
|
-
**kwargs,
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
def bulk_update(
|
|
71
|
-
self,
|
|
72
|
-
objs: Iterable[models.Model],
|
|
73
|
-
fields: Sequence[str],
|
|
74
|
-
bypass_hooks: bool = False,
|
|
75
|
-
bypass_validation: bool = False,
|
|
76
|
-
**kwargs: Any,
|
|
77
|
-
) -> int:
|
|
78
|
-
"""
|
|
79
|
-
Delegate to QuerySet's bulk_update implementation.
|
|
80
|
-
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
81
|
-
"""
|
|
82
|
-
return self.get_queryset().bulk_update(
|
|
83
|
-
objs,
|
|
84
|
-
fields,
|
|
85
|
-
bypass_hooks=bypass_hooks,
|
|
86
|
-
bypass_validation=bypass_validation,
|
|
87
|
-
**kwargs,
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
def bulk_delete(
|
|
91
|
-
self,
|
|
92
|
-
objs: Iterable[models.Model],
|
|
93
|
-
batch_size: int | None = None,
|
|
94
|
-
bypass_hooks: bool = False,
|
|
95
|
-
bypass_validation: bool = False,
|
|
96
|
-
**kwargs: Any,
|
|
97
|
-
) -> int:
|
|
98
|
-
"""
|
|
99
|
-
Delegate to QuerySet's bulk_delete implementation.
|
|
100
|
-
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
101
|
-
"""
|
|
102
|
-
return self.get_queryset().bulk_delete(
|
|
103
|
-
objs,
|
|
104
|
-
bypass_hooks=bypass_hooks,
|
|
105
|
-
bypass_validation=bypass_validation,
|
|
106
|
-
batch_size=batch_size,
|
|
107
|
-
**kwargs,
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
def delete(self) -> int:
|
|
111
|
-
"""
|
|
112
|
-
Delegate to QuerySet's delete implementation.
|
|
113
|
-
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
114
|
-
"""
|
|
115
|
-
return self.get_queryset().delete()
|
|
116
|
-
|
|
117
|
-
def update(self, **kwargs: Any) -> int:
|
|
118
|
-
"""
|
|
119
|
-
Delegate to QuerySet's update implementation.
|
|
120
|
-
This follows Django's pattern where Manager methods call QuerySet methods.
|
|
121
|
-
"""
|
|
122
|
-
return self.get_queryset().update(**kwargs)
|
|
123
|
-
|
|
124
|
-
def save(self, obj: models.Model) -> models.Model:
|
|
125
|
-
"""
|
|
126
|
-
Save a single object using the appropriate bulk operation.
|
|
127
|
-
"""
|
|
128
|
-
if obj.pk:
|
|
129
|
-
self.bulk_update(
|
|
130
|
-
[obj],
|
|
131
|
-
fields=[field.name for field in obj._meta.fields if field.name != "id"],
|
|
132
|
-
)
|
|
133
|
-
else:
|
|
134
|
-
self.bulk_create([obj])
|
|
135
|
-
return obj
|
|
102
|
+
def save(self, obj):
|
|
103
|
+
"""
|
|
104
|
+
Save a single object using the appropriate bulk operation.
|
|
105
|
+
"""
|
|
106
|
+
if obj.pk:
|
|
107
|
+
self.bulk_update(
|
|
108
|
+
[obj],
|
|
109
|
+
fields=[field.name for field in obj._meta.fields if field.name != "id"],
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
self.bulk_create([obj])
|
|
113
|
+
return obj
|
django_bulk_hooks/models.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from django.db import models
|
|
2
|
+
from django.db import models
|
|
3
|
+
|
|
3
4
|
from django_bulk_hooks.constants import (
|
|
4
5
|
AFTER_CREATE,
|
|
5
6
|
AFTER_DELETE,
|
|
@@ -41,18 +42,18 @@ class HookModelMixin(models.Model):
|
|
|
41
42
|
|
|
42
43
|
if is_create:
|
|
43
44
|
# For create operations, run VALIDATE_CREATE hooks for validation
|
|
44
|
-
ctx = HookContext(self.__class__
|
|
45
|
+
ctx = HookContext(self.__class__)
|
|
45
46
|
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
46
47
|
else:
|
|
47
48
|
# For update operations, run VALIDATE_UPDATE hooks for validation
|
|
48
49
|
try:
|
|
49
50
|
# Use _base_manager to avoid triggering hooks recursively
|
|
50
51
|
old_instance = self.__class__._base_manager.get(pk=self.pk)
|
|
51
|
-
ctx = HookContext(self.__class__
|
|
52
|
+
ctx = HookContext(self.__class__)
|
|
52
53
|
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
53
54
|
except self.__class__.DoesNotExist:
|
|
54
55
|
# If the old instance doesn't exist, treat as create
|
|
55
|
-
ctx = HookContext(self.__class__
|
|
56
|
+
ctx = HookContext(self.__class__)
|
|
56
57
|
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
57
58
|
|
|
58
59
|
def save(self, *args, bypass_hooks=False, **kwargs):
|
|
@@ -61,28 +62,12 @@ class HookModelMixin(models.Model):
|
|
|
61
62
|
logger.debug(f"save() called with bypass_hooks=True for {self.__class__.__name__} pk={self.pk}")
|
|
62
63
|
return self._base_manager.save(self, *args, **kwargs)
|
|
63
64
|
|
|
64
|
-
# Only create a new transaction if we're not already in one
|
|
65
|
-
# This allows for proper nested transaction handling
|
|
66
|
-
from django.db import connection
|
|
67
|
-
if connection.in_atomic_block:
|
|
68
|
-
# We're already in a transaction, don't create a new one
|
|
69
|
-
return self._save_with_hooks(*args, **kwargs)
|
|
70
|
-
else:
|
|
71
|
-
# We're not in a transaction, so create one
|
|
72
|
-
with transaction.atomic():
|
|
73
|
-
return self._save_with_hooks(*args, **kwargs)
|
|
74
|
-
|
|
75
|
-
def _save_with_hooks(self, *args, **kwargs):
|
|
76
|
-
"""Internal method to handle save with hooks."""
|
|
77
65
|
is_create = self.pk is None
|
|
78
|
-
|
|
66
|
+
|
|
79
67
|
if is_create:
|
|
80
68
|
logger.debug(f"save() creating new {self.__class__.__name__} instance")
|
|
81
69
|
# For create operations, we don't have old records
|
|
82
|
-
ctx = HookContext(self.__class__
|
|
83
|
-
|
|
84
|
-
# Run hooks - if any fail, the transaction will be rolled back
|
|
85
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
70
|
+
ctx = HookContext(self.__class__)
|
|
86
71
|
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
87
72
|
|
|
88
73
|
super().save(*args, **kwargs)
|
|
@@ -94,10 +79,7 @@ class HookModelMixin(models.Model):
|
|
|
94
79
|
try:
|
|
95
80
|
# Use _base_manager to avoid triggering hooks recursively
|
|
96
81
|
old_instance = self.__class__._base_manager.get(pk=self.pk)
|
|
97
|
-
ctx = HookContext(self.__class__
|
|
98
|
-
|
|
99
|
-
# Run hooks - if any fail, the transaction will be rolled back
|
|
100
|
-
run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
82
|
+
ctx = HookContext(self.__class__)
|
|
101
83
|
run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
|
|
102
84
|
|
|
103
85
|
super().save(*args, **kwargs)
|
|
@@ -105,10 +87,7 @@ class HookModelMixin(models.Model):
|
|
|
105
87
|
run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
|
|
106
88
|
except self.__class__.DoesNotExist:
|
|
107
89
|
# If the old instance doesn't exist, treat as create
|
|
108
|
-
ctx = HookContext(self.__class__
|
|
109
|
-
|
|
110
|
-
# Run hooks - if any fail, the transaction will be rolled back
|
|
111
|
-
run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
|
|
90
|
+
ctx = HookContext(self.__class__)
|
|
112
91
|
run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
|
|
113
92
|
|
|
114
93
|
super().save(*args, **kwargs)
|
|
@@ -122,22 +101,9 @@ class HookModelMixin(models.Model):
|
|
|
122
101
|
if bypass_hooks:
|
|
123
102
|
return self._base_manager.delete(self, *args, **kwargs)
|
|
124
103
|
|
|
125
|
-
|
|
126
|
-
# This allows for proper nested transaction handling
|
|
127
|
-
from django.db import connection
|
|
128
|
-
if connection.in_atomic_block:
|
|
129
|
-
# We're already in a transaction, don't create a new one
|
|
130
|
-
return self._delete_with_hooks(*args, **kwargs)
|
|
131
|
-
else:
|
|
132
|
-
# We're not in a transaction, so create one
|
|
133
|
-
with transaction.atomic():
|
|
134
|
-
return self._delete_with_hooks(*args, **kwargs)
|
|
135
|
-
|
|
136
|
-
def _delete_with_hooks(self, *args, **kwargs):
|
|
137
|
-
"""Internal method to handle delete with hooks."""
|
|
138
|
-
ctx = HookContext(self.__class__, bypass_hooks=False)
|
|
104
|
+
ctx = HookContext(self.__class__)
|
|
139
105
|
|
|
140
|
-
# Run hooks
|
|
106
|
+
# Run validation hooks first
|
|
141
107
|
run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
|
|
142
108
|
|
|
143
109
|
# Then run business logic hooks
|
django_bulk_hooks/priority.py
CHANGED
|
@@ -5,12 +5,12 @@ class Priority(IntEnum):
|
|
|
5
5
|
"""
|
|
6
6
|
Named priorities for django-bulk-hooks hooks.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
Hooks are sorted in
|
|
8
|
+
Lower values run earlier (higher priority).
|
|
9
|
+
Hooks are sorted in ascending order.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
HIGHEST = 0 # runs first
|
|
13
|
+
HIGH = 25 # runs early
|
|
14
14
|
NORMAL = 50 # default ordering
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
LOW = 75 # runs later
|
|
16
|
+
LOWEST = 100 # runs last
|