diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index 3650aa5ddf..153931f974 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -626,6 +626,53 @@ 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 { margin-left: 0px; } diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 381888c588..df8e3b2d37 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -78,6 +78,13 @@ class InvenTreeSetting(models.Model): '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': { 'name': _('Barcode Support'), 'description': _('Enable barcode scanner support'), diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 2777425ab4..3fa3197183 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -7,6 +7,7 @@ from django.apps import AppConfig from django.db.utils import OperationalError, ProgrammingError from django.conf import settings +from PIL import UnidentifiedImageError logger = logging.getLogger(__name__) @@ -38,9 +39,11 @@ class CompanyConfig(AppConfig): try: company.image.render_variations(replace=False) except FileNotFoundError: - logger.warning("Image file missing") + logger.warning(f"Image file '{company.image}' missing") company.image = None company.save() + except UnidentifiedImageError: + logger.warning(f"Image file '{company.image}' is invalid") except (OperationalError, ProgrammingError): # Getting here probably meant the database was in test mode pass diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py index 0ad95c3e8c..67ac402ba7 100644 --- a/InvenTree/company/forms.py +++ b/InvenTree/company/forms.py @@ -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): """ Form for editing a SupplierPart object """ diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 3035eeaa15..9331e5d895 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -2,19 +2,32 @@ {% load static %} {% load i18n %} +{% load inventree_extras %} + {% block page_title %} InvenTree | {% trans "Company" %} - {{ company.name }} {% endblock %} + {% block thumbnail %} -
- + +
+
+ + {% if allow_download %} + + {% endif %} +
+
{% 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( "{% 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 %} \ No newline at end of file diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py index f5fbeede47..b5ad06019b 100644 --- a/InvenTree/company/urls.py +++ b/InvenTree/company/urls.py @@ -21,6 +21,7 @@ company_detail_urls = [ url(r'^notes/', views.CompanyNotes.as_view(), name='company-notes'), 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 url(r'^.*$', views.CompanyDetail.as_view(), name='company-detail'), diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py index 59e9b23904..42457d6101 100644 --- a/InvenTree/company/views.py +++ b/InvenTree/company/views.py @@ -11,9 +11,14 @@ from django.views.generic import DetailView, ListView, UpdateView from django.urls import reverse from django.forms import HiddenInput +from django.core.files.base import ContentFile from moneyed import CURRENCIES +from PIL import Image +import requests +import io + from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.helpers import str2bool from InvenTree.views import InvenTreeRoleMixin @@ -28,6 +33,7 @@ from .forms import EditCompanyForm from .forms import CompanyImageForm from .forms import EditSupplierPartForm from .forms import EditPriceBreakForm +from .forms import CompanyImageDownloadForm import common.models import common.settings @@ -150,6 +156,84 @@ class CompanyDetail(DetailView): 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): """ View for uploading an image for the Company """ model = Company diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index b1089cd57c..d08e7680fe 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -7,6 +7,7 @@ from django.db.utils import OperationalError, ProgrammingError from django.apps import AppConfig from django.conf import settings +from PIL import UnidentifiedImageError logger = logging.getLogger(__name__) @@ -44,9 +45,11 @@ class PartConfig(AppConfig): try: part.image.render_variations(replace=False) except FileNotFoundError: - logger.warning("Image file missing") + logger.warning(f"Image file '{part.image}' missing") part.image = None part.save() + except UnidentifiedImageError: + logger.warning(f"Image file '{part.image}' is invalid") except (OperationalError, ProgrammingError): # Exception if the database has not been migrated yet pass diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 85a851e235..7962ec3252 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -37,6 +37,24 @@ class PartModelChoiceField(forms.ModelChoiceField): 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): """ Form for uploading a Part image """ diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index ebcbdef03a..96c9636c88 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -206,6 +206,12 @@ toggleId: '#part-menu-toggle', }); + {% if part.image %} + $('#part-thumb').click(function() { + showModalImage('{{ part.image.url }}'); + }); + {% endif %} + enableDragAndDrop( '#part-thumb', "{% url 'part-image-upload' part.id %}", @@ -294,6 +300,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() { launchModalForm("{% url 'part-image-select' part.id %}", @@ -303,7 +323,6 @@ }); }); - {% if roles.part.change %} $("#part-edit").click(function() { launchModalForm( "{% url 'part-edit' part.id %}", diff --git a/InvenTree/part/templates/part/part_thumb.html b/InvenTree/part/templates/part/part_thumb.html index e0314b1c7e..dd192843dc 100644 --- a/InvenTree/part/templates/part/part_thumb.html +++ b/InvenTree/part/templates/part/part_thumb.html @@ -1,20 +1,28 @@ {% load static %} {% load i18n %} +{% load inventree_extras %} + +{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
-
+ {% if roles.part.change %}
- + + {% if allow_download %} + + {% endif %}
+ {% endif %}
\ No newline at end of file diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index e21c6295e3..f275edede2 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -75,6 +75,7 @@ part_detail_urls = [ # Normal thumbnail with form 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-download/', views.PartImageDownloadFromURL.as_view(), name='part-image-download'), # Any other URLs go to the part detail page url(r'^.*$', views.PartDetail.as_view(), name='part-detail'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 889a7dc4b3..0208636a48 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -5,6 +5,7 @@ Django views for interacting with Part app # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.core.files.base import ContentFile from django.core.exceptions import ValidationError from django.db import transaction from django.db.utils import IntegrityError @@ -19,7 +20,11 @@ from django.conf import settings from moneyed import CURRENCIES +from PIL import Image + +import requests import os +import io from rapidfuzz import fuzz from decimal import Decimal, InvalidOperation @@ -831,6 +836,89 @@ 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 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): """ View for uploading a new Part image """ diff --git a/InvenTree/templates/InvenTree/settings/global.html b/InvenTree/templates/InvenTree/settings/global.html index d63593d866..c234bd1379 100644 --- a/InvenTree/templates/InvenTree/settings/global.html +++ b/InvenTree/templates/InvenTree/settings/global.html @@ -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_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_DOWNLOAD_FROM_URL" icon="fa-cloud-download-alt" %} diff --git a/InvenTree/templates/image_download.html b/InvenTree/templates/image_download.html new file mode 100644 index 0000000000..1191886711 --- /dev/null +++ b/InvenTree/templates/image_download.html @@ -0,0 +1,16 @@ +{% extends "modal_form.html" %} + +{% load inventree_extras %} +{% load i18n %} + +{% block pre_form_content %} +
+ {% trans "Specify URL for downloading image" %}: + + +
+{% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/js/modals.js b/InvenTree/templates/js/modals.js index 12639749f6..2c246d2a36 100644 --- a/InvenTree/templates/js/modals.js +++ b/InvenTree/templates/js/modals.js @@ -909,3 +909,42 @@ function launchModalForm(url, options = {}) { // Send the AJAX request $.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(); + }); +} \ No newline at end of file diff --git a/InvenTree/templates/modals.html b/InvenTree/templates/modals.html index 11166751f8..9850f482c5 100644 --- a/InvenTree/templates/modals.html +++ b/InvenTree/templates/modals.html @@ -1,5 +1,12 @@ {% load i18n %} + +