Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver 2021-06-22 21:17:20 +10:00
commit 6457250776
18 changed files with 582 additions and 58 deletions

49
.github/workflows/python.yaml vendored Normal file
View File

@ -0,0 +1,49 @@
# Run python library tests whenever code is pushed to master
name: Python Bindings
on:
push:
branches:
- master
pull_request:
branches-ignore:
- l10*
jobs:
python:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: 'sqlite3'
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install InvenTree
run: |
sudo apt-get update
sudo apt-get install python3-dev python3-pip python3-venv
pip3 install invoke
invoke install
invoke migrate
- name: Download Python Code
run: |
git clone --depth 1 https://github.com/inventree/inventree-python ./inventree-python
- name: Start Server
run: |
invoke import-records -f ./inventree-python/test/test_data.json
invoke server -a 127.0.0.1:8000 &
sleep 60
- name: Run Tests
run: |
cd inventree-python
invoke test

View File

@ -73,22 +73,50 @@ class InvenTreeAPITestCase(APITestCase):
ruleset.save() ruleset.save()
break break
def get(self, url, data={}, code=200): def get(self, url, data={}, expected_code=200):
""" """
Issue a GET request Issue a GET request
""" """
response = self.client.get(url, data, format='json') response = self.client.get(url, data, format='json')
self.assertEqual(response.status_code, code) if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response return response
def post(self, url, data): def post(self, url, data, expected_code=None):
""" """
Issue a POST request Issue a POST request
""" """
response = self.client.post(url, data=data, format='json') response = self.client.post(url, data=data, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def delete(self, url, expected_code=None):
"""
Issue a DELETE request
"""
response = self.client.delete(url)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def patch(self, url, data, expected_code=None):
"""
Issue a PATCH request
"""
response = self.client.patch(url, data=data, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response return response

View File

@ -6,12 +6,15 @@ Serializers used in various InvenTree apps
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from rest_framework import serializers
import os import os
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import serializers
from rest_framework.fields import empty
from rest_framework.exceptions import ValidationError
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@ -39,18 +42,34 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
but also ensures that the underlying model class data are checked on validation. but also ensures that the underlying model class data are checked on validation.
""" """
def validate(self, data): def run_validation(self, data=empty):
""" Perform serializer validation. """ Perform serializer validation.
In addition to running validators on the serializer fields, In addition to running validators on the serializer fields,
this class ensures that the underlying model is also validated. this class ensures that the underlying model is also validated.
""" """
# Run any native validation checks first (may throw an ValidationError) # Run any native validation checks first (may raise a ValidationError)
data = super(serializers.ModelSerializer, self).validate(data) data = super().run_validation(data)
# Now ensure the underlying model is correct # Now ensure the underlying model is correct
instance = self.Meta.model(**data)
instance.clean() if not hasattr(self, 'instance') or self.instance is None:
# No instance exists (we are creating a new one)
instance = self.Meta.model(**data)
else:
# Instance already exists (we are updating!)
instance = self.instance
# Update instance fields
for attr, value in data.items():
setattr(instance, attr, value)
# Run a 'full_clean' on the model.
# Note that by default, DRF does *not* perform full model validation!
try:
instance.full_clean()
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
return data return data

View File

@ -80,7 +80,7 @@ def heartbeat():
try: try:
from django_q.models import Success from django_q.models import Success
logger.warning("Could not perform heartbeat task - App registry not ready") logger.info("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady: except AppRegistryNotReady:
return return
@ -105,7 +105,7 @@ def delete_successful_tasks():
try: try:
from django_q.models import Success from django_q.models import Success
except AppRegistryNotReady: except AppRegistryNotReady:
logger.warning("Could not perform 'delete_successful_tasks' - App registry not ready") logger.info("Could not perform 'delete_successful_tasks' - App registry not ready")
return return
threshold = datetime.now() - timedelta(days=30) threshold = datetime.now() - timedelta(days=30)
@ -126,6 +126,7 @@ def check_for_updates():
import common.models import common.models
except AppRegistryNotReady: except AppRegistryNotReady:
# Apps not yet loaded! # Apps not yet loaded!
logger.info("Could not perform 'check_for_updates' - App registry not ready")
return return
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest') response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
@ -172,6 +173,7 @@ def update_exchange_rates():
from django.conf import settings from django.conf import settings
except AppRegistryNotReady: except AppRegistryNotReady:
# Apps not yet loaded! # Apps not yet loaded!
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
return return
except: except:
# Other error? # Other error?

View File

@ -40,7 +40,8 @@ def assign_bom_items(apps, schema_editor):
except BomItem.DoesNotExist: except BomItem.DoesNotExist:
pass pass
print(f"Assigned BomItem for {count_valid}/{count_total} entries") if count_total > 0:
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
def unassign_bom_items(apps, schema_editor): def unassign_bom_items(apps, schema_editor):

View File

@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
count += 1 count += 1
print(f"Updated {count} SupplierPriceBreak rows") if count > 0:
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor): def reverse_currencies(apps, schema_editor):
""" """

View File

@ -119,7 +119,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
data = { data = {
'MPN': 'MPN-TEST-123', 'MPN': 'MPN-TEST-123',
} }
response = self.client.patch(url, data, format='json') response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['MPN'], 'MPN-TEST-123') self.assertEqual(response.data['MPN'], 'MPN-TEST-123')

View File

@ -157,7 +157,7 @@ class POList(generics.ListCreateAPIView):
ordering = '-creation_date' ordering = '-creation_date'
class PODetail(generics.RetrieveUpdateAPIView): class PODetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a PurchaseOrder object """ """ API endpoint for detail view of a PurchaseOrder object """
queryset = PurchaseOrder.objects.all() queryset = PurchaseOrder.objects.all()
@ -382,7 +382,7 @@ class SOList(generics.ListCreateAPIView):
ordering = '-creation_date' ordering = '-creation_date'
class SODetail(generics.RetrieveUpdateAPIView): class SODetail(generics.RetrieveUpdateDestroyAPIView):
""" """
API endpoint for detail view of a SalesOrder object. API endpoint for detail view of a SalesOrder object.
""" """

View File

@ -93,8 +93,10 @@ class POSerializer(InvenTreeModelSerializer):
] ]
read_only_fields = [ read_only_fields = [
'reference',
'status' 'status'
'issue_date',
'complete_date',
'creation_date',
] ]
@ -110,8 +112,9 @@ class POLineItemSerializer(InvenTreeModelSerializer):
self.fields.pop('part_detail') self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail') self.fields.pop('supplier_part_detail')
quantity = serializers.FloatField() # TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
received = serializers.FloatField() quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True) part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True) supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
@ -226,8 +229,9 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
] ]
read_only_fields = [ read_only_fields = [
'reference', 'status',
'status' 'creation_date',
'shipment_date',
] ]
@ -313,7 +317,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True) allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
quantity = serializers.FloatField() # TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
quantity = serializers.FloatField(default=1)
allocated = serializers.FloatField(source='allocated_quantity', read_only=True) allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
sale_price_string = serializers.CharField(source='sale_price', read_only=True) sale_price_string = serializers.CharField(source='sale_price', read_only=True)

View File

@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_po_operations(self):
"""
Test that we can create / edit and delete a PurchaseOrder via the API
"""
n = PurchaseOrder.objects.count()
url = reverse('api-po-list')
# Initially we do not have "add" permission for the PurchaseOrder model,
# so this POST request should return 403
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'PO created via the API',
},
expected_code=403
)
# And no new PurchaseOrder objects should have been created
self.assertEqual(PurchaseOrder.objects.count(), n)
# Ok, now let's give this user the correct permission
self.assignRole('purchase_order.add')
# Initially we do not have "add" permission for the PurchaseOrder model,
# so this POST request should return 403
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'PO created via the API',
},
expected_code=201
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
pk = response.data['pk']
# Try to create a PO with identical reference (should fail!)
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'A different description',
},
expected_code=400
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
url = reverse('api-po-detail', kwargs={'pk': pk})
# Get detail info!
response = self.get(url)
self.assertEqual(response.data['pk'], pk)
self.assertEqual(response.data['reference'], '123456789-xyz')
# Try to alter (edit) the PurchaseOrder
response = self.patch(
url,
{
'reference': '12345-abc',
},
expected_code=200
)
# Reference should have changed
self.assertEqual(response.data['reference'], '12345-abc')
# Now, let's try to delete it!
# Initially, we do *not* have the required permission!
response = self.delete(url, expected_code=403)
# Now, add the "delete" permission!
self.assignRole("purchase_order.delete")
response = self.delete(url, expected_code=204)
# Number of PurchaseOrder objects should have decreased
self.assertEqual(PurchaseOrder.objects.count(), n)
# And if we try to access the detail view again, it has gone
response = self.get(url, expected_code=404)
class SalesOrderTest(OrderTest): class SalesOrderTest(OrderTest):
""" """
@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest):
response = self.get(url) response = self.get(url)
self.assertEqual(response.status_code, 200)
data = response.data data = response.data
self.assertEqual(data['pk'], 1) self.assertEqual(data['pk'], 1)
@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest):
url = reverse('api-so-attachment-list') url = reverse('api-so-attachment-list')
response = self.get(url) self.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) def test_so_operations(self):
"""
Test that we can create / edit and delete a SalesOrder via the API
"""
n = SalesOrder.objects.count()
url = reverse('api-so-list')
# Initially we do not have "add" permission for the SalesOrder model,
# so this POST request should return 403 (denied)
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Sales order',
},
expected_code=403,
)
self.assignRole('sales_order.add')
# Now we should be able to create a SalesOrder via the API
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Sales order',
},
expected_code=201
)
# Check that the new order has been created
self.assertEqual(SalesOrder.objects.count(), n + 1)
# Grab the PK for the newly created SalesOrder
pk = response.data['pk']
# Try to create a SO with identical reference (should fail)
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Another sales order',
},
expected_code=400
)
url = reverse('api-so-detail', kwargs={'pk': pk})
# Extract detail info for the SalesOrder
response = self.get(url)
self.assertEqual(response.data['reference'], '12345')
# Try to alter (edit) the SalesOrder
response = self.patch(
url,
{
'reference': '12345-a',
},
expected_code=200
)
# Reference should have changed
self.assertEqual(response.data['reference'], '12345-a')
# Now, let's try to delete this SalesOrder
# Initially, we do not have the required permission
response = self.delete(url, expected_code=403)
self.assignRole('sales_order.delete')
response = self.delete(url, expected_code=204)
# Check that the number of sales orders has decreased
self.assertEqual(SalesOrder.objects.count(), n)
# And the resource should no longer be available
response = self.get(url, expected_code=404)

View File

@ -6,7 +6,7 @@
name: 'M2x4 LPHS' name: 'M2x4 LPHS'
description: 'M2x4 low profile head screw' description: 'M2x4 low profile head screw'
category: 8 category: 8
link: www.acme.com/parts/m2x4lphs link: http://www.acme.com/parts/m2x4lphs
tree_id: 0 tree_id: 0
purchaseable: True purchaseable: True
level: 0 level: 0
@ -56,6 +56,7 @@
fields: fields:
name: 'C_22N_0805' name: 'C_22N_0805'
description: '22nF capacitor in 0805 package' description: '22nF capacitor in 0805 package'
purchaseable: true
category: 3 category: 3
tree_id: 0 tree_id: 0
level: 0 level: 0

View File

@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
count += 1 count += 1
print(f"Updated {count} SupplierPriceBreak rows") if count > 0:
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor): def reverse_currencies(apps, schema_editor):
""" """

View 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'),
),
]

View File

@ -321,6 +321,9 @@ class Part(MPTTModel):
verbose_name = _("Part") verbose_name = _("Part")
verbose_name_plural = _("Parts") verbose_name_plural = _("Parts")
ordering = ['name', ] ordering = ['name', ]
constraints = [
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
]
class MPTTMeta: class MPTTMeta:
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent # For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
@ -379,7 +382,7 @@ class Part(MPTTModel):
logger.info(f"Deleting unused image file '{previous.image}'") logger.info(f"Deleting unused image file '{previous.image}'")
previous.image.delete(save=False) previous.image.delete(save=False)
self.clean() self.full_clean()
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -642,23 +645,6 @@ class Part(MPTTModel):
'IPN': _('Duplicate IPN not allowed in part settings'), '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): def clean(self):
""" """
Perform cleaning operations for the Part model Perform cleaning operations for the Part model
@ -671,8 +657,6 @@ class Part(MPTTModel):
super().clean() super().clean()
self.validate_unique()
if self.trackable: if self.trackable:
for part in self.get_used_in().all(): for part in self.get_used_in().all():

View File

@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
from rest_framework import status from rest_framework import status
from django.urls import reverse from django.urls import reverse
from part.models import Part from part.models import Part, PartCategory
from stock.models import StockItem from stock.models import StockItem
from company.models import Company from company.models import Company
@ -230,6 +232,18 @@ class PartAPITest(InvenTreeAPITestCase):
response = self.client.get(url, data={'part': 10004}) response = self.client.get(url, data={'part': 10004})
self.assertEqual(len(response.data), 7) 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) # Try to post a new object (should succeed)
response = self.client.post( response = self.client.post(
url, url,
@ -237,6 +251,7 @@ class PartAPITest(InvenTreeAPITestCase):
'part': 10000, 'part': 10000,
'test_name': 'New Test', 'test_name': 'New Test',
'required': True, 'required': True,
'description': 'a test description'
}, },
format='json', format='json',
) )
@ -248,7 +263,8 @@ class PartAPITest(InvenTreeAPITestCase):
url, url,
data={ data={
'part': 10004, 'part': 10004,
'test_name': " newtest" 'test_name': " newtest",
'description': 'dafsdf',
}, },
format='json', format='json',
) )
@ -293,6 +309,171 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(len(data['results']), n) 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)
class PartAPIAggregationTest(InvenTreeAPITestCase): class PartAPIAggregationTest(InvenTreeAPITestCase):
""" """
Tests to ensure that the various aggregation annotations are working correctly... Tests to ensure that the various aggregation annotations are working correctly...
@ -319,6 +500,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
# Add a new part # Add a new part
self.part = Part.objects.create( self.part = Part.objects.create(
name='Banana', name='Banana',
description='This is a banana',
category=PartCategory.objects.get(pk=1),
) )
# Create some stock items associated with the part # Create some stock items associated with the part

View File

@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
import os import os
from .models import Part, PartTestTemplate from .models import Part, PartCategory, PartTestTemplate
from .models import rename_part_image, match_part_names from .models import rename_part_image, match_part_names
from .templatetags import inventree_extras from .templatetags import inventree_extras
@ -78,6 +78,61 @@ class PartTest(TestCase):
p = Part.objects.get(pk=100) p = Part.objects.get(pk=100)
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?") 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): def test_metadata(self):
self.assertEqual(self.r1.name, 'R_2K2_0805') self.assertEqual(self.r1.name, 'R_2K2_0805')
self.assertEqual(self.r1.get_absolute_url(), '/part/3/') self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
@ -277,21 +332,24 @@ class PartSettingsTest(TestCase):
""" """
# Create a part # 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) # Attempt to create a duplicate item (should fail)
with self.assertRaises(ValidationError): 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) # Attempt to create item with duplicate IPN (should be allowed by default)
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B') Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
# And attempt again with the same values (should fail) # And attempt again with the same values (should fail)
with self.assertRaises(ValidationError): 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 # Now update the settings so duplicate IPN values are *not* allowed
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user) InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
with self.assertRaises(ValidationError): 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()

View File

@ -199,7 +199,8 @@ def update_history(apps, schema_editor):
update_count += 1 update_count += 1
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries") if update_count > 0:
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
def reverse_update(apps, schema_editor): def reverse_update(apps, schema_editor):

View File

@ -26,7 +26,8 @@ def extract_purchase_price(apps, schema_editor):
# Find all the StockItem objects without a purchase_price which point to a PurchaseOrder # Find all the StockItem objects without a purchase_price which point to a PurchaseOrder
items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None) items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None)
print(f"Found {items.count()} stock items with missing purchase price information") if items.count() > 0:
print(f"Found {items.count()} stock items with missing purchase price information")
update_count = 0 update_count = 0
@ -56,7 +57,8 @@ def extract_purchase_price(apps, schema_editor):
break break
print(f"Updated pricing for {update_count} stock items") if update_count > 0:
print(f"Updated pricing for {update_count} stock items")
def reverse_operation(apps, schema_editor): def reverse_operation(apps, schema_editor):
""" """