django-bulk-hooks 0.1.231__tar.gz → 0.1.232__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 (26) hide show
  1. {django_bulk_hooks-0.1.231 → django_bulk_hooks-0.1.232}/PKG-INFO +14 -30
  2. {django_bulk_hooks-0.1.231 → django_bulk_hooks-0.1.232}/README.md +13 -29
  3. django_bulk_hooks-0.1.232/django_bulk_hooks/__init__.py +4 -0
  4. {django_bulk_hooks-0.1.231 → django_bulk_hooks-0.1.232}/django_bulk_hooks/conditions.py +30 -33
  5. django_bulk_hooks-0.1.232/django_bulk_hooks/context.py +53 -0
  6. django_bulk_hooks-0.1.232/django_bulk_hooks/decorators.py +137 -0
  7. django_bulk_hooks-0.1.232/django_bulk_hooks/engine.py +74 -0
  8. django_bulk_hooks-0.1.232/django_bulk_hooks/enums.py +17 -0
  9. django_bulk_hooks-0.1.232/django_bulk_hooks/handler.py +167 -0
  10. django_bulk_hooks-0.1.232/django_bulk_hooks/manager.py +113 -0
  11. {django_bulk_hooks-0.1.231 → django_bulk_hooks-0.1.232}/django_bulk_hooks/models.py +15 -51
  12. django_bulk_hooks-0.1.232/django_bulk_hooks/priority.py +16 -0
  13. {django_bulk_hooks-0.1.231 → django_bulk_hooks-0.1.232}/django_bulk_hooks/queryset.py +166 -308
  14. django_bulk_hooks-0.1.232/django_bulk_hooks/registry.py +34 -0
  15. {django_bulk_hooks-0.1.231 → django_bulk_hooks-0.1.232}/pyproject.toml +1 -1
  16. django_bulk_hooks-0.1.231/django_bulk_hooks/__init__.py +0 -15
  17. django_bulk_hooks-0.1.231/django_bulk_hooks/context.py +0 -81
  18. django_bulk_hooks-0.1.231/django_bulk_hooks/decorators.py +0 -240
  19. django_bulk_hooks-0.1.231/django_bulk_hooks/engine.py +0 -160
  20. django_bulk_hooks-0.1.231/django_bulk_hooks/enums.py +0 -14
  21. django_bulk_hooks-0.1.231/django_bulk_hooks/handler.py +0 -134
  22. django_bulk_hooks-0.1.231/django_bulk_hooks/manager.py +0 -135
  23. django_bulk_hooks-0.1.231/django_bulk_hooks/priority.py +0 -16
  24. django_bulk_hooks-0.1.231/django_bulk_hooks/registry.py +0 -201
  25. {django_bulk_hooks-0.1.231 → django_bulk_hooks-0.1.232}/LICENSE +0 -0
  26. {django_bulk_hooks-0.1.231 → django_bulk_hooks-0.1.232}/django_bulk_hooks/constants.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-bulk-hooks
3
- Version: 0.1.231
3
+ Version: 0.1.232
4
4
  Summary: Hook-style hooks for Django bulk operations like bulk_create and bulk_update.
5
5
  License: MIT
6
6
  Keywords: django,bulk,hooks
@@ -59,37 +59,21 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
59
59
  from .models import Account
60
60
 
61
61
  class AccountHooks(Hook):
62
- @hook(AFTER_UPDATE, condition=WhenFieldHasChanged('balance'))
63
- def _notify_balance_change(self, new_records, old_records, **kwargs):
64
- for new_record, old_record in zip(new_records, old_records):
65
- if old_record and new_record.balance != old_record.balance:
66
- print(f"Balance changed from {old_record.balance} to {new_record.balance}")
67
- ```
68
-
69
- ### Bulk Operations with Hooks
70
-
71
- ```python
72
- # For complete hook execution, use the update() method
73
- accounts = Account.objects.filter(active=True)
74
- accounts.update(balance=1000) # Runs all hooks automatically
75
-
76
- # For bulk operations with hooks
77
- accounts = Account.objects.filter(active=True)
78
- instances = list(accounts)
79
-
80
- # bulk_update now runs complete hook cycle by default
81
- accounts.bulk_update(instances, ['balance']) # Runs VALIDATE → BEFORE → DB update → AFTER
82
-
83
- # To skip hooks (for performance or when called from update())
84
- accounts.bulk_update(instances, ['balance'], bypass_hooks=True)
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])
85
75
  ```
86
76
 
87
- ### Understanding Hook Execution
88
-
89
- - **`update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
90
- - **`bulk_update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
91
- - **`bypass_hooks=True`**: Skips all hooks for performance or to prevent double execution
92
-
93
77
  ## 🛠 Supported Hook Events
94
78
 
95
79
  - `BEFORE_CREATE`, `AFTER_CREATE`
@@ -40,37 +40,21 @@ from django_bulk_hooks.conditions import WhenFieldHasChanged
40
40
  from .models import Account
41
41
 
42
42
  class AccountHooks(Hook):
43
- @hook(AFTER_UPDATE, condition=WhenFieldHasChanged('balance'))
44
- def _notify_balance_change(self, new_records, old_records, **kwargs):
45
- for new_record, old_record in zip(new_records, old_records):
46
- if old_record and new_record.balance != old_record.balance:
47
- print(f"Balance changed from {old_record.balance} to {new_record.balance}")
48
- ```
49
-
50
- ### Bulk Operations with Hooks
51
-
52
- ```python
53
- # For complete hook execution, use the update() method
54
- accounts = Account.objects.filter(active=True)
55
- accounts.update(balance=1000) # Runs all hooks automatically
56
-
57
- # For bulk operations with hooks
58
- accounts = Account.objects.filter(active=True)
59
- instances = list(accounts)
60
-
61
- # bulk_update now runs complete hook cycle by default
62
- accounts.bulk_update(instances, ['balance']) # Runs VALIDATE → BEFORE → DB update → AFTER
63
-
64
- # To skip hooks (for performance or when called from update())
65
- accounts.bulk_update(instances, ['balance'], bypass_hooks=True)
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])
66
56
  ```
67
57
 
68
- ### Understanding Hook Execution
69
-
70
- - **`update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
71
- - **`bulk_update()` method**: Runs complete hook cycle (VALIDATE → BEFORE → DB update → AFTER)
72
- - **`bypass_hooks=True`**: Skips all hooks for performance or to prevent double execution
73
-
74
58
  ## 🛠 Supported Hook Events
75
59
 
76
60
  - `BEFORE_CREATE`, `AFTER_CREATE`
@@ -0,0 +1,4 @@
1
+ from django_bulk_hooks.handler import Hook
2
+ from django_bulk_hooks.manager import BulkHookManager
3
+
4
+ __all__ = ["BulkHookManager", "Hook"]
@@ -1,14 +1,11 @@
1
1
  import logging
2
- from typing import Any, Optional
3
2
 
4
3
  logger = logging.getLogger(__name__)
5
4
 
6
5
 
7
- def resolve_dotted_attr(instance: Any, dotted_path: str) -> Any:
6
+ def resolve_dotted_attr(instance, dotted_path):
8
7
  """
9
8
  Recursively resolve a dotted attribute path, e.g., "type.category".
10
-
11
- Returns None if any intermediate value is None or missing.
12
9
  """
13
10
  for attr in dotted_path.split("."):
14
11
  if instance is None:
@@ -18,29 +15,29 @@ def resolve_dotted_attr(instance: Any, dotted_path: str) -> Any:
18
15
 
19
16
 
20
17
  class HookCondition:
21
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
18
+ def check(self, instance, original_instance=None):
22
19
  raise NotImplementedError
23
20
 
24
- def __call__(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
21
+ def __call__(self, instance, original_instance=None):
25
22
  return self.check(instance, original_instance)
26
23
 
27
- def __and__(self, other: "HookCondition") -> "HookCondition":
24
+ def __and__(self, other):
28
25
  return AndCondition(self, other)
29
26
 
30
- def __or__(self, other: "HookCondition") -> "HookCondition":
27
+ def __or__(self, other):
31
28
  return OrCondition(self, other)
32
29
 
33
- def __invert__(self) -> "HookCondition":
30
+ def __invert__(self):
34
31
  return NotCondition(self)
35
32
 
36
33
 
37
34
  class IsNotEqual(HookCondition):
38
- def __init__(self, field: str, value: Any, only_on_change: bool = False):
35
+ def __init__(self, field, value, only_on_change=False):
39
36
  self.field = field
40
37
  self.value = value
41
38
  self.only_on_change = only_on_change
42
39
 
43
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
40
+ def check(self, instance, original_instance=None):
44
41
  current = resolve_dotted_attr(instance, self.field)
45
42
  if self.only_on_change:
46
43
  if original_instance is None:
@@ -52,12 +49,12 @@ class IsNotEqual(HookCondition):
52
49
 
53
50
 
54
51
  class IsEqual(HookCondition):
55
- def __init__(self, field: str, value: Any, only_on_change: bool = False):
52
+ def __init__(self, field, value, only_on_change=False):
56
53
  self.field = field
57
54
  self.value = value
58
55
  self.only_on_change = only_on_change
59
56
 
60
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
57
+ def check(self, instance, original_instance=None):
61
58
  current = resolve_dotted_attr(instance, self.field)
62
59
  if self.only_on_change:
63
60
  if original_instance is None:
@@ -69,11 +66,11 @@ class IsEqual(HookCondition):
69
66
 
70
67
 
71
68
  class HasChanged(HookCondition):
72
- def __init__(self, field: str, has_changed: bool = True):
69
+ def __init__(self, field, has_changed=True):
73
70
  self.field = field
74
71
  self.has_changed = has_changed
75
72
 
76
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
73
+ def check(self, instance, original_instance=None):
77
74
  if not original_instance:
78
75
  return False
79
76
 
@@ -88,7 +85,7 @@ class HasChanged(HookCondition):
88
85
 
89
86
 
90
87
  class WasEqual(HookCondition):
91
- def __init__(self, field: str, value: Any, only_on_change: bool = False):
88
+ def __init__(self, field, value, only_on_change=False):
92
89
  """
93
90
  Check if a field's original value was `value`.
94
91
  If only_on_change is True, only return True when the field has changed away from that value.
@@ -97,7 +94,7 @@ class WasEqual(HookCondition):
97
94
  self.value = value
98
95
  self.only_on_change = only_on_change
99
96
 
100
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
97
+ def check(self, instance, original_instance=None):
101
98
  if original_instance is None:
102
99
  return False
103
100
  previous = resolve_dotted_attr(original_instance, self.field)
@@ -109,7 +106,7 @@ class WasEqual(HookCondition):
109
106
 
110
107
 
111
108
  class ChangesTo(HookCondition):
112
- def __init__(self, field: str, value: Any):
109
+ def __init__(self, field, value):
113
110
  """
114
111
  Check if a field's value has changed to `value`.
115
112
  Only returns True when original value != value and current value == value.
@@ -117,7 +114,7 @@ class ChangesTo(HookCondition):
117
114
  self.field = field
118
115
  self.value = value
119
116
 
120
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
117
+ def check(self, instance, original_instance=None):
121
118
  if original_instance is None:
122
119
  return False
123
120
  previous = resolve_dotted_attr(original_instance, self.field)
@@ -126,70 +123,70 @@ class ChangesTo(HookCondition):
126
123
 
127
124
 
128
125
  class IsGreaterThan(HookCondition):
129
- def __init__(self, field: str, value: Any):
126
+ def __init__(self, field, value):
130
127
  self.field = field
131
128
  self.value = value
132
129
 
133
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
130
+ def check(self, instance, original_instance=None):
134
131
  current = resolve_dotted_attr(instance, self.field)
135
132
  return current is not None and current > self.value
136
133
 
137
134
 
138
135
  class IsGreaterThanOrEqual(HookCondition):
139
- def __init__(self, field: str, value: Any):
136
+ def __init__(self, field, value):
140
137
  self.field = field
141
138
  self.value = value
142
139
 
143
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
140
+ def check(self, instance, original_instance=None):
144
141
  current = resolve_dotted_attr(instance, self.field)
145
142
  return current is not None and current >= self.value
146
143
 
147
144
 
148
145
  class IsLessThan(HookCondition):
149
- def __init__(self, field: str, value: Any):
146
+ def __init__(self, field, value):
150
147
  self.field = field
151
148
  self.value = value
152
149
 
153
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
150
+ def check(self, instance, original_instance=None):
154
151
  current = resolve_dotted_attr(instance, self.field)
155
152
  return current is not None and current < self.value
156
153
 
157
154
 
158
155
  class IsLessThanOrEqual(HookCondition):
159
- def __init__(self, field: str, value: Any):
156
+ def __init__(self, field, value):
160
157
  self.field = field
161
158
  self.value = value
162
159
 
163
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
160
+ def check(self, instance, original_instance=None):
164
161
  current = resolve_dotted_attr(instance, self.field)
165
162
  return current is not None and current <= self.value
166
163
 
167
164
 
168
165
  class AndCondition(HookCondition):
169
- def __init__(self, cond1: HookCondition, cond2: HookCondition):
166
+ def __init__(self, cond1, cond2):
170
167
  self.cond1 = cond1
171
168
  self.cond2 = cond2
172
169
 
173
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
170
+ def check(self, instance, original_instance=None):
174
171
  return self.cond1.check(instance, original_instance) and self.cond2.check(
175
172
  instance, original_instance
176
173
  )
177
174
 
178
175
 
179
176
  class OrCondition(HookCondition):
180
- def __init__(self, cond1: HookCondition, cond2: HookCondition):
177
+ def __init__(self, cond1, cond2):
181
178
  self.cond1 = cond1
182
179
  self.cond2 = cond2
183
180
 
184
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
181
+ def check(self, instance, original_instance=None):
185
182
  return self.cond1.check(instance, original_instance) or self.cond2.check(
186
183
  instance, original_instance
187
184
  )
188
185
 
189
186
 
190
187
  class NotCondition(HookCondition):
191
- def __init__(self, cond: HookCondition):
188
+ def __init__(self, cond):
192
189
  self.cond = cond
193
190
 
194
- def check(self, instance: Any, original_instance: Optional[Any] = None) -> bool:
191
+ def check(self, instance, original_instance=None):
195
192
  return not self.cond.check(instance, original_instance)
@@ -0,0 +1,53 @@
1
+ import threading
2
+ from collections import deque
3
+ from django_bulk_hooks.handler import hook_vars
4
+
5
+
6
+ _hook_context = threading.local()
7
+
8
+
9
+ def get_hook_queue():
10
+ if not hasattr(_hook_context, "queue"):
11
+ _hook_context.queue = deque()
12
+ return _hook_context.queue
13
+
14
+
15
+ def set_bypass_hooks(bypass_hooks):
16
+ """Set the current bypass_hooks state for the current thread."""
17
+ _hook_context.bypass_hooks = bypass_hooks
18
+
19
+
20
+ def get_bypass_hooks():
21
+ """Get the current bypass_hooks state for the current thread."""
22
+ return getattr(_hook_context, 'bypass_hooks', False)
23
+
24
+
25
+ class HookContext:
26
+ def __init__(self, model, bypass_hooks=False):
27
+ self.model = model
28
+ self.bypass_hooks = bypass_hooks
29
+ # Set the thread-local bypass state when creating a context
30
+ set_bypass_hooks(bypass_hooks)
31
+
32
+ @property
33
+ def is_executing(self):
34
+ """
35
+ Check if we're currently in a hook execution context.
36
+ Similar to Salesforce's Trigger.isExecuting.
37
+ Use this to prevent infinite recursion in hooks.
38
+ """
39
+ return hasattr(hook_vars, 'event') and hook_vars.event is not None
40
+
41
+ @property
42
+ def current_event(self):
43
+ """
44
+ Get the current hook event being executed.
45
+ """
46
+ return getattr(hook_vars, 'event', None)
47
+
48
+ @property
49
+ def execution_depth(self):
50
+ """
51
+ Get the current execution depth to detect deep recursion.
52
+ """
53
+ return getattr(hook_vars, 'depth', 0)
@@ -0,0 +1,137 @@
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
+ from django_bulk_hooks.registry import register_hook
7
+
8
+
9
+ def hook(event, *, model, condition=None, priority=DEFAULT_PRIORITY):
10
+ """
11
+ Decorator to annotate a method with multiple hooks hook registrations.
12
+ If no priority is provided, uses Priority.NORMAL (50).
13
+ """
14
+
15
+ def decorator(fn):
16
+ if not hasattr(fn, "hooks_hooks"):
17
+ fn.hooks_hooks = []
18
+ fn.hooks_hooks.append((model, event, condition, priority))
19
+ return fn
20
+
21
+ return decorator
22
+
23
+
24
+ def select_related(*related_fields):
25
+ """
26
+ Decorator that preloads related fields in-place on `new_records`, before the hook logic runs.
27
+
28
+ - Works with instance methods (resolves `self`)
29
+ - Avoids replacing model instances
30
+ - Populates Django's relation cache to avoid extra queries
31
+ """
32
+
33
+ def decorator(func):
34
+ sig = inspect.signature(func)
35
+
36
+ @wraps(func)
37
+ def wrapper(*args, **kwargs):
38
+ bound = sig.bind_partial(*args, **kwargs)
39
+ bound.apply_defaults()
40
+
41
+ if "new_records" not in bound.arguments:
42
+ raise TypeError(
43
+ "@preload_related requires a 'new_records' argument in the decorated function"
44
+ )
45
+
46
+ new_records = bound.arguments["new_records"]
47
+
48
+ if not isinstance(new_records, list):
49
+ raise TypeError(
50
+ f"@preload_related expects a list of model instances, got {type(new_records)}"
51
+ )
52
+
53
+ if not new_records:
54
+ return func(*args, **kwargs)
55
+
56
+ # Determine which instances actually need preloading
57
+ model_cls = new_records[0].__class__
58
+ ids_to_fetch = []
59
+ for obj in new_records:
60
+ if obj.pk is None:
61
+ continue
62
+ # if any related field is not already cached on the instance,
63
+ # mark it for fetching
64
+ if any(field not in obj._state.fields_cache for field in related_fields):
65
+ ids_to_fetch.append(obj.pk)
66
+
67
+ fetched = {}
68
+ if ids_to_fetch:
69
+ # Use the base manager to avoid recursion
70
+ fetched = model_cls._base_manager.select_related(*related_fields).in_bulk(ids_to_fetch)
71
+
72
+ for obj in new_records:
73
+ preloaded = fetched.get(obj.pk)
74
+ if not preloaded:
75
+ continue
76
+ for field in related_fields:
77
+ if field in obj._state.fields_cache:
78
+ # don't override values that were explicitly set or already loaded
79
+ continue
80
+ if "." in field:
81
+ raise ValueError(
82
+ f"@preload_related does not support nested fields like '{field}'"
83
+ )
84
+
85
+ try:
86
+ f = model_cls._meta.get_field(field)
87
+ if not (
88
+ f.is_relation and not f.many_to_many and not f.one_to_many
89
+ ):
90
+ continue
91
+ except FieldDoesNotExist:
92
+ continue
93
+
94
+ try:
95
+ rel_obj = getattr(preloaded, field)
96
+ setattr(obj, field, rel_obj)
97
+ obj._state.fields_cache[field] = rel_obj
98
+ except AttributeError:
99
+ pass
100
+
101
+ return func(*bound.args, **bound.kwargs)
102
+
103
+ return wrapper
104
+
105
+ return decorator
106
+
107
+
108
+ def bulk_hook(model_cls, event, when=None, priority=None):
109
+ """
110
+ Decorator to register a bulk hook for a model.
111
+
112
+ Args:
113
+ model_cls: The model class to hook into
114
+ event: The event to hook into (e.g., BEFORE_UPDATE, AFTER_UPDATE)
115
+ when: Optional condition for when the hook should run
116
+ priority: Optional priority for hook execution order
117
+ """
118
+ def decorator(func):
119
+ # Create a simple handler class for the function
120
+ class FunctionHandler:
121
+ def __init__(self):
122
+ self.func = func
123
+
124
+ def handle(self, new_instances, original_instances):
125
+ return self.func(new_instances, original_instances)
126
+
127
+ # Register the hook using the registry
128
+ register_hook(
129
+ model=model_cls,
130
+ event=event,
131
+ handler_cls=FunctionHandler,
132
+ method_name='handle',
133
+ condition=when,
134
+ priority=priority or DEFAULT_PRIORITY,
135
+ )
136
+ return func
137
+ return decorator
@@ -0,0 +1,74 @@
1
+ import logging
2
+
3
+ from django.core.exceptions import ValidationError
4
+
5
+ from django_bulk_hooks.registry import get_hooks
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def run(model_cls, event, new_records, old_records=None, ctx=None):
11
+ """
12
+ Run hooks for a given model, event, and records.
13
+ """
14
+ if not new_records:
15
+ return
16
+
17
+ # Get hooks for this model and event
18
+ hooks = get_hooks(model_cls, event)
19
+
20
+ if not hooks:
21
+ return
22
+
23
+ import traceback
24
+
25
+ stack = traceback.format_stack()
26
+ logger.debug(f"engine.run {model_cls.__name__}.{event} {len(new_records)} records")
27
+
28
+ # Check if we're in a bypass context
29
+ if ctx and hasattr(ctx, 'bypass_hooks') and ctx.bypass_hooks:
30
+ logger.debug("engine.run bypassed")
31
+ return
32
+
33
+ # For BEFORE_* events, run model.clean() first for validation
34
+ if event.startswith("before_"):
35
+ for instance in new_records:
36
+ try:
37
+ instance.clean()
38
+ except ValidationError as e:
39
+ logger.error("Validation failed for %s: %s", instance, e)
40
+ raise
41
+
42
+ # Process hooks
43
+ for handler_cls, method_name, condition, priority in hooks:
44
+ logger.debug(f"Processing {handler_cls.__name__}.{method_name}")
45
+ handler_instance = handler_cls()
46
+ func = getattr(handler_instance, method_name)
47
+
48
+ to_process_new = []
49
+ to_process_old = []
50
+
51
+ for new, original in zip(
52
+ new_records,
53
+ old_records or [None] * len(new_records),
54
+ strict=True,
55
+ ):
56
+ if not condition:
57
+ to_process_new.append(new)
58
+ to_process_old.append(original)
59
+ else:
60
+ condition_result = condition.check(new, original)
61
+ if condition_result:
62
+ to_process_new.append(new)
63
+ to_process_old.append(original)
64
+
65
+ if to_process_new:
66
+ logger.debug(f"Executing {handler_cls.__name__}.{method_name} for {len(to_process_new)} records")
67
+ try:
68
+ func(
69
+ new_records=to_process_new,
70
+ old_records=to_process_old if any(to_process_old) else None,
71
+ )
72
+ except Exception as e:
73
+ logger.debug(f"Hook execution failed: {e}")
74
+ raise
@@ -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