diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml new file mode 100644 index 0000000000..7e32685af1 --- /dev/null +++ b/.github/workflows/python.yaml @@ -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 + \ No newline at end of file diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index eb92bd80c1..f0f33ad1a5 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -73,22 +73,50 @@ class InvenTreeAPITestCase(APITestCase): ruleset.save() break - def get(self, url, data={}, code=200): + def get(self, url, data={}, expected_code=200): """ Issue a GET request """ 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 - def post(self, url, data): + def post(self, url, data, expected_code=None): """ Issue a POST request """ 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 diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index fa7674723c..2bc89ef637 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -6,12 +6,15 @@ Serializers used in various InvenTree apps # -*- coding: utf-8 -*- from __future__ import unicode_literals -from rest_framework import serializers - import os from django.conf import settings 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): @@ -39,18 +42,34 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): 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. In addition to running validators on the serializer fields, this class ensures that the underlying model is also validated. """ - # Run any native validation checks first (may throw an ValidationError) - data = super(serializers.ModelSerializer, self).validate(data) + # Run any native validation checks first (may raise a ValidationError) + data = super().run_validation(data) # 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 diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index d45df99152..4fb78cfbab 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -80,7 +80,7 @@ def heartbeat(): try: 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: return @@ -105,7 +105,7 @@ def delete_successful_tasks(): try: from django_q.models import Success 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 threshold = datetime.now() - timedelta(days=30) @@ -126,6 +126,7 @@ def check_for_updates(): import common.models except AppRegistryNotReady: # Apps not yet loaded! + logger.info("Could not perform 'check_for_updates' - App registry not ready") return 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 except AppRegistryNotReady: # Apps not yet loaded! + logger.info("Could not perform 'update_exchange_rates' - App registry not ready") return except: # Other error? diff --git a/InvenTree/build/migrations/0029_auto_20210601_1525.py b/InvenTree/build/migrations/0029_auto_20210601_1525.py index c5ea04b5c9..e8b2d58947 100644 --- a/InvenTree/build/migrations/0029_auto_20210601_1525.py +++ b/InvenTree/build/migrations/0029_auto_20210601_1525.py @@ -40,7 +40,8 @@ def assign_bom_items(apps, schema_editor): except BomItem.DoesNotExist: 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): diff --git a/InvenTree/company/migrations/0026_auto_20201110_1011.py b/InvenTree/company/migrations/0026_auto_20201110_1011.py index c6be37b967..20ec7d2f6f 100644 --- a/InvenTree/company/migrations/0026_auto_20201110_1011.py +++ b/InvenTree/company/migrations/0026_auto_20201110_1011.py @@ -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): """ diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index c43280c76c..d5cb573f47 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -119,7 +119,9 @@ class ManufacturerTest(InvenTreeAPITestCase): data = { 'MPN': 'MPN-TEST-123', } + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['MPN'], 'MPN-TEST-123') diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 6661bd568b..c22d76a52e 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -157,7 +157,7 @@ class POList(generics.ListCreateAPIView): ordering = '-creation_date' -class PODetail(generics.RetrieveUpdateAPIView): +class PODetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a PurchaseOrder object """ queryset = PurchaseOrder.objects.all() @@ -382,7 +382,7 @@ class SOList(generics.ListCreateAPIView): ordering = '-creation_date' -class SODetail(generics.RetrieveUpdateAPIView): +class SODetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a SalesOrder object. """ diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 9efbf947bb..821e8bf343 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -93,8 +93,10 @@ class POSerializer(InvenTreeModelSerializer): ] read_only_fields = [ - 'reference', 'status' + 'issue_date', + 'complete_date', + 'creation_date', ] @@ -110,8 +112,9 @@ class POLineItemSerializer(InvenTreeModelSerializer): self.fields.pop('part_detail') self.fields.pop('supplier_part_detail') - quantity = serializers.FloatField() - received = serializers.FloatField() + # TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values + quantity = serializers.FloatField(default=1) + received = serializers.FloatField(default=0) part_detail = PartBriefSerializer(source='get_base_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 = [ - 'reference', - 'status' + 'status', + 'creation_date', + 'shipment_date', ] @@ -313,7 +317,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer): part_detail = PartBriefSerializer(source='part', many=False, 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) fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True) sale_price_string = serializers.CharField(source='sale_price', read_only=True) diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index ee77f429e1..24ca8581d9 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest): 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): """ @@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest): response = self.get(url) - self.assertEqual(response.status_code, 200) - data = response.data self.assertEqual(data['pk'], 1) @@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest): 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) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 508a1577bb..3c24690efc 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -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 diff --git a/InvenTree/part/migrations/0056_auto_20201110_1125.py b/InvenTree/part/migrations/0056_auto_20201110_1125.py index b382dded71..862f9411c8 100644 --- a/InvenTree/part/migrations/0056_auto_20201110_1125.py +++ b/InvenTree/part/migrations/0056_auto_20201110_1125.py @@ -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): """ diff --git a/InvenTree/part/migrations/0068_part_unique_part.py b/InvenTree/part/migrations/0068_part_unique_part.py new file mode 100644 index 0000000000..2e87fc7b2a --- /dev/null +++ b/InvenTree/part/migrations/0068_part_unique_part.py @@ -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'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8aa370a7ae..8ddf049216 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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,7 +382,7 @@ class Part(MPTTModel): logger.info(f"Deleting unused image file '{previous.image}'") previous.image.delete(save=False) - self.clean() + self.full_clean() super().save(*args, **kwargs) @@ -642,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 @@ -671,8 +657,6 @@ class Part(MPTTModel): super().clean() - self.validate_unique() - if self.trackable: for part in self.get_used_in().all(): diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 0f5f59d3a3..176149c880 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1,8 +1,10 @@ +# -*- coding: utf-8 -*- + from rest_framework import status from django.urls import reverse -from part.models import Part +from part.models import Part, PartCategory from stock.models import StockItem from company.models import Company @@ -230,6 +232,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 +251,7 @@ class PartAPITest(InvenTreeAPITestCase): 'part': 10000, 'test_name': 'New Test', 'required': True, + 'description': 'a test description' }, format='json', ) @@ -248,7 +263,8 @@ class PartAPITest(InvenTreeAPITestCase): url, data={ 'part': 10004, - 'test_name': " newtest" + 'test_name': " newtest", + 'description': 'dafsdf', }, format='json', ) @@ -293,6 +309,171 @@ 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) + + class PartAPIAggregationTest(InvenTreeAPITestCase): """ Tests to ensure that the various aggregation annotations are working correctly... @@ -319,6 +500,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 diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 2bc24c3a99..e30c80549f 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -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() diff --git a/InvenTree/stock/migrations/0061_auto_20210511_0911.py b/InvenTree/stock/migrations/0061_auto_20210511_0911.py index 0ab37250c8..ba0ecc5207 100644 --- a/InvenTree/stock/migrations/0061_auto_20210511_0911.py +++ b/InvenTree/stock/migrations/0061_auto_20210511_0911.py @@ -199,7 +199,8 @@ def update_history(apps, schema_editor): 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): diff --git a/InvenTree/stock/migrations/0064_auto_20210621_1724.py b/InvenTree/stock/migrations/0064_auto_20210621_1724.py index 71314f366c..361ad02c80 100644 --- a/InvenTree/stock/migrations/0064_auto_20210621_1724.py +++ b/InvenTree/stock/migrations/0064_auto_20210621_1724.py @@ -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 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 @@ -56,7 +57,8 @@ def extract_purchase_price(apps, schema_editor): 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): """