diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 2843dc4eb8..068e3c1bc7 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -4,12 +4,36 @@ Provides helper functions used throughout the InvenTree project import io import json +import os.path from datetime import datetime +from PIL import Image from wsgiref.util import FileWrapper 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): """ Test if a string 'looks' like a boolean value. diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index e97386d229..1b2814ba52 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -78,7 +78,6 @@ class EditPartForm(HelperForm): 'purchaseable', 'salable', 'notes', - 'image', ] diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index a99839d34a..a59b97ce47 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -153,7 +153,7 @@ { accept_text: 'Activate', accept: function() { - inventreeUpdate( + inventreePut( "{% url 'api-part-detail' part.id %}", { active: true, @@ -176,7 +176,7 @@ { accept_text: 'Deactivate', accept: function() { - inventreeUpdate( + inventreePut( "{% url 'api-part-detail' part.id %}", { active: false, diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 5dc7add027..7b6ce44acf 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -13,12 +13,17 @@
- +
+
+ +
+ {% csrf_token %} +

@@ -96,6 +101,36 @@ {% block js_ready %} {{ 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() { launchModalForm( "{% url 'part-qr' part.id %}", diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 4cda10a0f0..7663318f96 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -47,6 +47,10 @@ part_detail_urls = [ 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'), # Any other URLs go to the part detail page diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index c97808888e..219bd1c740 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -7,6 +7,10 @@ from __future__ import unicode_literals 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.views.generic import DetailView, ListView 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 QRCodeView -from InvenTree.helpers import DownloadFile, str2bool +from InvenTree.helpers import DownloadFile, str2bool, TestIfImage, TestIfImageURL 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): """ View for editing Part object """ diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css index 3a9c1be184..78483daac5 100644 --- a/InvenTree/static/css/inventree.css +++ b/InvenTree/static/css/inventree.css @@ -10,6 +10,25 @@ 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 { padding-left: 6px; padding-right: 6px; @@ -28,7 +47,6 @@ .part-thumb { width: 150px; height: 150px; - border: 1px black solid; margin: 5px; padding: 5px; object-fit: contain; @@ -62,7 +80,7 @@ margin-right: 50px; margin-left: 50px; width: 100%; - //transition: 0.1s; + transition: 0.1s; } .body { @@ -181,4 +199,5 @@ @keyframes spin { from { transform: scale(1) rotate(0deg);} to { transform: scale(1) rotate(360deg);} -} \ No newline at end of file +} + diff --git a/InvenTree/static/script/inventree/api.js b/InvenTree/static/script/inventree/api.js index 27feec8b79..8c67f92979 100644 --- a/InvenTree/static/script/inventree/api.js +++ b/InvenTree/static/script/inventree/api.js @@ -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'; @@ -93,9 +130,9 @@ function getCompanies(filters={}, options={}) { } 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) { - return inventreeUpdate('/api/part/' + pk + '/', data, final); + return inventreePut('/api/part/' + pk + '/', data, final); } \ No newline at end of file diff --git a/InvenTree/static/script/inventree/inventree.js b/InvenTree/static/script/inventree/inventree.js new file mode 100644 index 0000000000..551058f101 --- /dev/null +++ b/InvenTree/static/script/inventree/inventree.js @@ -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; +} \ No newline at end of file diff --git a/InvenTree/static/script/inventree/part.js b/InvenTree/static/script/inventree/part.js index 7682d5a6c1..c3b0f8182d 100644 --- a/InvenTree/static/script/inventree/part.js +++ b/InvenTree/static/script/inventree/part.js @@ -41,7 +41,7 @@ function toggleStar(options) { if (response.length == 0) { // Zero length response = star does not exist // So let's add one! - inventreeUpdate( + inventreePut( url, { part: options.part, @@ -57,7 +57,7 @@ function toggleStar(options) { } else { var pk = response[0].pk; // There IS a star (delete it!) - inventreeUpdate( + inventreePut( url + pk + "/", { }, diff --git a/InvenTree/static/script/inventree/stock.js b/InvenTree/static/script/inventree/stock.js index 73199466f6..71bbed2ab3 100644 --- a/InvenTree/static/script/inventree/stock.js +++ b/InvenTree/static/script/inventree/stock.js @@ -141,7 +141,7 @@ function updateStock(items, options={}) { return false; } - inventreeUpdate("/api/stock/stocktake/", + inventreePut("/api/stock/stocktake/", { 'action': options.action, 'items[]': stocktake, @@ -226,7 +226,7 @@ function moveStockItems(items, options) { } function doMove(location, parts, notes) { - inventreeUpdate("/api/stock/move/", + inventreePut("/api/stock/move/", { location: location, 'parts[]': parts, diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 8331476cfa..2bc5cc9cd2 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -65,6 +65,8 @@ InvenTree + + @@ -76,18 +78,16 @@ InvenTree $(document).ready(function () { {% block js_ready %} {% endblock %} + + /* Run document-ready scripts. + * Ref: static/script/inventree/inventree.js + */ + inventreeDocReady(); + + /* Display any cached alert messages + * Ref: static/script/inventree/notification.js + */ showCachedAlerts(); - - $('#launch-about').click(function() { - var modal = $('#modal-about'); - - modal.modal({ - backdrop: 'static', - keyboard: 'false', - }); - - modal.modal('show'); - }); });