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 06c06bde05..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'),
@@ -97,6 +104,13 @@ class InvenTreeSetting(models.Model):
'validator': bool,
},
+ 'PART_ALLOW_EDIT_IPN': {
+ 'name': _('Allow Editing IPN'),
+ 'description': _('Allow changing the IPN value while editing a part'),
+ 'default': True,
+ 'validator': bool,
+ },
+
'PART_COPY_BOM': {
'name': _('Copy Part BOM Data'),
'description': _('Copy BOM data by default when duplicating a part'),
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/bom.py b/InvenTree/part/bom.py
index 092b3e3183..ccde26e2f7 100644
--- a/InvenTree/part/bom.py
+++ b/InvenTree/part/bom.py
@@ -232,14 +232,18 @@ class BomUploadManager:
# Fields which are absolutely necessary for valid upload
REQUIRED_HEADERS = [
- 'Part_Name',
'Quantity'
]
+
+ # Fields which are used for part matching (only one of them is needed)
+ PART_MATCH_HEADERS = [
+ 'Part_Name',
+ 'Part_IPN',
+ 'Part_ID',
+ ]
# Fields which would be helpful but are not required
OPTIONAL_HEADERS = [
- 'Part_IPN',
- 'Part_ID',
'Reference',
'Note',
'Overage',
@@ -251,7 +255,7 @@ class BomUploadManager:
'Overage'
]
- HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
+ HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS
def __init__(self, bom_file):
""" Initialize the BomUpload class with a user-uploaded file object """
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/models.py b/InvenTree/part/models.py
index b06469699b..2824a89e75 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -1372,7 +1372,7 @@ class Part(MPTTModel):
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
"""
- return self.get_bom_hash() == self.bom_checksum
+ return self.get_bom_hash() == self.bom_checksum or not self.has_bom
@transaction.atomic
def validate_bom(self, user):
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 65f859566b..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 """
@@ -910,6 +998,12 @@ class PartEdit(AjaxUpdateView):
form.fields['default_supplier'].queryset = SupplierPart.objects.filter(part=part)
+ # Check if IPN can be edited
+ ipn_edit_enable = InvenTreeSetting.get_setting('PART_ALLOW_EDIT_IPN')
+ if not ipn_edit_enable and not self.request.user.is_superuser:
+ # Admin can still change IPN
+ form.fields['IPN'].disabled = True
+
return form
@@ -1425,10 +1519,23 @@ class BomUpload(InvenTreeRoleMixin, FormView):
# Are there any missing columns?
self.missing_columns = []
+ # Check that all required fields are present
for col in BomUploadManager.REQUIRED_HEADERS:
if col not in self.column_selections.values():
self.missing_columns.append(col)
+ # Check that at least one of the part match field is present
+ part_match_found = False
+ for col in BomUploadManager.PART_MATCH_HEADERS:
+ if col in self.column_selections.values():
+ part_match_found = True
+ break
+
+ # If not, notify user
+ if not part_match_found:
+ for col in BomUploadManager.PART_MATCH_HEADERS:
+ self.missing_columns.append(col)
+
def handleFieldSelection(self):
""" Handle the output of the field selection form.
Here the user is presented with the raw data and must select the
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/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html
index bef951e203..092d9c576c 100644
--- a/InvenTree/templates/InvenTree/settings/part.html
+++ b/InvenTree/templates/InvenTree/settings/part.html
@@ -18,6 +18,7 @@
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
+ {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}