Pricing bug fix (#4422)

* Control when a new PartPricing object can be created

- Prevent this when calling from an on_delete signal
- There is an edge case where deleting a part triggers a series of on_delete signals inside an atomic transaction
- When the new PartPricing object is created,

* Add unit testing:

- Ensure PartPricing gets created when a new StockItem is added
- Part.delete() works without error
- PartPricing instances are deleted also

* style fixes
This commit is contained in:
Oliver 2023-02-26 16:36:11 +11:00 committed by GitHub
parent 0c5dc2865c
commit b657fb4405
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 72 additions and 15 deletions

View File

@ -23,7 +23,7 @@ def migrate_currencies(apps, schema_editor):
for the SupplierPriceBreak model, to a new django-money compatible currency. 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 # A list of available currency codes
currency_codes = CURRENCIES.keys() currency_codes = CURRENCIES.keys()

View File

@ -722,7 +722,7 @@ def after_save_supplier_price(sender, instance, created, **kwargs):
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if instance.part and instance.part.part: 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') @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 InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
if instance.part and instance.part.part: if instance.part and instance.part.part:
instance.part.part.schedule_pricing_update() instance.part.part.schedule_pricing_update(create=False)

View File

@ -389,7 +389,7 @@ class PurchaseOrder(Order):
# Schedule pricing update for any referenced parts # Schedule pricing update for any referenced parts
for line in self.lines.all(): for line in self.lines.all():
if line.part and line.part.part: 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) trigger_event('purchaseorder.completed', id=self.pk)
@ -782,7 +782,7 @@ class SalesOrder(Order):
# Schedule pricing update for any referenced parts # Schedule pricing update for any referenced parts
for line in self.lines.all(): 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) trigger_event('salesorder.completed', id=self.pk)

View File

@ -24,7 +24,7 @@ def migrate_currencies(apps, schema_editor):
for the SupplierPriceBreak model, to a new django-money compatible currency. 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 # A list of available currency codes
currency_codes = CURRENCIES.keys() currency_codes = CURRENCIES.keys()

View File

@ -1678,17 +1678,33 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
return pricing return pricing
def schedule_pricing_update(self): def schedule_pricing_update(self, create: bool = False):
"""Helper function to schedule a pricing update. """Helper function to schedule a pricing update.
Importantly, catches any errors which may occur during deletion of related objects, Importantly, catches any errors which may occur during deletion of related objects,
in particular due to post_delete signals. in particular due to post_delete signals.
Ref: https://github.com/inventree/InvenTree/pull/3986 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 try:
pricing.schedule_for_update() 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): def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
"""Return a simplified pricing string for this part. """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): def schedule_for_update(self, counter: int = 0):
"""Schedule this pricing to be updated""" """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: try:
if self.pk: if self.pk:
self.refresh_from_db() 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 # Update part pricing *unless* we are importing data
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): 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') @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 # Update part pricing *unless* we are importing data
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData(): 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): class BomItemSubstitute(models.Model):

View File

@ -448,3 +448,40 @@ class PartPricingTests(InvenTreeTestCase):
from django_q.models import OrmQ from django_q.models import OrmQ
self.assertEqual(OrmQ.objects.count(), 101) 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())

View File

@ -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) InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part)
# Schedule an update on parent part pricing # Schedule an update on parent part pricing
if InvenTree.ready.canAppAccessDatabase(): if InvenTree.ready.canAppAccessDatabase(allow_test=True):
instance.part.schedule_pricing_update() instance.part.schedule_pricing_update(create=False)
@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') @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 # Run this check in the background
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part) InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part)
if InvenTree.ready.canAppAccessDatabase(): if InvenTree.ready.canAppAccessDatabase(allow_test=True):
instance.part.schedule_pricing_update() instance.part.schedule_pricing_update(create=True)
class StockItemAttachment(InvenTreeAttachment): class StockItemAttachment(InvenTreeAttachment):