django-bulk-hooks 0.1.83__py3-none-any.whl → 0.2.100__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.

@@ -1,25 +1,276 @@
1
- from collections.abc import Callable
2
- from typing import Union
3
-
4
- from django_bulk_hooks.enums 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), [])
21
-
22
-
23
- def list_all_hooks():
24
- """Debug function to list all registered hooks"""
25
- return _hooks
1
+ """
2
+ Central registry for hook handlers.
3
+
4
+ Provides thread-safe registration and lookup of hooks with
5
+ deterministic priority ordering.
6
+ """
7
+
8
+ import logging
9
+ import threading
10
+ from collections.abc import Callable
11
+
12
+ from django_bulk_hooks.enums import Priority
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Type alias for hook info tuple
17
+ HookInfo = tuple[type, str, Callable | None, int]
18
+
19
+
20
+ class HookRegistry:
21
+ """
22
+ Central registry for all hook handlers.
23
+
24
+ Manages registration, lookup, and lifecycle of hooks with
25
+ thread-safe operations and deterministic ordering by priority.
26
+
27
+ This is a singleton - use get_registry() to access the instance.
28
+ """
29
+
30
+ def __init__(self):
31
+ """Initialize an empty registry with thread-safe storage."""
32
+ self._hooks: dict[tuple[type, str], list[HookInfo]] = {}
33
+ self._lock = threading.RLock()
34
+
35
+ def register(
36
+ self,
37
+ model: type,
38
+ event: str,
39
+ handler_cls: type,
40
+ method_name: str,
41
+ condition: Callable | None,
42
+ priority: int | Priority,
43
+ ) -> None:
44
+ """
45
+ Register a hook handler for a model and event.
46
+
47
+ Args:
48
+ model: Django model class
49
+ event: Event name (e.g., 'after_update', 'before_create')
50
+ handler_cls: Hook handler class
51
+ method_name: Name of the method to call on handler
52
+ condition: Optional condition to filter records
53
+ priority: Execution priority (lower values execute first)
54
+ """
55
+ with self._lock:
56
+ key = (model, event)
57
+ hooks = self._hooks.setdefault(key, [])
58
+
59
+ # Check for duplicates before adding
60
+ hook_info = (handler_cls, method_name, condition, priority)
61
+ if hook_info not in hooks:
62
+ hooks.append(hook_info)
63
+ # Sort by priority (lower values first)
64
+ hooks.sort(key=lambda x: x[3])
65
+ else:
66
+ pass # Hook already registered
67
+
68
+ def get_hooks(self, model: type, event: str) -> list[HookInfo]:
69
+ """
70
+ Get all hooks for a model and event.
71
+
72
+ Args:
73
+ model: Django model class
74
+ event: Event name
75
+
76
+ Returns:
77
+ List of hook info tuples (handler_cls, method_name, condition, priority)
78
+ sorted by priority (lower values first)
79
+ """
80
+ with self._lock:
81
+ key = (model, event)
82
+ hooks = self._hooks.get(key, [])
83
+ logger.debug(f"Retrieved {len(hooks)} hooks for {model.__name__}.{event}")
84
+ return hooks
85
+
86
+ def unregister(
87
+ self,
88
+ model: type,
89
+ event: str,
90
+ handler_cls: type,
91
+ method_name: str,
92
+ ) -> None:
93
+ """
94
+ Unregister a specific hook handler.
95
+
96
+ Used when child classes override parent hook methods.
97
+
98
+ Args:
99
+ model: Django model class
100
+ event: Event name
101
+ handler_cls: Hook handler class to remove
102
+ method_name: Method name to remove
103
+ """
104
+ with self._lock:
105
+ key = (model, event)
106
+ if key not in self._hooks:
107
+ return
108
+
109
+ hooks = self._hooks[key]
110
+ # Filter out the specific hook
111
+ self._hooks[key] = [
112
+ (h_cls, m_name, cond, pri) for h_cls, m_name, cond, pri in hooks if not (h_cls == handler_cls and m_name == method_name)
113
+ ]
114
+
115
+ # Clean up empty hook lists
116
+ if not self._hooks[key]:
117
+ del self._hooks[key]
118
+
119
+ def clear(self) -> None:
120
+ """
121
+ Clear all registered hooks.
122
+
123
+ Useful for testing to ensure clean state between tests.
124
+ """
125
+ with self._lock:
126
+ self._hooks.clear()
127
+
128
+ # Also clear HookMeta state to ensure complete reset
129
+ from django_bulk_hooks.handler import HookMeta
130
+
131
+ HookMeta._registered.clear()
132
+ HookMeta._class_hook_map.clear()
133
+
134
+ def list_all(self) -> dict[tuple[type, str], list[HookInfo]]:
135
+ """
136
+ Get all registered hooks for debugging.
137
+
138
+ Returns:
139
+ Dictionary mapping (model, event) tuples to lists of hook info
140
+ """
141
+ with self._lock:
142
+ return dict(self._hooks)
143
+
144
+ @property
145
+ def hooks(self) -> dict[tuple[type, str], list[HookInfo]]:
146
+ """
147
+ Expose internal hooks dictionary for testing purposes.
148
+
149
+ This property provides direct access to the internal hooks storage
150
+ to allow tests to clear the registry state between test runs.
151
+ """
152
+ return self._hooks
153
+
154
+ def count_hooks(
155
+ self,
156
+ model: type | None = None,
157
+ event: str | None = None,
158
+ ) -> int:
159
+ """
160
+ Count registered hooks, optionally filtered by model and/or event.
161
+
162
+ Args:
163
+ model: Optional model class to filter by
164
+ event: Optional event name to filter by
165
+
166
+ Returns:
167
+ Number of matching hooks
168
+ """
169
+ with self._lock:
170
+ if model is None and event is None:
171
+ # Count all hooks
172
+ return sum(len(hooks) for hooks in self._hooks.values())
173
+ if model is not None and event is not None:
174
+ # Count hooks for specific model and event
175
+ return len(self._hooks.get((model, event), []))
176
+ if model is not None:
177
+ # Count all hooks for a model
178
+ return sum(len(hooks) for (m, _), hooks in self._hooks.items() if m == model)
179
+ # event is not None
180
+ # Count all hooks for an event
181
+ return sum(len(hooks) for (_, e), hooks in self._hooks.items() if e == event)
182
+
183
+
184
+ # Global singleton registry
185
+ _registry: HookRegistry | None = None
186
+ _registry_lock = threading.Lock()
187
+
188
+
189
+ def get_registry() -> HookRegistry:
190
+ """
191
+ Get the global hook registry instance.
192
+
193
+ Creates the registry on first access (singleton pattern).
194
+ Thread-safe initialization.
195
+
196
+ Returns:
197
+ HookRegistry singleton instance
198
+ """
199
+ global _registry
200
+
201
+ if _registry is None:
202
+ with _registry_lock:
203
+ # Double-checked locking
204
+ if _registry is None:
205
+ _registry = HookRegistry()
206
+
207
+ return _registry
208
+
209
+
210
+ # Backward-compatible module-level functions
211
+ def register_hook(
212
+ model: type,
213
+ event: str,
214
+ handler_cls: type,
215
+ method_name: str,
216
+ condition: Callable | None,
217
+ priority: int | Priority,
218
+ ) -> None:
219
+ """
220
+ Register a hook handler (backward-compatible function).
221
+
222
+ Delegates to the global registry instance.
223
+ """
224
+ registry = get_registry()
225
+ registry.register(model, event, handler_cls, method_name, condition, priority)
226
+
227
+
228
+ def get_hooks(model: type, event: str) -> list[HookInfo]:
229
+ """
230
+ Get hooks for a model and event (backward-compatible function).
231
+
232
+ Delegates to the global registry instance.
233
+ """
234
+ registry = get_registry()
235
+ return registry.get_hooks(model, event)
236
+
237
+
238
+ def unregister_hook(
239
+ model: type,
240
+ event: str,
241
+ handler_cls: type,
242
+ method_name: str,
243
+ ) -> None:
244
+ """
245
+ Unregister a hook handler (backward-compatible function).
246
+
247
+ Delegates to the global registry instance.
248
+ """
249
+ registry = get_registry()
250
+ registry.unregister(model, event, handler_cls, method_name)
251
+
252
+
253
+ def clear_hooks() -> None:
254
+ """
255
+ Clear all registered hooks (backward-compatible function).
256
+
257
+ Delegates to the global registry instance.
258
+ Useful for testing.
259
+ """
260
+ registry = get_registry()
261
+ registry.clear()
262
+
263
+
264
+ def list_all_hooks() -> dict[tuple[type, str], list[HookInfo]]:
265
+ """
266
+ List all registered hooks (backward-compatible function).
267
+
268
+ Delegates to the global registry instance.
269
+ """
270
+ registry = get_registry()
271
+ return registry.list_all()
272
+
273
+
274
+ # Expose hooks dictionary for testing purposes
275
+ # This provides backward compatibility with tests that expect to access _hooks directly
276
+ _hooks = get_registry().hooks
@@ -0,0 +1,320 @@
1
+ Metadata-Version: 2.3
2
+ Name: django-bulk-hooks
3
+ Version: 0.2.100
4
+ Summary: Hook-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 (>=5.2.0,<6.0.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
+ ⚡ 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, Hook
58
+ from django_bulk_hooks.conditions import WhenFieldHasChanged
59
+ from .models import Account
60
+
61
+ class AccountHooks(Hook):
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 hooks hooks for individual model operations:
88
+
89
+ ```python
90
+ # These will hook BEFORE_CREATE and AFTER_CREATE hooks
91
+ account = Account.objects.create(balance=100.00)
92
+ account.save() # for new instances
93
+
94
+ # These will hook BEFORE_UPDATE and AFTER_UPDATE hooks
95
+ account.balance = 200.00
96
+ account.save() # for existing instances
97
+
98
+ # This will hook BEFORE_DELETE and AFTER_DELETE hooks
99
+ account.delete()
100
+ ```
101
+
102
+ ### Bulk Operations
103
+
104
+ Bulk operations also hook the same hooks:
105
+
106
+ ```python
107
+ # Bulk create - hooks 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 - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
115
+ for account in accounts:
116
+ account.balance *= 1.1
117
+ Account.objects.bulk_update(accounts) # fields are auto-detected
118
+
119
+ # Bulk delete - hooks 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 - hooks BEFORE_UPDATE and AFTER_UPDATE hooks
129
+ Account.objects.update(balance=0.00)
130
+
131
+ # Queryset delete - hooks BEFORE_DELETE and AFTER_DELETE hooks
132
+ Account.objects.delete()
133
+ ```
134
+
135
+ ### Subquery Support in Updates
136
+
137
+ When using `Subquery` objects in update operations, the computed values are automatically available in hooks. The system efficiently refreshes all instances in bulk for optimal performance:
138
+
139
+ ```python
140
+ from django.db.models import Subquery, OuterRef, Sum
141
+
142
+ def aggregate_revenue_by_ids(self, ids: Iterable[int]) -> int:
143
+ return self.find_by_ids(ids).update(
144
+ revenue=Subquery(
145
+ FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef("pk"))
146
+ .filter(is_revenue=True)
147
+ .values("daily_financial_aggregate_id")
148
+ .annotate(revenue_sum=Sum("amount"))
149
+ .values("revenue_sum")[:1],
150
+ ),
151
+ )
152
+
153
+ # In your hooks, you can now access the computed revenue value:
154
+ class FinancialAggregateHooks(Hook):
155
+ @hook(AFTER_UPDATE, model=DailyFinancialAggregate)
156
+ def log_revenue_update(self, new_records, old_records):
157
+ for new_record in new_records:
158
+ # This will now contain the computed value, not the Subquery object
159
+ print(f"Updated revenue: {new_record.revenue}")
160
+
161
+ # Bulk operations are optimized for performance:
162
+ def bulk_aggregate_revenue(self, ids: Iterable[int]) -> int:
163
+ # This will efficiently refresh all instances in a single query
164
+ return self.filter(id__in=ids).update(
165
+ revenue=Subquery(
166
+ FinancialTransaction.objects.filter(daily_financial_aggregate_id=OuterRef("pk"))
167
+ .filter(is_revenue=True)
168
+ .values("daily_financial_aggregate_id")
169
+ .annotate(revenue_sum=Sum("amount"))
170
+ .values("revenue_sum")[:1],
171
+ ),
172
+ )
173
+ ```
174
+
175
+ ## 🧠 Why?
176
+
177
+ Django's `bulk_` methods bypass signals and `save()`. This package fills that gap with:
178
+
179
+ - Hooks that behave consistently across creates/updates/deletes
180
+ - **NEW**: Individual model lifecycle hooks that work with `save()` and `delete()`
181
+ - Scalable performance via chunking (default 200)
182
+ - Support for `@hook` decorators and centralized hook classes
183
+ - **NEW**: Automatic hook hooking for admin operations and other Django features
184
+ - **NEW**: Proper ordering guarantees for old/new record pairing in hooks (Salesforce-like behavior)
185
+
186
+ ## 📦 Usage Examples
187
+
188
+ ### Individual Model Operations
189
+
190
+ ```python
191
+ # These automatically hook hooks
192
+ account = Account.objects.create(balance=100.00)
193
+ account.balance = 200.00
194
+ account.save()
195
+ account.delete()
196
+ ```
197
+
198
+ ### Bulk Operations
199
+
200
+ ```python
201
+ # These also hook hooks
202
+ Account.objects.bulk_create(accounts)
203
+ Account.objects.bulk_update(accounts) # fields are auto-detected
204
+ Account.objects.bulk_delete(accounts)
205
+ ```
206
+
207
+ ### Advanced Hook Usage
208
+
209
+ ```python
210
+ class AdvancedAccountHooks(Hook):
211
+ @hook(BEFORE_UPDATE, model=Account, condition=WhenFieldHasChanged("balance"))
212
+ def validate_balance_change(self, new_records, old_records):
213
+ for new_account, old_account in zip(new_records, old_records):
214
+ if new_account.balance < 0 and old_account.balance >= 0:
215
+ raise ValueError("Cannot set negative balance")
216
+
217
+ @hook(AFTER_CREATE, model=Account)
218
+ def send_welcome_email(self, new_records, old_records):
219
+ for account in new_records:
220
+ # Send welcome email logic here
221
+ pass
222
+ ```
223
+
224
+ ### Salesforce-like Ordering Guarantees
225
+
226
+ The system ensures that `old_records` and `new_records` are always properly paired, regardless of the order in which you pass objects to bulk operations:
227
+
228
+ ```python
229
+ class LoanAccountHooks(Hook):
230
+ @hook(BEFORE_UPDATE, model=LoanAccount)
231
+ def validate_account_number(self, new_records, old_records):
232
+ # old_records[i] always corresponds to new_records[i]
233
+ for new_account, old_account in zip(new_records, old_records):
234
+ if old_account.account_number != new_account.account_number:
235
+ raise ValidationError("Account number cannot be changed")
236
+
237
+ # This works correctly even with reordered objects:
238
+ accounts = [account1, account2, account3] # IDs: 1, 2, 3
239
+ reordered = [account3, account1, account2] # IDs: 3, 1, 2
240
+
241
+ # The hook will still receive properly paired old/new records
242
+ LoanAccount.objects.bulk_update(reordered) # fields are auto-detected
243
+ ```
244
+
245
+ ## 🧩 Integration with Other Managers
246
+
247
+ ### Recommended: QuerySet-based Composition (New Approach)
248
+
249
+ For the best compatibility and to avoid inheritance conflicts, use the queryset-based composition approach:
250
+
251
+ ```python
252
+ from django_bulk_hooks.queryset import HookQuerySet
253
+ from queryable_properties.managers import QueryablePropertiesManager
254
+
255
+ class MyManager(QueryablePropertiesManager):
256
+ """Manager that combines queryable properties with hooks"""
257
+
258
+ def get_queryset(self):
259
+ # Get the QueryableProperties QuerySet
260
+ qs = super().get_queryset()
261
+ # Apply hooks on top of it
262
+ return HookQuerySet.with_hooks(qs)
263
+
264
+ class Article(models.Model):
265
+ title = models.CharField(max_length=100)
266
+ published = models.BooleanField(default=False)
267
+
268
+ objects = MyManager()
269
+
270
+ # This gives you both queryable properties AND hooks
271
+ # No inheritance conflicts, no MRO issues!
272
+ ```
273
+
274
+ ### Alternative: Explicit Hook Application
275
+
276
+ For more control, you can apply hooks explicitly:
277
+
278
+ ```python
279
+ class MyManager(QueryablePropertiesManager):
280
+ def get_queryset(self):
281
+ return super().get_queryset()
282
+
283
+ def with_hooks(self):
284
+ """Apply hooks to this queryset"""
285
+ return HookQuerySet.with_hooks(self.get_queryset())
286
+
287
+ # Usage:
288
+ Article.objects.with_hooks().filter(published=True).update(title="Updated")
289
+ ```
290
+
291
+ ### Legacy: Manager Inheritance (Not Recommended)
292
+
293
+ The old inheritance approach still works but is not recommended due to potential MRO conflicts:
294
+
295
+ ```python
296
+ from django_bulk_hooks.manager import BulkHookManager
297
+ from queryable_properties.managers import QueryablePropertiesManager
298
+
299
+ class MyManager(BulkHookManager, QueryablePropertiesManager):
300
+ pass # ⚠️ Can cause inheritance conflicts
301
+ ```
302
+
303
+ **Why the new approach is better:**
304
+ - ✅ No inheritance conflicts
305
+ - ✅ No MRO (Method Resolution Order) issues
306
+ - ✅ Works with any manager combination
307
+ - ✅ Cleaner and more maintainable
308
+ - ✅ Follows Django's queryset enhancement patterns
309
+
310
+ Framework needs to:
311
+ Register these methods
312
+ Know when to execute them (BEFORE_UPDATE, AFTER_UPDATE)
313
+ Execute them in priority order
314
+ Pass ChangeSet to them
315
+ Handle errors (rollback on failure)
316
+
317
+ ## 📝 License
318
+
319
+ MIT © 2024 Augend / Konrad Beck
320
+
@@ -0,0 +1,27 @@
1
+ django_bulk_hooks/__init__.py,sha256=ZKjEi9Sj3lRr3hcEfknXAr1UXXwERzUCNgMkNXhW0mk,2119
2
+ django_bulk_hooks/changeset.py,sha256=qnMD3bR2cNh8ZM8J6ASR5ly5Rjx-tPzXBYkqIjKGW98,6568
3
+ django_bulk_hooks/conditions.py,sha256=ar4pGjtxLKmgSIlO4S6aZFKmaBNchLtxMmWpkn4g9RU,8114
4
+ django_bulk_hooks/constants.py,sha256=PxpEETaO6gdENcTPoXS586lerGKVP3nmjpDvOkmhYxI,509
5
+ django_bulk_hooks/context.py,sha256=mqaC5-yESDTA5ruI7fuXlt8qSgKuOFp0mjq7h1-4HdQ,1926
6
+ django_bulk_hooks/decorators.py,sha256=TdkO4FJyFrVU2zqK6Y_6JjEJ4v3nbKkk7aa22jN10sk,11994
7
+ django_bulk_hooks/dispatcher.py,sha256=EiFthoPKsgEsJEh1Xf0X2hS3-QJ1a0qFijPJ9ebng6k,26994
8
+ django_bulk_hooks/enums.py,sha256=Zo8_tJzuzZ2IKfVc7gZ-0tWPT8q1QhqZbAyoh9ZVJbs,381
9
+ django_bulk_hooks/factory.py,sha256=ezrVM5U023KZqOBbJXb6lYUP-pE7WJmi8Olh2Ew-7RA,18085
10
+ django_bulk_hooks/handler.py,sha256=SRCrMzgolrruTkvMnYBFmXLR-ABiw0JiH3605PEdCZM,4207
11
+ django_bulk_hooks/helpers.py,sha256=3rH9TJkdCPF7Vu--0tDaZzJg9Yxcv7yoSF1K1_-0psQ,8048
12
+ django_bulk_hooks/manager.py,sha256=sn4ALCuxRydjIJ91kB81Dhj4PitwytGa4wzxPos4I2Q,4096
13
+ django_bulk_hooks/models.py,sha256=H16AuIiRjkwTD-YDA9S_sMYfAzAFoBgKqiq4TvJuJ9M,3325
14
+ django_bulk_hooks/operations/__init__.py,sha256=BtJYjmRhe_sScivLsniDaZmBkm0ZLvcmzXFKL7QY2Xg,550
15
+ django_bulk_hooks/operations/analyzer.py,sha256=Fw4rjkhpfT8b2A4c7CSMfFRtLUFVimCCz_eGIBtcNiI,15126
16
+ django_bulk_hooks/operations/bulk_executor.py,sha256=URoZVkR-vKMgwLRrNe9aqoRV2i6vLJPr6N-E8CW5npY,29116
17
+ django_bulk_hooks/operations/coordinator.py,sha256=3n9bKpcn3_X-zos0tYX6JWS77JleeYMVawZu2DZ1LC4,34973
18
+ django_bulk_hooks/operations/field_utils.py,sha256=o07oKib6cN8YfFB13O-1YksFr_W9LMlA4Q0rKYskrco,14518
19
+ django_bulk_hooks/operations/mti_handler.py,sha256=00djtjfZ0rrOfiEii8TS1aBarC0qDpCBsFfWGrljvsc,26946
20
+ django_bulk_hooks/operations/mti_plans.py,sha256=HIRJgogHPpm6MV7nZZ-sZhMLUnozpZPV2SzwQHLRzYc,3667
21
+ django_bulk_hooks/operations/record_classifier.py,sha256=It85hJC2K-UsEOLbTR-QBdY5UPV-acQIJ91TSGa7pYo,7053
22
+ django_bulk_hooks/queryset.py,sha256=tPIkNESb47fTIpTrR6xUtc-k3gCFR15W0Xt2-HmvlJo,6811
23
+ django_bulk_hooks/registry.py,sha256=4HxP1mVK2z4VzvlohbEw2359wM21UJZJYagJJ1komM0,7947
24
+ django_bulk_hooks-0.2.100.dist-info/LICENSE,sha256=dguKIcbDGeZD-vXWdLyErPUALYOvtX_fO4Zjhq481uk,1088
25
+ django_bulk_hooks-0.2.100.dist-info/METADATA,sha256=nEA9mRH3Cmy-WDAUF4M3rUOzQw4GOgOXSN_0vmCP9UY,10556
26
+ django_bulk_hooks-0.2.100.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
27
+ django_bulk_hooks-0.2.100.dist-info/RECORD,,
@@ -1,53 +0,0 @@
1
- import logging
2
-
3
- from django.core.exceptions import ValidationError
4
- from django.db import models
5
- from django_bulk_hooks.registry import get_hooks
6
- from django_bulk_hooks.conditions import safe_get_related_object, safe_get_related_attr
7
-
8
- logger = logging.getLogger(__name__)
9
-
10
-
11
- def run(model_cls, event, new_instances, original_instances=None, ctx=None):
12
- hooks = get_hooks(model_cls, event)
13
-
14
- if not hooks:
15
- return
16
-
17
- # For BEFORE_* events, run model.clean() first for validation
18
- if event.startswith("before_"):
19
- for instance in new_instances:
20
- try:
21
- instance.clean()
22
- except ValidationError as e:
23
- logger.error("Validation failed for %s: %s", instance, e)
24
- raise
25
- except Exception as e:
26
- # Handle RelatedObjectDoesNotExist and other exceptions that might occur
27
- # when accessing foreign key fields on unsaved objects
28
- if "RelatedObjectDoesNotExist" in str(type(e).__name__):
29
- logger.debug("Skipping validation for unsaved object with unset foreign keys: %s", e)
30
- continue
31
- else:
32
- logger.error("Unexpected error during validation for %s: %s", instance, e)
33
- raise
34
-
35
- for handler_cls, method_name, condition, priority in hooks:
36
- handler_instance = handler_cls()
37
- func = getattr(handler_instance, method_name)
38
-
39
- to_process_new = []
40
- to_process_old = []
41
-
42
- for new, original in zip(
43
- new_instances,
44
- original_instances or [None] * len(new_instances),
45
- strict=True,
46
- ):
47
- if not condition or condition.check(new, original):
48
- to_process_new.append(new)
49
- to_process_old.append(original)
50
-
51
- if to_process_new:
52
- # Call the function with keyword arguments
53
- func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)