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 %}
\ 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" %}:
+
+
+ - {% trans "Must be a valid image URL" %}
+ - {% trans "Remote server must be accessible" %}
+ - {% trans "Remote image must not exceed maximum allowable file size" %}
+
+
+{% 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 %}
+
+
×
+
+
![]()
+
+
+