mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #1710 from SchrodingersGat/inventree-api-image-upload
Allow direct upload of images via the API
This commit is contained in:
commit
5e338dca3f
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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))
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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:
|
||||||
|
@ -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 """
|
||||||
|
@ -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):
|
||||||
|
@ -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)
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user