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

View File

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

View File

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

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):
""" Form for editing a SupplierPart object """

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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
$.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 %}
<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-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>