mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05: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:
		| @@ -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