2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-03 05:48:47 +00:00

Merge pull request #1410 from SchrodingersGat/image-downloader

Image downloader
This commit is contained in:
Oliver 2021-03-18 11:10:07 +11:00 committed by GitHub
commit 57289fe141
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 403 additions and 9 deletions

View File

@ -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;
} }

View File

@ -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'),

View File

@ -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

View File

@ -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 """

View File

@ -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 %}

View File

@ -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'),

View File

@ -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

View File

@ -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

View File

@ -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 """

View File

@ -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 %}",

View File

@ -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>

View File

@ -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'),

View File

@ -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 """

View File

@ -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>

View 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 %}

View File

@ -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();
});
}

View File

@ -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'>&times;</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>