mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13:05:42 +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:
@ -4,28 +4,9 @@ from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.forms import MatchItemForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.helpers import clean_decimal
|
||||
|
||||
from .models import Part, PartInternalPriceBreak, PartSellPriceBreak
|
||||
|
||||
|
||||
class PartImageDownloadForm(HelperForm):
|
||||
"""Form for downloading an image from a URL."""
|
||||
|
||||
url = forms.URLField(
|
||||
label=_('URL'),
|
||||
help_text=_('Image URL'),
|
||||
required=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = Part
|
||||
fields = [
|
||||
'url',
|
||||
]
|
||||
from .models import Part
|
||||
|
||||
|
||||
class BomMatchItemForm(MatchItemForm):
|
||||
@ -66,33 +47,3 @@ class PartPriceForm(forms.Form):
|
||||
fields = [
|
||||
'quantity',
|
||||
]
|
||||
|
||||
|
||||
class EditPartSalePriceBreakForm(HelperForm):
|
||||
"""Form for creating / editing a sale price for a part."""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartSellPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
'price',
|
||||
]
|
||||
|
||||
|
||||
class EditPartInternalPriceBreakForm(HelperForm):
|
||||
"""Form for creating / editing a internal price for a part."""
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartInternalPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
'price',
|
||||
]
|
||||
|
@ -1,8 +1,10 @@
|
||||
"""DRF data serializers for Part app."""
|
||||
|
||||
import imghdr
|
||||
import io
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models, transaction
|
||||
from django.db.models import ExpressionWrapper, F, FloatField, Q
|
||||
from django.db.models.functions import Coalesce
|
||||
@ -22,7 +24,7 @@ from InvenTree.serializers import (DataFileExtractSerializer,
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeImageSerializerField,
|
||||
InvenTreeModelSerializer,
|
||||
InvenTreeMoneySerializer)
|
||||
InvenTreeMoneySerializer, RemoteImageMixin)
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
|
||||
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
|
||||
@ -273,7 +275,7 @@ class PartBriefSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class PartSerializer(InvenTreeModelSerializer):
|
||||
class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for complete detail information of a part.
|
||||
|
||||
Used when displaying all details of a single component.
|
||||
@ -424,6 +426,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
'parameters',
|
||||
'pk',
|
||||
'purchaseable',
|
||||
'remote_image',
|
||||
'revision',
|
||||
'salable',
|
||||
'starred',
|
||||
@ -437,6 +440,31 @@ class PartSerializer(InvenTreeModelSerializer):
|
||||
'virtual',
|
||||
]
|
||||
|
||||
def save(self):
|
||||
"""Save the Part instance"""
|
||||
|
||||
super().save()
|
||||
|
||||
part = self.instance
|
||||
|
||||
# Check if an image was downloaded from a remote URL
|
||||
remote_img = getattr(self, 'remote_image_file', None)
|
||||
|
||||
if remote_img and part:
|
||||
fmt = remote_img.format or 'PNG'
|
||||
buffer = io.BytesIO()
|
||||
remote_img.save(buffer, format=fmt)
|
||||
|
||||
# Construct a simplified name for the image
|
||||
filename = f"part_{part.pk}_image.{fmt.lower()}"
|
||||
|
||||
part.image.save(
|
||||
filename,
|
||||
ContentFile(buffer.getvalue()),
|
||||
)
|
||||
|
||||
return self.instance
|
||||
|
||||
|
||||
class PartRelationSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for a PartRelated model."""
|
||||
|
@ -511,11 +511,17 @@
|
||||
{% if roles.part.change %}
|
||||
|
||||
if (global_settings.INVENTREE_DOWNLOAD_FROM_URL) {
|
||||
|
||||
$("#part-image-url").click(function() {
|
||||
launchModalForm(
|
||||
'{% url "part-image-download" part.id %}',
|
||||
constructForm(
|
||||
'{% url "api-part-detail" part.pk %}',
|
||||
{
|
||||
reload: true,
|
||||
method: 'PATCH',
|
||||
title: '{% trans "Download Image" %}',
|
||||
fields: {
|
||||
remote_image: {},
|
||||
},
|
||||
onSuccess: onSelectImage,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -21,7 +21,6 @@ part_detail_urls = [
|
||||
|
||||
# Normal thumbnail with form
|
||||
re_path(r'^thumb-select/?', views.PartImageSelect.as_view(), name='part-image-select'),
|
||||
re_path(r'^thumb-download/', views.PartImageDownloadFromURL.as_view(), name='part-image-download'),
|
||||
|
||||
# Any other URLs go to the part detail page
|
||||
re_path(r'^.*$', views.PartDetail.as_view(), name='part-detail'),
|
||||
|
@ -1,22 +1,18 @@
|
||||
"""Django views for interacting with Part app."""
|
||||
|
||||
import io
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.shortcuts import HttpResponseRedirect, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
import requests
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from PIL import Image
|
||||
|
||||
import common.settings as inventree_settings
|
||||
from common.files import FileManager
|
||||
@ -491,82 +487,6 @@ class PartQRCode(QRCodeView):
|
||||
return None
|
||||
|
||||
|
||||
class PartImageDownloadFromURL(AjaxUpdateView):
|
||||
"""View for downloading an image from a provided URL."""
|
||||
|
||||
model = Part
|
||||
|
||||
ajax_template_name = 'image_download.html'
|
||||
form_class = part_forms.PartImageDownloadForm
|
||||
ajax_form_title = _('Download Image')
|
||||
|
||||
def validate(self, part, form):
|
||||
"""Validate that the image data are correct.
|
||||
|
||||
- Try to download the image!
|
||||
"""
|
||||
# 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, part, form, **kwargs):
|
||||
"""Save the downloaded image to the part."""
|
||||
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"part_{part.pk}_image.{fmt.lower()}"
|
||||
|
||||
part.image.save(
|
||||
filename,
|
||||
ContentFile(buffer.getvalue()),
|
||||
)
|
||||
|
||||
|
||||
class PartImageSelect(AjaxUpdateView):
|
||||
"""View for selecting Part image from existing images."""
|
||||
|
||||
|
Reference in New Issue
Block a user