django-bulk-hooks 0.1.50__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-bulk-hooks might be problematic. Click here for more details.

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