Merge pull request #1710 from SchrodingersGat/inventree-api-image-upload

Allow direct upload of images via the API
This commit is contained in:
Oliver 2021-06-23 11:54:01 +10:00 committed by GitHub
commit 5e338dca3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 142 additions and 36 deletions

3
.gitignore vendored
View File

@ -35,6 +35,9 @@ local_settings.py
*.backup *.backup
*.old *.old
# Files used for testing
dummy_image.*
# Sphinx files # Sphinx files
docs/_build docs/_build

View File

@ -109,12 +109,12 @@ class InvenTreeAPITestCase(APITestCase):
return response return response
def patch(self, url, data, expected_code=None): def patch(self, url, data, files=None, expected_code=None):
""" """
Issue a PATCH request 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: if expected_code is not None:
self.assertEqual(response.status_code, expected_code) self.assertEqual(response.status_code, expected_code)

View File

@ -12,7 +12,7 @@ def isInTestMode():
return False return False
def canAppAccessDatabase(): def canAppAccessDatabase(allow_test=False):
""" """
Returns True if the apps.py file can access database records. Returns True if the apps.py file can access database records.
@ -39,6 +39,10 @@ def canAppAccessDatabase():
'compilemessages', 'compilemessages',
] ]
if not allow_test:
# Override for testing mode?
excluded_commands.append('test')
for cmd in excluded_commands: for cmd in excluded_commands:
if cmd in sys.argv: if cmd in sys.argv:
return False return False

View File

@ -101,3 +101,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
return None return None
return os.path.join(str(settings.MEDIA_URL), str(value)) return os.path.join(str(settings.MEDIA_URL), str(value))
class InvenTreeImageSerializerField(serializers.ImageField):
"""
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))

View File

@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) {
xhr.setRequestHeader('X-CSRFToken', csrftoken); xhr.setRequestHeader('X-CSRFToken', csrftoken);
}, },
url: url, url: url,
method: 'POST', method: options.method || 'POST',
data: data, data: data,
processData: false, processData: false,
contentType: false, contentType: false,

View File

@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) {
data - Other form data to upload data - Other form data to upload
success - Callback function in case of success success - Callback function in case of success
error - Callback function in case of error error - Callback function in case of error
method - HTTP method
*/ */
data = options.data || {}; data = options.data || {};
@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) {
if (options.error) { if (options.error) {
options.error(xhr, status, error); options.error(xhr, status, error);
} }
} },
method: options.method || 'POST',
} }
); );
} else { } else {

View File

@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
try: try:
from django_q.models import Schedule from django_q.models import Schedule
except (AppRegistryNotReady): 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 return
try: try:

View File

@ -10,23 +10,26 @@ import common.models
INVENTREE_SW_VERSION = "0.2.4 pre" 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 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: v6 -> 2021-06-23
- The updated StockItem "history tracking" now uses a different interface - 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 v4 -> 2021-06-01
- BOM items can now accept "variant stock" to be assigned against them - BOM items can now accept "variant stock" to be assigned against them
- Many slight API tweaks were needed to get this to work properly! - Many slight API tweaks were needed to get this to work properly!
v5 -> 2021-06-21 v3 -> 2021-05-22:
- Adds API interface for manufacturer part parameters - The updated StockItem "history tracking" now uses a different interface
""" """
INVENTREE_API_VERSION = 5
def inventreeInstanceName(): def inventreeInstanceName():
""" Returns the InstanceName settings for the current database """ """ Returns the InstanceName settings for the current database """

View File

@ -44,8 +44,6 @@ class CompanyConfig(AppConfig):
company.image.render_variations(replace=False) company.image.render_variations(replace=False)
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"Image file '{company.image}' missing") logger.warning(f"Image file '{company.image}' missing")
company.image = None
company.save()
except UnidentifiedImageError: except UnidentifiedImageError:
logger.warning(f"Image file '{company.image}' is invalid") logger.warning(f"Image file '{company.image}' is invalid")
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):

View File

@ -6,14 +6,15 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeImageSerializerField
from part.serializers import PartBriefSerializer
from .models import Company from .models import Company
from .models import ManufacturerPart, ManufacturerPartParameter from .models import ManufacturerPart, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak from .models import SupplierPart, SupplierPriceBreak
from InvenTree.serializers import InvenTreeModelSerializer
from part.serializers import PartBriefSerializer
class CompanyBriefSerializer(InvenTreeModelSerializer): class CompanyBriefSerializer(InvenTreeModelSerializer):
""" Serializer for Company object (limited detail) """ """ Serializer for Company object (limited detail) """
@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
image = serializers.CharField(source='get_thumbnail_url', read_only=True) image = InvenTreeImageSerializerField(required=False, allow_null=True)
parts_supplied = serializers.IntegerField(read_only=True) parts_supplied = serializers.IntegerField(read_only=True)
parts_manufactured = serializers.IntegerField(read_only=True) parts_manufactured = serializers.IntegerField(read_only=True)

View File

@ -139,9 +139,10 @@
enableDragAndDrop( enableDragAndDrop(
"#company-thumb", "#company-thumb",
"{% url 'company-image' company.id %}", "{% url 'api-company-detail' company.id %}",
{ {
label: 'image', label: 'image',
method: 'PATCH',
success: function(data, status, xhr) { success: function(data, status, xhr) {
location.reload(); location.reload();
} }

View File

@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase):
self.assertEqual(response.data['name'], 'ACME') self.assertEqual(response.data['name'], 'ACME')
# Change the name of the company # Change the name of the company
# Note we should not have the correct permissions (yet)
data = response.data data = response.data
data['name'] = 'ACMOO' data['name'] = 'ACMOO'
response = self.client.patch(url, data, format='json') response = self.client.patch(url, data, format='json', expected_code=400)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assignRole('company.change')
response = self.client.patch(url, data, format='json', expected_code=200)
self.assertEqual(response.data['name'], 'ACMOO') self.assertEqual(response.data['name'], 'ACMOO')
def test_company_search(self): def test_company_search(self):

View File

@ -39,7 +39,8 @@ class PartConfig(AppConfig):
logger.debug("InvenTree: Checking Part image thumbnails") logger.debug("InvenTree: Checking Part image thumbnails")
try: try:
for part in Part.objects.all(): # Only check parts which have images
for part in Part.objects.exclude(image=None):
if part.image: if part.image:
url = part.image.thumbnail.name url = part.image.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url) loc = os.path.join(settings.MEDIA_ROOT, url)
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
part.image.render_variations(replace=False) part.image.render_variations(replace=False)
except FileNotFoundError: except FileNotFoundError:
logger.warning(f"Image file '{part.image}' missing") logger.warning(f"Image file '{part.image}' missing")
part.image = None pass
part.save()
except UnidentifiedImageError: except UnidentifiedImageError:
logger.warning(f"Image file '{part.image}' is invalid") logger.warning(f"Image file '{part.image}' is invalid")
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):

View File

@ -7,12 +7,15 @@ from decimal import Decimal
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Coalesce 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 rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory, from .models import (BomItem, Part, PartAttachment, PartCategory,
@ -300,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True)
image = serializers.CharField(source='get_image_url', read_only=True) image = InvenTreeImageSerializerField(required=False, allow_null=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
starred = serializers.SerializerMethodField() starred = serializers.SerializerMethodField()

View File

@ -239,9 +239,10 @@
enableDragAndDrop( enableDragAndDrop(
'#part-thumb', '#part-thumb',
"{% url 'part-image-upload' part.id %}", "{% url 'api-part-detail' part.id %}",
{ {
label: 'image', label: 'image',
method: 'PATCH',
success: function(data, status, xhr) { success: function(data, status, xhr) {
location.reload(); location.reload();
} }

View File

@ -1,16 +1,19 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from rest_framework import status import PIL
from django.urls import reverse 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 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
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
class PartAPITest(InvenTreeAPITestCase): class PartAPITest(InvenTreeAPITestCase):
""" """
@ -473,6 +476,74 @@ class PartDetailTests(InvenTreeAPITestCase):
self.assertEqual(response.status_code, 200) 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): class PartAPIAggregationTest(InvenTreeAPITestCase):
""" """

View File

@ -13,7 +13,7 @@ class UsersConfig(AppConfig):
def ready(self): def ready(self):
if canAppAccessDatabase(): if canAppAccessDatabase(allow_test=True):
try: try:
self.assign_permissions() self.assign_permissions()

View File

@ -276,7 +276,7 @@ def update_group_roles(group, debug=False):
""" """
if not canAppAccessDatabase(): if not canAppAccessDatabase(allow_test=True):
return return
# List of permissions already associated with this group # List of permissions already associated with this group