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

@@ -29,7 +29,6 @@ class BulkOperationCoordinator:
29
29
  Services are created lazily and cached.
30
30
  """
31
31
 
32
-
33
32
  def __init__(self, queryset):
34
33
  """
35
34
  Initialize coordinator for a queryset.
@@ -137,13 +136,18 @@ class BulkOperationCoordinator:
137
136
  existing_record_ids = set()
138
137
  existing_pks_map = {}
139
138
  if update_conflicts and unique_fields:
139
+ # For MTI models, query the parent model that has the unique fields
140
+ query_model = None
141
+ if self.mti_handler.is_mti_model():
142
+ query_model = self.mti_handler.find_model_with_unique_fields(unique_fields)
143
+ logger.info(f"MTI model detected: querying {query_model.__name__} for unique fields {unique_fields}")
144
+
140
145
  existing_record_ids, existing_pks_map = self.record_classifier.classify_for_upsert(
141
- objs, unique_fields
142
- )
143
- logger.info(
144
- f"Upsert operation: {len(existing_record_ids)} existing, "
145
- f"{len(objs) - len(existing_record_ids)} new records"
146
+ objs, unique_fields, query_model=query_model
146
147
  )
148
+ logger.info(f"Upsert operation: {len(existing_record_ids)} existing, {len(objs) - len(existing_record_ids)} new records")
149
+ logger.debug(f"Existing record IDs: {existing_record_ids}")
150
+ logger.debug(f"Existing PKs map: {existing_pks_map}")
147
151
 
148
152
  # Build initial changeset
149
153
  changeset = build_changeset_for_create(
@@ -236,14 +240,17 @@ class BulkOperationCoordinator:
236
240
 
237
241
  @transaction.atomic
238
242
  def update_queryset(
239
- self, update_kwargs, bypass_hooks=False, bypass_validation=False,
243
+ self,
244
+ update_kwargs,
245
+ bypass_hooks=False,
246
+ bypass_validation=False,
240
247
  ):
241
248
  """
242
249
  Execute queryset.update() with full hook support.
243
-
250
+
244
251
  ARCHITECTURE & PERFORMANCE TRADE-OFFS
245
252
  ======================================
246
-
253
+
247
254
  To support hooks with queryset.update(), we must:
248
255
  1. Fetch old state (SELECT all matching rows)
249
256
  2. Execute database update (UPDATE in SQL)
@@ -252,29 +259,29 @@ class BulkOperationCoordinator:
252
259
  5. Run BEFORE_UPDATE hooks (CAN modify instances)
253
260
  6. Persist BEFORE_UPDATE modifications (bulk_update)
254
261
  7. Run AFTER_UPDATE hooks (read-only side effects)
255
-
262
+
256
263
  Performance Cost:
257
264
  - 2 SELECT queries (before/after)
258
265
  - 1 UPDATE query (actual update)
259
266
  - 1 bulk_update (if hooks modify data)
260
-
267
+
261
268
  Trade-off: Hooks require loading data into Python. If you need
262
269
  maximum performance and don't need hooks, use bypass_hooks=True.
263
-
270
+
264
271
  Hook Semantics:
265
272
  - BEFORE_UPDATE hooks run after the DB update and CAN modify instances
266
273
  - Modifications are auto-persisted (framework handles complexity)
267
274
  - AFTER_UPDATE hooks run after BEFORE_UPDATE and are read-only
268
275
  - This enables cascade logic and computed fields based on DB values
269
276
  - User expectation: BEFORE_UPDATE hooks can modify data
270
-
277
+
271
278
  Why this approach works well:
272
279
  - Allows hooks to see Subquery/F() computed values
273
280
  - Enables HasChanged conditions on complex expressions
274
281
  - Maintains SQL performance (Subquery stays in database)
275
282
  - Meets user expectations: BEFORE_UPDATE can modify instances
276
283
  - Clean separation: BEFORE for modifications, AFTER for side effects
277
-
284
+
278
285
  For true "prevent write" semantics, intercept at a higher level
279
286
  or use bulk_update() directly (which has true before semantics).
280
287
  """
@@ -291,19 +298,21 @@ class BulkOperationCoordinator:
291
298
  )
292
299
 
293
300
  def _execute_queryset_update_with_hooks(
294
- self, update_kwargs, bypass_validation=False,
301
+ self,
302
+ update_kwargs,
303
+ bypass_validation=False,
295
304
  ):
296
305
  """
297
306
  Execute queryset update with full hook lifecycle support.
298
-
307
+
299
308
  This method implements the fetch-update-fetch pattern required
300
309
  to support hooks with queryset.update(). BEFORE_UPDATE hooks can
301
310
  modify instances and modifications are auto-persisted.
302
-
311
+
303
312
  Args:
304
313
  update_kwargs: Dict of fields to update
305
314
  bypass_validation: Skip validation hooks if True
306
-
315
+
307
316
  Returns:
308
317
  Number of rows updated
309
318
  """
@@ -387,11 +396,11 @@ class BulkOperationCoordinator:
387
396
  def _run_before_update_hooks_with_tracking(self, instances, models_in_chain, changeset):
388
397
  """
389
398
  Run BEFORE_UPDATE hooks and detect modifications.
390
-
399
+
391
400
  This is what users expect - BEFORE_UPDATE hooks can modify instances
392
401
  and those modifications will be automatically persisted. The framework
393
402
  handles the complexity internally.
394
-
403
+
395
404
  Returns:
396
405
  Set of field names that were modified by hooks
397
406
  """
@@ -413,10 +422,10 @@ class BulkOperationCoordinator:
413
422
  def _snapshot_instance_state(self, instances):
414
423
  """
415
424
  Create a snapshot of current instance field values.
416
-
425
+
417
426
  Args:
418
427
  instances: List of model instances
419
-
428
+
420
429
  Returns:
421
430
  Dict mapping pk -> {field_name: value}
422
431
  """
@@ -446,11 +455,11 @@ class BulkOperationCoordinator:
446
455
  def _detect_modifications(self, instances, pre_hook_state):
447
456
  """
448
457
  Detect which fields were modified by comparing to snapshot.
449
-
458
+
450
459
  Args:
451
460
  instances: List of model instances
452
461
  pre_hook_state: Previous state snapshot from _snapshot_instance_state
453
-
462
+
454
463
  Returns:
455
464
  Set of field names that were modified
456
465
  """
@@ -477,16 +486,15 @@ class BulkOperationCoordinator:
477
486
  def _persist_hook_modifications(self, instances, modified_fields):
478
487
  """
479
488
  Persist modifications made by hooks using bulk_update.
480
-
489
+
481
490
  This creates a "cascade" effect similar to Salesforce workflows.
482
-
491
+
483
492
  Args:
484
493
  instances: List of modified instances
485
494
  modified_fields: Set of field names that were modified
486
495
  """
487
496
  logger.info(
488
- f"Hooks modified {len(modified_fields)} field(s): "
489
- f"{', '.join(sorted(modified_fields))}",
497
+ f"Hooks modified {len(modified_fields)} field(s): {', '.join(sorted(modified_fields))}",
490
498
  )
491
499
  logger.info("Auto-persisting modifications via bulk_update")
492
500
 
@@ -569,14 +577,14 @@ class BulkOperationCoordinator:
569
577
  def _build_changeset_for_model(self, original_changeset, target_model_cls):
570
578
  """
571
579
  Build a changeset for a specific model in the MTI inheritance chain.
572
-
580
+
573
581
  This allows parent model hooks to receive the same instances but with
574
582
  the correct model_cls for hook registration matching.
575
-
583
+
576
584
  Args:
577
585
  original_changeset: The original changeset (for child model)
578
586
  target_model_cls: The model class to build changeset for (parent model)
579
-
587
+
580
588
  Returns:
581
589
  ChangeSet for the target model
582
590
  """
@@ -600,18 +608,18 @@ class BulkOperationCoordinator:
600
608
  ):
601
609
  """
602
610
  Execute operation with hooks for entire MTI inheritance chain.
603
-
611
+
604
612
  This method dispatches hooks for both child and parent models when
605
613
  dealing with MTI models, ensuring parent model hooks fire when
606
614
  child instances are created/updated/deleted.
607
-
615
+
608
616
  Args:
609
617
  changeset: ChangeSet for the child model
610
618
  operation: Callable that performs the actual DB operation
611
619
  event_prefix: 'create', 'update', or 'delete'
612
620
  bypass_hooks: Skip all hooks if True
613
621
  bypass_validation: Skip validation hooks if True
614
-
622
+
615
623
  Returns:
616
624
  Result of operation
617
625
  """
@@ -649,8 +657,9 @@ class BulkOperationCoordinator:
649
657
  else:
650
658
  # Normal create operation
651
659
  from django_bulk_hooks.helpers import build_changeset_for_create
660
+
652
661
  changeset = build_changeset_for_create(changeset.model_cls, result)
653
-
662
+
654
663
  for model_cls in models_in_chain:
655
664
  model_changeset = self._build_changeset_for_model(changeset, model_cls)
656
665
  self.dispatcher.dispatch(model_changeset, f"after_{event_prefix}", bypass_hooks=False)
@@ -680,11 +689,13 @@ class BulkOperationCoordinator:
680
689
  for field_name in update_kwargs.keys():
681
690
  try:
682
691
  field = self.model_cls._meta.get_field(field_name)
683
- if (field.is_relation and
684
- not field.many_to_many and
685
- not field.one_to_many and
686
- hasattr(field, "attname") and
687
- field.attname == field_name):
692
+ if (
693
+ field.is_relation
694
+ and not field.many_to_many
695
+ and not field.one_to_many
696
+ and hasattr(field, "attname")
697
+ and field.attname == field_name
698
+ ):
688
699
  # This is a FK field being updated by its attname (e.g., business_id)
689
700
  # Add the relationship name (e.g., 'business') to skip list
690
701
  fk_relationships.add(field.name)
@@ -696,86 +707,114 @@ class BulkOperationCoordinator:
696
707
 
697
708
  def _is_upsert_operation(self, result_objects):
698
709
  """
699
- Check if the operation was an upsert (mixed create/update).
700
-
710
+ Check if the operation was an upsert (with update_conflicts=True).
711
+
701
712
  Args:
702
713
  result_objects: List of objects returned from the operation
703
-
714
+
704
715
  Returns:
705
716
  True if this was an upsert operation, False otherwise
706
717
  """
707
718
  if not result_objects:
708
719
  return False
709
-
720
+
710
721
  # Check if any object has upsert metadata
711
- return hasattr(result_objects[0], '_bulk_hooks_upsert_metadata')
722
+ return hasattr(result_objects[0], "_bulk_hooks_upsert_metadata")
712
723
 
713
724
  def _dispatch_upsert_after_hooks(self, result_objects, models_in_chain):
714
725
  """
715
726
  Dispatch after hooks for upsert operations, splitting by create/update.
716
-
727
+
717
728
  This matches Salesforce behavior:
718
729
  - Records that were created fire after_create hooks
719
730
  - Records that were updated fire after_update hooks
720
-
731
+
721
732
  Args:
722
733
  result_objects: List of objects returned from the operation
723
734
  models_in_chain: List of model classes in the MTI inheritance chain
724
735
  """
725
- # Split objects by operation type
736
+ # Split objects based on metadata set by the executor
726
737
  created_objects = []
727
738
  updated_objects = []
728
-
739
+
740
+ if not result_objects:
741
+ return
742
+
729
743
  for obj in result_objects:
730
- was_created = getattr(obj, '_bulk_hooks_was_created', True)
731
- if was_created:
732
- created_objects.append(obj)
744
+ # Check if metadata was set
745
+ if hasattr(obj, "_bulk_hooks_was_created"):
746
+ was_created = getattr(obj, "_bulk_hooks_was_created", True)
747
+ if was_created:
748
+ created_objects.append(obj)
749
+ else:
750
+ updated_objects.append(obj)
733
751
  else:
734
- updated_objects.append(obj)
735
-
736
- logger.info(
737
- f"Upsert after hooks: {len(created_objects)} created, "
738
- f"{len(updated_objects)} updated"
739
- )
740
-
752
+ # Fallback: if no metadata, check timestamps
753
+ model_cls = obj.__class__
754
+ if hasattr(model_cls, "created_at") and hasattr(model_cls, "updated_at"):
755
+ # Reload from DB to get accurate timestamps
756
+ db_obj = model_cls.objects.filter(pk=obj.pk).values("created_at", "updated_at").first()
757
+ if db_obj:
758
+ created_at = db_obj["created_at"]
759
+ updated_at = db_obj["updated_at"]
760
+ if created_at and updated_at:
761
+ time_diff = abs((updated_at - created_at).total_seconds())
762
+ if time_diff <= 1.0: # Within 1 second = just created
763
+ created_objects.append(obj)
764
+ else:
765
+ updated_objects.append(obj)
766
+ else:
767
+ # No timestamps, default to created
768
+ created_objects.append(obj)
769
+ else:
770
+ # Object not found, treat as created
771
+ created_objects.append(obj)
772
+ else:
773
+ # No timestamp fields, default to created
774
+ created_objects.append(obj)
775
+
776
+ logger.info(f"Upsert after hooks: {len(created_objects)} created, {len(updated_objects)} updated")
777
+
741
778
  # Dispatch after_create hooks for created objects
742
779
  if created_objects:
743
780
  from django_bulk_hooks.helpers import build_changeset_for_create
781
+
744
782
  create_changeset = build_changeset_for_create(self.model_cls, created_objects)
745
-
783
+
746
784
  for model_cls in models_in_chain:
747
785
  model_changeset = self._build_changeset_for_model(create_changeset, model_cls)
748
786
  self.dispatcher.dispatch(model_changeset, "after_create", bypass_hooks=False)
749
-
787
+
750
788
  # Dispatch after_update hooks for updated objects
751
789
  if updated_objects:
752
790
  # Fetch old records for proper change detection
753
791
  old_records_map = self.analyzer.fetch_old_records_map(updated_objects)
754
-
792
+
755
793
  from django_bulk_hooks.helpers import build_changeset_for_update
794
+
756
795
  update_changeset = build_changeset_for_update(
757
796
  self.model_cls,
758
797
  updated_objects,
759
798
  update_kwargs={}, # Empty since we don't know specific fields
760
799
  old_records_map=old_records_map,
761
800
  )
762
-
801
+
763
802
  for model_cls in models_in_chain:
764
803
  model_changeset = self._build_changeset_for_model(update_changeset, model_cls)
765
804
  self.dispatcher.dispatch(model_changeset, "after_update", bypass_hooks=False)
766
-
805
+
767
806
  # Clean up temporary metadata
768
807
  self._cleanup_upsert_metadata(result_objects)
769
808
 
770
809
  def _cleanup_upsert_metadata(self, result_objects):
771
810
  """
772
811
  Clean up temporary metadata added during upsert operations.
773
-
812
+
774
813
  Args:
775
814
  result_objects: List of objects to clean up
776
815
  """
777
816
  for obj in result_objects:
778
- if hasattr(obj, '_bulk_hooks_was_created'):
779
- delattr(obj, '_bulk_hooks_was_created')
780
- if hasattr(obj, '_bulk_hooks_upsert_metadata'):
781
- delattr(obj, '_bulk_hooks_upsert_metadata')
817
+ if hasattr(obj, "_bulk_hooks_was_created"):
818
+ delattr(obj, "_bulk_hooks_was_created")
819
+ if hasattr(obj, "_bulk_hooks_upsert_metadata"):
820
+ delattr(obj, "_bulk_hooks_upsert_metadata")
@@ -20,7 +20,7 @@ class MTIHandler:
20
20
 
21
21
  This service detects MTI models and builds execution plans.
22
22
  It does NOT execute database operations - that's the BulkExecutor's job.
23
-
23
+
24
24
  Responsibilities:
25
25
  - Detect MTI models
26
26
  - Build inheritance chains
@@ -45,8 +45,9 @@ class MTIHandler:
45
45
  Returns:
46
46
  bool: True if model has concrete parent models
47
47
  """
48
- for parent in self.model_cls._meta.all_parents:
49
- if parent._meta.concrete_model != self.model_cls._meta.concrete_model:
48
+ # Check if this model has concrete parent models (not abstract)
49
+ for parent in self.model_cls._meta.parents.keys():
50
+ if not parent._meta.abstract and parent._meta.concrete_model != self.model_cls._meta.concrete_model:
50
51
  return True
51
52
  return False
52
53
 
@@ -73,15 +74,11 @@ class MTIHandler:
73
74
  current_model = self.model_cls
74
75
 
75
76
  while current_model:
76
- if not current_model._meta.proxy:
77
+ if not current_model._meta.proxy and not current_model._meta.abstract:
77
78
  chain.append(current_model)
78
79
 
79
- # Get concrete parent models
80
- parents = [
81
- parent
82
- for parent in current_model._meta.parents.keys()
83
- if not parent._meta.proxy
84
- ]
80
+ # Get concrete parent models (not abstract, not proxy)
81
+ parents = [parent for parent in current_model._meta.parents.keys() if not parent._meta.proxy and not parent._meta.abstract]
85
82
 
86
83
  current_model = parents[0] if parents else None
87
84
 
@@ -113,6 +110,37 @@ class MTIHandler:
113
110
  """
114
111
  return list(model_cls._meta.local_fields)
115
112
 
113
+ def find_model_with_unique_fields(self, unique_fields):
114
+ """
115
+ Find which model in the inheritance chain to query for existing records.
116
+
117
+ For MTI upsert operations, we need to determine if the parent record exists
118
+ to properly fire AFTER_CREATE vs AFTER_UPDATE hooks. This is critical because:
119
+ - If parent exists but child doesn't: creating child for existing parent → AFTER_UPDATE
120
+ - If neither exists: creating both parent and child → AFTER_CREATE
121
+
122
+ Therefore, we return the root parent model to check if the parent record exists,
123
+ regardless of where the unique fields are defined.
124
+
125
+ Args:
126
+ unique_fields: List of field names forming the unique constraint
127
+
128
+ Returns:
129
+ Model class to query for existing records (root parent for MTI)
130
+ """
131
+ if not unique_fields:
132
+ return self.model_cls
133
+
134
+ inheritance_chain = self.get_inheritance_chain()
135
+
136
+ # For MTI models with multiple levels, return the root parent model
137
+ # This ensures we check if the parent exists, which determines create vs update hooks
138
+ if len(inheritance_chain) > 1:
139
+ return inheritance_chain[0] # Root parent model
140
+
141
+ # For non-MTI models (shouldn't happen, but safe fallback)
142
+ return self.model_cls
143
+
116
144
  # ==================== MTI BULK CREATE PLANNING ====================
117
145
 
118
146
  def build_create_plan(
@@ -127,10 +155,10 @@ class MTIHandler:
127
155
  ):
128
156
  """
129
157
  Build an execution plan for bulk creating MTI model instances.
130
-
158
+
131
159
  This method does NOT execute any database operations.
132
160
  It returns a plan that the BulkExecutor will execute.
133
-
161
+
134
162
  Args:
135
163
  objs: List of model instances to create
136
164
  batch_size: Number of objects per batch
@@ -139,7 +167,7 @@ class MTIHandler:
139
167
  update_fields: Fields to update on conflict
140
168
  existing_record_ids: Set of id() for objects that exist in DB (from RecordClassifier)
141
169
  existing_pks_map: Dict mapping id(obj) -> pk for existing records (from RecordClassifier)
142
-
170
+
143
171
  Returns:
144
172
  MTICreatePlan object
145
173
  """
@@ -205,9 +233,9 @@ class MTIHandler:
205
233
  ):
206
234
  """
207
235
  Build parent level objects for each level in the inheritance chain.
208
-
236
+
209
237
  This is pure in-memory object creation - no DB operations.
210
-
238
+
211
239
  Returns:
212
240
  List of ParentLevel objects
213
241
  """
@@ -255,16 +283,14 @@ class MTIHandler:
255
283
  # Check if this model has a matching constraint
256
284
  if normalized_unique and self._has_matching_constraint(model_class, normalized_unique):
257
285
  # Filter update fields
258
- filtered_updates = [
259
- uf for uf in (update_fields or []) if uf in model_fields_by_name
260
- ]
286
+ filtered_updates = [uf for uf in (update_fields or []) if uf in model_fields_by_name]
261
287
 
262
288
  # If no fields to update at this level but we need upsert to prevent
263
289
  # unique constraint violations, use one of the unique fields as a dummy
264
290
  # update field (updating it to itself is a safe no-op)
265
291
  if not filtered_updates and normalized_unique:
266
292
  filtered_updates = [normalized_unique[0]]
267
-
293
+
268
294
  # Only enable upsert if we have fields to update (real or dummy)
269
295
  if filtered_updates:
270
296
  level_update_conflicts = True
@@ -288,10 +314,8 @@ class MTIHandler:
288
314
  """Check if model has a unique constraint matching the given fields."""
289
315
  try:
290
316
  from django.db.models import UniqueConstraint
291
- constraint_field_sets = [
292
- tuple(c.fields) for c in model_class._meta.constraints
293
- if isinstance(c, UniqueConstraint)
294
- ]
317
+
318
+ constraint_field_sets = [tuple(c.fields) for c in model_class._meta.constraints if isinstance(c, UniqueConstraint)]
295
319
  except Exception:
296
320
  constraint_field_sets = []
297
321
 
@@ -319,12 +343,12 @@ class MTIHandler:
319
343
  def _create_parent_instance(self, source_obj, parent_model, current_parent):
320
344
  """
321
345
  Create a parent instance from source object (in-memory only).
322
-
346
+
323
347
  Args:
324
348
  source_obj: Original object with data
325
349
  parent_model: Parent model class to create instance of
326
350
  current_parent: Parent instance from previous level (if any)
327
-
351
+
328
352
  Returns:
329
353
  Parent model instance (not saved)
330
354
  """
@@ -335,8 +359,7 @@ class MTIHandler:
335
359
  if hasattr(source_obj, field.name):
336
360
  value = getattr(source_obj, field.name, None)
337
361
  if value is not None:
338
- if (field.is_relation and not field.many_to_many and
339
- not field.one_to_many):
362
+ if field.is_relation and not field.many_to_many and not field.one_to_many:
340
363
  # Handle FK fields
341
364
  if hasattr(value, "pk") and value.pk is not None:
342
365
  setattr(parent_obj, field.attname, value.pk)
@@ -348,8 +371,7 @@ class MTIHandler:
348
371
  # Link to parent if exists
349
372
  if current_parent is not None:
350
373
  for field in parent_model._meta.local_fields:
351
- if (hasattr(field, "remote_field") and field.remote_field and
352
- field.remote_field.model == current_parent.__class__):
374
+ if hasattr(field, "remote_field") and field.remote_field and field.remote_field.model == current_parent.__class__:
353
375
  setattr(parent_obj, field.name, current_parent)
354
376
  break
355
377
 
@@ -373,13 +395,13 @@ class MTIHandler:
373
395
  def _create_child_instance_template(self, source_obj, child_model):
374
396
  """
375
397
  Create a child instance template (in-memory only, without parent links).
376
-
398
+
377
399
  The executor will add parent links after creating parent objects.
378
-
400
+
379
401
  Args:
380
402
  source_obj: Original object with data
381
403
  child_model: Child model class
382
-
404
+
383
405
  Returns:
384
406
  Child model instance (not saved, no parent links)
385
407
  """
@@ -399,8 +421,7 @@ class MTIHandler:
399
421
  if hasattr(source_obj, field.name):
400
422
  value = getattr(source_obj, field.name, None)
401
423
  if value is not None:
402
- if (field.is_relation and not field.many_to_many and
403
- not field.one_to_many):
424
+ if field.is_relation and not field.many_to_many and not field.one_to_many:
404
425
  if hasattr(value, "pk") and value.pk is not None:
405
426
  setattr(child_obj, field.attname, value.pk)
406
427
  else:
@@ -430,14 +451,14 @@ class MTIHandler:
430
451
  def build_update_plan(self, objs, fields, batch_size=None):
431
452
  """
432
453
  Build an execution plan for bulk updating MTI model instances.
433
-
454
+
434
455
  This method does NOT execute any database operations.
435
-
456
+
436
457
  Args:
437
458
  objs: List of model instances to update
438
459
  fields: List of field names to update
439
460
  batch_size: Number of objects per batch
440
-
461
+
441
462
  Returns:
442
463
  MTIUpdatePlan object
443
464
  """
@@ -497,11 +518,13 @@ class MTIHandler:
497
518
  break
498
519
  filter_field = parent_link.attname if parent_link else "pk"
499
520
 
500
- field_groups.append(ModelFieldGroup(
501
- model_class=model,
502
- fields=model_fields,
503
- filter_field=filter_field,
504
- ))
521
+ field_groups.append(
522
+ ModelFieldGroup(
523
+ model_class=model,
524
+ fields=model_fields,
525
+ filter_field=filter_field,
526
+ )
527
+ )
505
528
 
506
529
  return MTIUpdatePlan(
507
530
  inheritance_chain=inheritance_chain,