django-bulk-hooks 0.1.66__tar.gz → 0.1.68__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.

Files changed (22) hide show
  1. django_bulk_hooks-0.1.68/PKG-INFO +195 -0
  2. django_bulk_hooks-0.1.68/README.md +175 -0
  3. django_bulk_hooks-0.1.68/django_bulk_hooks/__init__.py +4 -0
  4. django_bulk_hooks-0.1.68/django_bulk_hooks/constants.py +11 -0
  5. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/context.py +16 -16
  6. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/engine.py +19 -4
  7. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/handler.py +53 -29
  8. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/manager.py +38 -13
  9. django_bulk_hooks-0.1.68/django_bulk_hooks/models.py +95 -0
  10. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/queryset.py +3 -3
  11. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/registry.py +12 -6
  12. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/pyproject.toml +2 -2
  13. django_bulk_hooks-0.1.66/PKG-INFO +0 -102
  14. django_bulk_hooks-0.1.66/README.md +0 -82
  15. django_bulk_hooks-0.1.66/django_bulk_hooks/__init__.py +0 -3
  16. django_bulk_hooks-0.1.66/django_bulk_hooks/constants.py +0 -6
  17. django_bulk_hooks-0.1.66/django_bulk_hooks/models.py +0 -25
  18. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/LICENSE +0 -0
  19. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/conditions.py +0 -0
  20. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/decorators.py +0 -0
  21. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/enums.py +0 -0
  22. {django_bulk_hooks-0.1.66 → django_bulk_hooks-0.1.68}/django_bulk_hooks/priority.py +0 -0
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.1
2
+ Name: django-bulk-hooks
3
+ Version: 0.1.68
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
+ 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
+ ⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
24
+
25
+ `django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
26
+
27
+ ## ✨ Features
28
+
29
+ - Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
30
+ - BEFORE/AFTER hooks for create, update, delete
31
+ - Hook-aware manager that wraps Django's `bulk_` operations
32
+ - **NEW**: `HookModelMixin` for individual model lifecycle events
33
+ - Hook chaining, hook deduplication, and atomicity
34
+ - Class-based hook handlers with DI support
35
+ - Support for both bulk and individual model operations
36
+
37
+ ## 🚀 Quickstart
38
+
39
+ ```bash
40
+ pip install django-bulk-hooks
41
+ ```
42
+
43
+ ### Define Your Model
44
+
45
+ ```python
46
+ from django.db import models
47
+ from django_bulk_hooks.models import HookModelMixin
48
+
49
+ class Account(HookModelMixin):
50
+ balance = models.DecimalField(max_digits=10, decimal_places=2)
51
+ # The HookModelMixin automatically provides BulkHookManager
52
+ ```
53
+
54
+ ### Create a Hook Handler
55
+
56
+ ```python
57
+ from django_bulk_hooks import hook, AFTER_UPDATE, HookHandler
58
+ from django_bulk_hooks.conditions import WhenFieldHasChanged
59
+ from .models import Account
60
+
61
+ class AccountHookHandler(HookHandler):
62
+ @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
63
+ def log_balance_change(self, new_records, old_records):
64
+ print("Accounts updated:", [a.pk for a in new_records])
65
+
66
+ @hook(BEFORE_CREATE, model=Account)
67
+ def before_create(self, new_records, old_records):
68
+ for account in new_records:
69
+ if account.balance < 0:
70
+ raise ValueError("Account cannot have negative balance")
71
+
72
+ @hook(AFTER_DELETE, model=Account)
73
+ def after_delete(self, new_records, old_records):
74
+ print("Accounts deleted:", [a.pk for a in old_records])
75
+ ```
76
+
77
+ ## 🛠 Supported Hook Events
78
+
79
+ - `BEFORE_CREATE`, `AFTER_CREATE`
80
+ - `BEFORE_UPDATE`, `AFTER_UPDATE`
81
+ - `BEFORE_DELETE`, `AFTER_DELETE`
82
+
83
+ ## 🔄 Lifecycle Events
84
+
85
+ ### Individual Model Operations
86
+
87
+ The `HookModelMixin` automatically triggers hooks for individual model operations:
88
+
89
+ ```python
90
+ # These will trigger BEFORE_CREATE and AFTER_CREATE hooks
91
+ account = Account.objects.create(balance=100.00)
92
+ account.save() # for new instances
93
+
94
+ # These will trigger BEFORE_UPDATE and AFTER_UPDATE hooks
95
+ account.balance = 200.00
96
+ account.save() # for existing instances
97
+
98
+ # This will trigger BEFORE_DELETE and AFTER_DELETE hooks
99
+ account.delete()
100
+ ```
101
+
102
+ ### Bulk Operations
103
+
104
+ Bulk operations also trigger the same hooks:
105
+
106
+ ```python
107
+ # Bulk create - triggers BEFORE_CREATE and AFTER_CREATE hooks
108
+ accounts = [
109
+ Account(balance=100.00),
110
+ Account(balance=200.00),
111
+ ]
112
+ Account.objects.bulk_create(accounts)
113
+
114
+ # Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
115
+ for account in accounts:
116
+ account.balance *= 1.1
117
+ Account.objects.bulk_update(accounts, ['balance'])
118
+
119
+ # Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
120
+ Account.objects.bulk_delete(accounts)
121
+ ```
122
+
123
+ ### Queryset Operations
124
+
125
+ Queryset operations are also supported:
126
+
127
+ ```python
128
+ # Queryset update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
129
+ Account.objects.update(balance=0.00)
130
+
131
+ # Queryset delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
132
+ Account.objects.delete()
133
+ ```
134
+
135
+ ## 🧠 Why?
136
+
137
+ Django's `bulk_` methods bypass signals and `save()`. This package fills that gap with:
138
+
139
+ - Hooks that behave consistently across creates/updates/deletes
140
+ - **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`
141
+ - Scalable performance via chunking (default 200)
142
+ - Support for `@hook` decorators and centralized hook classes
143
+ - **NEW**: Automatic hook triggering for admin operations and other Django features
144
+
145
+ ## 📦 Usage Examples
146
+
147
+ ### Individual Model Operations
148
+
149
+ ```python
150
+ # These automatically trigger hooks
151
+ account = Account.objects.create(balance=100.00)
152
+ account.balance = 200.00
153
+ account.save()
154
+ account.delete()
155
+ ```
156
+
157
+ ### Bulk Operations
158
+
159
+ ```python
160
+ # These also trigger hooks
161
+ Account.objects.bulk_create(accounts)
162
+ Account.objects.bulk_update(accounts, ['balance'])
163
+ Account.objects.bulk_delete(accounts)
164
+ ```
165
+
166
+ ### Advanced Hook Usage
167
+
168
+ ```python
169
+ class AdvancedAccountHandler(HookHandler):
170
+ @hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
171
+ def validate_balance_change(self, new_records, old_records):
172
+ for new_account, old_account in zip(new_records, old_records):
173
+ if new_account.balance < 0 and old_account.balance >= 0:
174
+ raise ValueError("Cannot set negative balance")
175
+
176
+ @hook(AFTER_CREATE, model=Account)
177
+ def send_welcome_email(self, new_records, old_records):
178
+ for account in new_records:
179
+ # Send welcome email logic here
180
+ pass
181
+ ```
182
+
183
+ ## 🧩 Integration with Queryable Properties
184
+
185
+ You can extend from `BulkHookManager` to support formula fields or property querying.
186
+
187
+ ```python
188
+ class MyManager(BulkHookManager, QueryablePropertiesManager):
189
+ pass
190
+ ```
191
+
192
+ ## 📝 License
193
+
194
+ MIT © 2024 Augend / Konrad Beck
195
+
@@ -0,0 +1,175 @@
1
+
2
+ # django-bulk-hooks
3
+
4
+ ⚡ Bulk hooks for Django bulk operations and individual model lifecycle events.
5
+
6
+ `django-bulk-hooks` brings a declarative, hook-like experience to Django's `bulk_create`, `bulk_update`, and `bulk_delete` — including support for `BEFORE_` and `AFTER_` hooks, conditions, batching, and transactional safety. It also provides comprehensive lifecycle hooks for individual model operations.
7
+
8
+ ## ✨ Features
9
+
10
+ - Declarative hook system: `@hook(AFTER_UPDATE, condition=...)`
11
+ - BEFORE/AFTER hooks for create, update, delete
12
+ - Hook-aware manager that wraps Django's `bulk_` operations
13
+ - **NEW**: `HookModelMixin` for individual model lifecycle events
14
+ - Hook chaining, hook deduplication, and atomicity
15
+ - Class-based hook handlers with DI support
16
+ - Support for both bulk and individual model operations
17
+
18
+ ## 🚀 Quickstart
19
+
20
+ ```bash
21
+ pip install django-bulk-hooks
22
+ ```
23
+
24
+ ### Define Your Model
25
+
26
+ ```python
27
+ from django.db import models
28
+ from django_bulk_hooks.models import HookModelMixin
29
+
30
+ class Account(HookModelMixin):
31
+ balance = models.DecimalField(max_digits=10, decimal_places=2)
32
+ # The HookModelMixin automatically provides BulkHookManager
33
+ ```
34
+
35
+ ### Create a Hook Handler
36
+
37
+ ```python
38
+ from django_bulk_hooks import hook, AFTER_UPDATE, HookHandler
39
+ from django_bulk_hooks.conditions import WhenFieldHasChanged
40
+ from .models import Account
41
+
42
+ class AccountHookHandler(HookHandler):
43
+ @hook(AFTER_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
44
+ def log_balance_change(self, new_records, old_records):
45
+ print("Accounts updated:", [a.pk for a in new_records])
46
+
47
+ @hook(BEFORE_CREATE, model=Account)
48
+ def before_create(self, new_records, old_records):
49
+ for account in new_records:
50
+ if account.balance < 0:
51
+ raise ValueError("Account cannot have negative balance")
52
+
53
+ @hook(AFTER_DELETE, model=Account)
54
+ def after_delete(self, new_records, old_records):
55
+ print("Accounts deleted:", [a.pk for a in old_records])
56
+ ```
57
+
58
+ ## 🛠 Supported Hook Events
59
+
60
+ - `BEFORE_CREATE`, `AFTER_CREATE`
61
+ - `BEFORE_UPDATE`, `AFTER_UPDATE`
62
+ - `BEFORE_DELETE`, `AFTER_DELETE`
63
+
64
+ ## 🔄 Lifecycle Events
65
+
66
+ ### Individual Model Operations
67
+
68
+ The `HookModelMixin` automatically triggers hooks for individual model operations:
69
+
70
+ ```python
71
+ # These will trigger BEFORE_CREATE and AFTER_CREATE hooks
72
+ account = Account.objects.create(balance=100.00)
73
+ account.save() # for new instances
74
+
75
+ # These will trigger BEFORE_UPDATE and AFTER_UPDATE hooks
76
+ account.balance = 200.00
77
+ account.save() # for existing instances
78
+
79
+ # This will trigger BEFORE_DELETE and AFTER_DELETE hooks
80
+ account.delete()
81
+ ```
82
+
83
+ ### Bulk Operations
84
+
85
+ Bulk operations also trigger the same hooks:
86
+
87
+ ```python
88
+ # Bulk create - triggers BEFORE_CREATE and AFTER_CREATE hooks
89
+ accounts = [
90
+ Account(balance=100.00),
91
+ Account(balance=200.00),
92
+ ]
93
+ Account.objects.bulk_create(accounts)
94
+
95
+ # Bulk update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
96
+ for account in accounts:
97
+ account.balance *= 1.1
98
+ Account.objects.bulk_update(accounts, ['balance'])
99
+
100
+ # Bulk delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
101
+ Account.objects.bulk_delete(accounts)
102
+ ```
103
+
104
+ ### Queryset Operations
105
+
106
+ Queryset operations are also supported:
107
+
108
+ ```python
109
+ # Queryset update - triggers BEFORE_UPDATE and AFTER_UPDATE hooks
110
+ Account.objects.update(balance=0.00)
111
+
112
+ # Queryset delete - triggers BEFORE_DELETE and AFTER_DELETE hooks
113
+ Account.objects.delete()
114
+ ```
115
+
116
+ ## 🧠 Why?
117
+
118
+ Django's `bulk_` methods bypass signals and `save()`. This package fills that gap with:
119
+
120
+ - Hooks that behave consistently across creates/updates/deletes
121
+ - **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`
122
+ - Scalable performance via chunking (default 200)
123
+ - Support for `@hook` decorators and centralized hook classes
124
+ - **NEW**: Automatic hook triggering for admin operations and other Django features
125
+
126
+ ## 📦 Usage Examples
127
+
128
+ ### Individual Model Operations
129
+
130
+ ```python
131
+ # These automatically trigger hooks
132
+ account = Account.objects.create(balance=100.00)
133
+ account.balance = 200.00
134
+ account.save()
135
+ account.delete()
136
+ ```
137
+
138
+ ### Bulk Operations
139
+
140
+ ```python
141
+ # These also trigger hooks
142
+ Account.objects.bulk_create(accounts)
143
+ Account.objects.bulk_update(accounts, ['balance'])
144
+ Account.objects.bulk_delete(accounts)
145
+ ```
146
+
147
+ ### Advanced Hook Usage
148
+
149
+ ```python
150
+ class AdvancedAccountHandler(HookHandler):
151
+ @hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
152
+ def validate_balance_change(self, new_records, old_records):
153
+ for new_account, old_account in zip(new_records, old_records):
154
+ if new_account.balance < 0 and old_account.balance >= 0:
155
+ raise ValueError("Cannot set negative balance")
156
+
157
+ @hook(AFTER_CREATE, model=Account)
158
+ def send_welcome_email(self, new_records, old_records):
159
+ for account in new_records:
160
+ # Send welcome email logic here
161
+ pass
162
+ ```
163
+
164
+ ## 🧩 Integration with Queryable Properties
165
+
166
+ You can extend from `BulkHookManager` to support formula fields or property querying.
167
+
168
+ ```python
169
+ class MyManager(BulkHookManager, QueryablePropertiesManager):
170
+ pass
171
+ ```
172
+
173
+ ## 📝 License
174
+
175
+ MIT © 2024 Augend / Konrad Beck
@@ -0,0 +1,4 @@
1
+ from django_bulk_hooks.handler import HookHandler
2
+ from django_bulk_hooks.manager import BulkHookManager
3
+
4
+ __all__ = ["BulkHookManager", "HookHandler"]
@@ -0,0 +1,11 @@
1
+ BEFORE_CREATE = "before_create"
2
+ AFTER_CREATE = "after_create"
3
+ BEFORE_UPDATE = "before_update"
4
+ AFTER_UPDATE = "after_update"
5
+ BEFORE_DELETE = "before_delete"
6
+ AFTER_DELETE = "after_delete"
7
+
8
+ # Validation constants - run during clean() for admin/form validation
9
+ VALIDATE_CREATE = "validate_create"
10
+ VALIDATE_UPDATE = "validate_update"
11
+ VALIDATE_DELETE = "validate_delete"
@@ -1,16 +1,16 @@
1
- import threading
2
- from collections import deque
3
-
4
- _hook_context = threading.local()
5
-
6
-
7
- def get_hook_queue():
8
- if not hasattr(_hook_context, "queue"):
9
- _hook_context.queue = deque()
10
- return _hook_context.queue
11
-
12
-
13
- class TriggerContext:
14
- def __init__(self, model_cls, metadata=None):
15
- self.model_cls = model_cls
16
- self.metadata = metadata or {}
1
+ import threading
2
+ from collections import deque
3
+
4
+ _hook_context = threading.local()
5
+
6
+
7
+ def get_hook_queue():
8
+ if not hasattr(_hook_context, "queue"):
9
+ _hook_context.queue = deque()
10
+ return _hook_context.queue
11
+
12
+
13
+ class HookContext:
14
+ def __init__(self, model_cls, metadata=None):
15
+ self.model_cls = model_cls
16
+ self.metadata = metadata or {}
@@ -1,5 +1,5 @@
1
1
  import logging
2
-
2
+ from django.core.exceptions import ValidationError
3
3
  from django_bulk_hooks.registry import get_hooks
4
4
 
5
5
  logger = logging.getLogger(__name__)
@@ -8,19 +8,34 @@ logger = logging.getLogger(__name__)
8
8
  def run(model_cls, event, new_instances, original_instances=None, ctx=None):
9
9
  hooks = get_hooks(model_cls, event)
10
10
 
11
- logger.debug(
12
- "Executing engine.run: model=%s, event=%s, #new_instances=%d, #original_instances=%d",
11
+ logger.info(
12
+ "Executing engine.run: model=%s, event=%s, #new_instances=%d, #original_instances=%d, #hooks=%d",
13
13
  model_cls.__name__,
14
14
  event,
15
15
  len(new_instances),
16
16
  len(original_instances or []),
17
+ len(hooks),
17
18
  )
18
19
 
20
+ if not hooks:
21
+ logger.info("No hooks found for model=%s, event=%s", model_cls.__name__, event)
22
+ return
23
+
24
+ # For BEFORE_* events, run model.clean() first for validation
25
+ if event.startswith("before_"):
26
+ logger.debug("Running model.clean() for %d instances", len(new_instances))
27
+ for instance in new_instances:
28
+ try:
29
+ instance.clean()
30
+ except ValidationError as e:
31
+ logger.error("Validation failed for %s: %s", instance, e)
32
+ raise
33
+
19
34
  for handler_cls, method_name, condition, priority in hooks:
20
35
  handler_instance = handler_cls()
21
36
  func = getattr(handler_instance, method_name)
22
37
 
23
- logger.debug(
38
+ logger.info(
24
39
  "Executing hook %s for %s.%s with priority=%s",
25
40
  func.__name__,
26
41
  model_cls.__name__,
@@ -3,12 +3,14 @@ import threading
3
3
  from collections import deque
4
4
 
5
5
  from django.db import transaction
6
+
6
7
  from django_bulk_hooks.registry import get_hooks, register_hook
7
8
 
8
9
  logger = logging.getLogger(__name__)
9
10
 
10
- # Thread-local hook context and trigger state
11
- class TriggerVars(threading.local):
11
+
12
+ # Thread-local hook context and hook state
13
+ class HookVars(threading.local):
12
14
  def __init__(self):
13
15
  self.new = None
14
16
  self.old = None
@@ -16,48 +18,53 @@ class TriggerVars(threading.local):
16
18
  self.model = None
17
19
  self.depth = 0
18
20
 
19
- trigger = TriggerVars()
21
+
22
+ hook_vars = HookVars()
20
23
 
21
24
  # Hook queue per thread
22
25
  _hook_context = threading.local()
23
26
 
27
+
24
28
  def get_hook_queue():
25
29
  if not hasattr(_hook_context, "queue"):
26
30
  _hook_context.queue = deque()
27
31
  return _hook_context.queue
28
32
 
29
- class TriggerContextState:
33
+
34
+ class HookContextState:
30
35
  @property
31
36
  def is_before(self):
32
- return trigger.event.startswith("before_") if trigger.event else False
37
+ return hook_vars.event.startswith("before_") if hook_vars.event else False
33
38
 
34
39
  @property
35
40
  def is_after(self):
36
- return trigger.event.startswith("after_") if trigger.event else False
41
+ return hook_vars.event.startswith("after_") if hook_vars.event else False
37
42
 
38
43
  @property
39
44
  def is_create(self):
40
- return "create" in trigger.event if trigger.event else False
45
+ return "create" in hook_vars.event if hook_vars.event else False
41
46
 
42
47
  @property
43
48
  def is_update(self):
44
- return "update" in trigger.event if trigger.event else False
49
+ return "update" in hook_vars.event if hook_vars.event else False
45
50
 
46
51
  @property
47
52
  def new(self):
48
- return trigger.new
53
+ return hook_vars.new
49
54
 
50
55
  @property
51
56
  def old(self):
52
- return trigger.old
57
+ return hook_vars.old
53
58
 
54
59
  @property
55
60
  def model(self):
56
- return trigger.model
61
+ return hook_vars.model
57
62
 
58
- Trigger = TriggerContextState()
59
63
 
60
- class TriggerHandlerMeta(type):
64
+ Hook = HookContextState()
65
+
66
+
67
+ class HookHandlerMeta(type):
61
68
  _registered = set()
62
69
 
63
70
  def __new__(mcs, name, bases, namespace):
@@ -66,7 +73,14 @@ class TriggerHandlerMeta(type):
66
73
  if hasattr(method, "hooks_hooks"):
67
74
  for model_cls, event, condition, priority in method.hooks_hooks:
68
75
  key = (model_cls, event, cls, method_name)
69
- if key not in TriggerHandlerMeta._registered:
76
+ if key not in HookHandlerMeta._registered:
77
+ logger.info(
78
+ "Registering hook via HookHandlerMeta: model=%s, event=%s, handler_cls=%s, method_name=%s",
79
+ model_cls.__name__,
80
+ event,
81
+ cls.__name__,
82
+ method_name,
83
+ )
70
84
  register_hook(
71
85
  model=model_cls,
72
86
  event=event,
@@ -75,10 +89,11 @@ class TriggerHandlerMeta(type):
75
89
  condition=condition,
76
90
  priority=priority,
77
91
  )
78
- TriggerHandlerMeta._registered.add(key)
92
+ HookHandlerMeta._registered.add(key)
79
93
  return cls
80
94
 
81
- class TriggerHandler(metaclass=TriggerHandlerMeta):
95
+
96
+ class HookHandler(metaclass=HookHandlerMeta):
82
97
  @classmethod
83
98
  def handle(
84
99
  cls,
@@ -109,11 +124,11 @@ class TriggerHandler(metaclass=TriggerHandlerMeta):
109
124
  old_records,
110
125
  **kwargs,
111
126
  ):
112
- trigger.depth += 1
113
- trigger.new = new_records
114
- trigger.old = old_records
115
- trigger.event = event
116
- trigger.model = model
127
+ hook_vars.depth += 1
128
+ hook_vars.new = new_records
129
+ hook_vars.old = old_records
130
+ hook_vars.event = event
131
+ hook_vars.model = model
117
132
 
118
133
  hooks = sorted(get_hooks(model, event), key=lambda x: x[3])
119
134
  logger.debug("Processing %d hooks for %s.%s", len(hooks), model.__name__, event)
@@ -126,14 +141,21 @@ class TriggerHandler(metaclass=TriggerHandlerMeta):
126
141
 
127
142
  for handler_cls, method_name, condition, priority in hooks:
128
143
  if condition is not None:
129
- checks = [condition.check(n, o) for n, o in zip(new_local, old_local)]
144
+ checks = [
145
+ condition.check(n, o) for n, o in zip(new_local, old_local)
146
+ ]
130
147
  if not any(checks):
131
148
  continue
132
149
 
133
150
  handler = handler_cls()
134
151
  method = getattr(handler, method_name)
135
152
 
136
- logger.info("Running hook %s.%s on %d items", handler_cls.__name__, method_name, len(new_local))
153
+ logger.info(
154
+ "Running hook %s.%s on %d items",
155
+ handler_cls.__name__,
156
+ method_name,
157
+ len(new_local),
158
+ )
137
159
  try:
138
160
  method(
139
161
  new_records=new_local,
@@ -141,7 +163,9 @@ class TriggerHandler(metaclass=TriggerHandlerMeta):
141
163
  **kwargs,
142
164
  )
143
165
  except Exception:
144
- logger.exception("Error in hook %s.%s", handler_cls.__name__, method_name)
166
+ logger.exception(
167
+ "Error in hook %s.%s", handler_cls.__name__, method_name
168
+ )
145
169
 
146
170
  conn = transaction.get_connection()
147
171
  try:
@@ -150,8 +174,8 @@ class TriggerHandler(metaclass=TriggerHandlerMeta):
150
174
  else:
151
175
  _execute()
152
176
  finally:
153
- trigger.new = None
154
- trigger.old = None
155
- trigger.event = None
156
- trigger.model = None
157
- trigger.depth -= 1
177
+ hook_vars.new = None
178
+ hook_vars.old = None
179
+ hook_vars.event = None
180
+ hook_vars.model = None
181
+ hook_vars.depth -= 1
@@ -1,4 +1,7 @@
1
+ import logging
2
+
1
3
  from django.db import models, transaction
4
+
2
5
  from django_bulk_hooks import engine
3
6
  from django_bulk_hooks.constants import (
4
7
  AFTER_CREATE,
@@ -7,22 +10,24 @@ from django_bulk_hooks.constants import (
7
10
  BEFORE_CREATE,
8
11
  BEFORE_DELETE,
9
12
  BEFORE_UPDATE,
13
+ VALIDATE_CREATE,
14
+ VALIDATE_UPDATE,
15
+ VALIDATE_DELETE,
10
16
  )
11
- from django_bulk_hooks.context import TriggerContext
12
- from django_bulk_hooks.queryset import LifecycleQuerySet
13
- import logging
17
+ from django_bulk_hooks.context import HookContext
18
+ from django_bulk_hooks.queryset import HookQuerySet
14
19
 
15
20
  logger = logging.getLogger(__name__)
16
21
 
17
22
 
18
- class BulkLifecycleManager(models.Manager):
23
+ class BulkHookManager(models.Manager):
19
24
  CHUNK_SIZE = 200
20
25
 
21
26
  def get_queryset(self):
22
- return LifecycleQuerySet(self.model, using=self._db)
27
+ return HookQuerySet(self.model, using=self._db)
23
28
 
24
29
  @transaction.atomic
25
- def bulk_update(self, objs, fields, bypass_hooks=False, **kwargs):
30
+ def bulk_update(self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs):
26
31
  if not objs:
27
32
  return []
28
33
 
@@ -35,7 +40,13 @@ class BulkLifecycleManager(models.Manager):
35
40
 
36
41
  if not bypass_hooks:
37
42
  originals = list(model_cls.objects.filter(pk__in=[obj.pk for obj in objs]))
38
- ctx = TriggerContext(model_cls)
43
+ ctx = HookContext(model_cls)
44
+
45
+ # Run validation hooks first
46
+ if not bypass_validation:
47
+ engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
48
+
49
+ # Then run business logic hooks
39
50
  engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
40
51
 
41
52
  # Automatically detect fields that were modified during BEFORE_UPDATE hooks
@@ -104,7 +115,7 @@ class BulkLifecycleManager(models.Manager):
104
115
  return modified_fields
105
116
 
106
117
  @transaction.atomic
107
- def bulk_create(self, objs, bypass_hooks=False, **kwargs):
118
+ def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
108
119
  model_cls = self.model
109
120
 
110
121
  if any(not isinstance(obj, model_cls) for obj in objs):
@@ -115,7 +126,13 @@ class BulkLifecycleManager(models.Manager):
115
126
  result = []
116
127
 
117
128
  if not bypass_hooks:
118
- ctx = TriggerContext(model_cls)
129
+ ctx = HookContext(model_cls)
130
+
131
+ # Run validation hooks first
132
+ if not bypass_validation:
133
+ engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
134
+
135
+ # Then run business logic hooks
119
136
  engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
120
137
 
121
138
  for i in range(0, len(objs), self.CHUNK_SIZE):
@@ -128,7 +145,7 @@ class BulkLifecycleManager(models.Manager):
128
145
  return result
129
146
 
130
147
  @transaction.atomic
131
- def bulk_delete(self, objs, batch_size=None, bypass_hooks=False):
148
+ def bulk_delete(self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False):
132
149
  if not objs:
133
150
  return []
134
151
 
@@ -139,17 +156,25 @@ class BulkLifecycleManager(models.Manager):
139
156
  f"bulk_delete expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
140
157
  )
141
158
 
142
- ctx = TriggerContext(model_cls)
159
+ ctx = HookContext(model_cls)
143
160
 
144
161
  if not bypass_hooks:
145
- logger.debug("Executing BEFORE_DELETE hooks for %s", model_cls.__name__)
162
+ logger.info("Executing hooks for %s", model_cls.__name__)
163
+ logger.info("Number of objects to delete: %d", len(objs))
164
+
165
+ # Run validation hooks first
166
+ if not bypass_validation:
167
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
168
+
169
+ # Then run business logic hooks
146
170
  engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
147
171
 
148
172
  pks = [obj.pk for obj in objs if obj.pk is not None]
149
173
  model_cls.objects.filter(pk__in=pks).delete()
150
174
 
151
175
  if not bypass_hooks:
152
- logger.debug("Executing AFTER_DELETE hooks for %s", model_cls.__name__)
176
+ logger.info("Executing AFTER_DELETE hooks for %s", model_cls.__name__)
177
+ logger.info("Number of objects deleted: %d", len(objs))
153
178
  engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
154
179
 
155
180
  return objs
@@ -0,0 +1,95 @@
1
+ from django.db import models, transaction
2
+
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
+ VALIDATE_CREATE,
11
+ VALIDATE_DELETE,
12
+ VALIDATE_UPDATE,
13
+ )
14
+ from django_bulk_hooks.context import HookContext
15
+ from django_bulk_hooks.engine import run
16
+ from django_bulk_hooks.manager import BulkHookManager
17
+
18
+
19
+ class HookModelMixin(models.Model):
20
+ objects = BulkHookManager()
21
+
22
+ class Meta:
23
+ abstract = True
24
+
25
+ def clean(self):
26
+ """
27
+ Override clean() to trigger validation hooks.
28
+ This ensures that when Django calls clean() (like in admin forms),
29
+ it triggers the VALIDATE_* hooks for validation only.
30
+ """
31
+ super().clean()
32
+
33
+ # Determine if this is a create or update operation
34
+ is_create = self.pk is None
35
+
36
+ if is_create:
37
+ # For create operations, run VALIDATE_CREATE hooks for validation
38
+ ctx = HookContext(self.__class__)
39
+ run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
40
+ else:
41
+ # For update operations, run VALIDATE_UPDATE hooks for validation
42
+ try:
43
+ old_instance = self.__class__.objects.get(pk=self.pk)
44
+ ctx = HookContext(self.__class__)
45
+ run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
46
+ except self.__class__.DoesNotExist:
47
+ # If the old instance doesn't exist, treat as create
48
+ ctx = HookContext(self.__class__)
49
+ run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
50
+
51
+ def save(self, *args, **kwargs):
52
+ is_create = self.pk is None
53
+
54
+ if is_create:
55
+ # For create operations, we don't have old records
56
+ ctx = HookContext(self.__class__)
57
+ run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
58
+
59
+ super().save(*args, **kwargs)
60
+
61
+ run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
62
+ else:
63
+ # For update operations, we need to get the old record
64
+ try:
65
+ old_instance = self.__class__.objects.get(pk=self.pk)
66
+ ctx = HookContext(self.__class__)
67
+ run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
68
+
69
+ super().save(*args, **kwargs)
70
+
71
+ run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
72
+ except self.__class__.DoesNotExist:
73
+ # If the old instance doesn't exist, treat as create
74
+ ctx = HookContext(self.__class__)
75
+ run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
76
+
77
+ super().save(*args, **kwargs)
78
+
79
+ run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
80
+
81
+ return self
82
+
83
+ def delete(self, *args, **kwargs):
84
+ ctx = HookContext(self.__class__)
85
+
86
+ # Run validation hooks first
87
+ run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
88
+
89
+ # Then run business logic hooks
90
+ run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
91
+
92
+ result = super().delete(*args, **kwargs)
93
+
94
+ run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
95
+ return result
@@ -1,7 +1,7 @@
1
1
  from django.db import models, transaction
2
2
 
3
3
 
4
- class LifecycleQuerySet(models.QuerySet):
4
+ class HookQuerySet(models.QuerySet):
5
5
  @transaction.atomic
6
6
  def delete(self):
7
7
  objs = list(self)
@@ -29,9 +29,9 @@ class LifecycleQuerySet(models.QuerySet):
29
29
 
30
30
  # Run BEFORE_UPDATE hooks
31
31
  from django_bulk_hooks import engine
32
- from django_bulk_hooks.context import TriggerContext
32
+ from django_bulk_hooks.context import HookContext
33
33
 
34
- ctx = TriggerContext(model_cls)
34
+ ctx = HookContext(model_cls)
35
35
  engine.run(model_cls, "before_update", instances, originals, ctx=ctx)
36
36
 
37
37
  # Use Django's built-in update logic directly
@@ -1,10 +1,9 @@
1
+ import logging
1
2
  from collections.abc import Callable
2
3
  from typing import Union
3
4
 
4
5
  from django_bulk_hooks.priority import Priority
5
6
 
6
- import logging
7
-
8
7
  logger = logging.getLogger(__name__)
9
8
 
10
9
  _hooks: dict[tuple[type, str], list[tuple[type, str, Callable, int]]] = {}
@@ -18,7 +17,7 @@ def register_hook(
18
17
  hooks.append((handler_cls, method_name, condition, priority))
19
18
  # keep sorted by priority
20
19
  hooks.sort(key=lambda x: x[3])
21
- logger.debug(
20
+ logger.info(
22
21
  "Registering hook: model=%s, event=%s, handler_cls=%s, method_name=%s, condition=%s, priority=%s",
23
22
  model.__name__,
24
23
  event,
@@ -30,10 +29,17 @@ def register_hook(
30
29
 
31
30
 
32
31
  def get_hooks(model, event):
33
- logger.debug(
32
+ hooks = _hooks.get((model, event), [])
33
+ logger.info(
34
34
  "Retrieving hooks: model=%s, event=%s, hooks_found=%d",
35
35
  model.__name__,
36
36
  event,
37
- len(_hooks.get((model, event), [])),
37
+ len(hooks),
38
38
  )
39
- return _hooks.get((model, event), [])
39
+ return hooks
40
+
41
+
42
+ def list_all_hooks():
43
+ """Debug function to list all registered hooks"""
44
+ logger.debug("All registered hooks: %s", _hooks)
45
+ return _hooks
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
  name = "django-bulk-hooks"
3
- version = "0.1.66"
4
- description = "Lifecycle-style hooks for Django bulk operations like bulk_create and bulk_update."
3
+ version = "0.1.68"
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"
7
7
  license = "MIT"
@@ -1,102 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: django-bulk-hooks
3
- Version: 0.1.66
4
- Summary: Lifecycle-style hooks for Django bulk operations like bulk_create and bulk_update.
5
- License: MIT
6
- Keywords: django,bulk,hooks
7
- Author: Konrad Beck
8
- Author-email: konrad.beck@merchantcapital.co.za
9
- Requires-Python: >=3.11,<4.0
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Classifier: Programming Language :: Python :: 3.13
15
- Requires-Dist: Django (>=4.0)
16
- Project-URL: Homepage, https://github.com/AugendLimited/django-bulk-hooks
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
-
@@ -1,82 +0,0 @@
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
@@ -1,3 +0,0 @@
1
- from django_bulk_hooks.manager import BulkLifecycleManager
2
-
3
- __all__ = ["BulkLifecycleManager"]
@@ -1,6 +0,0 @@
1
- BEFORE_CREATE = "before_create"
2
- AFTER_CREATE = "after_create"
3
- BEFORE_UPDATE = "before_update"
4
- AFTER_UPDATE = "after_update"
5
- BEFORE_DELETE = "before_delete"
6
- AFTER_DELETE = "after_delete"
@@ -1,25 +0,0 @@
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