mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge remote-tracking branch 'inventree/master'
This commit is contained in:
		
							
								
								
									
										49
									
								
								.github/workflows/python.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								.github/workflows/python.yaml
									
									
									
									
										vendored
									
									
										Normal 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 | ||||||
|  |    | ||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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? | ||||||
|   | |||||||
| @@ -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): | ||||||
|   | |||||||
| @@ -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): | ||||||
|     """ |     """ | ||||||
|   | |||||||
| @@ -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') | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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. | ||||||
|     """ |     """ | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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) | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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): | ||||||
|     """ |     """ | ||||||
|   | |||||||
							
								
								
									
										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 = _("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(): | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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() | ||||||
|   | |||||||
| @@ -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): | ||||||
|   | |||||||
| @@ -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): | ||||||
|     """ |     """ | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user