Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-05-07 00:19:18 +10:00
commit 364f78d20c
12 changed files with 308 additions and 31 deletions

View File

@ -4,12 +4,36 @@ Provides helper functions used throughout the InvenTree project
import io import io
import json import json
import os.path
from datetime import datetime from datetime import datetime
from PIL import Image
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
def TestIfImage(img):
""" Test if an image file is indeed an image """
try:
Image.open(img).verify()
return True
except:
return False
def TestIfImageURL(url):
""" Test if an image URL (or filename) looks like a valid image format.
Simply tests the extension against a set of allowed values
"""
return os.path.splitext(os.path.basename(url))[-1].lower() in [
'.jpg', '.jpeg',
'.png', '.bmp',
'.tif', '.tiff',
'.webp',
]
def str2bool(text, test=True): def str2bool(text, test=True):
""" Test if a string 'looks' like a boolean value. """ Test if a string 'looks' like a boolean value.

View File

@ -78,7 +78,6 @@ class EditPartForm(HelperForm):
'purchaseable', 'purchaseable',
'salable', 'salable',
'notes', 'notes',
'image',
] ]

View File

@ -153,7 +153,7 @@
{ {
accept_text: 'Activate', accept_text: 'Activate',
accept: function() { accept: function() {
inventreeUpdate( inventreePut(
"{% url 'api-part-detail' part.id %}", "{% url 'api-part-detail' part.id %}",
{ {
active: true, active: true,
@ -176,7 +176,7 @@
{ {
accept_text: 'Deactivate', accept_text: 'Deactivate',
accept: function() { accept: function() {
inventreeUpdate( inventreePut(
"{% url 'api-part-detail' part.id %}", "{% url 'api-part-detail' part.id %}",
{ {
active: false, active: false,

View File

@ -13,12 +13,17 @@
<div class="col-sm-6"> <div class="col-sm-6">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<img class="part-thumb" id="part-thumb" <form id='upload-photo' method='post' action="{% url 'part-image-upload' part.id %}">
<div class='dropzone' id='part-thumb'>
<img class="part-thumb"
{% 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>
{% csrf_token %}
</form>
</div> </div>
<div class="media-body"> <div class="media-body">
<h4> <h4>
@ -96,6 +101,36 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$('#part-thumb').on('drop', function(event) {
var transfer = event.originalEvent.dataTransfer;
var formData = new FormData();
if (isFileTransfer(transfer)) {
formData.append('image_file', transfer.files[0]);
} else if (isOnlineTransfer(transfer)) {
formData.append('image_url', getImageUrlFromTransfer(transfer));
} else {
console.log('Unknown transfer');
return;
}
inventreeFormDataUpload(
"{% url 'part-image-upload' part.id %}",
formData,
{
success: function(data, status, xhr) {
location.reload();
},
error: function(xhr, status, error) {
showAlertDialog('Error uploading image', renderErrorMessage(xhr));
}
}
);
});
$("#show-qr-code").click(function() { $("#show-qr-code").click(function() {
launchModalForm( launchModalForm(
"{% url 'part-qr' part.id %}", "{% url 'part-qr' part.id %}",

View File

@ -47,6 +47,10 @@ part_detail_urls = [
url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'), url(r'^qr_code/?', views.PartQRCode.as_view(), name='part-qr'),
# Drag-and-drop thumbnail upload
url(r'^thumbnail-upload/?', views.UploadPartImage.as_view(), name='part-image-upload'),
# Normal thumbnail with form
url(r'^thumbnail/?', views.PartImage.as_view(), name='part-image'), url(r'^thumbnail/?', views.PartImage.as_view(), name='part-image'),
# Any other URLs go to the part detail page # Any other URLs go to the part detail page

View File

@ -7,6 +7,10 @@ from __future__ import unicode_literals
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict from django.forms.models import model_to_dict
@ -29,7 +33,7 @@ from .forms import EditSupplierPartForm
from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxView, AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.views import QRCodeView from InvenTree.views import QRCodeView
from InvenTree.helpers import DownloadFile, str2bool from InvenTree.helpers import DownloadFile, str2bool, TestIfImage, TestIfImageURL
class PartIndex(ListView): class PartIndex(ListView):
@ -268,6 +272,95 @@ class PartImage(AjaxUpdateView):
} }
class UploadPartImage(AjaxView):
""" View for uploading a Part image via AJAX request.
e.g. via a "drag and drop" event.
There are two ways to upload a file:
1. Attach an image file as request.FILES['image_file']
- Image is validated saved
- Part object is saved
2. Attach an iamge URL as request.POST['image_url']
NOT YET IMPLEMENTED
- Image URL is valiated
- Image is downloaded and validated
- Part object is saved
"""
model = Part
def post(self, request, *args, **kwargs):
response = {}
status = 200
try:
part = Part.objects.get(pk=kwargs.get('pk'))
except Part.DoesNotExist:
response['error'] = 'Part not found'
return JsonResponse(response, status=404)
# Direct image upload
if 'image_file' in request.FILES:
image = request.FILES['image_file']
if TestIfImage(image):
part.image = image
part.clean()
part.save()
response['success'] = 'File was uploaded successfully'
status = 200
else:
response['error'] = 'Not a valid image file'
status = 400
return JsonResponse(response, status=status)
elif 'image_url' in request.POST:
image_url = request.POST['image_url']
validator = URLValidator()
try:
validator(image_url)
except ValidationError:
response['error'] = 'Invalid image URL'
response['url'] = image_url
return JsonResponse(response, status=400)
# Test the the URL at least looks like an image
if not TestIfImageURL(image_url):
response['error'] = 'Invalid image URL'
return JsonResponse(response, status=400)
response['error'] = 'Cannot download cross-site images (yet)'
response['url'] = image_url
response['apology'] = 'deepest'
return JsonResponse(response, status=400)
# TODO - Attempt to download the image file here
"""
head = requests.head(url, stream=True, headers={'User-agent': 'Mozilla/5.0'})
if head.headers['Content-Length'] < SOME_MAX_LENGTH:
image = requests.get(url, stream=True, headers={'User-agent': 'Mozilla/5.0'})
file = io.BytesIO(image.raw.read())
- Save the file?
"""
# Default response
return JsonResponse(response, status=status)
class PartEdit(AjaxUpdateView): class PartEdit(AjaxUpdateView):
""" View for editing Part object """ """ View for editing Part object """

View File

@ -10,6 +10,25 @@
color: #ffcc00; color: #ffcc00;
} }
/* dropzone class - for Drag-n-Drop file uploads */
.dropzone {
border: 1px solid #555;
z-index: 2;
}
.dropzone * {
pointer-events: none;
}
.dragover {
background-color: #55A;
border: 1px dashed #111;
opacity: 0.1;
-moz-opacity: 10%;
-webkit-opacity: 10%;
}
.btn-glyph { .btn-glyph {
padding-left: 6px; padding-left: 6px;
padding-right: 6px; padding-right: 6px;
@ -28,7 +47,6 @@
.part-thumb { .part-thumb {
width: 150px; width: 150px;
height: 150px; height: 150px;
border: 1px black solid;
margin: 5px; margin: 5px;
padding: 5px; padding: 5px;
object-fit: contain; object-fit: contain;
@ -62,7 +80,7 @@
margin-right: 50px; margin-right: 50px;
margin-left: 50px; margin-left: 50px;
width: 100%; width: 100%;
//transition: 0.1s; transition: 0.1s;
} }
.body { .body {
@ -182,3 +200,4 @@
from { transform: scale(1) rotate(0deg);} from { transform: scale(1) rotate(0deg);}
to { transform: scale(1) rotate(360deg);} to { transform: scale(1) rotate(360deg);}
} }

View File

@ -42,7 +42,44 @@ function inventreeGet(url, filters={}, options={}) {
}); });
} }
function inventreeUpdate(url, data={}, options={}) { function inventreeFormDataUpload(url, data, options={}) {
/* Upload via AJAX using the FormData approach.
*
* Note that the following AJAX parameters are required for FormData upload
*
* processData: false
* contentType: false
*/
// CSRF cookie token
var csrftoken = getCookie('csrftoken');
return $.ajax({
beforeSend: function(xhr, settings) {
xhr.setRequestHeader('X-CSRFToken', csrftoken);
},
url: url,
method: 'POST',
data: data,
processData: false,
contentType: false,
success: function(data, status, xhr) {
console.log('Form data upload success');
if (options.success) {
options.success(data, status, xhr);
}
},
error: function(xhr, status, error) {
console.log('Form data upload failure: ' + status);
if (options.error) {
options.error(xhr, status, error);
}
}
});
}
function inventreePut(url, data={}, options={}) {
var method = options.method || 'PUT'; var method = options.method || 'PUT';
@ -93,9 +130,9 @@ function getCompanies(filters={}, options={}) {
} }
function updateStockItem(pk, data, final=false) { function updateStockItem(pk, data, final=false) {
return inventreeUpdate('/api/stock/' + pk + '/', data, final); return inventreePut('/api/stock/' + pk + '/', data, final);
} }
function updatePart(pk, data, final=false) { function updatePart(pk, data, final=false) {
return inventreeUpdate('/api/part/' + pk + '/', data, final); return inventreePut('/api/part/' + pk + '/', data, final);
} }

View File

@ -0,0 +1,66 @@
function inventreeDocReady() {
/* Run this function when the HTML document is loaded.
* This will be called for every page that extends "base.html"
*/
window.addEventListener("dragover",function(e){
e = e || event;
e.preventDefault();
},false);
window.addEventListener("drop",function(e){
e = e || event;
e.preventDefault();
},false);
/* Add drag-n-drop functionality to any element
* marked with the class 'dropzone'
*/
$('.dropzone').on('dragenter', function() {
$(this).addClass('dragover');
});
$('.dropzone').on('dragleave drop', function() {
$(this).removeClass('dragover');
});
// Callback to launch the 'About' window
$('#launch-about').click(function() {
var modal = $('#modal-about');
modal.modal({
backdrop: 'static',
keyboard: 'false',
});
modal.modal('show');
});
}
function isFileTransfer(transfer) {
/* Determine if a transfer (e.g. drag-and-drop) is a file transfer
*/
return transfer.files.length > 0;
}
function isOnlineTransfer(transfer) {
/* Determine if a drag-and-drop transfer is from another website.
* e.g. dragged from another browser window
*/
return transfer.items.length > 0;
}
function getImageUrlFromTransfer(transfer) {
/* Extract external image URL from a drag-and-dropped image
*/
var url = transfer.getData('text/html').match(/src\s*=\s*"(.+?)"/)[1];
console.log('Image URL: ' + url);
return url;
}

View File

@ -41,7 +41,7 @@ function toggleStar(options) {
if (response.length == 0) { if (response.length == 0) {
// Zero length response = star does not exist // Zero length response = star does not exist
// So let's add one! // So let's add one!
inventreeUpdate( inventreePut(
url, url,
{ {
part: options.part, part: options.part,
@ -57,7 +57,7 @@ function toggleStar(options) {
} else { } else {
var pk = response[0].pk; var pk = response[0].pk;
// There IS a star (delete it!) // There IS a star (delete it!)
inventreeUpdate( inventreePut(
url + pk + "/", url + pk + "/",
{ {
}, },

View File

@ -141,7 +141,7 @@ function updateStock(items, options={}) {
return false; return false;
} }
inventreeUpdate("/api/stock/stocktake/", inventreePut("/api/stock/stocktake/",
{ {
'action': options.action, 'action': options.action,
'items[]': stocktake, 'items[]': stocktake,
@ -226,7 +226,7 @@ function moveStockItems(items, options) {
} }
function doMove(location, parts, notes) { function doMove(location, parts, notes) {
inventreeUpdate("/api/stock/move/", inventreePut("/api/stock/move/",
{ {
location: location, location: location,
'parts[]': parts, 'parts[]': parts,

View File

@ -65,6 +65,8 @@ InvenTree
<script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script> <script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script> <script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/api.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/tables.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script> <script type='text/javascript' src="{% static 'script/inventree/modals.js' %}"></script>
@ -76,18 +78,16 @@ InvenTree
$(document).ready(function () { $(document).ready(function () {
{% block js_ready %} {% block js_ready %}
{% endblock %} {% endblock %}
/* Run document-ready scripts.
* Ref: static/script/inventree/inventree.js
*/
inventreeDocReady();
/* Display any cached alert messages
* Ref: static/script/inventree/notification.js
*/
showCachedAlerts(); showCachedAlerts();
$('#launch-about').click(function() {
var modal = $('#modal-about');
modal.modal({
backdrop: 'static',
keyboard: 'false',
});
modal.modal('show');
});
}); });
</script> </script>