As putting all the pieces together working for a complex uploader is a complex task, I share here my solution.
The elements of this answer
There can be arbitrary number of uploaders per page. In this example there are two separate upload buttons on the page, called driving and medical.
Resize the image to 1024 x 1024 on the client-side before uploading
Show preview of uploaded image for the user during the upload
HTML code as Jinja 2 templates
Bootstrap 3.x themed upload button and progress bar for the upload
Pyramid style JavaScript resource loading
Jinja 2 HTML template code which renders one individual upload widget (upload_snippet.html
), it takes parameters id and name and upload_target:
<div id="upload-{{ id }}">
<div id="medical-license" class="btn btn-block btn-file">
<i class="fa fa-camera"></i> {{ name }}
<input type="file" class="file-select" data-url="{{ upload_target }}" data-param-name="{{ id }}">
</div>
<p>
<div class="preview" style="display: none"></div>
</p>
<div class="progress progress-xxx" style="display: none">
<div class="progress-bar progress-bar-striped progress-bar-xxx active" role="progressbar" aria-valuenow="45" aria-valuemin="0" aria-valuemax="100" style="width: 0%">
</div>
</div>
<div class="success" style="display: none">
<div class="alert alert-success">
{{ name }} upload completed
</div>
</div>
<div class="error" style="display: none">
<div class="alert alert-danger">
<span class="error-message"></span>
</div>
</div>
</div>
The main Jinja 2HTML template which constructs two upload widgets. It styles them to look like Bootstrap buttons:
{% extends "site/base.html" %}
{% block extra_head %}
<style>
/* http://www.abeautifulsite.net/whipping-file-inputs-into-shape-with-bootstrap-3/ */
.btn-file {
position: relative;
overflow: hidden;
}
.btn-file input[type=file] {
position: absolute;
top: 0;
right: 0;
min-width: 100%;
min-height: 100%;
font-size: 100px;
text-align: right;
filter: alpha(opacity=0);
opacity: 0;
outline: none;
background: white;
cursor: inherit;
display: block;
}
.preview {
width: 128px;
height: 128px;
margin: 0 auto;
}
</style>
{% endblock %}
{% block content_section %}
<!-- Header -->
<section id="license-information">
<div class="row">
<div class="col-md-12">
<h1>Upload information</h1>
</div>
</div>
<div class="row">
<div class="col-md-6">
{% with id='medical', name='Medical license' %}
{% include "upload_snippet.html" %}
{% endwith %}
{% with id='driving', name='Driving license or other id' %}
{% include "upload_snippet.html" %}
{% endwith %}
</div>
</div>
</section>
{% endblock content_section %}
{% block custom_script %}
<!-- The jQuery UI widget factory, can be omitted if jQuery UI is already included -->
<script src="{{ 'xxx:static/jquery-file-upload/js/vendor/jquery.ui.widget.js'| static_url }}"></script>
<!-- The Load Image plugin is included for the preview images and image resizing functionality -->
<script src="{{ 'xxx:static/jquery-file-upload/js/load-image.all.min.js' | static_url }}"></script>
<!-- The Canvas to Blob plugin is included for image resizing functionality -->
<script src="{{ 'xxx:static/jquery-file-upload/js/canvas-to-blob.js' | static_url }}"></script>
<!-- The basic File Upload plugin -->
<script src="{{ 'xxx:static/jquery-file-upload/js/jquery.fileupload.js' | static_url }}"></script>
<!-- The File Upload processing plugin -->
<script src="{{ 'xxx:static/jquery-file-upload/js/jquery.fileupload-process.js' | static_url }} "></script>
<!-- The File Upload image preview & resize plugin -->
<script src="{{ 'xxx:static/jquery-file-upload/js/jquery.fileupload-image.js' | static_url }} "></script>
<script>
window.nextURL = "{{ after_both_files_are_uploaded }}"
</script>
<script>
"use strict";
var state = {
medical: false,
driving: false
}
// Make styled elements to trigger file input
$(document).on('change', '.btn-file :file', function() {
var input = $(this),
numFiles = input.get(0).files ? input.get(0).files.length : 1,
label = input.val().replace(/\\/g, '/').replace(/.*\//, '');
input.trigger('fileselect', [numFiles, label]);
});
function checkForward() {
// Is all upload done and we can go to the next page?
if(state.medical && state.driving) {
window.location = window.nextURL;
}
}
function doUpload(name) {
var baseElem = $("#upload-" + name);
if(baseElem.length != 1) {
throw new Error("Wooops, bad DOM tree");
}
function onStart() {
baseElem.find(".progress").show();
baseElem.find(".error").hide();
baseElem.find(".success").hide();
}
function onDone(result, data) {
baseElem.find(".progress").hide();
if(data.result.status == "ok") {
// All ok, check if we can proceed
baseElem.find(".success").show();
state[name] = true;
checkForward();
} else {
// Server responded us it didn't like the file and gave a specific error message
var msg = data.result.message;
baseElem.find(".error-message").text(msg);
baseElem.find(".error").show();
state[name] = false;
}
}
function onError(result, data) {
baseElem.find(".progress").hide();
baseElem.find(".error-message").text("Upload could not be completed. Please contact the support.");
baseElem.find(".error").show();
state[name] = false;
}
function onProgress(e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
baseElem.find(".progress-bar").css("width", progress + "%");
}
function onPreview(e, data) {
var canvas = data.files[0].preview;
var dataURL = canvas.toDataURL();
baseElem.find(".preview").css("background-image", 'url(' + dataURL +')');
baseElem.find(".preview").css({width: canvas.width, height: canvas.height});
baseElem.find(".preview").show();
}
var upload = baseElem.find('.file-select');
upload.fileupload({
dataType: 'json',
// Enable image resizing, except for Android and Opera,
// which actually support image resizing, but fail to
// send Blob objects via XHR requests:
// disableImageResize: /Android(?!.*Chrome)|Opera/
// .test(window.navigator && navigator.userAgent),
disableImageResize: false,
imageMaxWidth: 1024,
imageMaxHeight: 1024,
imageCrop: false, // Force cropped images,
previewMaxWidth: 128,
previewMaxHeight: 128,
maxFileSize: 7*1024*1024
});
upload.bind("fileuploaddone", onDone);
upload.bind("fileuploadstart", onStart);
upload.bind("fileuploadfail", onError);
upload.bind("fileuploadprogress", onProgress);
upload.bind('fileuploadprocessalways', onPreview);
}
$(document).ready(function() {
doUpload("medical");
doUpload("driving");
});
</script>
{% endblock %}
Then a simple server-side Pyramid view which decodes the payload and checks the user uploaded an image and not a random file. The result is an JSON response which JavaScript can decode:
@view_config(route_name='upload_target', renderer='json')
def upload_target(request):
"""AJAX upload of driving license and medical images."""
if "medical" in request.params:
license = "medical"
files = request.params["medical"]
elif "driving" in request.params:
license = "driving"
files = request.params["driving"]
else:
raise RuntimeError("Unsupported upload type")
# # TODO: use chunks, do not buffer 100%
# path = user.prepare_upload_path()
storage = io.open(where_to_save, "wb")
fp = files.file
# Now we test for a valid image upload
image_test = imghdr.what(fp)
if image_test == None:
return {"status": "fail", "message": "Only JPEG and PNG image file upload supported."}
fp.seek(0)
data = fp.read()
assert len(data) > 0
storage.write(data)
storage.close()
return {"status": "ok"}