Merge pull request #428 from torchbox/feature/multi-image-upload

Multi Image Upload
This commit is contained in:
Karl Hobley 2014-07-24 12:31:41 +01:00
commit 14fdc00de9
22 changed files with 2903 additions and 9 deletions

View file

@ -101,6 +101,13 @@ $(function(){
}
});
/* Dropzones */
$('.drop-zone').on('dragover', function(){
$(this).addClass('hovered');
}).on('dragleave dragend drop', function(){
$(this).removeClass('hovered');
});
/* Header search behaviour */
if(window.headerSearch){
var search_current_index = 0;

File diff suppressed because one or more lines are too long

View file

@ -214,4 +214,13 @@ img{
/* utility class to allow things to be scrollable if their contents can't wrap more nicely */
.overflow{
overflow:auto;
}
}
.status-msg{
&.success{
color:$color-green;
}
&.failure{
color:$color-red;
}
}

View file

@ -316,6 +316,7 @@ input[type=submit], input[type=reset], input[type=button], button{
> li{
@include row();
position:relative;
overflow:hidden;
background-color:white;
padding:1em 10em 1em 1.5em; /* 10em padding leaves room for controls */
margin-bottom:1em;
@ -726,6 +727,25 @@ ul.tagit li.tagit-choice-editable{
}
}
/* file drop zones */
.drop-zone{
@include border-radius(5px);
border:2px dashed $color-grey-4;
padding:$mobile-nice-padding;
background-color:$color-grey-5;
margin-bottom:1em;
text-align:center;
.drop-zone-help{
border:0;
}
&.hovered{
border-color:$color-teal;
background-color:$color-input-focus;
}
}
/* Transitions */
fieldset, input, textarea, select{
@include transition(background-color 0.2s ease);

View file

@ -0,0 +1,25 @@
.progress{
@include border-radius(1.2em);
background-color:$color-teal-dark;
border:1px solid $color-teal;
opacity:0;
&.active{
opacity:1;
@include transition(opacity 0.3s ease);
}
.bar{
@include border-radius(1.5em);
@include transition(width 0.3s ease);
overflow:hidden;
box-sizing:border-box;
text-align:right;
line-height:1.2em;
color:white;
font-size:0.85em;
background-color:$color-teal;
height:1.2em;
padding-right:1em;
}
}

View file

@ -13,6 +13,7 @@
@import "components/messages.scss";
@import "components/formatters.scss";
@import "components/header.scss";
@import "components/progressbar.scss";
@import "components/datetimepicker.scss";
@import "fonts.scss";

View file

@ -14,6 +14,11 @@ def get_image_form():
widgets={'file': forms.FileInput()})
def get_image_form_for_multi():
# exclude the file widget
return modelform_factory(get_image_model(), exclude=('file',))
class ImageInsertionForm(forms.Form):
"""
Form for selecting parameters of the image (e.g. format) prior to insertion

View file

@ -0,0 +1,155 @@
$(function(){
// Redirect users that don't support filereader
if(!$('html').hasClass('filereader')){
document.location.href = window.simple_upload_url;
return false;
}
// prevents browser default drag/drop
$(document).bind('drop dragover', function (e) {
e.preventDefault();
});
$('#fileupload').fileupload({
dataType: 'html',
sequentialUploads: true,
dropZone: $('.drop-zone'),
acceptFileTypes: /(\.|\/)(gif|jpe?g|png)$/i,
previewMinWidth:150,
previewMaxWidth:150,
previewMinHeight:150,
previewMaxHeight:150,
add: function (e, data) {
var $this = $(this);
var that = $this.data('blueimp-fileupload') || $this.data('fileupload')
var li = $($('#upload-list-item').html()).addClass('upload-uploading')
var options = that.options;
$('#upload-list').append(li);
data.context = li;
data.process(function () {
return $this.fileupload('process', data);
}).always(function () {
data.context.removeClass('processing');
data.context.find('.left').each(function(index, elm){
$(elm).append(data.files[index].name);
});
data.context.find('.preview .thumb').each(function (index, elm) {
$(elm).addClass('hasthumb')
$(elm).append(data.files[index].preview);
});
}).done(function () {
data.context.find('.start').prop('disabled', false);
if ((that._trigger('added', e, data) !== false) &&
(options.autoUpload || data.autoUpload) &&
data.autoUpload !== false) {
data.submit();
}
}).fail(function () {
if (data.files.error) {
data.context.each(function (index) {
var error = data.files[index].error;
if (error) {
$(this).find('.error').text(error);
}
});
}
});
},
progress: function (e, data) {
if (e.isDefaultPrevented()) {
return false;
}
var progress = Math.floor(data.loaded / data.total * 100);
data.context.each(function () {
$(this).find('.progress').addClass('active').attr('aria-valuenow', progress).find('.bar').css(
'width',
progress + '%'
).html(progress + '%');
});
},
progressall: function (e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
$('#overall-progress').addClass('active').attr('aria-valuenow', progress).find('.bar').css(
'width',
progress + '%'
).html(progress + '%');
if (progress >= 100){
$('#overall-progress').removeClass('active').find('.bar').css('width','0%');
}
},
done: function (e, data) {
var itemElement = $(data.context);
var response = $.parseJSON(data.result);
if(response.success){
itemElement.addClass('upload-success')
$('.right', itemElement).append(response.form);
// run tagit enhancement
$('.tag_field input', itemElement).tagit(window.tagit_opts);
} else {
itemElement.addClass('upload-failure');
$('.right .error_messages', itemElement).append(response.error_message);
}
},
fail: function(e, data){
var itemElement = $(data.context);
itemElement.addClass('upload-failure');
},
always: function(e, data){
var itemElement = $(data.context);
itemElement.removeClass('upload-uploading').addClass('upload-complete');
},
});
// ajax-enhance forms added on done()
$('#upload-list').on('submit', 'form', function(e){
var form = $(this);
var itemElement = form.closest('#upload-list > li');
console.log(form);
e.preventDefault();
$.post(this.action, form.serialize(), function(data) {
if (data.success) {
itemElement.slideUp(function(){$(this).remove()});
}else{
form.replaceWith(data.form);
// run tagit enhancement on new form
$('.tag_field input', form).tagit(window.tagit_opts);
}
});
});
$('#upload-list').on('click', '.delete', function(e){
var form = $(this).closest('form');
var itemElement = form.closest('#upload-list > li');
e.preventDefault();
var CSRFToken = $('input[name="csrfmiddlewaretoken"]', form).val();
$.post(this.href, {csrfmiddlewaretoken: CSRFToken}, function(data) {
if (data.success) {
itemElement.slideUp(function(){$(this).remove()});
}else{
}
});
});
});

View file

@ -0,0 +1 @@
!function(a){"use strict";var b=a.HTMLCanvasElement&&a.HTMLCanvasElement.prototype,c=a.Blob&&function(){try{return Boolean(new Blob)}catch(a){return!1}}(),d=c&&a.Uint8Array&&function(){try{return 100===new Blob([new Uint8Array(100)]).size}catch(a){return!1}}(),e=a.BlobBuilder||a.WebKitBlobBuilder||a.MozBlobBuilder||a.MSBlobBuilder,f=(c||e)&&a.atob&&a.ArrayBuffer&&a.Uint8Array&&function(a){var b,f,g,h,i,j;for(b=a.split(",")[0].indexOf("base64")>=0?atob(a.split(",")[1]):decodeURIComponent(a.split(",")[1]),f=new ArrayBuffer(b.length),g=new Uint8Array(f),h=0;h<b.length;h+=1)g[h]=b.charCodeAt(h);return i=a.split(",")[0].split(":")[1].split(";")[0],c?new Blob([d?g:f],{type:i}):(j=new e,j.append(f),j.getBlob(i))};a.HTMLCanvasElement&&!b.toBlob&&(b.mozGetAsFile?b.toBlob=function(a,c,d){d&&b.toDataURL&&f?a(f(this.toDataURL(c,d))):a(this.mozGetAsFile("blob",c))}:b.toDataURL&&f&&(b.toBlob=function(a,b,c){a(f(this.toDataURL(b,c)))})),"function"==typeof define&&define.amd?define(function(){return f}):a.dataURLtoBlob=f}(this);

View file

@ -0,0 +1,315 @@
/*
* jQuery File Upload Image Preview & Resize Plugin 1.7.2
* https://github.com/blueimp/jQuery-File-Upload
*
* Copyright 2013, Sebastian Tschan
* https://blueimp.net
*
* Licensed under the MIT license:
* http://www.opensource.org/licenses/MIT
*/
/* jshint nomen:false */
/* global define, window, Blob */
(function (factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// Register as an anonymous AMD module:
define([
'jquery',
'load-image',
'load-image-meta',
'load-image-exif',
'load-image-ios',
'canvas-to-blob',
'./jquery.fileupload-process'
], factory);
} else {
// Browser globals:
factory(
window.jQuery,
window.loadImage
);
}
}(function ($, loadImage) {
'use strict';
// Prepend to the default processQueue:
$.blueimp.fileupload.prototype.options.processQueue.unshift(
{
action: 'loadImageMetaData',
disableImageHead: '@',
disableExif: '@',
disableExifThumbnail: '@',
disableExifSub: '@',
disableExifGps: '@',
disabled: '@disableImageMetaDataLoad'
},
{
action: 'loadImage',
// Use the action as prefix for the "@" options:
prefix: true,
fileTypes: '@',
maxFileSize: '@',
noRevoke: '@',
disabled: '@disableImageLoad'
},
{
action: 'resizeImage',
// Use "image" as prefix for the "@" options:
prefix: 'image',
maxWidth: '@',
maxHeight: '@',
minWidth: '@',
minHeight: '@',
crop: '@',
orientation: '@',
forceResize: '@',
disabled: '@disableImageResize'
},
{
action: 'saveImage',
quality: '@imageQuality',
type: '@imageType',
disabled: '@disableImageResize'
},
{
action: 'saveImageMetaData',
disabled: '@disableImageMetaDataSave'
},
{
action: 'resizeImage',
// Use "preview" as prefix for the "@" options:
prefix: 'preview',
maxWidth: '@',
maxHeight: '@',
minWidth: '@',
minHeight: '@',
crop: '@',
orientation: '@',
thumbnail: '@',
canvas: '@',
disabled: '@disableImagePreview'
},
{
action: 'setImage',
name: '@imagePreviewName',
disabled: '@disableImagePreview'
},
{
action: 'deleteImageReferences',
disabled: '@disableImageReferencesDeletion'
}
);
// The File Upload Resize plugin extends the fileupload widget
// with image resize functionality:
$.widget('blueimp.fileupload', $.blueimp.fileupload, {
options: {
// The regular expression for the types of images to load:
// matched against the file type:
loadImageFileTypes: /^image\/(gif|jpeg|png|svg\+xml)$/,
// The maximum file size of images to load:
loadImageMaxFileSize: 10000000, // 10MB
// The maximum width of resized images:
imageMaxWidth: 1920,
// The maximum height of resized images:
imageMaxHeight: 1080,
// Defines the image orientation (1-8) or takes the orientation
// value from Exif data if set to true:
imageOrientation: false,
// Define if resized images should be cropped or only scaled:
imageCrop: false,
// Disable the resize image functionality by default:
disableImageResize: true,
// The maximum width of the preview images:
previewMaxWidth: 80,
// The maximum height of the preview images:
previewMaxHeight: 80,
// Defines the preview orientation (1-8) or takes the orientation
// value from Exif data if set to true:
previewOrientation: true,
// Create the preview using the Exif data thumbnail:
previewThumbnail: true,
// Define if preview images should be cropped or only scaled:
previewCrop: false,
// Define if preview images should be resized as canvas elements:
previewCanvas: true
},
processActions: {
// Loads the image given via data.files and data.index
// as img element, if the browser supports the File API.
// Accepts the options fileTypes (regular expression)
// and maxFileSize (integer) to limit the files to load:
loadImage: function (data, options) {
if (options.disabled) {
return data;
}
var that = this,
file = data.files[data.index],
dfd = $.Deferred();
if (($.type(options.maxFileSize) === 'number' &&
file.size > options.maxFileSize) ||
(options.fileTypes &&
!options.fileTypes.test(file.type)) ||
!loadImage(
file,
function (img) {
if (img.src) {
data.img = img;
}
dfd.resolveWith(that, [data]);
},
options
)) {
return data;
}
return dfd.promise();
},
// Resizes the image given as data.canvas or data.img
// and updates data.canvas or data.img with the resized image.
// Also stores the resized image as preview property.
// Accepts the options maxWidth, maxHeight, minWidth,
// minHeight, canvas and crop:
resizeImage: function (data, options) {
if (options.disabled || !(data.canvas || data.img)) {
return data;
}
options = $.extend({canvas: true}, options);
var that = this,
dfd = $.Deferred(),
img = (options.canvas && data.canvas) || data.img,
resolve = function (newImg) {
if (newImg && (newImg.width !== img.width ||
newImg.height !== img.height ||
options.forceResize)) {
data[newImg.getContext ? 'canvas' : 'img'] = newImg;
}
data.preview = newImg;
dfd.resolveWith(that, [data]);
},
thumbnail;
if (data.exif) {
if (options.orientation === true) {
options.orientation = data.exif.get('Orientation');
}
if (options.thumbnail) {
thumbnail = data.exif.get('Thumbnail');
if (thumbnail) {
loadImage(thumbnail, resolve, options);
return dfd.promise();
}
}
// Prevent orienting the same image twice:
if (data.orientation) {
delete options.orientation;
} else {
data.orientation = options.orientation;
}
}
if (img) {
resolve(loadImage.scale(img, options));
return dfd.promise();
}
return data;
},
// Saves the processed image given as data.canvas
// inplace at data.index of data.files:
saveImage: function (data, options) {
if (!data.canvas || options.disabled) {
return data;
}
var that = this,
file = data.files[data.index],
dfd = $.Deferred();
if (data.canvas.toBlob) {
data.canvas.toBlob(
function (blob) {
if (!blob.name) {
if (file.type === blob.type) {
blob.name = file.name;
} else if (file.name) {
blob.name = file.name.replace(
/\..+$/,
'.' + blob.type.substr(6)
);
}
}
// Don't restore invalid meta data:
if (file.type !== blob.type) {
delete data.imageHead;
}
// Store the created blob at the position
// of the original file in the files list:
data.files[data.index] = blob;
dfd.resolveWith(that, [data]);
},
options.type || file.type,
options.quality
);
} else {
return data;
}
return dfd.promise();
},
loadImageMetaData: function (data, options) {
if (options.disabled) {
return data;
}
var that = this,
dfd = $.Deferred();
loadImage.parseMetaData(data.files[data.index], function (result) {
$.extend(data, result);
dfd.resolveWith(that, [data]);
}, options);
return dfd.promise();
},
saveImageMetaData: function (data, options) {
if (!(data.imageHead && data.canvas &&
data.canvas.toBlob && !options.disabled)) {
return data;
}
var file = data.files[data.index],
blob = new Blob([
data.imageHead,
// Resized images always have a head size of 20 bytes,
// including the JPEG marker and a minimal JFIF header:
this._blobSlice.call(file, 20)
], {type: file.type});
blob.name = file.name;
data.files[data.index] = blob;
return data;
},
// Sets the resized version of the image as a property of the
// file object, must be called after "saveImage":
setImage: function (data, options) {
if (data.preview && !options.disabled) {
data.files[data.index][options.name || 'preview'] = data.preview;
}
return data;
},
deleteImageReferences: function (data, options) {
if (!options.disabled) {
delete data.img;
delete data.canvas;
delete data.preview;
delete data.imageHead;
}
return data;
}
}
});
}));

View file

@ -0,0 +1,172 @@
/*
* jQuery File Upload Processing Plugin 1.3.0
* https://github.com/blueimp/jQuery-File-Upload
*
* Copyright 2012, Sebastian Tschan
* https://blueimp.net
*
* Licensed under the MIT license:
* http://www.opensource.org/licenses/MIT
*/
/* jshint nomen:false */
/* global define, window */
(function (factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// Register as an anonymous AMD module:
define([
'jquery',
'./jquery.fileupload'
], factory);
} else {
// Browser globals:
factory(
window.jQuery
);
}
}(function ($) {
'use strict';
var originalAdd = $.blueimp.fileupload.prototype.options.add;
// The File Upload Processing plugin extends the fileupload widget
// with file processing functionality:
$.widget('blueimp.fileupload', $.blueimp.fileupload, {
options: {
// The list of processing actions:
processQueue: [
/*
{
action: 'log',
type: 'debug'
}
*/
],
add: function (e, data) {
var $this = $(this);
data.process(function () {
return $this.fileupload('process', data);
});
originalAdd.call(this, e, data);
}
},
processActions: {
/*
log: function (data, options) {
console[options.type](
'Processing "' + data.files[data.index].name + '"'
);
}
*/
},
_processFile: function (data, originalData) {
var that = this,
dfd = $.Deferred().resolveWith(that, [data]),
chain = dfd.promise();
this._trigger('process', null, data);
$.each(data.processQueue, function (i, settings) {
var func = function (data) {
if (originalData.errorThrown) {
return $.Deferred()
.rejectWith(that, [originalData]).promise();
}
return that.processActions[settings.action].call(
that,
data,
settings
);
};
chain = chain.pipe(func, settings.always && func);
});
chain
.done(function () {
that._trigger('processdone', null, data);
that._trigger('processalways', null, data);
})
.fail(function () {
that._trigger('processfail', null, data);
that._trigger('processalways', null, data);
});
return chain;
},
// Replaces the settings of each processQueue item that
// are strings starting with an "@", using the remaining
// substring as key for the option map,
// e.g. "@autoUpload" is replaced with options.autoUpload:
_transformProcessQueue: function (options) {
var processQueue = [];
$.each(options.processQueue, function () {
var settings = {},
action = this.action,
prefix = this.prefix === true ? action : this.prefix;
$.each(this, function (key, value) {
if ($.type(value) === 'string' &&
value.charAt(0) === '@') {
settings[key] = options[
value.slice(1) || (prefix ? prefix +
key.charAt(0).toUpperCase() + key.slice(1) : key)
];
} else {
settings[key] = value;
}
});
processQueue.push(settings);
});
options.processQueue = processQueue;
},
// Returns the number of files currently in the processsing queue:
processing: function () {
return this._processing;
},
// Processes the files given as files property of the data parameter,
// returns a Promise object that allows to bind callbacks:
process: function (data) {
var that = this,
options = $.extend({}, this.options, data);
if (options.processQueue && options.processQueue.length) {
this._transformProcessQueue(options);
if (this._processing === 0) {
this._trigger('processstart');
}
$.each(data.files, function (index) {
var opts = index ? $.extend({}, options) : options,
func = function () {
if (data.errorThrown) {
return $.Deferred()
.rejectWith(that, [data]).promise();
}
return that._processFile(opts, data);
};
opts.index = index;
that._processing += 1;
that._processingQueue = that._processingQueue.pipe(func, func)
.always(function () {
that._processing -= 1;
if (that._processing === 0) {
that._trigger('processstop');
}
});
});
}
return this._processingQueue;
},
_create: function () {
this._super();
this._processing = 0;
this._processingQueue = $.Deferred().resolveWith(this)
.promise();
}
});
}));

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,214 @@
/*
* jQuery Iframe Transport Plugin 1.8.2
* https://github.com/blueimp/jQuery-File-Upload
*
* Copyright 2011, Sebastian Tschan
* https://blueimp.net
*
* Licensed under the MIT license:
* http://www.opensource.org/licenses/MIT
*/
/* global define, window, document */
(function (factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// Register as an anonymous AMD module:
define(['jquery'], factory);
} else {
// Browser globals:
factory(window.jQuery);
}
}(function ($) {
'use strict';
// Helper variable to create unique names for the transport iframes:
var counter = 0;
// The iframe transport accepts four additional options:
// options.fileInput: a jQuery collection of file input fields
// options.paramName: the parameter name for the file form data,
// overrides the name property of the file input field(s),
// can be a string or an array of strings.
// options.formData: an array of objects with name and value properties,
// equivalent to the return data of .serializeArray(), e.g.:
// [{name: 'a', value: 1}, {name: 'b', value: 2}]
// options.initialIframeSrc: the URL of the initial iframe src,
// by default set to "javascript:false;"
$.ajaxTransport('iframe', function (options) {
if (options.async) {
// javascript:false as initial iframe src
// prevents warning popups on HTTPS in IE6:
/*jshint scripturl: true */
var initialIframeSrc = options.initialIframeSrc || 'javascript:false;',
/*jshint scripturl: false */
form,
iframe,
addParamChar;
return {
send: function (_, completeCallback) {
form = $('<form style="display:none;"></form>');
form.attr('accept-charset', options.formAcceptCharset);
addParamChar = /\?/.test(options.url) ? '&' : '?';
// XDomainRequest only supports GET and POST:
if (options.type === 'DELETE') {
options.url = options.url + addParamChar + '_method=DELETE';
options.type = 'POST';
} else if (options.type === 'PUT') {
options.url = options.url + addParamChar + '_method=PUT';
options.type = 'POST';
} else if (options.type === 'PATCH') {
options.url = options.url + addParamChar + '_method=PATCH';
options.type = 'POST';
}
// IE versions below IE8 cannot set the name property of
// elements that have already been added to the DOM,
// so we set the name along with the iframe HTML markup:
counter += 1;
iframe = $(
'<iframe src="' + initialIframeSrc +
'" name="iframe-transport-' + counter + '"></iframe>'
).bind('load', function () {
var fileInputClones,
paramNames = $.isArray(options.paramName) ?
options.paramName : [options.paramName];
iframe
.unbind('load')
.bind('load', function () {
var response;
// Wrap in a try/catch block to catch exceptions thrown
// when trying to access cross-domain iframe contents:
try {
response = iframe.contents();
// Google Chrome and Firefox do not throw an
// exception when calling iframe.contents() on
// cross-domain requests, so we unify the response:
if (!response.length || !response[0].firstChild) {
throw new Error();
}
} catch (e) {
response = undefined;
}
// The complete callback returns the
// iframe content document as response object:
completeCallback(
200,
'success',
{'iframe': response}
);
// Fix for IE endless progress bar activity bug
// (happens on form submits to iframe targets):
$('<iframe src="' + initialIframeSrc + '"></iframe>')
.appendTo(form);
window.setTimeout(function () {
// Removing the form in a setTimeout call
// allows Chrome's developer tools to display
// the response result
form.remove();
}, 0);
});
form
.prop('target', iframe.prop('name'))
.prop('action', options.url)
.prop('method', options.type);
if (options.formData) {
$.each(options.formData, function (index, field) {
$('<input type="hidden"/>')
.prop('name', field.name)
.val(field.value)
.appendTo(form);
});
}
if (options.fileInput && options.fileInput.length &&
options.type === 'POST') {
fileInputClones = options.fileInput.clone();
// Insert a clone for each file input field:
options.fileInput.after(function (index) {
return fileInputClones[index];
});
if (options.paramName) {
options.fileInput.each(function (index) {
$(this).prop(
'name',
paramNames[index] || options.paramName
);
});
}
// Appending the file input fields to the hidden form
// removes them from their original location:
form
.append(options.fileInput)
.prop('enctype', 'multipart/form-data')
// enctype must be set as encoding for IE:
.prop('encoding', 'multipart/form-data');
// Remove the HTML5 form attribute from the input(s):
options.fileInput.removeAttr('form');
}
form.submit();
// Insert the file input fields at their original location
// by replacing the clones with the originals:
if (fileInputClones && fileInputClones.length) {
options.fileInput.each(function (index, input) {
var clone = $(fileInputClones[index]);
// Restore the original name and form properties:
$(input)
.prop('name', clone.prop('name'))
.attr('form', clone.attr('form'));
clone.replaceWith(input);
});
}
});
form.append(iframe).appendTo(document.body);
},
abort: function () {
if (iframe) {
// javascript:false as iframe src aborts the request
// and prevents warning popups on HTTPS in IE6.
// concat is used to avoid the "Script URL" JSLint error:
iframe
.unbind('load')
.prop('src', initialIframeSrc);
}
if (form) {
form.remove();
}
}
};
}
});
// The iframe transport returns the iframe content document as response.
// The following adds converters from iframe to text, json, html, xml
// and script.
// Please note that the Content-Type for JSON responses has to be text/plain
// or text/html, if the browser doesn't include application/json in the
// Accept header, else IE will show a download dialog.
// The Content-Type for XML responses on the other hand has to be always
// application/xml or text/xml, so IE properly parses the XML response.
// See also
// https://github.com/blueimp/jQuery-File-Upload/wiki/Setup#content-type-negotiation
$.ajaxSetup({
converters: {
'iframe text': function (iframe) {
return iframe && $(iframe[0].body).text();
},
'iframe json': function (iframe) {
return iframe && $.parseJSON($(iframe[0].body).text());
},
'iframe html': function (iframe) {
return iframe && $(iframe[0].body).html();
},
'iframe xml': function (iframe) {
var xmlDoc = iframe && iframe[0];
return xmlDoc && $.isXMLDoc(xmlDoc) ? xmlDoc :
$.parseXML((xmlDoc.XMLDocument && xmlDoc.XMLDocument.xml) ||
$(xmlDoc.body).html());
},
'iframe script': function (iframe) {
return iframe && $.globalEval($(iframe[0].body).text());
}
}
});
}));

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,120 @@
@import "../../wagtailadmin/static/wagtailadmin/scss/variables.scss";
@import "../../wagtailadmin/static/wagtailadmin/scss/mixins.scss";
@import "../../wagtailadmin/static/wagtailadmin/scss/grid.scss";
.replace-file-input{
display:inline-block;
position:relative;
overflow:hidden;
padding-bottom:2px;
input[type=file]{
padding:0;
opacity:0;
position:absolute;
top:0;
right:0;
direction:ltr;
width:auto;
display:block;
font-size:5em;
&:hover{
cursor:pointer;
}
}
&:hover{
cursor:pointer;
button{
background-color:$color-teal-darker;
}
}
}
.upload-list{
> li{
padding:1em;
}
.left{
text-align:center;
}
.preview{
width:150px;
min-height:150px;
display:block;
position:relative;
text-align:center;
max-width:100%;
margin:auto;
}
.progress, .thumb, .thumb:before, canvas, img{
position:absolute;
max-width:100%;
}
.progress{
z-index:4;
top:60%;
left:20%;
right:20%;
width:60%;
@include box-shadow(0 0 5px 2px rgba(255, 255, 255, 0.4));
}
.thumb{
top:0;right:0;bottom:0;left:0;
z-index:1;
width:100%;
}
.thumb:before, canvas, img{
left:0;
right:0;
top:0;
bottom:0;
margin:auto;
}
.thumb:before{
z-index:2;
top:0;
width:1em;
font-size:10em;
line-height:1.4em;
color:lighten($color-grey-4, 4%);
}
canvas, img{
z-index:3;
}
.hasthumb{
&:before{
display:none;
}
}
.status-msg{
display:none;
}
.upload-complete{
.progress{
opacity:0;
}
}
.upload-uploading{
}
.upload-success{
.status-msg.success{
display:block;
}
}
.upload-failure{
border-color:$color-red;
.status-msg.failure{
display:block;
}
}
}

View file

@ -17,7 +17,7 @@
{% block content %}
{% trans "Images" as im_str %}
{% trans "Add an image" as add_img_str %}
{% include "wagtailadmin/shared/header.html" with title=im_str add_link="wagtailimages_add_image" icon="image" add_text=add_img_str search_url="wagtailimages_index" %}
{% include "wagtailadmin/shared/header.html" with title=im_str add_link="wagtailimages_add_multiple" icon="image" add_text=add_img_str search_url="wagtailimages_index" %}
<div class="nice-padding">
<div id="image-results">

View file

@ -0,0 +1,77 @@
{% extends "wagtailadmin/base.html" %}
{% load image_tags i18n compress static %}
{% block titletag %}{% trans "Add multiple images" %}{% endblock %}
{% block bodyclass %}menu-images{% endblock %}
{% block extra_css %}
{% compress css %}
<link rel="stylesheet" href="{{ STATIC_URL }}wagtailimages/scss/add-multiple.scss" type="text/x-scss" />
{% endcompress %}
{% include "wagtailadmin/shared/tag_field_css.html" %}
{% endblock %}
{% block content %}
{% trans "Add images" as add_str %}
{% include "wagtailadmin/shared/header.html" with title=add_str icon="image" %}
<div class="nice-padding">
<div class="drop-zone">
<p>{% trans "Drag and drop images into this area to upload immediately." %}</p>
<form action="{% url 'wagtailimages_add_multiple' %}" method="POST" enctype="multipart/form-data">
<div class="replace-file-input">
<button class="bicolor icon icon-plus">{% trans "Or choose from your computer" %}</button>
<input id="fileupload" type="file" name="files[]" data-url="{% url 'wagtailimages_add_multiple' %}" multiple>
</div>
{% csrf_token %}
</form>
</div>
<div id="overall-progress" class="progress progress-secondary">
<div class="bar" style="width: 0%;">0%</div>
</div>
<ul id="upload-list" class="upload-list multiple"></ul>
</div>
<script id="upload-list-item" type="text/template">
<li class="row">
<div class="left col3">
<div class="preview">
<div class="thumb icon icon-image"></div>
<div class="progress">
<div class="bar" style="width: 0%;"></div>
</div>
</div>
</div>
<div class="right col9">
<p class="status-msg success">{% trans "Upload successful. Please update this image with a more appropriate title, if necessary. You may also delete the image completely if the upload wasn't required." %}</p>
<p class="status-msg failure">{% trans "Sorry, upload failed." %}</p>
<p class="status-msg failure error_messages"></p>
</div>
</li>
</script>
{% endblock %}
{% block extra_js %}
<!-- this exact order of plugins is vital -->
<script src="{{ STATIC_URL }}wagtailimages/js/vendor/load-image.min.js"></script>
<script src="{{ STATIC_URL }}wagtailimages/js/vendor/canvas-to-blob.min.js"></script>
<script src="{{ STATIC_URL }}wagtailimages/js/vendor/jquery.iframe-transport.js"></script>
<script src="{{ STATIC_URL }}wagtailimages/js/vendor/jquery.fileupload.js"></script>
<script src="{{ STATIC_URL }}wagtailimages/js/vendor/jquery.fileupload-process.js"></script>
<script src="{{ STATIC_URL }}wagtailimages/js/vendor/jquery.fileupload-image.js"></script>
<script src="{{ STATIC_URL }}wagtailadmin/js/vendor/tag-it.js"></script>
<!-- Main script -->
<script src="{{ STATIC_URL }}wagtailimages/js/add-multiple.js"></script>
{% include "wagtailadmin/shared/tag_field_js.html" %}
{% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %}
<script>
window.simple_upload_url = "{% url 'wagtailimages_add_image' %}";
window.tagit_opts = {
autocomplete: {source: "{{ autocomplete_url|addslashes }}"}
};
</script>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% load i18n %}
<form action="{% url 'wagtailimages_edit_multiple' image.id %}" method="POST" enctype="multipart/form-data">
<ul class="fields">
{% csrf_token %}
{% for field in form %}
{% include "wagtailadmin/shared/field_as_li.html" %}
{% endfor %}
<li>
<input type="submit" value="{% trans 'Update' %}" />
<a href="{% url 'wagtailimages_delete_multiple' image.id %}" class="delete button button-secondary no">{% trans "Delete" %}</a>
</li>
</ul>
</form>

View file

@ -1,3 +1,5 @@
import json
from mock import MagicMock
from django.utils import six
@ -470,3 +472,209 @@ class TestFormat(TestCase):
register_image_format(self.format)
result = get_image_format('test name')
self.assertEqual(result, self.format)
class TestMultipleImageUploader(TestCase, WagtailTestUtils):
"""
This tests the multiple image upload views located in wagtailimages/views/multiple.py
"""
def setUp(self):
self.login()
# Create an image for running tests on
self.image = Image.objects.create(
title="Test image",
file=get_test_image_file(),
)
def test_add(self):
"""
This tests that the add view responds correctly on a GET request
"""
# Send request
response = self.client.get(reverse('wagtailimages_add_multiple'))
# Check response
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailimages/multiple/add.html')
def test_add_post(self):
"""
This tests that a POST request to the add view saves the image and returns an edit form
"""
response = self.client.post(reverse('wagtailimages_add_multiple'), {
'files[]': SimpleUploadedFile('test.png', get_test_image_file().file.getvalue()),
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
self.assertTemplateUsed(response, 'wagtailimages/multiple/edit_form.html')
# Check image
self.assertIn('image', response.context)
self.assertEqual(response.context['image'].title, 'test.png')
# Check form
self.assertIn('form', response.context)
self.assertEqual(response.context['form'].initial['title'], 'test.png')
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('image_id', response_json)
self.assertIn('form', response_json)
self.assertIn('success', response_json)
self.assertEqual(response_json['image_id'], response.context['image'].id)
self.assertTrue(response_json['success'])
def test_add_post_noajax(self):
"""
This tests that only AJAX requests are allowed to POST to the add view
"""
response = self.client.post(reverse('wagtailimages_add_multiple'), {})
# Check response
self.assertEqual(response.status_code, 400)
def test_add_post_nofile(self):
"""
This tests that the add view checks for a file when a user POSTs to it
"""
response = self.client.post(reverse('wagtailimages_add_multiple'), {}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 400)
def test_add_post_badfile(self):
"""
This tests that the add view checks for a file when a user POSTs to it
"""
response = self.client.post(reverse('wagtailimages_add_multiple'), {
'files[]': SimpleUploadedFile('test.png', b"This is not an image!"),
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
# Check JSON
response_json = json.loads(response.content.decode())
self.assertNotIn('image_id', response_json)
self.assertNotIn('form', response_json)
self.assertIn('success', response_json)
self.assertIn('error_message', response_json)
self.assertFalse(response_json['success'])
self.assertEqual(response_json['error_message'], 'Not a valid image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png).')
def test_edit_get(self):
"""
This tests that a GET request to the edit view returns a 405 "METHOD NOT ALLOWED" response
"""
# Send request
response = self.client.get(reverse('wagtailimages_edit_multiple', args=(self.image.id, )))
# Check response
self.assertEqual(response.status_code, 405)
def test_edit_post(self):
"""
This tests that a POST request to the edit view edits the image
"""
# Send request
response = self.client.post(reverse('wagtailimages_edit_multiple', args=(self.image.id, )), {
('image-%d-title' % self.image.id): "New title!",
('image-%d-tags' % self.image.id): "",
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('image_id', response_json)
self.assertNotIn('form', response_json)
self.assertIn('success', response_json)
self.assertEqual(response_json['image_id'], self.image.id)
self.assertTrue(response_json['success'])
def test_edit_post_noajax(self):
"""
This tests that a POST request to the edit view without AJAX returns a 400 response
"""
# Send request
response = self.client.post(reverse('wagtailimages_edit_multiple', args=(self.image.id, )), {
('image-%d-title' % self.image.id): "New title!",
('image-%d-tags' % self.image.id): "",
})
# Check response
self.assertEqual(response.status_code, 400)
def test_edit_post_validation_error(self):
"""
This tests that a POST request to the edit page returns a json document with "success=False"
and a form with the validation error indicated
"""
# Send request
response = self.client.post(reverse('wagtailimages_edit_multiple', args=(self.image.id, )), {
('image-%d-title' % self.image.id): "", # Required
('image-%d-tags' % self.image.id): "",
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
self.assertTemplateUsed(response, 'wagtailimages/multiple/edit_form.html')
# Check that a form error was raised
self.assertFormError(response, 'form', 'title', "This field is required.")
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('image_id', response_json)
self.assertIn('form', response_json)
self.assertIn('success', response_json)
self.assertEqual(response_json['image_id'], self.image.id)
self.assertFalse(response_json['success'])
def test_delete_get(self):
"""
This tests that a GET request to the delete view returns a 405 "METHOD NOT ALLOWED" response
"""
# Send request
response = self.client.get(reverse('wagtailimages_delete_multiple', args=(self.image.id, )))
# Check response
self.assertEqual(response.status_code, 405)
def test_delete_post(self):
"""
This tests that a POST request to the delete view deletes the image
"""
# Send request
response = self.client.post(reverse('wagtailimages_delete_multiple', args=(self.image.id, )), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
# Make sure the image is deleted
self.assertFalse(Image.objects.filter(id=self.image.id).exists())
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('image_id', response_json)
self.assertIn('success', response_json)
self.assertEqual(response_json['image_id'], self.image.id)
self.assertTrue(response_json['success'])
def test_edit_post_noajax(self):
"""
This tests that a POST request to the delete view without AJAX returns a 400 response
"""
# Send request
response = self.client.post(reverse('wagtailimages_delete_multiple', args=(self.image.id, )))
# Check response
self.assertEqual(response.status_code, 400)

View file

@ -1,5 +1,5 @@
from django.conf.urls import url
from wagtail.wagtailimages.views import images, chooser
from wagtail.wagtailimages.views import images, chooser, multiple
urlpatterns = [
url(r'^$', images.index, name='wagtailimages_index'),
@ -7,6 +7,10 @@ urlpatterns = [
url(r'^(\d+)/delete/$', images.delete, name='wagtailimages_delete_image'),
url(r'^add/$', images.add, name='wagtailimages_add_image'),
url(r'^multiple/add/$', multiple.add, name='wagtailimages_add_multiple'),
url(r'^multiple/(\d+)/$', multiple.edit, name='wagtailimages_edit_multiple'),
url(r'^multiple/(\d+)/delete/$', multiple.delete, name='wagtailimages_delete_multiple'),
url(r'^chooser/$', chooser.chooser, name='wagtailimages_chooser'),
url(r'^chooser/(\d+)/$', chooser.image_chosen, name='wagtailimages_image_chosen'),
url(r'^chooser/upload/$', chooser.chooser_upload, name='wagtailimages_chooser_upload'),

View file

@ -14,15 +14,22 @@ def validate_image_format(f):
extension = 'jpeg'
if extension not in ['gif', 'jpeg', 'png']:
raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension."))
raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png)."))
if not f.closed:
# Open image file
file_position = f.tell()
f.seek(0)
image = Image.open(f)
try:
image = Image.open(f)
except IOError:
# Uploaded file is not even an image file (or corrupted)
raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png)."))
f.seek(file_position)
# Check that the internal format matches the extension
# It is possible to upload PSD files if their extension is set to jpg, png or gif. This should catch them out
if image.format.upper() != extension.upper():
raise ValidationError(_("Not a valid %s image. Please use a gif, jpeg or png file with the correct file extension.") % (extension.upper()))
raise ValidationError(_("Not a valid %s image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png).") % (extension.upper()))

View file

@ -0,0 +1,113 @@
import json
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import permission_required
from django.views.decorators.http import require_POST
from django.core.exceptions import PermissionDenied, ValidationError
from django.views.decorators.vary import vary_on_headers
from django.http import HttpResponse, HttpResponseBadRequest
from django.template import RequestContext
from django.template.loader import render_to_string
from django.utils.translation import ugettext as _
from wagtail.wagtailimages.models import get_image_model
from wagtail.wagtailimages.forms import get_image_form_for_multi
from wagtail.wagtailimages.utils import validate_image_format
def json_response(document):
return HttpResponse(json.dumps(document), content_type='application/json')
@permission_required('wagtailimages.add_image')
@vary_on_headers('X-Requested-With')
def add(request):
Image = get_image_model()
ImageForm = get_image_form_for_multi()
if request.method == 'POST':
if not request.is_ajax():
return HttpResponseBadRequest("Cannot POST to this view without AJAX")
if not request.FILES:
return HttpResponseBadRequest("Must upload a file")
# Check that the uploaded file is valid
try:
validate_image_format(request.FILES['files[]'])
except ValidationError as e:
return json_response({
'success': False,
'error_message': '\n'.join(e.messages),
})
# Save it
image = Image(uploaded_by_user=request.user, title=request.FILES['files[]'].name, file=request.FILES['files[]'])
image.save()
# Success! Send back an edit form for this image to the user
form = ImageForm(instance=image, prefix='image-%d' % image.id)
return json_response({
'success': True,
'image_id': int(image.id),
'form': render_to_string('wagtailimages/multiple/edit_form.html', {
'image': image,
'form': form,
}, context_instance=RequestContext(request)),
})
return render(request, 'wagtailimages/multiple/add.html', {})
@require_POST
@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
def edit(request, image_id, callback=None):
Image = get_image_model()
ImageForm = get_image_form_for_multi()
image = get_object_or_404(Image, id=image_id)
if not request.is_ajax():
return HttpResponseBadRequest("Cannot POST to this view without AJAX")
if not image.is_editable_by_user(request.user):
raise PermissionDenied
form = ImageForm(request.POST, request.FILES, instance=image, prefix='image-'+image_id)
if form.is_valid():
form.save()
return json_response({
'success': True,
'image_id': int(image_id),
})
else:
return json_response({
'success': False,
'image_id': int(image_id),
'form': render_to_string('wagtailimages/multiple/edit_form.html', {
'image': image,
'form': form,
}, context_instance=RequestContext(request)),
})
@require_POST
@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
def delete(request, image_id):
image = get_object_or_404(get_image_model(), id=image_id)
if not request.is_ajax():
return HttpResponseBadRequest("Cannot POST to this view without AJAX")
if not image.is_editable_by_user(request.user):
raise PermissionDenied
image.delete()
return json_response({
'success': True,
'image_id': int(image_id),
})