diff --git a/InvenTree/company/migrations/0026_auto_20201110_1011.py b/InvenTree/company/migrations/0026_auto_20201110_1011.py index f61b64d29f..4b1f155458 100644 --- a/InvenTree/company/migrations/0026_auto_20201110_1011.py +++ b/InvenTree/company/migrations/0026_auto_20201110_1011.py @@ -23,7 +23,7 @@ def migrate_currencies(apps, schema_editor): for the SupplierPriceBreak model, to a new django-money compatible currency. """ - logger.info("Updating currency references for SupplierPriceBreak model...") + logger.debug("Updating currency references for SupplierPriceBreak model...") # A list of available currency codes currency_codes = CURRENCIES.keys() diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 99010b8d72..b6af76a022 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -722,7 +722,7 @@ def after_save_supplier_price(sender, instance, created, **kwargs): if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): if instance.part and instance.part.part: - instance.part.part.schedule_pricing_update() + instance.part.part.schedule_pricing_update(create=True) @receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break') @@ -732,4 +732,4 @@ def after_delete_supplier_price(sender, instance, **kwargs): if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): if instance.part and instance.part.part: - instance.part.part.schedule_pricing_update() + instance.part.part.schedule_pricing_update(create=False) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index e1e82d97fe..27be5c8f5d 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -389,7 +389,7 @@ class PurchaseOrder(Order): # Schedule pricing update for any referenced parts for line in self.lines.all(): if line.part and line.part.part: - line.part.part.schedule_pricing_update() + line.part.part.schedule_pricing_update(create=True) trigger_event('purchaseorder.completed', id=self.pk) @@ -782,7 +782,7 @@ class SalesOrder(Order): # Schedule pricing update for any referenced parts for line in self.lines.all(): - line.part.schedule_pricing_update() + line.part.schedule_pricing_update(create=True) trigger_event('salesorder.completed', id=self.pk) diff --git a/InvenTree/part/migrations/0056_auto_20201110_1125.py b/InvenTree/part/migrations/0056_auto_20201110_1125.py index bb3d930f99..f3d0faa26b 100644 --- a/InvenTree/part/migrations/0056_auto_20201110_1125.py +++ b/InvenTree/part/migrations/0056_auto_20201110_1125.py @@ -24,7 +24,7 @@ def migrate_currencies(apps, schema_editor): for the SupplierPriceBreak model, to a new django-money compatible currency. """ - logger.info("Updating currency references for SupplierPriceBreak model...") + logger.debug("Updating currency references for SupplierPriceBreak model...") # A list of available currency codes currency_codes = CURRENCIES.keys() diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index fe6d1f37e3..cf9fb516d8 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1678,17 +1678,33 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): return pricing - def schedule_pricing_update(self): + def schedule_pricing_update(self, create: bool = False): """Helper function to schedule a pricing update. Importantly, catches any errors which may occur during deletion of related objects, in particular due to post_delete signals. Ref: https://github.com/inventree/InvenTree/pull/3986 + + Arguments: + create: Whether or not a new PartPricing object should be created if it does not already exist """ - pricing = self.pricing - pricing.schedule_for_update() + try: + self.refresh_from_db() + except Part.DoesNotExist: + return + + try: + pricing = self.pricing + + if create or pricing.pk: + pricing.schedule_for_update() + except IntegrityError: + # If this part instance has been deleted, + # some post-delete or post-save signals may still be fired + # which can cause issues down the track + pass def get_price_info(self, quantity=1, buy=True, bom=True, internal=False): """Return a simplified pricing string for this part. @@ -2261,6 +2277,10 @@ class PartPricing(common.models.MetaMixin): def schedule_for_update(self, counter: int = 0): """Schedule this pricing to be updated""" + if not self.part or not self.part.pk or not Part.objects.filter(pk=self.part.pk).exists(): + logger.warning("Referenced part instance does not exist - skipping pricing update.") + return + try: if self.pk: self.refresh_from_db() @@ -3710,7 +3730,7 @@ def update_pricing_after_edit(sender, instance, created, **kwargs): # Update part pricing *unless* we are importing data if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): - instance.part.schedule_pricing_update() + instance.part.schedule_pricing_update(create=True) @receiver(post_delete, sender=BomItem, dispatch_uid='post_delete_bom_item') @@ -3721,7 +3741,7 @@ def update_pricing_after_delete(sender, instance, **kwargs): # Update part pricing *unless* we are importing data if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): - instance.part.schedule_pricing_update() + instance.part.schedule_pricing_update(create=False) class BomItemSubstitute(models.Model): diff --git a/InvenTree/part/test_pricing.py b/InvenTree/part/test_pricing.py index 8e715e4d7c..a85ead3360 100644 --- a/InvenTree/part/test_pricing.py +++ b/InvenTree/part/test_pricing.py @@ -448,3 +448,40 @@ class PartPricingTests(InvenTreeTestCase): from django_q.models import OrmQ self.assertEqual(OrmQ.objects.count(), 101) + + def test_delete_part_with_stock_items(self): + """Test deleting a part instance with stock items. + + This is to test a specific edge condition which was discovered that caused an IntegrityError. + Ref: https://github.com/inventree/InvenTree/issues/4419 + + Essentially a series of on_delete listeners caused a new PartPricing object to be created, + but it pointed to a Part instance which was slated to be deleted inside an atomic transaction. + """ + + p = part.models.Part.objects.create( + name="my part", + description="my part description", + active=False, + ) + + # Create some stock items + for _idx in range(3): + stock.models.StockItem.objects.create( + part=p, + quantity=10, + purchase_price=Money(10, 'USD') + ) + + # Check that a PartPricing object exists + self.assertTrue(part.models.PartPricing.objects.filter(part=p).exists()) + + # Delete the part + p.delete() + + # Check that the PartPricing object has been deleted + self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists()) + + # Try to update pricing (should fail gracefully as the Part has been deleted) + p.schedule_pricing_update(create=False) + self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists()) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 77928b3fee..4dc95c313c 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -2004,8 +2004,8 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs): InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part) # Schedule an update on parent part pricing - if InvenTree.ready.canAppAccessDatabase(): - instance.part.schedule_pricing_update() + if InvenTree.ready.canAppAccessDatabase(allow_test=True): + instance.part.schedule_pricing_update(create=False) @receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') @@ -2017,8 +2017,8 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs): # Run this check in the background InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part) - if InvenTree.ready.canAppAccessDatabase(): - instance.part.schedule_pricing_update() + if InvenTree.ready.canAppAccessDatabase(allow_test=True): + instance.part.schedule_pricing_update(create=True) class StockItemAttachment(InvenTreeAttachment):