mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-07 15:58:49 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
0f8a682085
@ -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'),
|
||||||
@ -97,6 +104,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool,
|
'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': {
|
'PART_COPY_BOM': {
|
||||||
'name': _('Copy Part BOM Data'),
|
'name': _('Copy Part BOM Data'),
|
||||||
'description': _('Copy BOM data by default when duplicating a part'),
|
'description': _('Copy BOM data by default when duplicating a part'),
|
||||||
|
@ -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
|
||||||
|
@ -232,14 +232,18 @@ class BomUploadManager:
|
|||||||
|
|
||||||
# Fields which are absolutely necessary for valid upload
|
# Fields which are absolutely necessary for valid upload
|
||||||
REQUIRED_HEADERS = [
|
REQUIRED_HEADERS = [
|
||||||
'Part_Name',
|
|
||||||
'Quantity'
|
'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
|
# Fields which would be helpful but are not required
|
||||||
OPTIONAL_HEADERS = [
|
OPTIONAL_HEADERS = [
|
||||||
'Part_IPN',
|
|
||||||
'Part_ID',
|
|
||||||
'Reference',
|
'Reference',
|
||||||
'Note',
|
'Note',
|
||||||
'Overage',
|
'Overage',
|
||||||
@ -251,7 +255,7 @@ class BomUploadManager:
|
|||||||
'Overage'
|
'Overage'
|
||||||
]
|
]
|
||||||
|
|
||||||
HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
|
HEADERS = REQUIRED_HEADERS + PART_MATCH_HEADERS + OPTIONAL_HEADERS
|
||||||
|
|
||||||
def __init__(self, bom_file):
|
def __init__(self, bom_file):
|
||||||
""" Initialize the BomUpload class with a user-uploaded file object """
|
""" Initialize the BomUpload class with a user-uploaded file object """
|
||||||
|
@ -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 """
|
||||||
|
|
||||||
|
@ -1372,7 +1372,7 @@ class Part(MPTTModel):
|
|||||||
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
|
""" 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
|
@transaction.atomic
|
||||||
def validate_bom(self, user):
|
def validate_bom(self, user):
|
||||||
|
@ -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 """
|
||||||
|
|
||||||
@ -910,6 +998,12 @@ class PartEdit(AjaxUpdateView):
|
|||||||
|
|
||||||
form.fields['default_supplier'].queryset = SupplierPart.objects.filter(part=part)
|
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
|
return form
|
||||||
|
|
||||||
|
|
||||||
@ -1425,10 +1519,23 @@ class BomUpload(InvenTreeRoleMixin, FormView):
|
|||||||
# Are there any missing columns?
|
# Are there any missing columns?
|
||||||
self.missing_columns = []
|
self.missing_columns = []
|
||||||
|
|
||||||
|
# Check that all required fields are present
|
||||||
for col in BomUploadManager.REQUIRED_HEADERS:
|
for col in BomUploadManager.REQUIRED_HEADERS:
|
||||||
if col not in self.column_selections.values():
|
if col not in self.column_selections.values():
|
||||||
self.missing_columns.append(col)
|
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):
|
def handleFieldSelection(self):
|
||||||
""" Handle the output of the field selection form.
|
""" Handle the output of the field selection form.
|
||||||
Here the user is presented with the raw data and must select the
|
Here the user is presented with the raw data and must select the
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
|
{% 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_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_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}
|
||||||
<tr><td colspan='5 '></td></tr>
|
<tr><td colspan='5 '></td></tr>
|
||||||
|
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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user