mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Merge pull request #1410 from SchrodingersGat/image-downloader
Image downloader
This commit is contained in:
		| @@ -626,6 +626,53 @@ | |||||||
|     z-index: 11000; |     z-index: 11000; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .modal-close { | ||||||
|  |     position: absolute; | ||||||
|  |     top: 15px; | ||||||
|  |     right: 35px; | ||||||
|  |     color: #f1f1f1; | ||||||
|  |     font-size: 40px; | ||||||
|  |     font-weight: bold; | ||||||
|  |     transition: 0.25s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-close:hover, | ||||||
|  | .modal-close:focus { | ||||||
|  |     color: #bbb; | ||||||
|  |     text-decoration: none; | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-image-content { | ||||||
|  |     margin: auto; | ||||||
|  |     display: block; | ||||||
|  |     width: 80%; | ||||||
|  |     max-width: 700px; | ||||||
|  |     text-align: center; | ||||||
|  |     color: #ccc; | ||||||
|  |     padding: 10px 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media only screen and (max-width: 700px){ | ||||||
|  |     .modal-image-content { | ||||||
|  |       width: 100%; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .modal-image { | ||||||
|  |     display: none; | ||||||
|  |     position: fixed; | ||||||
|  |     z-index: 10000; | ||||||
|  |     padding-top: 100px; | ||||||
|  |     left: 0; | ||||||
|  |     top: 0; | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     overflow: auto; | ||||||
|  |     background-color: rgb(0,0,0); /* Fallback color */ | ||||||
|  |     background-color: rgba(0,0,0,0.85); /* Black w/ opacity */ | ||||||
|  | } | ||||||
|  |  | ||||||
| .js-modal-form .checkbox { | .js-modal-form .checkbox { | ||||||
|     margin-left: 0px; |     margin-left: 0px; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -78,6 +78,13 @@ class InvenTreeSetting(models.Model): | |||||||
|             'choices': djmoney.settings.CURRENCY_CHOICES, |             'choices': djmoney.settings.CURRENCY_CHOICES, | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|  |         'INVENTREE_DOWNLOAD_FROM_URL': { | ||||||
|  |             'name': _('Download from URL'), | ||||||
|  |             'description': _('Allow download of remote images and files from external URL'), | ||||||
|  |             'validator': bool, | ||||||
|  |             'default': False, | ||||||
|  |         }, | ||||||
|  |  | ||||||
|         'BARCODE_ENABLE': { |         'BARCODE_ENABLE': { | ||||||
|             'name': _('Barcode Support'), |             'name': _('Barcode Support'), | ||||||
|             'description': _('Enable barcode scanner support'), |             'description': _('Enable barcode scanner support'), | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ from django.apps import AppConfig | |||||||
| from django.db.utils import OperationalError, ProgrammingError | from django.db.utils import OperationalError, ProgrammingError | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
|  |  | ||||||
|  | from PIL import UnidentifiedImageError | ||||||
|  |  | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
|  |  | ||||||
| @@ -38,9 +39,11 @@ class CompanyConfig(AppConfig): | |||||||
|                         try: |                         try: | ||||||
|                             company.image.render_variations(replace=False) |                             company.image.render_variations(replace=False) | ||||||
|                         except FileNotFoundError: |                         except FileNotFoundError: | ||||||
|                             logger.warning("Image file missing") |                             logger.warning(f"Image file '{company.image}' missing") | ||||||
|                             company.image = None |                             company.image = None | ||||||
|                             company.save() |                             company.save() | ||||||
|  |                         except UnidentifiedImageError: | ||||||
|  |                             logger.warning(f"Image file '{company.image}' is invalid") | ||||||
|         except (OperationalError, ProgrammingError): |         except (OperationalError, ProgrammingError): | ||||||
|             # Getting here probably meant the database was in test mode |             # Getting here probably meant the database was in test mode | ||||||
|             pass |             pass | ||||||
|   | |||||||
| @@ -66,6 +66,24 @@ class CompanyImageForm(HelperForm): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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: | ||||||
|  |         model = Company | ||||||
|  |         fields = [ | ||||||
|  |             'url', | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class EditSupplierPartForm(HelperForm): | class EditSupplierPartForm(HelperForm): | ||||||
|     """ Form for editing a SupplierPart object """ |     """ Form for editing a SupplierPart object """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,19 +2,32 @@ | |||||||
|  |  | ||||||
| {% load static %} | {% load static %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  | {% load inventree_extras %} | ||||||
|  |  | ||||||
|  |  | ||||||
| {% block page_title %} | {% block page_title %} | ||||||
| InvenTree | {% trans "Company" %} - {{ company.name }} | InvenTree | {% trans "Company" %} - {{ company.name }} | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
|  |  | ||||||
| {% block thumbnail %} | {% block thumbnail %} | ||||||
| <div class='dropzone' id='company-thumb'> | {% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %} | ||||||
|     <img class="part-thumb" |  | ||||||
|  | <div class='dropzone part-thumb-container' id='company-thumb'> | ||||||
|  |     <img class="part-thumb" id='company-image' | ||||||
|     {% if company.image %} |     {% if company.image %} | ||||||
|     src="{{ company.image.url }}" |     src="{{ company.image.url }}" | ||||||
|     {% else %} |     {% else %} | ||||||
|     src="{% static 'img/blank_image.png' %}" |     src="{% static 'img/blank_image.png' %}" | ||||||
|     {% endif %}/> |     {% endif %}/> | ||||||
|  |     <div class='btn-row part-thumb-overlay'> | ||||||
|  |         <div class='btn-group'> | ||||||
|  |             <button type='button' class='btn btn-default btn-glyph' title='{% trans "Upload new image" %}' id='company-image-upload'><span class='fas fa-file-upload'></span></button> | ||||||
|  |             {% if allow_download %} | ||||||
|  |             <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Download image from URL' %}" id='company-image-url'><span class='fas fa-cloud-download-alt'></span></button> | ||||||
|  |             {% endif %} | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
| </div> | </div> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| @@ -135,7 +148,13 @@ InvenTree | {% trans "Company" %} - {{ company.name }} | |||||||
|         } |         } | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     $("#company-thumb").click(function() { |     {% if company.image %} | ||||||
|  |     $('#company-image').click(function() { | ||||||
|  |         showModalImage('{{ company.image.url }}'); | ||||||
|  |     }); | ||||||
|  |     {% endif %} | ||||||
|  |  | ||||||
|  |     $("#company-image-upload").click(function() { | ||||||
|         launchModalForm( |         launchModalForm( | ||||||
|             "{% url 'company-image' company.id %}", |             "{% url 'company-image' company.id %}", | ||||||
|             { |             { | ||||||
| @@ -144,4 +163,17 @@ InvenTree | {% trans "Company" %} - {{ company.name }} | |||||||
|         ); |         ); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     {% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %} | ||||||
|  |  | ||||||
|  |     {% if allow_download %} | ||||||
|  |     $('#company-image-url').click(function() { | ||||||
|  |         launchModalForm( | ||||||
|  |             '{% url "company-image-download" company.id %}', | ||||||
|  |             { | ||||||
|  |                 reload: true, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |     }); | ||||||
|  |     {% endif %} | ||||||
|  |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
| @@ -21,6 +21,7 @@ company_detail_urls = [ | |||||||
|     url(r'^notes/', views.CompanyNotes.as_view(), name='company-notes'), |     url(r'^notes/', views.CompanyNotes.as_view(), name='company-notes'), | ||||||
|  |  | ||||||
|     url(r'^thumbnail/', views.CompanyImage.as_view(), name='company-image'), |     url(r'^thumbnail/', views.CompanyImage.as_view(), name='company-image'), | ||||||
|  |     url(r'^thumb-download/', views.CompanyImageDownloadFromURL.as_view(), name='company-image-download'), | ||||||
|  |  | ||||||
|     # Any other URL |     # Any other URL | ||||||
|     url(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'), |     url(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'), | ||||||
|   | |||||||
| @@ -11,9 +11,14 @@ from django.views.generic import DetailView, ListView, UpdateView | |||||||
|  |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.forms import HiddenInput | from django.forms import HiddenInput | ||||||
|  | from django.core.files.base import ContentFile | ||||||
|  |  | ||||||
| from moneyed import CURRENCIES | from moneyed import CURRENCIES | ||||||
|  |  | ||||||
|  | from PIL import Image | ||||||
|  | import requests | ||||||
|  | import io | ||||||
|  |  | ||||||
| from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView | from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView | ||||||
| from InvenTree.helpers import str2bool | from InvenTree.helpers import str2bool | ||||||
| from InvenTree.views import InvenTreeRoleMixin | from InvenTree.views import InvenTreeRoleMixin | ||||||
| @@ -28,6 +33,7 @@ from .forms import EditCompanyForm | |||||||
| from .forms import CompanyImageForm | from .forms import CompanyImageForm | ||||||
| from .forms import EditSupplierPartForm | from .forms import EditSupplierPartForm | ||||||
| from .forms import EditPriceBreakForm | from .forms import EditPriceBreakForm | ||||||
|  | from .forms import CompanyImageDownloadForm | ||||||
|  |  | ||||||
| import common.models | import common.models | ||||||
| import common.settings | import common.settings | ||||||
| @@ -150,6 +156,84 @@ class CompanyDetail(DetailView): | |||||||
|         return ctx |         return ctx | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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 not response.status_code == 200: | ||||||
|  |             form.add_error('url', f"{_('Invalid response')}: {response.status_code}") | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         response.raw.decode_content = True | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             self.image = Image.open(response.raw).convert() | ||||||
|  |             self.image.verify() | ||||||
|  |         except: | ||||||
|  |             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 CompanyImage(AjaxUpdateView): | class CompanyImage(AjaxUpdateView): | ||||||
|     """ View for uploading an image for the Company """ |     """ View for uploading an image for the Company """ | ||||||
|     model = Company |     model = Company | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ from django.db.utils import OperationalError, ProgrammingError | |||||||
| from django.apps import AppConfig | from django.apps import AppConfig | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
|  |  | ||||||
|  | from PIL import UnidentifiedImageError | ||||||
|  |  | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
|  |  | ||||||
| @@ -44,9 +45,11 @@ class PartConfig(AppConfig): | |||||||
|                         try: |                         try: | ||||||
|                             part.image.render_variations(replace=False) |                             part.image.render_variations(replace=False) | ||||||
|                         except FileNotFoundError: |                         except FileNotFoundError: | ||||||
|                             logger.warning("Image file missing") |                             logger.warning(f"Image file '{part.image}' missing") | ||||||
|                             part.image = None |                             part.image = None | ||||||
|                             part.save() |                             part.save() | ||||||
|  |                         except UnidentifiedImageError: | ||||||
|  |                             logger.warning(f"Image file '{part.image}' is invalid") | ||||||
|         except (OperationalError, ProgrammingError): |         except (OperationalError, ProgrammingError): | ||||||
|             # Exception if the database has not been migrated yet |             # Exception if the database has not been migrated yet | ||||||
|             pass |             pass | ||||||
|   | |||||||
| @@ -37,6 +37,24 @@ class PartModelChoiceField(forms.ModelChoiceField): | |||||||
|         return label |         return label | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PartImageDownloadForm(HelperForm): | ||||||
|  |     """ | ||||||
|  |     Form for downloading an image from a URL | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     url = forms.URLField( | ||||||
|  |         label=_('URL'), | ||||||
|  |         help_text=_('Image URL'), | ||||||
|  |         required=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = Part | ||||||
|  |         fields = [ | ||||||
|  |             'url', | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class PartImageForm(HelperForm): | class PartImageForm(HelperForm): | ||||||
|     """ Form for uploading a Part image """ |     """ Form for uploading a Part image """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -206,6 +206,12 @@ | |||||||
|         toggleId: '#part-menu-toggle', |         toggleId: '#part-menu-toggle', | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     {% if part.image %} | ||||||
|  |     $('#part-thumb').click(function() { | ||||||
|  |         showModalImage('{{ part.image.url }}'); | ||||||
|  |     }); | ||||||
|  |     {% endif %} | ||||||
|  |  | ||||||
|     enableDragAndDrop( |     enableDragAndDrop( | ||||||
|         '#part-thumb', |         '#part-thumb', | ||||||
|         "{% url 'part-image-upload' part.id %}", |         "{% url 'part-image-upload' part.id %}", | ||||||
| @@ -295,6 +301,20 @@ | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|      |      | ||||||
|  |     {% if roles.part.change %} | ||||||
|  |      | ||||||
|  |     {% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %} | ||||||
|  |     {% if allow_download %} | ||||||
|  |     $("#part-image-url").click(function() { | ||||||
|  |         launchModalForm( | ||||||
|  |             '{% url "part-image-download" part.id %}', | ||||||
|  |             { | ||||||
|  |                 reload: true, | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|  |     }); | ||||||
|  |     {% endif %} | ||||||
|  |  | ||||||
|     $("#part-image-select").click(function() { |     $("#part-image-select").click(function() { | ||||||
|         launchModalForm("{% url 'part-image-select' part.id %}", |         launchModalForm("{% url 'part-image-select' part.id %}", | ||||||
|                         { |                         { | ||||||
| @@ -303,7 +323,6 @@ | |||||||
|                         }); |                         }); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     {% if roles.part.change %} |  | ||||||
|     $("#part-edit").click(function() { |     $("#part-edit").click(function() { | ||||||
|         launchModalForm( |         launchModalForm( | ||||||
|             "{% url 'part-edit' part.id %}", |             "{% url 'part-edit' part.id %}", | ||||||
|   | |||||||
| @@ -1,20 +1,28 @@ | |||||||
| {% load static %} | {% load static %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  | {% load inventree_extras %} | ||||||
|  |  | ||||||
|  | {% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %} | ||||||
|  |  | ||||||
| <div class="media"> | <div class="media"> | ||||||
|     <div class="media-left part-thumb-container"> |     <div class="media-left part-thumb-container"> | ||||||
|       <div class='dropzone' id='part-thumb'> |       <div class='dropzone' id='part-thumb'> | ||||||
|           <img class="part-thumb" |           <img class="part-thumb" id='part-image' | ||||||
|               {% if part.image %} |               {% if part.image %} | ||||||
|               src="{{ part.image.url }}" |               src="{{ part.image.url }}" | ||||||
|               {% else %} |               {% else %} | ||||||
|               src="{% static 'img/blank_image.png' %}" |               src="{% static 'img/blank_image.png' %}" | ||||||
|               {% endif %}/> |               {% endif %}/> | ||||||
|       </div> |       </div> | ||||||
|  |       {% if roles.part.change %} | ||||||
|       <div class='btn-row part-thumb-overlay'> |       <div class='btn-row part-thumb-overlay'> | ||||||
|           <div class='btn-group'> |           <div class='btn-group'> | ||||||
|               <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Select from existing images' %}" id='part-image-select'><span class='fas fa-th'></span></button> |               <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Select from existing images' %}" id='part-image-select'><span class='fas fa-th'></span></button> | ||||||
|               <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Upload new image' %}" id='part-image-upload'><span class='fas fa-file-image'></span></button> |               <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Upload new image' %}" id='part-image-upload'><span class='fas fa-file-upload'></span></button> | ||||||
|  |               {% if allow_download %} | ||||||
|  |               <button type='button' class='btn btn-default btn-glyph' title="{% trans 'Download image from URL' %}" id='part-image-url'><span class='fas fa-cloud-download-alt'></span></button> | ||||||
|  |               {% endif %} | ||||||
|           </div> |           </div> | ||||||
|       </div> |       </div> | ||||||
|  |       {% endif %} | ||||||
|     </div> |     </div> | ||||||
| @@ -75,6 +75,7 @@ part_detail_urls = [ | |||||||
|     # Normal thumbnail with form |     # Normal thumbnail with form | ||||||
|     url(r'^thumbnail/?', views.PartImageUpload.as_view(), name='part-image-upload'), |     url(r'^thumbnail/?', views.PartImageUpload.as_view(), name='part-image-upload'), | ||||||
|     url(r'^thumb-select/?', views.PartImageSelect.as_view(), name='part-image-select'), |     url(r'^thumb-select/?', views.PartImageSelect.as_view(), name='part-image-select'), | ||||||
|  |     url(r'^thumb-download/', views.PartImageDownloadFromURL.as_view(), name='part-image-download'), | ||||||
|  |  | ||||||
|     # Any other URLs go to the part detail page |     # Any other URLs go to the part detail page | ||||||
|     url(r'^.*$', views.PartDetail.as_view(), name='part-detail'), |     url(r'^.*$', views.PartDetail.as_view(), name='part-detail'), | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ Django views for interacting with Part app | |||||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||||
| from __future__ import unicode_literals | from __future__ import unicode_literals | ||||||
|  |  | ||||||
|  | from django.core.files.base import ContentFile | ||||||
| from django.core.exceptions import ValidationError | from django.core.exceptions import ValidationError | ||||||
| from django.db import transaction | from django.db import transaction | ||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| @@ -19,7 +20,11 @@ from django.conf import settings | |||||||
|  |  | ||||||
| from moneyed import CURRENCIES | from moneyed import CURRENCIES | ||||||
|  |  | ||||||
|  | from PIL import Image | ||||||
|  |  | ||||||
|  | import requests | ||||||
| import os | import os | ||||||
|  | import io | ||||||
|  |  | ||||||
| from rapidfuzz import fuzz | from rapidfuzz import fuzz | ||||||
| from decimal import Decimal, InvalidOperation | from decimal import Decimal, InvalidOperation | ||||||
| @@ -831,6 +836,89 @@ class PartQRCode(QRCodeView): | |||||||
|             return None |             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 not response.status_code == 200: | ||||||
|  |             form.add_error('url', f"{_('Invalid response')}: {response.status_code}") | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         response.raw.decode_content = True | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             self.image = Image.open(response.raw).convert() | ||||||
|  |             self.image.verify() | ||||||
|  |         except: | ||||||
|  |             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 PartImageUpload(AjaxUpdateView): | class PartImageUpload(AjaxUpdateView): | ||||||
|     """ View for uploading a new Part image """ |     """ View for uploading a new Part image """ | ||||||
|  |  | ||||||
|   | |||||||
| @@ -19,6 +19,7 @@ | |||||||
|         {% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %} |         {% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %} |         {% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %} | ||||||
|         {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %} |         {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %} | ||||||
|  |         {% include "InvenTree/settings/setting.html" with key="INVENTREE_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %} | ||||||
|     </tbody> |     </tbody> | ||||||
| </table> | </table> | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								InvenTree/templates/image_download.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								InvenTree/templates/image_download.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | {% extends "modal_form.html" %} | ||||||
|  |  | ||||||
|  | {% load inventree_extras %} | ||||||
|  | {% load i18n %} | ||||||
|  |  | ||||||
|  | {% block pre_form_content %} | ||||||
|  | <div class='alert alert-block alert-info'> | ||||||
|  |     {% trans "Specify URL for downloading image" %}: | ||||||
|  |  | ||||||
|  |     <ul> | ||||||
|  |         <li>{% trans "Must be a valid image URL" %}</li> | ||||||
|  |         <li>{% trans "Remote server must be accessible" %}</li> | ||||||
|  |         <li>{% trans "Remote image must not exceed maximum allowable file size" %}</li> | ||||||
|  |     </ul> | ||||||
|  | </div> | ||||||
|  | {% endblock %} | ||||||
| @@ -909,3 +909,42 @@ function launchModalForm(url, options = {}) { | |||||||
|     // Send the AJAX request |     // Send the AJAX request | ||||||
|     $.ajax(ajax_data); |     $.ajax(ajax_data); | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | function hideModalImage() { | ||||||
|  |  | ||||||
|  |     var modal = $('#modal-image-dialog'); | ||||||
|  |  | ||||||
|  |     modal.animate({ | ||||||
|  |         opacity: 0.0, | ||||||
|  |     }, 250, function() { | ||||||
|  |         modal.hide(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | function showModalImage(image_url) { | ||||||
|  |     // Display full-screen modal image | ||||||
|  |  | ||||||
|  |     console.log('showing modal image: ' + image_url); | ||||||
|  |  | ||||||
|  |     var modal = $('#modal-image-dialog'); | ||||||
|  |  | ||||||
|  |     // Set image content | ||||||
|  |     $('#modal-image').attr('src', image_url); | ||||||
|  |  | ||||||
|  |     modal.show(); | ||||||
|  |  | ||||||
|  |     modal.animate({ | ||||||
|  |         opacity: 1.0, | ||||||
|  |     }, 250); | ||||||
|  |  | ||||||
|  |     $('#modal-image-close').click(function() { | ||||||
|  |         hideModalImage(); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     modal.click(function() { | ||||||
|  |         hideModalImage(); | ||||||
|  |     }); | ||||||
|  | } | ||||||
| @@ -1,5 +1,12 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
|  | <div class='modal fade modal-image' role='dialog' id='modal-image-dialog'> | ||||||
|  |   <span class='modal-close' id='modal-image-close'>×</span> | ||||||
|  |  | ||||||
|  |   <img class='modal-image-content' id='modal-image'> | ||||||
|  |  | ||||||
|  | </div> | ||||||
|  |  | ||||||
| <div class='modal fade modal-fixed-footer modal-primary' tabindex='-1' role='dialog' id='modal-form'> | <div class='modal fade modal-fixed-footer modal-primary' tabindex='-1' role='dialog' id='modal-form'> | ||||||
|     <div class='modal-dialog'> |     <div class='modal-dialog'> | ||||||
|         <div class='modal-content'> |         <div class='modal-content'> | ||||||
| @@ -78,8 +85,10 @@ | |||||||
|               </button> |               </button> | ||||||
|               <h3 id='modal-title'>Alert Information</h3> |               <h3 id='modal-title'>Alert Information</h3> | ||||||
|             </div> |             </div> | ||||||
|  |             <div class='modal-form-content-wrapper'> | ||||||
|               <div class='modal-form-content'> |               <div class='modal-form-content'> | ||||||
|               </div> |               </div> | ||||||
|  |             </div> | ||||||
|             <div class='modal-footer'> |             <div class='modal-footer'> | ||||||
|                 <button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button> |                 <button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button> | ||||||
|             </div> |             </div> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user