mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #1410 from SchrodingersGat/image-downloader
Image downloader
This commit is contained in:
commit
57289fe141
@ -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;
|
||||
}
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -2,19 +2,32 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
|
||||
{% block page_title %}
|
||||
InvenTree | {% trans "Company" %} - {{ company.name }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block thumbnail %}
|
||||
<div class='dropzone' id='company-thumb'>
|
||||
<img class="part-thumb"
|
||||
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
|
||||
|
||||
<div class='dropzone part-thumb-container' id='company-thumb'>
|
||||
<img class="part-thumb" id='company-image'
|
||||
{% if company.image %}
|
||||
src="{{ company.image.url }}"
|
||||
{% else %}
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% 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>
|
||||
{% 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 %}
|
@ -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'),
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -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 %}",
|
||||
|
@ -1,20 +1,28 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
|
||||
|
||||
<div class="media">
|
||||
<div class="media-left part-thumb-container">
|
||||
<div class='dropzone' id='part-thumb'>
|
||||
<img class="part-thumb"
|
||||
<img class="part-thumb" id='part-image'
|
||||
{% if part.image %}
|
||||
src="{{ part.image.url }}"
|
||||
{% else %}
|
||||
src="{% static 'img/blank_image.png' %}"
|
||||
{% endif %}/>
|
||||
</div>
|
||||
{% if roles.part.change %}
|
||||
<div class='btn-row part-thumb-overlay'>
|
||||
<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 '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>
|
||||
{% endif %}
|
||||
</div>
|
@ -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'),
|
||||
|
@ -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 """
|
||||
|
||||
|
@ -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" %}
|
||||
</tbody>
|
||||
</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
|
||||
$.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 %}
|
||||
|
||||
<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-dialog'>
|
||||
<div class='modal-content'>
|
||||
@ -78,7 +85,9 @@
|
||||
</button>
|
||||
<h3 id='modal-title'>Alert Information</h3>
|
||||
</div>
|
||||
<div class='modal-form-content'>
|
||||
<div class='modal-form-content-wrapper'>
|
||||
<div class='modal-form-content'>
|
||||
</div>
|
||||
</div>
|
||||
<div class='modal-footer'>
|
||||
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||
|
Loading…
Reference in New Issue
Block a user