django-bulk-hooks 0.1.101__py3-none-any.whl → 0.1.103__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,19 +1,13 @@
1
1
  import logging
2
2
 
3
3
  from django.core.exceptions import ValidationError
4
- from django.db import models
4
+
5
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
6
 
8
7
  logger = logging.getLogger(__name__)
9
8
 
10
9
 
11
- # Cache for hook handlers to avoid creating them repeatedly
12
- _handler_cache = {}
13
-
14
10
  def run(model_cls, event, new_instances, original_instances=None, ctx=None):
15
- # Get hooks from cache or fetch them
16
- cache_key = (model_cls, event)
17
11
  hooks = get_hooks(model_cls, event)
18
12
 
19
13
  if not hooks:
@@ -27,112 +21,23 @@ def run(model_cls, event, new_instances, original_instances=None, ctx=None):
27
21
  except ValidationError as e:
28
22
  logger.error("Validation failed for %s: %s", instance, e)
29
23
  raise
30
- except Exception as e:
31
- # Handle RelatedObjectDoesNotExist and other exceptions that might occur
32
- # when accessing foreign key fields on unsaved objects
33
- if "RelatedObjectDoesNotExist" in str(type(e).__name__):
34
- logger.debug("Skipping validation for unsaved object with unset foreign keys: %s", e)
35
- continue
36
- else:
37
- logger.error("Unexpected error during validation for %s: %s", instance, e)
38
- raise
39
-
40
- # Pre-create None list for originals if needed
41
- if original_instances is None:
42
- original_instances = [None] * len(new_instances)
43
-
44
- # Process all hooks
45
- for handler_cls, method_name, condition, priority, select_related_fields in hooks:
46
- # Get or create handler instance from cache
47
- handler_key = (handler_cls, method_name)
48
- if handler_key not in _handler_cache:
49
- handler_instance = handler_cls()
50
- func = getattr(handler_instance, method_name)
51
- _handler_cache[handler_key] = (handler_instance, func)
52
- else:
53
- handler_instance, func = _handler_cache[handler_key]
54
-
55
- # Apply select_related if specified
56
- if select_related_fields:
57
- new_instances_with_related = _apply_select_related(new_instances, select_related_fields)
58
- else:
59
- new_instances_with_related = new_instances
60
-
61
- # Filter instances based on condition
62
- if condition:
63
- to_process_new = []
64
- to_process_old = []
65
-
66
- logger.debug(f"Checking condition {condition.__class__.__name__} for {len(new_instances)} instances")
67
- for new, original in zip(new_instances_with_related, original_instances, strict=True):
68
- logger.debug(f"Checking instance {new.__class__.__name__}(pk={new.pk})")
69
- try:
70
- matches = condition.check(new, original)
71
- logger.debug(f"Condition check result: {matches}")
72
- if matches:
73
- to_process_new.append(new)
74
- to_process_old.append(original)
75
- except Exception as e:
76
- logger.error(f"Error checking condition: {e}")
77
- raise
78
-
79
- # Only call if we have matching instances
80
- if to_process_new:
81
- logger.debug(f"Running hook for {len(to_process_new)} matching instances")
82
- func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
83
- else:
84
- logger.debug("No instances matched condition")
85
- else:
86
- # No condition, process all instances
87
- logger.debug("No condition, processing all instances")
88
- func(new_records=new_instances_with_related, old_records=original_instances if any(original_instances) else None)
89
-
90
-
91
- def _apply_select_related(instances, related_fields):
92
- """
93
- Apply select_related to instances to prevent queries in loops.
94
- This function bulk loads related objects and caches them on the instances.
95
- """
96
- if not instances:
97
- return instances
98
-
99
- # Separate instances with and without PKs
100
- instances_with_pk = [obj for obj in instances if obj.pk is not None]
101
- instances_without_pk = [obj for obj in instances if obj.pk is None]
102
-
103
- # Bulk load related objects for instances with PKs
104
- if instances_with_pk:
105
- model_cls = instances_with_pk[0].__class__
106
- pks = [obj.pk for obj in instances_with_pk]
107
-
108
- # Bulk fetch with select_related
109
- fetched_instances = model_cls.objects.select_related(*related_fields).in_bulk(pks)
110
-
111
- # Apply cached related objects to original instances
112
- for obj in instances_with_pk:
113
- fetched_obj = fetched_instances.get(obj.pk)
114
- if fetched_obj:
115
- for field in related_fields:
116
- if field not in obj._state.fields_cache:
117
- try:
118
- rel_obj = getattr(fetched_obj, field)
119
- setattr(obj, field, rel_obj)
120
- obj._state.fields_cache[field] = rel_obj
121
- except AttributeError:
122
- pass
123
-
124
- # Handle instances without PKs (e.g., BEFORE_CREATE)
125
- for obj in instances_without_pk:
126
- for field in related_fields:
127
- # Check if the foreign key field is set
128
- fk_field_name = f"{field}_id"
129
- if hasattr(obj, fk_field_name) and getattr(obj, fk_field_name) is not None:
130
- # The foreign key ID is set, so we can try to get the related object safely
131
- rel_obj = safe_get_related_object(obj, field)
132
- if rel_obj is not None:
133
- # Ensure it's cached to prevent future queries
134
- if not hasattr(obj._state, 'fields_cache'):
135
- obj._state.fields_cache = {}
136
- obj._state.fields_cache[field] = rel_obj
137
24
 
138
- return instances
25
+ for handler_cls, method_name, condition, priority in hooks:
26
+ handler_instance = handler_cls()
27
+ func = getattr(handler_instance, method_name)
28
+
29
+ to_process_new = []
30
+ to_process_old = []
31
+
32
+ for new, original in zip(
33
+ new_instances,
34
+ original_instances or [None] * len(new_instances),
35
+ strict=True,
36
+ ):
37
+ if not condition or condition.check(new, original):
38
+ to_process_new.append(new)
39
+ to_process_old.append(original)
40
+
41
+ if to_process_new:
42
+ # Call the function with keyword arguments
43
+ func(new_records=to_process_new, old_records=to_process_old if any(to_process_old) else None)
@@ -1,4 +1,3 @@
1
- import inspect
2
1
  import logging
3
2
  import threading
4
3
  from collections import deque
@@ -75,11 +74,6 @@ class HookMeta(type):
75
74
  for model_cls, event, condition, priority in method.hooks_hooks:
76
75
  key = (model_cls, event, cls, method_name)
77
76
  if key not in HookMeta._registered:
78
- # Check if the method has been decorated with select_related
79
- select_related_fields = getattr(
80
- method, "_select_related_fields", None
81
- )
82
-
83
77
  register_hook(
84
78
  model=model_cls,
85
79
  event=event,
@@ -87,13 +81,12 @@ class HookMeta(type):
87
81
  method_name=method_name,
88
82
  condition=condition,
89
83
  priority=priority,
90
- select_related_fields=select_related_fields,
91
84
  )
92
85
  HookMeta._registered.add(key)
93
86
  return cls
94
87
 
95
88
 
96
- class HookHandler(metaclass=HookMeta):
89
+ class Hook(metaclass=HookMeta):
97
90
  @classmethod
98
91
  def handle(
99
92
  cls,
@@ -138,17 +131,10 @@ class HookHandler(metaclass=HookMeta):
138
131
  if len(old_local) < len(new_local):
139
132
  old_local += [None] * (len(new_local) - len(old_local))
140
133
 
141
- for handler_cls, method_name, condition, priority, select_related_fields in hooks:
142
- # Apply select_related if specified to prevent queries in loops
143
- if select_related_fields:
144
- from django_bulk_hooks.engine import _apply_select_related
145
- new_local_with_related = _apply_select_related(new_local, select_related_fields)
146
- else:
147
- new_local_with_related = new_local
148
-
134
+ for handler_cls, method_name, condition, priority in hooks:
149
135
  if condition is not None:
150
136
  checks = [
151
- condition.check(n, o) for n, o in zip(new_local_with_related, old_local)
137
+ condition.check(n, o) for n, o in zip(new_local, old_local)
152
138
  ]
153
139
  if not any(checks):
154
140
  continue
@@ -156,21 +142,10 @@ class HookHandler(metaclass=HookMeta):
156
142
  handler = handler_cls()
157
143
  method = getattr(handler, method_name)
158
144
 
159
- # Inspect the method signature to determine parameter order
160
- import inspect
161
-
162
- sig = inspect.signature(method)
163
- params = list(sig.parameters.keys())
164
-
165
- # Remove 'self' from params if it exists
166
- if params and params[0] == "self":
167
- params = params[1:]
168
-
169
- # Always call with keyword arguments to make order irrelevant
170
145
  try:
171
146
  method(
147
+ new_records=new_local,
172
148
  old_records=old_local,
173
- new_records=new_local_with_related,
174
149
  **kwargs,
175
150
  )
176
151
  except Exception:
@@ -17,82 +17,15 @@ from django_bulk_hooks.queryset import HookQuerySet
17
17
 
18
18
 
19
19
  class BulkHookManager(models.Manager):
20
- # Default chunk sizes - can be overridden per model
21
- DEFAULT_CHUNK_SIZE = 200
22
- DEFAULT_RELATED_CHUNK_SIZE = 500 # Higher for related object fetching
23
-
24
- def __init__(self):
25
- super().__init__()
26
- self._chunk_size = self.DEFAULT_CHUNK_SIZE
27
- self._related_chunk_size = self.DEFAULT_RELATED_CHUNK_SIZE
28
- self._prefetch_related_fields = set()
29
- self._select_related_fields = set()
30
-
31
- def configure(self, chunk_size=None, related_chunk_size=None,
32
- select_related=None, prefetch_related=None):
33
- """
34
- Configure bulk operation parameters for this manager.
35
-
36
- Args:
37
- chunk_size: Number of objects to process in each bulk operation chunk
38
- related_chunk_size: Number of objects to fetch in each related object query
39
- select_related: List of fields to always select_related in bulk operations
40
- prefetch_related: List of fields to always prefetch_related in bulk operations
41
- """
42
- if chunk_size is not None:
43
- self._chunk_size = chunk_size
44
- if related_chunk_size is not None:
45
- self._related_chunk_size = related_chunk_size
46
- if select_related:
47
- self._select_related_fields.update(select_related)
48
- if prefetch_related:
49
- self._prefetch_related_fields.update(prefetch_related)
50
-
51
- def _load_originals_optimized(self, pks, fields_to_fetch=None):
52
- """
53
- Optimized loading of original instances with smart batching and field selection.
54
- """
55
- queryset = self.model.objects.filter(pk__in=pks)
56
-
57
- # Only select specific fields if provided
58
- if fields_to_fetch:
59
- queryset = queryset.only('pk', *fields_to_fetch)
60
-
61
- # Apply configured related field optimizations
62
- if self._select_related_fields:
63
- queryset = queryset.select_related(*self._select_related_fields)
64
- if self._prefetch_related_fields:
65
- queryset = queryset.prefetch_related(*self._prefetch_related_fields)
66
-
67
- # Batch load in chunks to avoid memory issues
68
- all_originals = []
69
- for i in range(0, len(pks), self._related_chunk_size):
70
- chunk_pks = pks[i:i + self._related_chunk_size]
71
- chunk_originals = list(queryset.filter(pk__in=chunk_pks))
72
- all_originals.extend(chunk_originals)
73
-
74
- return all_originals
75
-
76
- def _get_fields_to_fetch(self, objs, fields):
77
- """
78
- Determine which fields need to be fetched based on what's being updated
79
- and what's needed for hooks.
80
- """
81
- fields_to_fetch = set(fields)
82
-
83
- # Add fields needed by registered hooks
84
- from django_bulk_hooks.registry import get_hooks
85
- hooks = get_hooks(self.model, "before_update") + get_hooks(self.model, "after_update")
86
-
87
- for handler_cls, method_name, condition, _ in hooks:
88
- if condition:
89
- # If there's a condition, we need all fields it might access
90
- fields_to_fetch.update(condition.get_required_fields())
91
-
92
- return fields_to_fetch
20
+ CHUNK_SIZE = 200
21
+
22
+ def get_queryset(self):
23
+ return HookQuerySet(self.model, using=self._db)
93
24
 
94
25
  @transaction.atomic
95
- def bulk_update(self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs):
26
+ def bulk_update(
27
+ self, objs, fields, bypass_hooks=False, bypass_validation=False, **kwargs
28
+ ):
96
29
  if not objs:
97
30
  return []
98
31
 
@@ -104,42 +37,36 @@ class BulkHookManager(models.Manager):
104
37
  )
105
38
 
106
39
  if not bypass_hooks:
107
- # Determine which fields we need to fetch
108
- fields_to_fetch = self._get_fields_to_fetch(objs, fields)
109
-
110
- # Load originals efficiently
111
- pks = [obj.pk for obj in objs if obj.pk is not None]
112
- originals = self._load_originals_optimized(pks, fields_to_fetch)
113
-
114
- # Create a mapping for quick lookup
115
- original_map = {obj.pk: obj for obj in originals}
116
-
117
- # Align originals with new instances
118
- aligned_originals = [original_map.get(obj.pk) for obj in objs]
40
+ # Load originals for hook comparison and ensure they match the order of new instances
41
+ original_map = {
42
+ obj.pk: obj for obj in model_cls.objects.filter(pk__in=[obj.pk for obj in objs])
43
+ }
44
+ originals = [original_map.get(obj.pk) for obj in objs]
119
45
 
120
46
  ctx = HookContext(model_cls)
121
47
 
122
48
  # Run validation hooks first
123
49
  if not bypass_validation:
124
- engine.run(model_cls, VALIDATE_UPDATE, objs, aligned_originals, ctx=ctx)
50
+ engine.run(model_cls, VALIDATE_UPDATE, objs, originals, ctx=ctx)
125
51
 
126
52
  # Then run business logic hooks
127
- engine.run(model_cls, BEFORE_UPDATE, objs, aligned_originals, ctx=ctx)
53
+ engine.run(model_cls, BEFORE_UPDATE, objs, originals, ctx=ctx)
128
54
 
129
55
  # Automatically detect fields that were modified during BEFORE_UPDATE hooks
130
- modified_fields = self._detect_modified_fields(objs, aligned_originals)
56
+ modified_fields = self._detect_modified_fields(objs, originals)
131
57
  if modified_fields:
58
+ # Convert to set for efficient union operation
132
59
  fields_set = set(fields)
133
60
  fields_set.update(modified_fields)
134
61
  fields = list(fields_set)
135
62
 
136
- # Process in chunks
137
- for i in range(0, len(objs), self._chunk_size):
138
- chunk = objs[i:i + self._chunk_size]
63
+ for i in range(0, len(objs), self.CHUNK_SIZE):
64
+ chunk = objs[i : i + self.CHUNK_SIZE]
65
+ # Call the base implementation to avoid re-triggering this method
139
66
  super(models.Manager, self).bulk_update(chunk, fields, **kwargs)
140
67
 
141
68
  if not bypass_hooks:
142
- engine.run(model_cls, AFTER_UPDATE, objs, aligned_originals, ctx=ctx)
69
+ engine.run(model_cls, AFTER_UPDATE, objs, originals, ctx=ctx)
143
70
 
144
71
  return objs
145
72
 
@@ -151,17 +78,11 @@ class BulkHookManager(models.Manager):
151
78
  if not original_instances:
152
79
  return set()
153
80
 
154
- # Create a mapping of pk to original instance for efficient lookup
155
- original_map = {obj.pk: obj for obj in original_instances if obj.pk is not None}
156
-
157
81
  modified_fields = set()
158
82
 
159
- for new_instance in new_instances:
160
- if new_instance.pk is None:
161
- continue
162
-
163
- original = original_map.get(new_instance.pk)
164
- if not original:
83
+ # Since original_instances is now ordered to match new_instances, we can zip them directly
84
+ for new_instance, original in zip(new_instances, original_instances):
85
+ if new_instance.pk is None or original is None:
165
86
  continue
166
87
 
167
88
  # Compare all fields to detect changes
@@ -188,52 +109,42 @@ class BulkHookManager(models.Manager):
188
109
 
189
110
  @transaction.atomic
190
111
  def bulk_create(self, objs, bypass_hooks=False, bypass_validation=False, **kwargs):
191
- if not objs:
192
- return []
193
-
194
112
  model_cls = self.model
195
- result = []
196
113
 
197
114
  if any(not isinstance(obj, model_cls) for obj in objs):
198
115
  raise TypeError(
199
116
  f"bulk_create expected instances of {model_cls.__name__}, but got {set(type(obj).__name__ for obj in objs)}"
200
117
  )
201
118
 
119
+ result = []
120
+
202
121
  if not bypass_hooks:
203
122
  ctx = HookContext(model_cls)
204
123
 
205
- # Process validation in chunks to avoid memory issues
124
+ # Run validation hooks first
206
125
  if not bypass_validation:
207
- for i in range(0, len(objs), self._chunk_size):
208
- chunk = objs[i:i + self._chunk_size]
209
- engine.run(model_cls, VALIDATE_CREATE, chunk, ctx=ctx)
126
+ engine.run(model_cls, VALIDATE_CREATE, objs, ctx=ctx)
210
127
 
211
- # Process before_create hooks in chunks
212
- for i in range(0, len(objs), self._chunk_size):
213
- chunk = objs[i:i + self._chunk_size]
214
- engine.run(model_cls, BEFORE_CREATE, chunk, ctx=ctx)
128
+ # Then run business logic hooks
129
+ engine.run(model_cls, BEFORE_CREATE, objs, ctx=ctx)
215
130
 
216
- # Perform bulk create in chunks
217
- for i in range(0, len(objs), self._chunk_size):
218
- chunk = objs[i:i + self._chunk_size]
219
- created_chunk = super(models.Manager, self).bulk_create(chunk, **kwargs)
220
- result.extend(created_chunk)
131
+ for i in range(0, len(objs), self.CHUNK_SIZE):
132
+ chunk = objs[i : i + self.CHUNK_SIZE]
133
+ result.extend(super(models.Manager, self).bulk_create(chunk, **kwargs))
221
134
 
222
135
  if not bypass_hooks:
223
- # Process after_create hooks in chunks
224
- for i in range(0, len(result), self._chunk_size):
225
- chunk = result[i:i + self._chunk_size]
226
- engine.run(model_cls, AFTER_CREATE, chunk, ctx=ctx)
136
+ engine.run(model_cls, AFTER_CREATE, result, ctx=ctx)
227
137
 
228
138
  return result
229
139
 
230
140
  @transaction.atomic
231
- def bulk_delete(self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False):
141
+ def bulk_delete(
142
+ self, objs, batch_size=None, bypass_hooks=False, bypass_validation=False
143
+ ):
232
144
  if not objs:
233
145
  return []
234
146
 
235
147
  model_cls = self.model
236
- chunk_size = batch_size or self._chunk_size
237
148
 
238
149
  if any(not isinstance(obj, model_cls) for obj in objs):
239
150
  raise TypeError(
@@ -243,25 +154,21 @@ class BulkHookManager(models.Manager):
243
154
  ctx = HookContext(model_cls)
244
155
 
245
156
  if not bypass_hooks:
246
- # Process hooks in chunks
247
- for i in range(0, len(objs), chunk_size):
248
- chunk = objs[i:i + chunk_size]
249
-
250
- if not bypass_validation:
251
- engine.run(model_cls, VALIDATE_DELETE, chunk, ctx=ctx)
252
- engine.run(model_cls, BEFORE_DELETE, chunk, ctx=ctx)
253
-
254
- # Collect PKs and delete in chunks
157
+ # Run validation hooks first
158
+ if not bypass_validation:
159
+ engine.run(model_cls, VALIDATE_DELETE, objs, ctx=ctx)
160
+
161
+ # Then run business logic hooks
162
+ engine.run(model_cls, BEFORE_DELETE, objs, ctx=ctx)
163
+
255
164
  pks = [obj.pk for obj in objs if obj.pk is not None]
256
- for i in range(0, len(pks), chunk_size):
257
- chunk_pks = pks[i:i + chunk_size]
258
- model_cls._base_manager.filter(pk__in=chunk_pks).delete()
165
+
166
+ # Use base manager for the actual deletion to prevent recursion
167
+ # The hooks have already been fired above, so we don't need them again
168
+ model_cls._base_manager.filter(pk__in=pks).delete()
259
169
 
260
170
  if not bypass_hooks:
261
- # Process after_delete hooks in chunks
262
- for i in range(0, len(objs), chunk_size):
263
- chunk = objs[i:i + chunk_size]
264
- engine.run(model_cls, AFTER_DELETE, chunk, ctx=ctx)
171
+ engine.run(model_cls, AFTER_DELETE, objs, ctx=ctx)
265
172
 
266
173
  return objs
267
174
 
@@ -1,8 +1,4 @@
1
- import contextlib
2
- from functools import wraps
3
-
4
1
  from django.db import models, transaction
5
- from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
6
2
 
7
3
  from django_bulk_hooks.constants import (
8
4
  AFTER_CREATE,
@@ -20,32 +16,6 @@ from django_bulk_hooks.engine import run
20
16
  from django_bulk_hooks.manager import BulkHookManager
21
17
 
22
18
 
23
- @contextlib.contextmanager
24
- def patch_foreign_key_behavior():
25
- """
26
- Temporarily patches Django's foreign key descriptor to return None instead of raising
27
- RelatedObjectDoesNotExist when accessing an unset foreign key field.
28
- """
29
- original_get = ForwardManyToOneDescriptor.__get__
30
-
31
- @wraps(original_get)
32
- def safe_get(self, instance, cls=None):
33
- if instance is None:
34
- return self
35
- try:
36
- return original_get(self, instance, cls)
37
- except self.RelatedObjectDoesNotExist:
38
- return None
39
-
40
- # Patch the descriptor
41
- ForwardManyToOneDescriptor.__get__ = safe_get
42
- try:
43
- yield
44
- finally:
45
- # Restore original behavior
46
- ForwardManyToOneDescriptor.__get__ = original_get
47
-
48
-
49
19
  class HookModelMixin(models.Model):
50
20
  objects = BulkHookManager()
51
21
 
@@ -58,88 +28,68 @@ class HookModelMixin(models.Model):
58
28
  This ensures that when Django calls clean() (like in admin forms),
59
29
  it triggers the VALIDATE_* hooks for validation only.
60
30
  """
61
- # Call Django's clean first
62
31
  super().clean()
63
32
 
64
- # Skip hook validation during admin form validation
65
- # This prevents RelatedObjectDoesNotExist errors when Django hasn't
66
- # fully set up the object's relationships yet
67
- if hasattr(self, "_state") and getattr(self._state, "validating", False):
68
- return
69
-
70
33
  # Determine if this is a create or update operation
71
34
  is_create = self.pk is None
72
35
 
73
36
  if is_create:
74
37
  # For create operations, run VALIDATE_CREATE hooks for validation
75
38
  ctx = HookContext(self.__class__)
76
- with patch_foreign_key_behavior():
77
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
39
+ run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
78
40
  else:
79
41
  # For update operations, run VALIDATE_UPDATE hooks for validation
80
42
  try:
81
43
  old_instance = self.__class__.objects.get(pk=self.pk)
82
44
  ctx = HookContext(self.__class__)
83
- with patch_foreign_key_behavior():
84
- run(
85
- self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
86
- )
45
+ run(self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx)
87
46
  except self.__class__.DoesNotExist:
88
47
  # If the old instance doesn't exist, treat as create
89
48
  ctx = HookContext(self.__class__)
90
- with patch_foreign_key_behavior():
91
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
49
+ run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
92
50
 
93
51
  def save(self, *args, **kwargs):
94
52
  is_create = self.pk is None
95
- ctx = HookContext(self.__class__)
96
53
 
97
- # Run BEFORE hooks before saving to allow field modifications
98
- with patch_foreign_key_behavior():
99
- if is_create:
100
- # For create operations
101
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
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__)
102
75
  run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
103
- else:
104
- # For update operations
105
- try:
106
- old_instance = self.__class__.objects.get(pk=self.pk)
107
- run(
108
- self.__class__, VALIDATE_UPDATE, [self], [old_instance], ctx=ctx
109
- )
110
- run(self.__class__, BEFORE_UPDATE, [self], [old_instance], ctx=ctx)
111
- except self.__class__.DoesNotExist:
112
- # If the old instance doesn't exist, treat as create
113
- run(self.__class__, VALIDATE_CREATE, [self], ctx=ctx)
114
- run(self.__class__, BEFORE_CREATE, [self], ctx=ctx)
115
-
116
- # Now let Django save with any modifications from BEFORE hooks
117
- super().save(*args, **kwargs)
118
-
119
- # Then run AFTER hooks
120
- with patch_foreign_key_behavior():
121
- if is_create:
122
- # For create operations
76
+
77
+ super().save(*args, **kwargs)
78
+
123
79
  run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
124
- else:
125
- # For update operations
126
- try:
127
- old_instance = self.__class__.objects.get(pk=self.pk)
128
- run(self.__class__, AFTER_UPDATE, [self], [old_instance], ctx=ctx)
129
- except self.__class__.DoesNotExist:
130
- # If the old instance doesn't exist, treat as create
131
- run(self.__class__, AFTER_CREATE, [self], ctx=ctx)
132
80
 
133
81
  return self
134
82
 
135
83
  def delete(self, *args, **kwargs):
136
84
  ctx = HookContext(self.__class__)
137
85
 
138
- # Use a single context manager for all hooks
139
- with patch_foreign_key_behavior():
140
- run(self.__class__, VALIDATE_DELETE, [self], ctx=ctx)
141
- run(self.__class__, BEFORE_DELETE, [self], ctx=ctx)
142
- result = super().delete(*args, **kwargs)
143
- run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
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)
144
93
 
94
+ run(self.__class__, AFTER_DELETE, [self], ctx=ctx)
145
95
  return result
@@ -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