django-bulk-hooks 0.2.61__tar.gz → 0.2.63__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.
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/PKG-INFO +1 -1
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/changeset.py +214 -211
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/conditions.py +7 -3
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/decorators.py +5 -15
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/dispatcher.py +97 -7
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/handler.py +2 -2
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/helpers.py +251 -245
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/manager.py +150 -150
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/models.py +74 -87
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/operations/analyzer.py +319 -319
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/operations/bulk_executor.py +22 -31
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/operations/coordinator.py +10 -7
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/operations/field_utils.py +5 -13
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/operations/mti_handler.py +10 -5
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/operations/mti_plans.py +103 -103
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/operations/record_classifier.py +1 -1
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/queryset.py +5 -1
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/registry.py +0 -2
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/pyproject.toml +1 -1
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/LICENSE +0 -0
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/README.md +0 -0
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/__init__.py +0 -0
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/constants.py +0 -0
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/context.py +0 -0
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/enums.py +0 -0
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/factory.py +0 -0
- {django_bulk_hooks-0.2.61 → django_bulk_hooks-0.2.63}/django_bulk_hooks/operations/__init__.py +0 -0
|
@@ -1,211 +1,214 @@
|
|
|
1
|
-
"""
|
|
2
|
-
ChangeSet and RecordChange classes for Salesforce-style hook context.
|
|
3
|
-
|
|
4
|
-
Provides a first-class abstraction for tracking changes in bulk operations,
|
|
5
|
-
similar to Salesforce's Hook.new, Hook.old, and Hook.newMap.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class RecordChange:
|
|
10
|
-
"""
|
|
11
|
-
Represents a single record change with old/new state.
|
|
12
|
-
|
|
13
|
-
Similar to accessing Hook.newMap.get(id) in Salesforce, but with
|
|
14
|
-
additional conveniences like O(1) field change detection.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
def __init__(self, new_record, old_record=None, changed_fields=None):
|
|
18
|
-
"""
|
|
19
|
-
Initialize a RecordChange.
|
|
20
|
-
|
|
21
|
-
Args:
|
|
22
|
-
new_record: The new/current state of the record
|
|
23
|
-
old_record: The old/previous state of the record (None for creates)
|
|
24
|
-
changed_fields: Optional pre-computed set of changed field names.
|
|
25
|
-
If None, will be computed lazily on first access.
|
|
26
|
-
"""
|
|
27
|
-
self.new_record = new_record
|
|
28
|
-
self.old_record = old_record
|
|
29
|
-
self._changed_fields = changed_fields
|
|
30
|
-
self._pk = getattr(new_record, "pk", None) if new_record else None
|
|
31
|
-
|
|
32
|
-
@property
|
|
33
|
-
def pk(self):
|
|
34
|
-
"""Primary key of the record."""
|
|
35
|
-
return self._pk
|
|
36
|
-
|
|
37
|
-
@property
|
|
38
|
-
def changed_fields(self):
|
|
39
|
-
"""
|
|
40
|
-
Set of field names that have changed.
|
|
41
|
-
|
|
42
|
-
Computed lazily on first access and cached for O(1) subsequent checks.
|
|
43
|
-
"""
|
|
44
|
-
if self._changed_fields is None:
|
|
45
|
-
self._changed_fields = self._compute_changed_fields()
|
|
46
|
-
return self._changed_fields
|
|
47
|
-
|
|
48
|
-
def has_changed(self, field_name):
|
|
49
|
-
"""
|
|
50
|
-
O(1) check if a specific field has changed.
|
|
51
|
-
|
|
52
|
-
Args:
|
|
53
|
-
field_name: Name of the field to check
|
|
54
|
-
|
|
55
|
-
Returns:
|
|
56
|
-
True if the field value changed, False otherwise
|
|
57
|
-
"""
|
|
58
|
-
return field_name in self.changed_fields
|
|
59
|
-
|
|
60
|
-
def get_old_value(self, field_name):
|
|
61
|
-
"""
|
|
62
|
-
Get the old value for a field.
|
|
63
|
-
|
|
64
|
-
Args:
|
|
65
|
-
field_name: Name of the field
|
|
66
|
-
|
|
67
|
-
Returns:
|
|
68
|
-
The old value, or None if no old record exists
|
|
69
|
-
"""
|
|
70
|
-
if self.old_record is None:
|
|
71
|
-
return None
|
|
72
|
-
return getattr(self.old_record, field_name, None)
|
|
73
|
-
|
|
74
|
-
def get_new_value(self, field_name):
|
|
75
|
-
"""
|
|
76
|
-
Get the new value for a field.
|
|
77
|
-
|
|
78
|
-
Args:
|
|
79
|
-
field_name: Name of the field
|
|
80
|
-
|
|
81
|
-
Returns:
|
|
82
|
-
The new value
|
|
83
|
-
"""
|
|
84
|
-
return getattr(self.new_record, field_name, None)
|
|
85
|
-
|
|
86
|
-
def _compute_changed_fields(self):
|
|
87
|
-
"""
|
|
88
|
-
Compute which fields have changed between old and new records.
|
|
89
|
-
|
|
90
|
-
Uses Django's field.get_prep_value() for proper comparison that
|
|
91
|
-
handles database-level transformations.
|
|
92
|
-
|
|
93
|
-
Returns:
|
|
94
|
-
Set of field names that have changed
|
|
95
|
-
"""
|
|
96
|
-
if self.old_record is None:
|
|
97
|
-
return set()
|
|
98
|
-
|
|
99
|
-
# Import here to avoid circular dependency
|
|
100
|
-
from .operations.field_utils import get_changed_fields
|
|
101
|
-
|
|
102
|
-
model_cls = self.new_record.__class__
|
|
103
|
-
return get_changed_fields(self.old_record, self.new_record, model_cls)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
class ChangeSet:
|
|
107
|
-
"""
|
|
108
|
-
Collection of RecordChanges for a bulk operation.
|
|
109
|
-
|
|
110
|
-
Similar to Salesforce's Hook context (Hook.new, Hook.old, Hook.newMap),
|
|
111
|
-
but enhanced for Python's bulk operations paradigm with O(1) lookups and
|
|
112
|
-
additional metadata.
|
|
113
|
-
"""
|
|
114
|
-
|
|
115
|
-
def __init__(self, model_cls, changes, operation_type, operation_meta=None):
|
|
116
|
-
"""
|
|
117
|
-
Initialize a ChangeSet.
|
|
118
|
-
|
|
119
|
-
Args:
|
|
120
|
-
model_cls: The Django model class
|
|
121
|
-
changes: List of RecordChange instances
|
|
122
|
-
operation_type: Type of operation ('create', 'update', 'delete')
|
|
123
|
-
operation_meta: Optional dict of additional metadata (e.g., update_kwargs)
|
|
124
|
-
"""
|
|
125
|
-
self.model_cls = model_cls
|
|
126
|
-
self.changes = changes # List[RecordChange]
|
|
127
|
-
self.operation_type = operation_type
|
|
128
|
-
self.operation_meta = operation_meta or {}
|
|
129
|
-
|
|
130
|
-
# Build PK -> RecordChange map for O(1) lookups (like Hook.newMap)
|
|
131
|
-
self._pk_to_change = {c.pk: c for c in changes if c.pk is not None}
|
|
132
|
-
|
|
133
|
-
@property
|
|
134
|
-
def new_records(self):
|
|
135
|
-
"""
|
|
136
|
-
List of new/current record states.
|
|
137
|
-
|
|
138
|
-
Similar to Hook.new in Salesforce.
|
|
139
|
-
"""
|
|
140
|
-
return [c.new_record for c in self.changes if c.new_record is not None]
|
|
141
|
-
|
|
142
|
-
@property
|
|
143
|
-
def old_records(self):
|
|
144
|
-
"""
|
|
145
|
-
List of old/previous record states.
|
|
146
|
-
|
|
147
|
-
Similar to Hook.old in Salesforce.
|
|
148
|
-
Only includes records that have old states (excludes creates).
|
|
149
|
-
"""
|
|
150
|
-
return [c.old_record for c in self.changes if c.old_record is not None]
|
|
151
|
-
|
|
152
|
-
def has_field_changed(self, pk, field_name):
|
|
153
|
-
"""
|
|
154
|
-
O(1) check if a field changed for a specific record.
|
|
155
|
-
|
|
156
|
-
Args:
|
|
157
|
-
pk: Primary key of the record
|
|
158
|
-
field_name: Name of the field to check
|
|
159
|
-
|
|
160
|
-
Returns:
|
|
161
|
-
True if the field changed, False otherwise
|
|
162
|
-
"""
|
|
163
|
-
change = self._pk_to_change.get(pk)
|
|
164
|
-
return change.has_changed(field_name) if change else False
|
|
165
|
-
|
|
166
|
-
def get_old_value(self, pk, field_name):
|
|
167
|
-
"""
|
|
168
|
-
Get the old value for a specific record and field.
|
|
169
|
-
|
|
170
|
-
Args:
|
|
171
|
-
pk: Primary key of the record
|
|
172
|
-
field_name: Name of the field
|
|
173
|
-
|
|
174
|
-
Returns:
|
|
175
|
-
The old value, or None if not found
|
|
176
|
-
"""
|
|
177
|
-
change = self._pk_to_change.get(pk)
|
|
178
|
-
return change.get_old_value(field_name) if change else None
|
|
179
|
-
|
|
180
|
-
def get_new_value(self, pk, field_name):
|
|
181
|
-
"""
|
|
182
|
-
Get the new value for a specific record and field.
|
|
183
|
-
|
|
184
|
-
Args:
|
|
185
|
-
pk: Primary key of the record
|
|
186
|
-
field_name: Name of the field
|
|
187
|
-
|
|
188
|
-
Returns:
|
|
189
|
-
The new value, or None if not found
|
|
190
|
-
"""
|
|
191
|
-
change = self._pk_to_change.get(pk)
|
|
192
|
-
return change.get_new_value(field_name) if change else None
|
|
193
|
-
|
|
194
|
-
def chunk(self, chunk_size):
|
|
195
|
-
"""
|
|
196
|
-
Split ChangeSet into smaller chunks for memory-efficient processing.
|
|
197
|
-
|
|
198
|
-
Useful for processing very large bulk operations without loading
|
|
199
|
-
all data into memory at once.
|
|
200
|
-
|
|
201
|
-
Args:
|
|
202
|
-
chunk_size: Number of changes per chunk
|
|
203
|
-
|
|
204
|
-
Yields:
|
|
205
|
-
ChangeSet instances, each with up to chunk_size changes
|
|
206
|
-
"""
|
|
207
|
-
for i in range(0, len(self.changes), chunk_size):
|
|
208
|
-
chunk_changes = self.changes[i : i + chunk_size]
|
|
209
|
-
yield ChangeSet(
|
|
210
|
-
self.model_cls,
|
|
211
|
-
|
|
1
|
+
"""
|
|
2
|
+
ChangeSet and RecordChange classes for Salesforce-style hook context.
|
|
3
|
+
|
|
4
|
+
Provides a first-class abstraction for tracking changes in bulk operations,
|
|
5
|
+
similar to Salesforce's Hook.new, Hook.old, and Hook.newMap.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RecordChange:
|
|
10
|
+
"""
|
|
11
|
+
Represents a single record change with old/new state.
|
|
12
|
+
|
|
13
|
+
Similar to accessing Hook.newMap.get(id) in Salesforce, but with
|
|
14
|
+
additional conveniences like O(1) field change detection.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, new_record, old_record=None, changed_fields=None):
|
|
18
|
+
"""
|
|
19
|
+
Initialize a RecordChange.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
new_record: The new/current state of the record
|
|
23
|
+
old_record: The old/previous state of the record (None for creates)
|
|
24
|
+
changed_fields: Optional pre-computed set of changed field names.
|
|
25
|
+
If None, will be computed lazily on first access.
|
|
26
|
+
"""
|
|
27
|
+
self.new_record = new_record
|
|
28
|
+
self.old_record = old_record
|
|
29
|
+
self._changed_fields = changed_fields
|
|
30
|
+
self._pk = getattr(new_record, "pk", None) if new_record else None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def pk(self):
|
|
34
|
+
"""Primary key of the record."""
|
|
35
|
+
return self._pk
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def changed_fields(self):
|
|
39
|
+
"""
|
|
40
|
+
Set of field names that have changed.
|
|
41
|
+
|
|
42
|
+
Computed lazily on first access and cached for O(1) subsequent checks.
|
|
43
|
+
"""
|
|
44
|
+
if self._changed_fields is None:
|
|
45
|
+
self._changed_fields = self._compute_changed_fields()
|
|
46
|
+
return self._changed_fields
|
|
47
|
+
|
|
48
|
+
def has_changed(self, field_name):
|
|
49
|
+
"""
|
|
50
|
+
O(1) check if a specific field has changed.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
field_name: Name of the field to check
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if the field value changed, False otherwise
|
|
57
|
+
"""
|
|
58
|
+
return field_name in self.changed_fields
|
|
59
|
+
|
|
60
|
+
def get_old_value(self, field_name):
|
|
61
|
+
"""
|
|
62
|
+
Get the old value for a field.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
field_name: Name of the field
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The old value, or None if no old record exists
|
|
69
|
+
"""
|
|
70
|
+
if self.old_record is None:
|
|
71
|
+
return None
|
|
72
|
+
return getattr(self.old_record, field_name, None)
|
|
73
|
+
|
|
74
|
+
def get_new_value(self, field_name):
|
|
75
|
+
"""
|
|
76
|
+
Get the new value for a field.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
field_name: Name of the field
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The new value
|
|
83
|
+
"""
|
|
84
|
+
return getattr(self.new_record, field_name, None)
|
|
85
|
+
|
|
86
|
+
def _compute_changed_fields(self):
|
|
87
|
+
"""
|
|
88
|
+
Compute which fields have changed between old and new records.
|
|
89
|
+
|
|
90
|
+
Uses Django's field.get_prep_value() for proper comparison that
|
|
91
|
+
handles database-level transformations.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Set of field names that have changed
|
|
95
|
+
"""
|
|
96
|
+
if self.old_record is None:
|
|
97
|
+
return set()
|
|
98
|
+
|
|
99
|
+
# Import here to avoid circular dependency
|
|
100
|
+
from .operations.field_utils import get_changed_fields
|
|
101
|
+
|
|
102
|
+
model_cls = self.new_record.__class__
|
|
103
|
+
return get_changed_fields(self.old_record, self.new_record, model_cls)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ChangeSet:
|
|
107
|
+
"""
|
|
108
|
+
Collection of RecordChanges for a bulk operation.
|
|
109
|
+
|
|
110
|
+
Similar to Salesforce's Hook context (Hook.new, Hook.old, Hook.newMap),
|
|
111
|
+
but enhanced for Python's bulk operations paradigm with O(1) lookups and
|
|
112
|
+
additional metadata.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, model_cls, changes, operation_type, operation_meta=None):
|
|
116
|
+
"""
|
|
117
|
+
Initialize a ChangeSet.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
model_cls: The Django model class
|
|
121
|
+
changes: List of RecordChange instances
|
|
122
|
+
operation_type: Type of operation ('create', 'update', 'delete')
|
|
123
|
+
operation_meta: Optional dict of additional metadata (e.g., update_kwargs)
|
|
124
|
+
"""
|
|
125
|
+
self.model_cls = model_cls
|
|
126
|
+
self.changes = changes # List[RecordChange]
|
|
127
|
+
self.operation_type = operation_type
|
|
128
|
+
self.operation_meta = operation_meta or {}
|
|
129
|
+
|
|
130
|
+
# Build PK -> RecordChange map for O(1) lookups (like Hook.newMap)
|
|
131
|
+
self._pk_to_change = {c.pk: c for c in changes if c.pk is not None}
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def new_records(self):
|
|
135
|
+
"""
|
|
136
|
+
List of new/current record states.
|
|
137
|
+
|
|
138
|
+
Similar to Hook.new in Salesforce.
|
|
139
|
+
"""
|
|
140
|
+
return [c.new_record for c in self.changes if c.new_record is not None]
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def old_records(self):
|
|
144
|
+
"""
|
|
145
|
+
List of old/previous record states.
|
|
146
|
+
|
|
147
|
+
Similar to Hook.old in Salesforce.
|
|
148
|
+
Only includes records that have old states (excludes creates).
|
|
149
|
+
"""
|
|
150
|
+
return [c.old_record for c in self.changes if c.old_record is not None]
|
|
151
|
+
|
|
152
|
+
def has_field_changed(self, pk, field_name):
|
|
153
|
+
"""
|
|
154
|
+
O(1) check if a field changed for a specific record.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
pk: Primary key of the record
|
|
158
|
+
field_name: Name of the field to check
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True if the field changed, False otherwise
|
|
162
|
+
"""
|
|
163
|
+
change = self._pk_to_change.get(pk)
|
|
164
|
+
return change.has_changed(field_name) if change else False
|
|
165
|
+
|
|
166
|
+
def get_old_value(self, pk, field_name):
|
|
167
|
+
"""
|
|
168
|
+
Get the old value for a specific record and field.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
pk: Primary key of the record
|
|
172
|
+
field_name: Name of the field
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The old value, or None if not found
|
|
176
|
+
"""
|
|
177
|
+
change = self._pk_to_change.get(pk)
|
|
178
|
+
return change.get_old_value(field_name) if change else None
|
|
179
|
+
|
|
180
|
+
def get_new_value(self, pk, field_name):
|
|
181
|
+
"""
|
|
182
|
+
Get the new value for a specific record and field.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
pk: Primary key of the record
|
|
186
|
+
field_name: Name of the field
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The new value, or None if not found
|
|
190
|
+
"""
|
|
191
|
+
change = self._pk_to_change.get(pk)
|
|
192
|
+
return change.get_new_value(field_name) if change else None
|
|
193
|
+
|
|
194
|
+
def chunk(self, chunk_size):
|
|
195
|
+
"""
|
|
196
|
+
Split ChangeSet into smaller chunks for memory-efficient processing.
|
|
197
|
+
|
|
198
|
+
Useful for processing very large bulk operations without loading
|
|
199
|
+
all data into memory at once.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
chunk_size: Number of changes per chunk
|
|
203
|
+
|
|
204
|
+
Yields:
|
|
205
|
+
ChangeSet instances, each with up to chunk_size changes
|
|
206
|
+
"""
|
|
207
|
+
for i in range(0, len(self.changes), chunk_size):
|
|
208
|
+
chunk_changes = self.changes[i : i + chunk_size]
|
|
209
|
+
yield ChangeSet(
|
|
210
|
+
self.model_cls,
|
|
211
|
+
chunk_changes,
|
|
212
|
+
self.operation_type,
|
|
213
|
+
self.operation_meta,
|
|
214
|
+
)
|
|
@@ -40,7 +40,9 @@ def resolve_field_path(instance, field_path):
|
|
|
40
40
|
if field.is_relation and not field.many_to_many:
|
|
41
41
|
# Use attname for the final FK field access
|
|
42
42
|
current_instance = getattr(
|
|
43
|
-
current_instance,
|
|
43
|
+
current_instance,
|
|
44
|
+
field.attname,
|
|
45
|
+
None,
|
|
44
46
|
)
|
|
45
47
|
continue
|
|
46
48
|
except:
|
|
@@ -203,7 +205,8 @@ class AndCondition(HookCondition):
|
|
|
203
205
|
|
|
204
206
|
def check(self, instance, original_instance=None):
|
|
205
207
|
return self.cond1.check(instance, original_instance) and self.cond2.check(
|
|
206
|
-
instance,
|
|
208
|
+
instance,
|
|
209
|
+
original_instance,
|
|
207
210
|
)
|
|
208
211
|
|
|
209
212
|
|
|
@@ -214,7 +217,8 @@ class OrCondition(HookCondition):
|
|
|
214
217
|
|
|
215
218
|
def check(self, instance, original_instance=None):
|
|
216
219
|
return self.cond1.check(instance, original_instance) or self.cond2.check(
|
|
217
|
-
instance,
|
|
220
|
+
instance,
|
|
221
|
+
original_instance,
|
|
218
222
|
)
|
|
219
223
|
|
|
220
224
|
|
|
@@ -76,17 +76,11 @@ def select_related(*related_fields):
|
|
|
76
76
|
except (FieldDoesNotExist, AttributeError):
|
|
77
77
|
continue
|
|
78
78
|
|
|
79
|
-
if
|
|
80
|
-
relation_field.is_relation
|
|
81
|
-
and not relation_field.many_to_many
|
|
82
|
-
and not relation_field.one_to_many
|
|
83
|
-
):
|
|
79
|
+
if relation_field.is_relation and not relation_field.many_to_many and not relation_field.one_to_many:
|
|
84
80
|
validated_fields.append(field)
|
|
85
81
|
direct_relation_fields[field] = relation_field
|
|
86
82
|
|
|
87
|
-
unsaved_related_ids_by_field = {
|
|
88
|
-
field: set() for field in direct_relation_fields
|
|
89
|
-
}
|
|
83
|
+
unsaved_related_ids_by_field = {field: set() for field in direct_relation_fields}
|
|
90
84
|
|
|
91
85
|
saved_ids_to_fetch = []
|
|
92
86
|
for obj in records:
|
|
@@ -94,10 +88,7 @@ def select_related(*related_fields):
|
|
|
94
88
|
needs_fetch = False
|
|
95
89
|
if hasattr(obj, "_state") and hasattr(obj._state, "fields_cache"):
|
|
96
90
|
try:
|
|
97
|
-
needs_fetch = any(
|
|
98
|
-
field not in obj._state.fields_cache
|
|
99
|
-
for field in related_fields
|
|
100
|
-
)
|
|
91
|
+
needs_fetch = any(field not in obj._state.fields_cache for field in related_fields)
|
|
101
92
|
except (TypeError, AttributeError):
|
|
102
93
|
needs_fetch = True
|
|
103
94
|
else:
|
|
@@ -134,9 +125,7 @@ def select_related(*related_fields):
|
|
|
134
125
|
except Exception:
|
|
135
126
|
fetched_saved = {}
|
|
136
127
|
|
|
137
|
-
fetched_unsaved_by_field = {
|
|
138
|
-
field: {} for field in direct_relation_fields
|
|
139
|
-
}
|
|
128
|
+
fetched_unsaved_by_field = {field: {} for field in direct_relation_fields}
|
|
140
129
|
|
|
141
130
|
for field_name, relation_field in direct_relation_fields.items():
|
|
142
131
|
related_ids = unsaved_related_ids_by_field[field_name]
|
|
@@ -282,6 +271,7 @@ def bulk_hook(model_cls, event, when=None, priority=None):
|
|
|
282
271
|
|
|
283
272
|
# Check function signature to determine which format to use
|
|
284
273
|
import inspect
|
|
274
|
+
|
|
285
275
|
sig = inspect.signature(func)
|
|
286
276
|
params = list(sig.parameters.keys())
|
|
287
277
|
|
|
@@ -122,13 +122,15 @@ class HookDispatcher:
|
|
|
122
122
|
condition: Optional condition to filter records
|
|
123
123
|
changeset: ChangeSet with all record changes
|
|
124
124
|
"""
|
|
125
|
-
#
|
|
125
|
+
# NEW: Preload relationships needed for condition evaluation
|
|
126
126
|
if condition:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
condition_relationships = self._extract_condition_relationships(condition, changeset.model_cls)
|
|
128
|
+
if condition_relationships:
|
|
129
|
+
self._preload_condition_relationships(changeset, condition_relationships)
|
|
130
|
+
|
|
131
|
+
# Filter records based on condition (now safe - relationships are preloaded)
|
|
132
|
+
if condition:
|
|
133
|
+
filtered_changes = [change for change in changeset.changes if condition.check(change.new_record, change.old_record)]
|
|
132
134
|
|
|
133
135
|
if not filtered_changes:
|
|
134
136
|
# No records match condition, skip this hook
|
|
@@ -208,6 +210,94 @@ class HookDispatcher:
|
|
|
208
210
|
)
|
|
209
211
|
raise
|
|
210
212
|
|
|
213
|
+
def _extract_condition_relationships(self, condition, model_cls):
|
|
214
|
+
"""
|
|
215
|
+
Extract relationship paths that a condition might access.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
condition: HookCondition instance
|
|
219
|
+
model_cls: The model class
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
set: Set of relationship field names to preload
|
|
223
|
+
"""
|
|
224
|
+
relationships = set()
|
|
225
|
+
|
|
226
|
+
# Guard against Mock objects and non-condition objects
|
|
227
|
+
if not hasattr(condition, 'check') or hasattr(condition, '_mock_name'):
|
|
228
|
+
return relationships
|
|
229
|
+
|
|
230
|
+
# Handle different condition types
|
|
231
|
+
if hasattr(condition, 'field'):
|
|
232
|
+
# Extract relationships from field path (e.g., "status__value" -> "status")
|
|
233
|
+
field_path = condition.field
|
|
234
|
+
if isinstance(field_path, str):
|
|
235
|
+
if '__' in field_path:
|
|
236
|
+
# Take the first part before __ (the relationship to preload)
|
|
237
|
+
rel_field = field_path.split('__')[0]
|
|
238
|
+
relationships.add(rel_field)
|
|
239
|
+
elif self._is_relationship_field(model_cls, field_path):
|
|
240
|
+
relationships.add(field_path)
|
|
241
|
+
|
|
242
|
+
# Handle composite conditions (AndCondition, OrCondition)
|
|
243
|
+
if hasattr(condition, 'cond1') and hasattr(condition, 'cond2'):
|
|
244
|
+
relationships.update(self._extract_condition_relationships(condition.cond1, model_cls))
|
|
245
|
+
relationships.update(self._extract_condition_relationships(condition.cond2, model_cls))
|
|
246
|
+
|
|
247
|
+
# Handle NotCondition
|
|
248
|
+
if hasattr(condition, 'cond'):
|
|
249
|
+
relationships.update(self._extract_condition_relationships(condition.cond, model_cls))
|
|
250
|
+
|
|
251
|
+
return relationships
|
|
252
|
+
|
|
253
|
+
def _is_relationship_field(self, model_cls, field_name):
|
|
254
|
+
"""Check if a field is a relationship field."""
|
|
255
|
+
try:
|
|
256
|
+
field = model_cls._meta.get_field(field_name)
|
|
257
|
+
return field.is_relation and not field.many_to_many
|
|
258
|
+
except:
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
def _preload_condition_relationships(self, changeset, relationships):
|
|
262
|
+
"""
|
|
263
|
+
Preload relationships needed for condition evaluation.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
changeset: ChangeSet with records
|
|
267
|
+
relationships: Set of relationship field names to preload
|
|
268
|
+
"""
|
|
269
|
+
if not relationships or not changeset.new_records:
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
# Use Django's select_related to preload relationships
|
|
273
|
+
relationship_list = list(relationships)
|
|
274
|
+
|
|
275
|
+
# Preload for new_records
|
|
276
|
+
if changeset.new_records:
|
|
277
|
+
# Use select_related on the queryset
|
|
278
|
+
ids = [obj.pk for obj in changeset.new_records if obj.pk is not None]
|
|
279
|
+
if ids:
|
|
280
|
+
preloaded = changeset.model_cls.objects.filter(pk__in=ids).select_related(*relationship_list).in_bulk()
|
|
281
|
+
# Update the objects in changeset with preloaded relationships
|
|
282
|
+
for obj in changeset.new_records:
|
|
283
|
+
if obj.pk and obj.pk in preloaded:
|
|
284
|
+
preloaded_obj = preloaded[obj.pk]
|
|
285
|
+
for rel in relationship_list:
|
|
286
|
+
if hasattr(preloaded_obj, rel):
|
|
287
|
+
setattr(obj, rel, getattr(preloaded_obj, rel))
|
|
288
|
+
|
|
289
|
+
# Also handle unsaved objects by preloading their FK targets
|
|
290
|
+
for obj in changeset.new_records:
|
|
291
|
+
if obj.pk is None: # Unsaved object
|
|
292
|
+
for rel in relationship_list:
|
|
293
|
+
if hasattr(obj, f'{rel}_id'):
|
|
294
|
+
rel_id = getattr(obj, f'{rel}_id')
|
|
295
|
+
if rel_id:
|
|
296
|
+
# Load the related object
|
|
297
|
+
rel_model = getattr(changeset.model_cls._meta.get_field(rel).remote_field, 'model')
|
|
298
|
+
rel_obj = rel_model.objects.get(pk=rel_id)
|
|
299
|
+
setattr(obj, rel, rel_obj)
|
|
300
|
+
|
|
211
301
|
|
|
212
302
|
# Global dispatcher instance
|
|
213
303
|
_dispatcher: HookDispatcher | None = None
|
|
@@ -235,7 +325,7 @@ def get_dispatcher():
|
|
|
235
325
|
def reset_dispatcher():
|
|
236
326
|
"""
|
|
237
327
|
Reset the global dispatcher instance.
|
|
238
|
-
|
|
328
|
+
|
|
239
329
|
Useful for testing to ensure clean state between tests.
|
|
240
330
|
"""
|
|
241
331
|
global _dispatcher
|
|
@@ -8,7 +8,8 @@ logger = logging.getLogger(__name__)
|
|
|
8
8
|
class HookMeta(type):
|
|
9
9
|
_registered = set()
|
|
10
10
|
_class_hook_map: dict[
|
|
11
|
-
type,
|
|
11
|
+
type,
|
|
12
|
+
set[tuple],
|
|
12
13
|
] = {} # Track which hooks belong to which class
|
|
13
14
|
|
|
14
15
|
def __new__(mcs, name, bases, namespace):
|
|
@@ -103,4 +104,3 @@ class Hook(metaclass=HookMeta):
|
|
|
103
104
|
All hook execution logic has been moved to HookDispatcher for
|
|
104
105
|
a single, consistent execution path.
|
|
105
106
|
"""
|
|
106
|
-
|