From e29594811baecb9fa4b7db41a8938f7a6f500418 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 22 Jun 2021 22:09:30 +1000 Subject: [PATCH 1/8] Allow direct upload of images via the API --- InvenTree/InvenTree/serializers.py | 14 ++++++++++++++ InvenTree/part/serializers.py | 12 ++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 2bc89ef637..14e78f94b1 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -101,3 +101,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField): return None return os.path.join(str(settings.MEDIA_URL), str(value)) + + +class InvenTreeImageSerializierField(serializers.FileField): + """ + Custom image serializer. + On upload, validate that the file is a valid image file + """ + + def to_representation(self, value): + + if not value: + return None + + return os.path.join(str(settings.MEDIA_URL), str(value)) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 76275bd5e1..7d70a843d8 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -7,12 +7,15 @@ from decimal import Decimal from django.db import models from django.db.models import Q from django.db.models.functions import Coalesce -from InvenTree.serializers import (InvenTreeAttachmentSerializerField, - InvenTreeModelSerializer) -from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus + from rest_framework import serializers from sql_util.utils import SubqueryCount, SubquerySum from djmoney.contrib.django_rest_framework import MoneyField + +from InvenTree.serializers import (InvenTreeAttachmentSerializerField, + InvenTreeImageSerializierField, + InvenTreeModelSerializer) +from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from stock.models import StockItem from .models import (BomItem, Part, PartAttachment, PartCategory, @@ -300,7 +303,8 @@ class PartSerializer(InvenTreeModelSerializer): stock_item_count = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True) - image = serializers.CharField(source='get_image_url', read_only=True) + image = InvenTreeImageSerializerField(required=False) + # image = serializers.CharField(source='get_image_url', read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) starred = serializers.SerializerMethodField() From 2fc7c3d88330709b59e226b6d79bb37aa9df73fe Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 22 Jun 2021 22:16:11 +1000 Subject: [PATCH 2/8] fix typo --- InvenTree/InvenTree/serializers.py | 2 +- InvenTree/part/serializers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 14e78f94b1..032e10bf81 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -103,7 +103,7 @@ class InvenTreeAttachmentSerializerField(serializers.FileField): return os.path.join(str(settings.MEDIA_URL), str(value)) -class InvenTreeImageSerializierField(serializers.FileField): +class InvenTreeImageSerializerField(serializers.FileField): """ Custom image serializer. On upload, validate that the file is a valid image file diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 7d70a843d8..294a5e5918 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -13,7 +13,7 @@ from sql_util.utils import SubqueryCount, SubquerySum from djmoney.contrib.django_rest_framework import MoneyField from InvenTree.serializers import (InvenTreeAttachmentSerializerField, - InvenTreeImageSerializierField, + InvenTreeImageSerializerField, InvenTreeModelSerializer) from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from stock.models import StockItem From b8e4b58df00e8ea061412d2901eeef9ad6c05daf Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 22 Jun 2021 22:19:22 +1000 Subject: [PATCH 3/8] Catch potential error updating image that does not exist... --- InvenTree/InvenTree/tasks.py | 2 +- InvenTree/company/apps.py | 2 -- InvenTree/part/apps.py | 6 +++--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 4fb78cfbab..f0fe504072 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs): try: from django_q.models import Schedule except (AppRegistryNotReady): - logger.warning("Could not start background tasks - App registry not ready") + logger.info("Could not start background tasks - App registry not ready") return try: diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 4366f63434..76798c5ad4 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -44,8 +44,6 @@ class CompanyConfig(AppConfig): company.image.render_variations(replace=False) except FileNotFoundError: logger.warning(f"Image file '{company.image}' missing") - company.image = None - company.save() except UnidentifiedImageError: logger.warning(f"Image file '{company.image}' is invalid") except (OperationalError, ProgrammingError): diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index 1b233bccac..0c57e2c1ab 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -39,7 +39,8 @@ class PartConfig(AppConfig): logger.debug("InvenTree: Checking Part image thumbnails") try: - for part in Part.objects.all(): + # Only check parts which have images + for part in Part.objects.exclude(image=None): if part.image: url = part.image.thumbnail.name loc = os.path.join(settings.MEDIA_ROOT, url) @@ -50,8 +51,7 @@ class PartConfig(AppConfig): part.image.render_variations(replace=False) except FileNotFoundError: logger.warning(f"Image file '{part.image}' missing") - part.image = None - part.save() + pass except UnidentifiedImageError: logger.warning(f"Image file '{part.image}' is invalid") except (OperationalError, ProgrammingError): From f199feb8d90ad2aa2d3a426c5468f2c358ef78c7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 23 Jun 2021 08:34:45 +1000 Subject: [PATCH 4/8] Use the part detail API for uploading a new image --- InvenTree/InvenTree/static/script/inventree/api.js | 2 +- InvenTree/InvenTree/static/script/inventree/inventree.js | 4 +++- InvenTree/part/templates/part/part_base.html | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/static/script/inventree/api.js b/InvenTree/InvenTree/static/script/inventree/api.js index 52aba80ef5..b43bcc8419 100644 --- a/InvenTree/InvenTree/static/script/inventree/api.js +++ b/InvenTree/InvenTree/static/script/inventree/api.js @@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) { xhr.setRequestHeader('X-CSRFToken', csrftoken); }, url: url, - method: 'POST', + method: options.method || 'POST', data: data, processData: false, contentType: false, diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 1a6fdec47a..79e02d7da5 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) { data - Other form data to upload success - Callback function in case of success error - Callback function in case of error + method - HTTP method */ data = options.data || {}; @@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) { if (options.error) { options.error(xhr, status, error); } - } + }, + method: options.method || 'POST', } ); } else { diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 7e1d33bdea..6bb1320e5a 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -239,9 +239,10 @@ enableDragAndDrop( '#part-thumb', - "{% url 'part-image-upload' part.id %}", + "{% url 'api-part-detail' part.id %}", { label: 'image', + method: 'PATCH', success: function(data, status, xhr) { location.reload(); } From 878f26c77095e8c45a8477098d7b79c4f909506b Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 23 Jun 2021 08:39:06 +1000 Subject: [PATCH 5/8] Enable upload of company image via the API --- InvenTree/company/serializers.py | 3 ++- InvenTree/company/templates/company/company_base.html | 3 ++- InvenTree/part/serializers.py | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 471d26bd3f..8fc6941504 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -11,6 +11,7 @@ from .models import ManufacturerPart, ManufacturerPartParameter from .models import SupplierPart, SupplierPriceBreak from InvenTree.serializers import InvenTreeModelSerializer +from InvenTree.serializers import InvenTreeImageSerializerField from part.serializers import PartBriefSerializer @@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer): url = serializers.CharField(source='get_absolute_url', read_only=True) - image = serializers.CharField(source='get_thumbnail_url', read_only=True) + image = InvenTreeImageSerializerField(required=False) parts_supplied = serializers.IntegerField(read_only=True) parts_manufactured = serializers.IntegerField(read_only=True) diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 1eefade272..70af753d66 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -139,9 +139,10 @@ enableDragAndDrop( "#company-thumb", - "{% url 'company-image' company.id %}", + "{% url 'api-company-detail' company.id %}", { label: 'image', + method: 'PATCH', success: function(data, status, xhr) { location.reload(); } diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 294a5e5918..fe0ceea560 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -304,7 +304,6 @@ class PartSerializer(InvenTreeModelSerializer): suppliers = serializers.IntegerField(read_only=True) image = InvenTreeImageSerializerField(required=False) - # image = serializers.CharField(source='get_image_url', read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) starred = serializers.SerializerMethodField() From a866001ffe164f001317e4695963cf2a4258c37f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 23 Jun 2021 08:40:51 +1000 Subject: [PATCH 6/8] Bump API version to 6 --- InvenTree/InvenTree/version.py | 15 ++++++++++----- InvenTree/company/serializers.py | 8 ++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index a47ef3b667..3681be0f0b 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,22 +10,27 @@ import common.models INVENTREE_SW_VERSION = "0.2.4 pre" +INVENTREE_API_VERSION = 6 + """ Increment thi API version number whenever there is a significant change to the API that any clients need to know about -v3 -> 2021-05-22: - - The updated StockItem "history tracking" now uses a different interface +v6 -> 2021-06-23 + - Part and Company images can now be directly uploaded via the REST API + +v5 -> 2021-06-21 + - Adds API interface for manufacturer part parameters v4 -> 2021-06-01 - BOM items can now accept "variant stock" to be assigned against them - Many slight API tweaks were needed to get this to work properly! -v5 -> 2021-06-21 - - Adds API interface for manufacturer part parameters +v3 -> 2021-05-22: + - The updated StockItem "history tracking" now uses a different interface """ -INVENTREE_API_VERSION = 5 + def inventreeInstanceName(): diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 8fc6941504..11daf8898b 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -6,15 +6,15 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount -from .models import Company -from .models import ManufacturerPart, ManufacturerPartParameter -from .models import SupplierPart, SupplierPriceBreak - from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeImageSerializerField from part.serializers import PartBriefSerializer +from .models import Company +from .models import ManufacturerPart, ManufacturerPartParameter +from .models import SupplierPart, SupplierPriceBreak + class CompanyBriefSerializer(InvenTreeModelSerializer): """ Serializer for Company object (limited detail) """ From 5ba7aeaa277b14a2c4ce7ef1f3d97adad9307bb6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 23 Jun 2021 10:28:21 +1000 Subject: [PATCH 7/8] Fixes: - Use DRF ImageField, not FileField - Ensure that permissions get updated correctly in 'test' mode - Allow file upload in the APITester class --- .gitignore | 3 ++ InvenTree/InvenTree/api_tester.py | 4 +- InvenTree/InvenTree/ready.py | 6 ++- InvenTree/InvenTree/serializers.py | 2 +- InvenTree/InvenTree/version.py | 2 - InvenTree/part/test_api.py | 79 ++++++++++++++++++++++++++++-- InvenTree/users/apps.py | 2 +- InvenTree/users/models.py | 2 +- 8 files changed, 88 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 5dd3580ef6..c53a837e24 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ local_settings.py *.backup *.old +# Files used for testing +dummy_image.* + # Sphinx files docs/_build diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index f0f33ad1a5..a803e6797f 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -109,12 +109,12 @@ class InvenTreeAPITestCase(APITestCase): return response - def patch(self, url, data, expected_code=None): + def patch(self, url, data, files=None, expected_code=None): """ Issue a PATCH request """ - response = self.client.patch(url, data=data, format='json') + response = self.client.patch(url, data=data, files=files, format='json') if expected_code is not None: self.assertEqual(response.status_code, expected_code) diff --git a/InvenTree/InvenTree/ready.py b/InvenTree/InvenTree/ready.py index 4acbcae9af..7d63861f4b 100644 --- a/InvenTree/InvenTree/ready.py +++ b/InvenTree/InvenTree/ready.py @@ -12,7 +12,7 @@ def isInTestMode(): return False -def canAppAccessDatabase(): +def canAppAccessDatabase(allow_test=False): """ Returns True if the apps.py file can access database records. @@ -39,6 +39,10 @@ def canAppAccessDatabase(): 'compilemessages', ] + if not allow_test: + # Override for testing mode? + excluded_commands.append('test') + for cmd in excluded_commands: if cmd in sys.argv: return False diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 032e10bf81..f093255ff0 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -103,7 +103,7 @@ class InvenTreeAttachmentSerializerField(serializers.FileField): return os.path.join(str(settings.MEDIA_URL), str(value)) -class InvenTreeImageSerializerField(serializers.FileField): +class InvenTreeImageSerializerField(serializers.ImageField): """ Custom image serializer. On upload, validate that the file is a valid image file diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 3681be0f0b..08fa5e0ae4 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -31,8 +31,6 @@ v3 -> 2021-05-22: """ - - def inventreeInstanceName(): """ Returns the InstanceName settings for the current database """ return common.models.InvenTreeSetting.get_setting("INVENTREE_INSTANCE", "") diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 176149c880..d606b12637 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- -from rest_framework import status +import PIL from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from InvenTree.api_tester import InvenTreeAPITestCase +from InvenTree.status_codes import StockStatus + from part.models import Part, PartCategory from stock.models import StockItem from company.models import Company -from InvenTree.api_tester import InvenTreeAPITestCase -from InvenTree.status_codes import StockStatus - class PartAPITest(InvenTreeAPITestCase): """ @@ -473,6 +476,74 @@ class PartDetailTests(InvenTreeAPITestCase): self.assertEqual(response.status_code, 200) + def test_image_upload(self): + """ + Test that we can upload an image to the part API + """ + + self.assignRole('part.add') + + # Create a new part + response = self.client.post( + reverse('api-part-list'), + { + 'name': 'imagine', + 'description': 'All the people', + 'category': 1, + }, + expected_code=201 + ) + + pk = response.data['pk'] + + url = reverse('api-part-detail', kwargs={'pk': pk}) + + p = Part.objects.get(pk=pk) + + # Part should not have an image! + with self.assertRaises(ValueError): + print(p.image.file) + + # Create a custom APIClient for file uploads + # Ref: https://stackoverflow.com/questions/40453947/how-to-generate-a-file-upload-test-request-with-django-rest-frameworks-apireq + upload_client = APIClient() + upload_client.force_authenticate(user=self.user) + + # Try to upload a non-image file + with open('dummy_image.txt', 'w') as dummy_image: + dummy_image.write('hello world') + + with open('dummy_image.txt', 'rb') as dummy_image: + response = upload_client.patch( + url, + { + 'image': dummy_image, + }, + format='multipart', + ) + + self.assertEqual(response.status_code, 400) + + # Now try to upload a valid image file + img = PIL.Image.new('RGB', (128, 128), color='red') + img.save('dummy_image.jpg') + + with open('dummy_image.jpg', 'rb') as dummy_image: + response = upload_client.patch( + url, + { + 'image': dummy_image, + }, + format='multipart', + ) + + self.assertEqual(response.status_code, 200) + + # And now check that the image has been set + p = Part.objects.get(pk=pk) + + print("Image:", p.image.file) + class PartAPIAggregationTest(InvenTreeAPITestCase): """ diff --git a/InvenTree/users/apps.py b/InvenTree/users/apps.py index a9f671895d..f6666e3a94 100644 --- a/InvenTree/users/apps.py +++ b/InvenTree/users/apps.py @@ -13,7 +13,7 @@ class UsersConfig(AppConfig): def ready(self): - if canAppAccessDatabase(): + if canAppAccessDatabase(allow_test=True): try: self.assign_permissions() diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 09d70c3501..2763ba0e10 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -276,7 +276,7 @@ def update_group_roles(group, debug=False): """ - if not canAppAccessDatabase(): + if not canAppAccessDatabase(allow_test=True): return # List of permissions already associated with this group From 36e6b9f164b5f6980c60ace3717afbd80d24e23f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 23 Jun 2021 11:40:37 +1000 Subject: [PATCH 8/8] Set allow_null flag on image fields --- InvenTree/company/serializers.py | 2 +- InvenTree/company/test_api.py | 9 +++++++-- InvenTree/part/serializers.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 11daf8898b..1e97756987 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -53,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer): url = serializers.CharField(source='get_absolute_url', read_only=True) - image = InvenTreeImageSerializerField(required=False) + image = InvenTreeImageSerializerField(required=False, allow_null=True) parts_supplied = serializers.IntegerField(read_only=True) parts_manufactured = serializers.IntegerField(read_only=True) diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index d5cb573f47..dd42b97801 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase): self.assertEqual(response.data['name'], 'ACME') # Change the name of the company + # Note we should not have the correct permissions (yet) data = response.data data['name'] = 'ACMOO' - response = self.client.patch(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.patch(url, data, format='json', expected_code=400) + + self.assignRole('company.change') + + response = self.client.patch(url, data, format='json', expected_code=200) + self.assertEqual(response.data['name'], 'ACMOO') def test_company_search(self): diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index fe0ceea560..6c47f1310f 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -303,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer): stock_item_count = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True) - image = InvenTreeImageSerializerField(required=False) + image = InvenTreeImageSerializerField(required=False, allow_null=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) starred = serializers.SerializerMethodField()