2
0
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:
Oliver
2022-07-25 11:17:59 +10:00
committed by GitHub
parent d32054b53b
commit eecb26676e
18 changed files with 279 additions and 301 deletions

View File

@ -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',
]

View File

@ -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."""

View File

@ -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,
}
);
});

View File

@ -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'),

View File

@ -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."""