mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-20 13:56:30 +00:00
Merge branch 'master' into part-import
This commit is contained in:
@ -111,6 +111,13 @@ class PartCategoryResource(ModelResource):
|
||||
PartCategory.objects.rebuild()
|
||||
|
||||
|
||||
class PartCategoryInline(admin.TabularInline):
|
||||
"""
|
||||
Inline for PartCategory model
|
||||
"""
|
||||
model = PartCategory
|
||||
|
||||
|
||||
class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
|
||||
resource_class = PartCategoryResource
|
||||
@ -119,6 +126,10 @@ class PartCategoryAdmin(ImportExportModelAdmin):
|
||||
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
inlines = [
|
||||
PartCategoryInline,
|
||||
]
|
||||
|
||||
|
||||
class PartRelatedAdmin(admin.ModelAdmin):
|
||||
''' Class to manage PartRelated objects '''
|
||||
|
@ -706,6 +706,7 @@ class PartList(generics.ListCreateAPIView):
|
||||
'creation_date',
|
||||
'IPN',
|
||||
'in_stock',
|
||||
'category',
|
||||
]
|
||||
|
||||
# Default ordering
|
||||
|
@ -39,7 +39,8 @@ class PartConfig(AppConfig):
|
||||
logger.debug("InvenTree: Checking Part image thumbnails")
|
||||
|
||||
try:
|
||||
for part in Part.objects.all():
|
||||
# Only check parts which have images
|
||||
for part in Part.objects.exclude(image=None):
|
||||
if part.image:
|
||||
url = part.image.thumbnail.name
|
||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
|
||||
part.image.render_variations(replace=False)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Image file '{part.image}' missing")
|
||||
part.image = None
|
||||
part.save()
|
||||
pass
|
||||
except UnidentifiedImageError:
|
||||
logger.warning(f"Image file '{part.image}' is invalid")
|
||||
except (OperationalError, ProgrammingError):
|
||||
|
@ -6,7 +6,7 @@
|
||||
name: 'M2x4 LPHS'
|
||||
description: 'M2x4 low profile head screw'
|
||||
category: 8
|
||||
link: www.acme.com/parts/m2x4lphs
|
||||
link: http://www.acme.com/parts/m2x4lphs
|
||||
tree_id: 0
|
||||
purchaseable: True
|
||||
level: 0
|
||||
@ -56,6 +56,7 @@
|
||||
fields:
|
||||
name: 'C_22N_0805'
|
||||
description: '22nF capacitor in 0805 package'
|
||||
purchaseable: true
|
||||
category: 3
|
||||
tree_id: 0
|
||||
level: 0
|
||||
|
@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
if count > 0:
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
"""
|
||||
|
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal file
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-21 23:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0067_partinternalpricebreak'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='part',
|
||||
constraint=models.UniqueConstraint(fields=('name', 'IPN', 'revision'), name='unique_part'),
|
||||
),
|
||||
]
|
@ -321,6 +321,9 @@ class Part(MPTTModel):
|
||||
verbose_name = _("Part")
|
||||
verbose_name_plural = _("Parts")
|
||||
ordering = ['name', ]
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
|
||||
]
|
||||
|
||||
class MPTTMeta:
|
||||
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
||||
@ -379,8 +382,7 @@ class Part(MPTTModel):
|
||||
logger.info(f"Deleting unused image file '{previous.image}'")
|
||||
previous.image.delete(save=False)
|
||||
|
||||
self.clean()
|
||||
self.validate_unique()
|
||||
self.full_clean()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -643,23 +645,6 @@ class Part(MPTTModel):
|
||||
'IPN': _('Duplicate IPN not allowed in part settings'),
|
||||
})
|
||||
|
||||
# Part name uniqueness should be case insensitive
|
||||
try:
|
||||
parts = Part.objects.exclude(id=self.id).filter(
|
||||
name__iexact=self.name,
|
||||
IPN__iexact=self.IPN,
|
||||
revision__iexact=self.revision)
|
||||
|
||||
if parts.exists():
|
||||
msg = _("Part must be unique for name, IPN and revision")
|
||||
raise ValidationError({
|
||||
"name": msg,
|
||||
"IPN": msg,
|
||||
"revision": msg,
|
||||
})
|
||||
except Part.DoesNotExist:
|
||||
pass
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Perform cleaning operations for the Part model
|
||||
@ -1494,16 +1479,17 @@ class Part(MPTTModel):
|
||||
|
||||
return True
|
||||
|
||||
def get_price_info(self, quantity=1, buy=True, bom=True):
|
||||
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
|
||||
""" Return a simplified pricing string for this part
|
||||
|
||||
Args:
|
||||
quantity: Number of units to calculate price for
|
||||
buy: Include supplier pricing (default = True)
|
||||
bom: Include BOM pricing (default = True)
|
||||
internal: Include internal pricing (default = False)
|
||||
"""
|
||||
|
||||
price_range = self.get_price_range(quantity, buy, bom)
|
||||
price_range = self.get_price_range(quantity, buy, bom, internal)
|
||||
|
||||
if price_range is None:
|
||||
return None
|
||||
@ -1591,9 +1577,10 @@ class Part(MPTTModel):
|
||||
|
||||
- Supplier price (if purchased from suppliers)
|
||||
- BOM price (if built from other parts)
|
||||
- Internal price (if set for the part)
|
||||
|
||||
Returns:
|
||||
Minimum of the supplier price or BOM price. If no pricing available, returns None
|
||||
Minimum of the supplier, BOM or internal price. If no pricing available, returns None
|
||||
"""
|
||||
|
||||
# only get internal price if set and should be used
|
||||
@ -2514,7 +2501,9 @@ class BomItem(models.Model):
|
||||
def price_range(self):
|
||||
""" Return the price-range for this BOM item. """
|
||||
|
||||
prange = self.sub_part.get_price_range(self.quantity)
|
||||
# get internal price setting
|
||||
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||
prange = self.sub_part.get_price_range(self.quantity, intenal=use_internal)
|
||||
|
||||
if prange is None:
|
||||
return prange
|
||||
|
@ -7,12 +7,15 @@ from decimal import Decimal
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Coalesce
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
InvenTreeModelSerializer)
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from djmoney.contrib.django_rest_framework import MoneyField
|
||||
|
||||
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer)
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||
@ -300,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
stock_item_count = serializers.IntegerField(read_only=True)
|
||||
suppliers = serializers.IntegerField(read_only=True)
|
||||
|
||||
image = serializers.CharField(source='get_image_url', read_only=True)
|
||||
image = InvenTreeImageSerializerField(required=False, allow_null=True)
|
||||
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||
starred = serializers.SerializerMethodField()
|
||||
|
||||
|
@ -138,7 +138,7 @@
|
||||
<hr>
|
||||
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
|
||||
The part single price is the current purchase price for that supplier part."></i></h4>
|
||||
{% if price_history|length > 1 %}
|
||||
{% if price_history|length > 0 %}
|
||||
<div style="max-width: 99%; min-height: 300px">
|
||||
<canvas id="StockPriceChart"></canvas>
|
||||
</div>
|
||||
|
@ -239,11 +239,19 @@
|
||||
|
||||
enableDragAndDrop(
|
||||
'#part-thumb',
|
||||
"{% url 'part-image-upload' part.id %}",
|
||||
"{% url 'api-part-detail' part.id %}",
|
||||
{
|
||||
label: 'image',
|
||||
method: 'PATCH',
|
||||
success: function(data, status, xhr) {
|
||||
location.reload();
|
||||
|
||||
// If image / thumbnail data present, live update
|
||||
if (data.image) {
|
||||
$('#part-image').attr('src', data.image);
|
||||
} else {
|
||||
// Otherwise, reload the page
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -5,6 +5,7 @@ over and above the built-in Django tags.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings as djangosettings
|
||||
@ -114,6 +115,14 @@ def inventree_title(*args, **kwargs):
|
||||
return version.inventreeInstanceTitle()
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def python_version(*args, **kwargs):
|
||||
"""
|
||||
Return the current python version
|
||||
"""
|
||||
return sys.version.split(' ')[0]
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inventree_version(*args, **kwargs):
|
||||
""" Return InvenTree version string """
|
||||
|
@ -1,14 +1,19 @@
|
||||
from rest_framework import status
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import PIL
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
from company.models import Company
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import StockStatus
|
||||
|
||||
from part.models import Part, PartCategory
|
||||
from stock.models import StockItem
|
||||
from company.models import Company
|
||||
|
||||
|
||||
class PartAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
@ -230,6 +235,18 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
response = self.client.get(url, data={'part': 10004})
|
||||
self.assertEqual(len(response.data), 7)
|
||||
|
||||
# Try to post a new object (missing description)
|
||||
response = self.client.post(
|
||||
url,
|
||||
data={
|
||||
'part': 10000,
|
||||
'test_name': 'My very first test',
|
||||
'required': False,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# Try to post a new object (should succeed)
|
||||
response = self.client.post(
|
||||
url,
|
||||
@ -237,6 +254,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
'part': 10000,
|
||||
'test_name': 'New Test',
|
||||
'required': True,
|
||||
'description': 'a test description'
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
@ -248,7 +266,8 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
url,
|
||||
data={
|
||||
'part': 10004,
|
||||
'test_name': " newtest"
|
||||
'test_name': " newtest",
|
||||
'description': 'dafsdf',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
@ -293,6 +312,239 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(len(data['results']), n)
|
||||
|
||||
|
||||
class PartDetailTests(InvenTreeAPITestCase):
|
||||
"""
|
||||
Test that we can create / edit / delete Part objects via the API
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'test_templates',
|
||||
]
|
||||
|
||||
roles = [
|
||||
'part.change',
|
||||
'part.add',
|
||||
'part.delete',
|
||||
'part_category.change',
|
||||
'part_category.add',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_part_operations(self):
|
||||
n = Part.objects.count()
|
||||
|
||||
# Create a part
|
||||
response = self.client.post(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'name': 'my test api part',
|
||||
'description': 'a part created with the API',
|
||||
'category': 1,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
pk = response.data['pk']
|
||||
|
||||
# Check that a new part has been added
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
part = Part.objects.get(pk=pk)
|
||||
|
||||
self.assertEqual(part.name, 'my test api part')
|
||||
|
||||
# Edit the part
|
||||
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||
|
||||
# Let's change the name of the part
|
||||
|
||||
response = self.client.patch(url, {
|
||||
'name': 'a new better name',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['pk'], pk)
|
||||
self.assertEqual(response.data['name'], 'a new better name')
|
||||
|
||||
part = Part.objects.get(pk=pk)
|
||||
|
||||
# Name has been altered
|
||||
self.assertEqual(part.name, 'a new better name')
|
||||
|
||||
# Part count should not have changed
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
# Now, try to set the name to the *same* value
|
||||
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
|
||||
response = self.client.patch(url, {
|
||||
'name': 'a new better name',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Try to remove the part
|
||||
response = self.client.delete(url)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
# Part count should have reduced
|
||||
self.assertEqual(Part.objects.count(), n)
|
||||
|
||||
def test_duplicates(self):
|
||||
"""
|
||||
Check that trying to create 'duplicate' parts results in errors
|
||||
"""
|
||||
|
||||
# Create a part
|
||||
response = self.client.post(reverse('api-part-list'), {
|
||||
'name': 'part',
|
||||
'description': 'description',
|
||||
'IPN': 'IPN-123',
|
||||
'category': 1,
|
||||
'revision': 'A',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
n = Part.objects.count()
|
||||
|
||||
# Check that we cannot create a duplicate in a different category
|
||||
response = self.client.post(reverse('api-part-list'), {
|
||||
'name': 'part',
|
||||
'description': 'description',
|
||||
'IPN': 'IPN-123',
|
||||
'category': 2,
|
||||
'revision': 'A',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# Check that only 1 matching part exists
|
||||
parts = Part.objects.filter(
|
||||
name='part',
|
||||
description='description',
|
||||
IPN='IPN-123'
|
||||
)
|
||||
|
||||
self.assertEqual(parts.count(), 1)
|
||||
|
||||
# A new part should *not* have been created
|
||||
self.assertEqual(Part.objects.count(), n)
|
||||
|
||||
# But a different 'revision' *can* be created
|
||||
response = self.client.post(reverse('api-part-list'), {
|
||||
'name': 'part',
|
||||
'description': 'description',
|
||||
'IPN': 'IPN-123',
|
||||
'category': 2,
|
||||
'revision': 'B',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
# Now, check that we cannot *change* an existing part to conflict
|
||||
pk = response.data['pk']
|
||||
|
||||
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||
|
||||
# Attempt to alter the revision code
|
||||
response = self.client.patch(
|
||||
url,
|
||||
{
|
||||
'revision': 'A',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# But we *can* change it to a unique revision code
|
||||
response = self.client.patch(
|
||||
url,
|
||||
{
|
||||
'revision': 'C',
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_image_upload(self):
|
||||
"""
|
||||
Test that we can upload an image to the part API
|
||||
"""
|
||||
|
||||
self.assignRole('part.add')
|
||||
|
||||
# Create a new part
|
||||
response = self.client.post(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'name': 'imagine',
|
||||
'description': 'All the people',
|
||||
'category': 1,
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
pk = response.data['pk']
|
||||
|
||||
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||
|
||||
p = Part.objects.get(pk=pk)
|
||||
|
||||
# Part should not have an image!
|
||||
with self.assertRaises(ValueError):
|
||||
print(p.image.file)
|
||||
|
||||
# Create a custom APIClient for file uploads
|
||||
# Ref: https://stackoverflow.com/questions/40453947/how-to-generate-a-file-upload-test-request-with-django-rest-frameworks-apireq
|
||||
upload_client = APIClient()
|
||||
upload_client.force_authenticate(user=self.user)
|
||||
|
||||
# Try to upload a non-image file
|
||||
with open('dummy_image.txt', 'w') as dummy_image:
|
||||
dummy_image.write('hello world')
|
||||
|
||||
with open('dummy_image.txt', 'rb') as dummy_image:
|
||||
response = upload_client.patch(
|
||||
url,
|
||||
{
|
||||
'image': dummy_image,
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# Now try to upload a valid image file
|
||||
img = PIL.Image.new('RGB', (128, 128), color='red')
|
||||
img.save('dummy_image.jpg')
|
||||
|
||||
with open('dummy_image.jpg', 'rb') as dummy_image:
|
||||
response = upload_client.patch(
|
||||
url,
|
||||
{
|
||||
'image': dummy_image,
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# And now check that the image has been set
|
||||
p = Part.objects.get(pk=pk)
|
||||
|
||||
print("Image:", p.image.file)
|
||||
|
||||
|
||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Tests to ensure that the various aggregation annotations are working correctly...
|
||||
@ -319,6 +571,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
# Add a new part
|
||||
self.part = Part.objects.create(
|
||||
name='Banana',
|
||||
description='This is a banana',
|
||||
category=PartCategory.objects.get(pk=1),
|
||||
)
|
||||
|
||||
# Create some stock items associated with the part
|
||||
|
@ -23,7 +23,7 @@ class TestParams(TestCase):
|
||||
def test_str(self):
|
||||
|
||||
t1 = PartParameterTemplate.objects.get(pk=1)
|
||||
self.assertEquals(str(t1), 'Length (mm)')
|
||||
self.assertEqual(str(t1), 'Length (mm)')
|
||||
|
||||
p1 = PartParameter.objects.get(pk=1)
|
||||
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
|
||||
|
@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import os
|
||||
|
||||
from .models import Part, PartTestTemplate
|
||||
from .models import Part, PartCategory, PartTestTemplate
|
||||
from .models import rename_part_image, match_part_names
|
||||
from .templatetags import inventree_extras
|
||||
|
||||
@ -78,6 +78,61 @@ class PartTest(TestCase):
|
||||
p = Part.objects.get(pk=100)
|
||||
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
||||
|
||||
def test_duplicate(self):
|
||||
"""
|
||||
Test that we cannot create a "duplicate" Part
|
||||
"""
|
||||
|
||||
n = Part.objects.count()
|
||||
|
||||
cat = PartCategory.objects.get(pk=1)
|
||||
|
||||
Part.objects.create(
|
||||
category=cat,
|
||||
name='part',
|
||||
description='description',
|
||||
IPN='IPN',
|
||||
revision='A',
|
||||
)
|
||||
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
part = Part(
|
||||
category=cat,
|
||||
name='part',
|
||||
description='description',
|
||||
IPN='IPN',
|
||||
revision='A',
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
part.validate_unique()
|
||||
|
||||
try:
|
||||
part.save()
|
||||
self.assertTrue(False)
|
||||
except:
|
||||
pass
|
||||
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
# But we should be able to create a part with a different revision
|
||||
part_2 = Part.objects.create(
|
||||
category=cat,
|
||||
name='part',
|
||||
description='description',
|
||||
IPN='IPN',
|
||||
revision='B',
|
||||
)
|
||||
|
||||
self.assertEqual(Part.objects.count(), n + 2)
|
||||
|
||||
# Now, check that we cannot *change* part_2 to conflict
|
||||
part_2.revision = 'A'
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
part_2.validate_unique()
|
||||
|
||||
def test_metadata(self):
|
||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||
@ -277,21 +332,24 @@ class PartSettingsTest(TestCase):
|
||||
"""
|
||||
|
||||
# Create a part
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='A')
|
||||
|
||||
# Attempt to create a duplicate item (should fail)
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
|
||||
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='A')
|
||||
part.validate_unique()
|
||||
|
||||
# Attempt to create item with duplicate IPN (should be allowed by default)
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||
|
||||
# And attempt again with the same values (should fail)
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||
part.validate_unique()
|
||||
|
||||
# Now update the settings so duplicate IPN values are *not* allowed
|
||||
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='C')
|
||||
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
|
||||
part.full_clean()
|
||||
|
@ -981,7 +981,7 @@ class PartPricingView(PartDetail):
|
||||
part = self.get_part()
|
||||
# Stock history
|
||||
if part.total_stock > 1:
|
||||
ret = []
|
||||
price_history = []
|
||||
stock = part.stock_entries(include_variants=False, in_stock=True) # .order_by('purchase_order__date')
|
||||
stock = stock.prefetch_related('purchase_order', 'supplier_part')
|
||||
|
||||
@ -1008,17 +1008,19 @@ class PartPricingView(PartDetail):
|
||||
line['date'] = stock_item.purchase_order.issue_date.strftime('%d.%m.%Y')
|
||||
else:
|
||||
line['date'] = stock_item.tracking_info.first().date.strftime('%d.%m.%Y')
|
||||
ret.append(line)
|
||||
price_history.append(line)
|
||||
|
||||
ctx['price_history'] = ret
|
||||
ctx['price_history'] = price_history
|
||||
|
||||
# BOM Information for Pie-Chart
|
||||
if part.has_bom:
|
||||
# get internal price setting
|
||||
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||
ctx_bom_parts = []
|
||||
# iterate over all bom-items
|
||||
for item in part.bom_items.all():
|
||||
ctx_item = {'name': str(item.sub_part)}
|
||||
price, qty = item.sub_part.get_price_range(quantity), item.quantity
|
||||
price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
|
||||
|
||||
price_min, price_max = 0, 0
|
||||
if price: # check if price available
|
||||
|
Reference in New Issue
Block a user