mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-09 21:30:54 +00:00
Refctor image downloader (#3393)
* Adds configurable setting for maximum remote image size * Add helper function for downloading image from remote URL - Will replace existing function - Performs more thorough sanity checking * Replace existing image downloading code - part image uses new generic function - company image uses new generic function * Rearrange settings * Refactor and cleanup existing views / forms * Add unit testing for image downloader function * Refactor image downloader forms - Part image download now uses the API - Company image download now uses the API - Remove outdated forms / views / templates * Increment API version * Prevent remote image download via API if the setting is not enabled * Do not attempt to validate or extract image from blank URL * Fix custom save() serializer methods
This commit is contained in:
@@ -1,48 +0,0 @@
|
||||
"""Django Forms for interacting with Company app."""
|
||||
|
||||
import django.forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.forms import HelperForm
|
||||
|
||||
from .models import Company, SupplierPriceBreak
|
||||
|
||||
|
||||
class CompanyImageDownloadForm(HelperForm):
|
||||
"""Form for downloading an image from a URL."""
|
||||
|
||||
url = django.forms.URLField(
|
||||
label=_('URL'),
|
||||
help_text=_('Image URL'),
|
||||
required=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = Company
|
||||
fields = [
|
||||
'url',
|
||||
]
|
||||
|
||||
|
||||
class EditPriceBreakForm(HelperForm):
|
||||
"""Form for creating / editing a supplier price break."""
|
||||
|
||||
quantity = RoundingDecimalFormField(
|
||||
max_digits=10,
|
||||
decimal_places=5,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Price break quantity'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
'price',
|
||||
]
|
@@ -1,5 +1,8 @@
|
||||
"""JSON serializers for Company app."""
|
||||
|
||||
import io
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
@@ -11,7 +14,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer)
|
||||
InvenTreeMoneySerializer, RemoteImageMixin)
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
||||
@@ -39,7 +42,7 @@ class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class CompanySerializer(InvenTreeModelSerializer):
|
||||
class CompanySerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for Company object (full detail)"""
|
||||
|
||||
@staticmethod
|
||||
@@ -95,8 +98,33 @@ class CompanySerializer(InvenTreeModelSerializer):
|
||||
'notes',
|
||||
'parts_supplied',
|
||||
'parts_manufactured',
|
||||
'remote_image',
|
||||
]
|
||||
|
||||
def save(self):
|
||||
"""Save the Company instance"""
|
||||
super().save()
|
||||
|
||||
company = self.instance
|
||||
|
||||
# Check if an image was downloaded from a remote URL
|
||||
remote_img = getattr(self, 'remote_image_file', None)
|
||||
|
||||
if remote_img and company:
|
||||
fmt = remote_img.format or 'PNG'
|
||||
buffer = io.BytesIO()
|
||||
remote_img.save(buffer, format=fmt)
|
||||
|
||||
# Construct a simplified name for the image
|
||||
filename = f"company_{company.pk}_image.{fmt.lower()}"
|
||||
|
||||
company.image.save(
|
||||
filename,
|
||||
ContentFile(buffer.getvalue()),
|
||||
)
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for ManufacturerPart object."""
|
||||
|
@@ -216,12 +216,19 @@
|
||||
if (global_settings.INVENTREE_DOWNLOAD_FROM_URL) {
|
||||
|
||||
$('#company-image-url').click(function() {
|
||||
launchModalForm(
|
||||
'{% url "company-image-download" company.id %}',
|
||||
constructForm(
|
||||
'{% url "api-company-detail" company.pk %}',
|
||||
{
|
||||
reload: true,
|
||||
method: 'PATCH',
|
||||
title: '{% trans "Download Image" %}',
|
||||
fields: {
|
||||
remote_image: {},
|
||||
},
|
||||
onSuccess: function(data) {
|
||||
reloadImage(data);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -4,18 +4,12 @@ from django.urls import include, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
company_detail_urls = [
|
||||
|
||||
re_path(r'^thumb-download/', views.CompanyImageDownloadFromURL.as_view(), name='company-image-download'),
|
||||
|
||||
# Any other URL
|
||||
re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'),
|
||||
]
|
||||
|
||||
|
||||
company_urls = [
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/', include(company_detail_urls)),
|
||||
# Detail URLs for a specific Company instance
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'),
|
||||
])),
|
||||
|
||||
re_path(r'suppliers/', views.CompanyIndex.as_view(), name='supplier-index'),
|
||||
re_path(r'manufacturers/', views.CompanyIndex.as_view(), name='manufacturer-index'),
|
||||
|
@@ -1,19 +1,12 @@
|
||||
"""Django views for interacting with Company app."""
|
||||
|
||||
import io
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from InvenTree.views import AjaxUpdateView, InvenTreeRoleMixin
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from plugin.views import InvenTreePluginViewMixin
|
||||
|
||||
from .forms import CompanyImageDownloadForm
|
||||
from .models import Company, ManufacturerPart, SupplierPart
|
||||
|
||||
|
||||
@@ -103,78 +96,6 @@ class CompanyDetail(InvenTreePluginViewMixin, DetailView):
|
||||
permission_required = 'company.view_company'
|
||||
|
||||
|
||||
class CompanyImageDownloadFromURL(AjaxUpdateView):
|
||||
"""View for downloading an image from a provided URL."""
|
||||
|
||||
model = Company
|
||||
ajax_template_name = 'image_download.html'
|
||||
form_class = CompanyImageDownloadForm
|
||||
ajax_form_title = _('Download Image')
|
||||
|
||||
def validate(self, company, form):
|
||||
"""Validate that the image data are correct."""
|
||||
# First ensure that the normal validation routines pass
|
||||
if not form.is_valid():
|
||||
return
|
||||
|
||||
# We can now extract a valid URL from the form data
|
||||
url = form.cleaned_data.get('url', None)
|
||||
|
||||
# Download the file
|
||||
response = requests.get(url, stream=True)
|
||||
|
||||
# Look at response header, reject if too large
|
||||
content_length = response.headers.get('Content-Length', '0')
|
||||
|
||||
try:
|
||||
content_length = int(content_length)
|
||||
except (ValueError):
|
||||
# If we cannot extract meaningful length, just assume it's "small enough"
|
||||
content_length = 0
|
||||
|
||||
# TODO: Factor this out into a configurable setting
|
||||
MAX_IMG_LENGTH = 10 * 1024 * 1024
|
||||
|
||||
if content_length > MAX_IMG_LENGTH:
|
||||
form.add_error('url', _('Image size exceeds maximum allowable size for download'))
|
||||
return
|
||||
|
||||
self.response = response
|
||||
|
||||
# Check for valid response code
|
||||
if response.status_code != 200:
|
||||
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
|
||||
return
|
||||
|
||||
response.raw.decode_content = True
|
||||
|
||||
try:
|
||||
self.image = Image.open(response.raw).convert()
|
||||
self.image.verify()
|
||||
except Exception:
|
||||
form.add_error('url', _("Supplied URL is not a valid image file"))
|
||||
return
|
||||
|
||||
def save(self, company, form, **kwargs):
|
||||
"""Save the downloaded image to the company."""
|
||||
fmt = self.image.format
|
||||
|
||||
if not fmt:
|
||||
fmt = 'PNG'
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
self.image.save(buffer, format=fmt)
|
||||
|
||||
# Construct a simplified name for the image
|
||||
filename = f"company_{company.pk}_image.{fmt.lower()}"
|
||||
|
||||
company.image.save(
|
||||
filename,
|
||||
ContentFile(buffer.getvalue()),
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerPartDetail(InvenTreePluginViewMixin, DetailView):
|
||||
"""Detail view for ManufacturerPart."""
|
||||
model = ManufacturerPart
|
||||
|
Reference in New Issue
Block a user