mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +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