mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'debug-fix'
This commit is contained in:
commit
89306ddafb
4
.github/workflows/docker.yaml
vendored
4
.github/workflows/docker.yaml
vendored
@ -21,6 +21,10 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- 'master'
|
- 'master'
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'master'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
# Build the docker image
|
# Build the docker image
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -42,6 +42,7 @@ dummy_image.*
|
|||||||
_tmp.csv
|
_tmp.csv
|
||||||
inventree/label.pdf
|
inventree/label.pdf
|
||||||
inventree/label.png
|
inventree/label.png
|
||||||
|
inventree/my_special*
|
||||||
|
|
||||||
# Sphinx files
|
# Sphinx files
|
||||||
docs/_build
|
docs/_build
|
||||||
|
@ -59,6 +59,13 @@ def get_config_file(create=True) -> Path:
|
|||||||
def load_config_data() -> map:
|
def load_config_data() -> map:
|
||||||
"""Load configuration data from the config file."""
|
"""Load configuration data from the config file."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print("load_config_data()")
|
||||||
|
print("- cwd:", os.getcwd())
|
||||||
|
print("- exe:", sys.executable)
|
||||||
|
print("- path:", sys.path)
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
cfg_file = get_config_file()
|
cfg_file = get_config_file()
|
||||||
|
@ -31,6 +31,16 @@ INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
|
|||||||
# Determine if we are running in "test" mode e.g. "manage.py test"
|
# Determine if we are running in "test" mode e.g. "manage.py test"
|
||||||
TESTING = 'test' in sys.argv
|
TESTING = 'test' in sys.argv
|
||||||
|
|
||||||
|
# Note: The following fix is "required" for docker build workflow
|
||||||
|
# Note: 2022-12-12 still unsure why...
|
||||||
|
if TESTING:
|
||||||
|
# Ensure that sys.path includes global python libs
|
||||||
|
python_dir = os.path.dirname(sys.executable)
|
||||||
|
python_lib = os.path.join(python_dir, "lib", "site-packages")
|
||||||
|
|
||||||
|
if python_lib not in sys.path:
|
||||||
|
sys.path.append(python_lib)
|
||||||
|
|
||||||
# Are environment variables manipulated by tests? Needs to be set by testing code
|
# Are environment variables manipulated by tests? Needs to be set by testing code
|
||||||
TESTING_ENV = False
|
TESTING_ENV = False
|
||||||
|
|
||||||
|
@ -642,6 +642,9 @@ class CustomLoginView(LoginView):
|
|||||||
# Initiate form
|
# Initiate form
|
||||||
form = self.get_form_class()(request.GET.dict(), request=request)
|
form = self.get_form_class()(request.GET.dict(), request=request)
|
||||||
|
|
||||||
|
# Validate form data
|
||||||
|
form.is_valid()
|
||||||
|
|
||||||
# Try to login
|
# Try to login
|
||||||
form.full_clean()
|
form.full_clean()
|
||||||
return form.login(request)
|
return form.login(request)
|
||||||
|
@ -704,7 +704,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.pricing.schedule_for_update()
|
instance.part.part.schedule_pricing_update()
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break')
|
@receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break')
|
||||||
@ -714,4 +714,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.pricing.schedule_for_update()
|
instance.part.part.schedule_pricing_update()
|
||||||
|
@ -390,7 +390,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.pricing.schedule_for_update()
|
line.part.part.schedule_pricing_update()
|
||||||
|
|
||||||
trigger_event('purchaseorder.completed', id=self.pk)
|
trigger_event('purchaseorder.completed', id=self.pk)
|
||||||
|
|
||||||
@ -778,7 +778,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.pricing.schedule_for_update()
|
line.part.schedule_pricing_update()
|
||||||
|
|
||||||
trigger_event('salesorder.completed', id=self.pk)
|
trigger_event('salesorder.completed', id=self.pk)
|
||||||
|
|
||||||
|
@ -771,6 +771,10 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
'IPN': _('Duplicate IPN not allowed in part settings'),
|
'IPN': _('Duplicate IPN not allowed in part settings'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Ensure unique across (Name, revision, IPN) (as specified)
|
||||||
|
if Part.objects.exclude(pk=self.pk).filter(name=self.name, revision=self.revision, IPN=self.IPN).exists():
|
||||||
|
raise ValidationError(_("Part with this Name, IPN and Revision already exists."))
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Perform cleaning operations for the Part model.
|
"""Perform cleaning operations for the Part model.
|
||||||
|
|
||||||
@ -1699,6 +1703,20 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
|||||||
|
|
||||||
return pricing
|
return pricing
|
||||||
|
|
||||||
|
def schedule_pricing_update(self):
|
||||||
|
"""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
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.pricing.schedule_for_update()
|
||||||
|
except (PartPricing.DoesNotExist, IntegrityError):
|
||||||
|
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.
|
||||||
|
|
||||||
@ -2293,23 +2311,35 @@ class PartPricing(models.Model):
|
|||||||
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 self.pk is None:
|
try:
|
||||||
self.save()
|
self.refresh_from_db()
|
||||||
|
except (PartPricing.DoesNotExist, IntegrityError):
|
||||||
|
# Error thrown if this PartPricing instance has already been removed
|
||||||
|
return
|
||||||
|
|
||||||
self.refresh_from_db()
|
# Ensure that the referenced part still exists in the database
|
||||||
|
try:
|
||||||
|
p = self.part
|
||||||
|
p.refresh_from_db()
|
||||||
|
except IntegrityError:
|
||||||
|
return
|
||||||
|
|
||||||
if self.scheduled_for_update:
|
if self.scheduled_for_update:
|
||||||
# Ignore if the pricing is already scheduled to be updated
|
# Ignore if the pricing is already scheduled to be updated
|
||||||
logger.info(f"Pricing for {self.part} already scheduled for update - skipping")
|
logger.info(f"Pricing for {p} already scheduled for update - skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
if counter > 25:
|
if counter > 25:
|
||||||
# Prevent infinite recursion / stack depth issues
|
# Prevent infinite recursion / stack depth issues
|
||||||
logger.info(counter, f"Skipping pricing update for {self.part} - maximum depth exceeded")
|
logger.info(counter, f"Skipping pricing update for {p} - maximum depth exceeded")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.scheduled_for_update = True
|
try:
|
||||||
self.save()
|
self.scheduled_for_update = True
|
||||||
|
self.save()
|
||||||
|
except IntegrityError:
|
||||||
|
# An IntegrityError here likely indicates that the referenced part has already been deleted
|
||||||
|
return
|
||||||
|
|
||||||
import part.tasks as part_tasks
|
import part.tasks as part_tasks
|
||||||
|
|
||||||
@ -2372,7 +2402,11 @@ class PartPricing(models.Model):
|
|||||||
# Update the currency which was used to perform the calculation
|
# Update the currency which was used to perform the calculation
|
||||||
self.currency = currency_code_default()
|
self.currency = currency_code_default()
|
||||||
|
|
||||||
self.update_overall_cost()
|
try:
|
||||||
|
self.update_overall_cost()
|
||||||
|
except IntegrityError:
|
||||||
|
# If something has happened to the Part model, might throw an error
|
||||||
|
pass
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@ -3557,7 +3591,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.pricing.schedule_for_update()
|
instance.part.schedule_pricing_update()
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=BomItem, dispatch_uid='post_delete_bom_item')
|
@receiver(post_delete, sender=BomItem, dispatch_uid='post_delete_bom_item')
|
||||||
@ -3568,7 +3602,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.pricing.schedule_for_update()
|
instance.part.schedule_pricing_update()
|
||||||
|
|
||||||
|
|
||||||
class BomItemSubstitute(models.Model):
|
class BomItemSubstitute(models.Model):
|
||||||
|
@ -126,7 +126,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||||||
# Create parts in this category
|
# Create parts in this category
|
||||||
for jj in range(10):
|
for jj in range(10):
|
||||||
Part.objects.create(
|
Part.objects.create(
|
||||||
name=f"Part xyz {jj}",
|
name=f"Part xyz {jj}_{ii}",
|
||||||
description="A test part",
|
description="A test part",
|
||||||
category=child
|
category=child
|
||||||
)
|
)
|
||||||
@ -339,7 +339,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||||||
# Create parts in the category to be deleted
|
# Create parts in the category to be deleted
|
||||||
for jj in range(3):
|
for jj in range(3):
|
||||||
parts.append(Part.objects.create(
|
parts.append(Part.objects.create(
|
||||||
name=f"Part xyz {jj}",
|
name=f"Part xyz {i}_{jj}",
|
||||||
description="Child part of the deleted category",
|
description="Child part of the deleted category",
|
||||||
category=cat_to_delete
|
category=cat_to_delete
|
||||||
))
|
))
|
||||||
@ -349,7 +349,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||||||
# Create child categories under the category to be deleted
|
# Create child categories under the category to be deleted
|
||||||
for ii in range(3):
|
for ii in range(3):
|
||||||
child = PartCategory.objects.create(
|
child = PartCategory.objects.create(
|
||||||
name=f"Child parent_cat {ii}",
|
name=f"Child parent_cat {i}_{ii}",
|
||||||
description="A child category of the deleted category",
|
description="A child category of the deleted category",
|
||||||
parent=cat_to_delete
|
parent=cat_to_delete
|
||||||
)
|
)
|
||||||
@ -358,7 +358,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
|||||||
# Create parts in the child categories
|
# Create parts in the child categories
|
||||||
for jj in range(3):
|
for jj in range(3):
|
||||||
child_categories_parts.append(Part.objects.create(
|
child_categories_parts.append(Part.objects.create(
|
||||||
name=f"Part xyz {jj}",
|
name=f"Part xyz {i}_{jj}_{ii}",
|
||||||
description="Child part in the child category of the deleted category",
|
description="Child part in the child category of the deleted category",
|
||||||
category=child
|
category=child
|
||||||
))
|
))
|
||||||
@ -881,11 +881,15 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
"""
|
"""
|
||||||
url = reverse('api-part-list')
|
url = reverse('api-part-list')
|
||||||
|
|
||||||
response = self.post(url, {
|
response = self.post(
|
||||||
'name': 'all defaults',
|
url,
|
||||||
'description': 'my test part',
|
{
|
||||||
'category': 1,
|
'name': 'all defaults',
|
||||||
})
|
'description': 'my test part',
|
||||||
|
'category': 1,
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
data = response.data
|
data = response.data
|
||||||
|
|
||||||
@ -903,23 +907,31 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.user
|
self.user
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.post(url, {
|
response = self.post(
|
||||||
'name': 'all defaults',
|
url,
|
||||||
'description': 'my test part 2',
|
{
|
||||||
'category': 1,
|
'name': 'all defaults 2',
|
||||||
})
|
'description': 'my test part 2',
|
||||||
|
'category': 1,
|
||||||
|
},
|
||||||
|
expected_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
# Part should now be purchaseable by default
|
# Part should now be purchaseable by default
|
||||||
self.assertTrue(response.data['purchaseable'])
|
self.assertTrue(response.data['purchaseable'])
|
||||||
|
|
||||||
# "default" values should not be used if the value is specified
|
# "default" values should not be used if the value is specified
|
||||||
response = self.post(url, {
|
response = self.post(
|
||||||
'name': 'all defaults',
|
url,
|
||||||
'description': 'my test part 2',
|
{
|
||||||
'category': 1,
|
'name': 'all defaults 3',
|
||||||
'active': False,
|
'description': 'my test part 3',
|
||||||
'purchaseable': False,
|
'category': 1,
|
||||||
})
|
'active': False,
|
||||||
|
'purchaseable': False,
|
||||||
|
},
|
||||||
|
expected_code=201
|
||||||
|
)
|
||||||
|
|
||||||
self.assertFalse(response.data['active'])
|
self.assertFalse(response.data['active'])
|
||||||
self.assertFalse(response.data['purchaseable'])
|
self.assertFalse(response.data['purchaseable'])
|
||||||
@ -2752,3 +2764,18 @@ class PartInternalPriceBreakTest(InvenTreeAPITestCase):
|
|||||||
round(Decimal(data['price']), 4),
|
round(Decimal(data['price']), 4),
|
||||||
round(Decimal(p), 4)
|
round(Decimal(p), 4)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Now, ensure that we can delete the Part via the API
|
||||||
|
# In particular this test checks that there are no circular post_delete relationships
|
||||||
|
# Ref: https://github.com/inventree/InvenTree/pull/3986
|
||||||
|
|
||||||
|
# First, ensure the part instance can be deleted
|
||||||
|
p = Part.objects.get(pk=1)
|
||||||
|
p.active = False
|
||||||
|
p.save()
|
||||||
|
|
||||||
|
response = self.delete(reverse("api-part-detail", kwargs={"pk": 1}))
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
with self.assertRaises(Part.DoesNotExist):
|
||||||
|
p.refresh_from_db()
|
||||||
|
@ -328,3 +328,44 @@ class PartPricingTests(InvenTreeTestCase):
|
|||||||
|
|
||||||
self.assertEqual(pricing.purchase_cost_min, Money('1.333333', 'USD'))
|
self.assertEqual(pricing.purchase_cost_min, Money('1.333333', 'USD'))
|
||||||
self.assertEqual(pricing.purchase_cost_max, Money('1.764706', 'USD'))
|
self.assertEqual(pricing.purchase_cost_max, Money('1.764706', 'USD'))
|
||||||
|
|
||||||
|
def test_delete_with_pricing(self):
|
||||||
|
"""Test for deleting a part which has pricing information"""
|
||||||
|
|
||||||
|
# Create some pricing data
|
||||||
|
self.create_price_breaks()
|
||||||
|
|
||||||
|
# Check that pricing does exist
|
||||||
|
pricing = self.part.pricing
|
||||||
|
|
||||||
|
pricing.update_pricing()
|
||||||
|
pricing.save()
|
||||||
|
|
||||||
|
self.assertIsNotNone(pricing.overall_min)
|
||||||
|
self.assertIsNotNone(pricing.overall_max)
|
||||||
|
|
||||||
|
self.part.active = False
|
||||||
|
self.part.save()
|
||||||
|
|
||||||
|
# Remove the part from the database
|
||||||
|
self.part.delete()
|
||||||
|
|
||||||
|
# Check that the pricing was removed also
|
||||||
|
with self.assertRaises(part.models.PartPricing.DoesNotExist):
|
||||||
|
pricing.refresh_from_db()
|
||||||
|
|
||||||
|
def test_delete_without_pricing(self):
|
||||||
|
"""Test that we can delete a part which does not have pricing information"""
|
||||||
|
|
||||||
|
pricing = self.part.pricing
|
||||||
|
|
||||||
|
self.assertIsNone(pricing.pk)
|
||||||
|
|
||||||
|
self.part.active = False
|
||||||
|
self.part.save()
|
||||||
|
|
||||||
|
self.part.delete()
|
||||||
|
|
||||||
|
# Check that part was actually deleted
|
||||||
|
with self.assertRaises(part.models.Part.DoesNotExist):
|
||||||
|
self.part.refresh_from_db()
|
||||||
|
Loading…
Reference in New Issue
Block a user