├── .gitignore ├── .travis.yml ├── MANIFEST.in ├── README.rst ├── resumable ├── __init__.py ├── fields.py ├── files.py ├── models.py ├── static │ └── resumable │ │ └── js │ │ ├── django-resumable.js │ │ ├── init.js │ │ └── resumable.js ├── templates │ └── resumable │ │ └── file_input.html ├── tests │ ├── __init__.py │ ├── app.py │ ├── fixtures │ │ └── files │ │ │ ├── 147292_seagull.ogg │ │ │ └── chunks │ │ │ ├── 147292_seagull.ogg_part_0001 │ │ │ ├── 147292_seagull.ogg_part_0002 │ │ │ ├── 147292_seagull.ogg_part_0003 │ │ │ ├── 147292_seagull.ogg_part_0004 │ │ │ ├── 147292_seagull.ogg_part_0005 │ │ │ ├── 147292_seagull.ogg_part_0006 │ │ │ ├── 147292_seagull.ogg_part_0007 │ │ │ ├── 49028_craw.ogg │ │ │ ├── 49028_craw.ogg_part_0001 │ │ │ ├── 49028_craw.ogg_part_0002 │ │ │ ├── 49028_craw.ogg_part_0003 │ │ │ ├── 49028_craw.ogg_part_0004 │ │ │ └── chunk │ ├── settings.py │ ├── templates │ │ └── form.html │ └── tests.py ├── views.py └── widgets.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | 23 | #Translations 24 | *.mo 25 | 26 | #Mr Developer 27 | .mr.developer.cfg 28 | 29 | #Vim 30 | *.sw* 31 | 32 | #Autoenv 33 | .env 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.4" 4 | - "3.5" 5 | - "3.6" 6 | install: "pip install django python-magic" 7 | script: PYTHONPATH=. DJANGO_SETTINGS_MODULE=resumable.tests.settings django-admin test 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-include resumable/templates * 3 | recursive-include resumable/static * 4 | recursive-exclude resumable *.pyc 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-resumable 2 | ---------------- 3 | 4 | .. image:: https://travis-ci.org/jeanphix/django-resumable.svg 5 | :target: https://travis-ci.org/jeanphix/django-resumable 6 | :alt: Build Status 7 | 8 | ``django-resumable`` provides django backend stuff that handles `resumable.js `_ xhr uploads. 9 | 10 | 11 | 12 | Installation 13 | ------------ 14 | 15 | * ``pip install django-resumable`` 16 | * Add ``resumable`` to your ``INSTALLED_APPS`` 17 | 18 | 19 | Views 20 | ----- 21 | 22 | In order to upload files asynchronous, you must define an endpoint that will deal 23 | with uploaded file chunks: 24 | 25 | .. code-block:: python 26 | 27 | from django.contrib.auth.decorators import login_required 28 | 29 | from resumable.views import ResumableUploadView 30 | 31 | 32 | urlpatterns += patterns('', 33 | url('^upload/$', login_required(ResumableUploadView.as_view()), 34 | name='upload'), 35 | ) 36 | 37 | You should also consider having per user chunk upload directory: 38 | 39 | .. code-block:: python 40 | 41 | class MyResumableUploadView(ResumableUploadView): 42 | @property 43 | def chunks_dir(self): 44 | return self.request.user.profile.chunks_dir 45 | 46 | 47 | Fields 48 | ------ 49 | 50 | If you want to handle resumable upload within your forms, you can use the ``ResumableFileField`` 51 | that works like django core ``FileField``: 52 | 53 | .. code-block:: python 54 | 55 | from django.conf import settings 56 | from django.core.urlresolvers import reverse 57 | 58 | from resumable.fields import ResumableFileField 59 | 60 | 61 | class ResumableForm(Form): 62 | file = ResumableFileField( 63 | allowed_mimes=("audio/ogg",), 64 | upload_url=lambda: reverse('upload'), 65 | chunks_dir=getattr(settings, 'FILE_UPLOAD_TEMP_DIR') 66 | ) 67 | 68 | 69 | Javascript 70 | ---------- 71 | 72 | ``django-resumable`` comes with extendable frontend scripts that work out of the box: 73 | 74 | .. code-block:: html 75 | 76 | {% load staticfiles %} 77 | 78 | 79 | 80 |
81 |
82 | {% csrf_token %} 83 | {{ form.as_p }} 84 |
85 |

86 |
87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /resumable/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/__init__.py -------------------------------------------------------------------------------- /resumable/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.forms.fields import FileField 3 | from django.core.exceptions import ValidationError 4 | from django.utils.translation import ugettext_lazy as _ 5 | 6 | from .widgets import ResumableFileInput 7 | 8 | 9 | class ResumableFileField(FileField): 10 | default_error_messages = dict(FileField.default_error_messages, **{ 11 | 'invalid_mime': _(u'Invalid file type') 12 | }) 13 | widget = ResumableFileInput 14 | 15 | def __init__(self, *args, **kwargs): 16 | self.allowed_mimes = kwargs.pop('allowed_mimes', None) 17 | upload_url = kwargs.pop('upload_url', None) 18 | chunks_dir = kwargs.pop('chunks_dir', None) 19 | super(ResumableFileField, self).__init__(*args, **kwargs) 20 | self.upload_url = upload_url 21 | self.chunks_dir = chunks_dir 22 | 23 | @property 24 | def chunks_dir(self): 25 | if not hasattr(self.widget, 'chunks_dir'): 26 | raise Exception("You must set a `chunk_dir`.") 27 | return self.widget.storage 28 | 29 | @chunks_dir.setter 30 | def chunks_dir(self, chunks_dir): 31 | self.widget.chunks_dir = chunks_dir 32 | 33 | def clean(self, data, initial): 34 | f = super(ResumableFileField, self).clean(data, initial) 35 | if self.allowed_mimes is not None and \ 36 | f.content_type not in self.allowed_mimes: 37 | raise ValidationError(self.error_messages['invalid_mime']) 38 | return f 39 | 40 | @property 41 | def upload_url(self): 42 | if 'data-upload-url' not in self.widget.attrs: 43 | raise Exception("You must set the upload url.") 44 | 45 | return self.widget.attrs['data-upload-url'] 46 | 47 | @upload_url.setter 48 | def upload_url(self, url): 49 | self.widget.attrs['data-upload-url'] = url 50 | -------------------------------------------------------------------------------- /resumable/files.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import fnmatch 3 | 4 | 5 | class ResumableFile(object): 6 | def __init__(self, storage, kwargs): 7 | self.storage = storage 8 | self.kwargs = kwargs 9 | self.chunk_suffix = "_part_" 10 | 11 | @property 12 | def chunk_exists(self): 13 | """Checks if the requested chunk exists.""" 14 | name = "%s%s%s" % (self.filename, 15 | self.chunk_suffix, 16 | self.kwargs.get('resumableChunkNumber').zfill(4)) 17 | if not self.storage.exists(name): 18 | return False 19 | chunk_size = int(self.kwargs.get('resumableCurrentChunkSize')) 20 | return self.storage.size(name) == chunk_size 21 | 22 | def chunk_names(self): 23 | """Iterates over all stored chunks and yields their names.""" 24 | file_names = sorted(self.storage.listdir('')[1]) 25 | pattern = '%s%s*' % (self.filename, self.chunk_suffix) 26 | for name in file_names: 27 | if fnmatch.fnmatch(name, pattern): 28 | yield name 29 | 30 | def chunks(self): 31 | """Yield the contents of every chunk, FileSystemStorage.save compatible 32 | """ 33 | for name in self.chunk_names(): 34 | yield self.storage.open(name).read() 35 | 36 | def delete_chunks(self): 37 | [self.storage.delete(chunk) for chunk in self.chunk_names()] 38 | 39 | @property 40 | def filename(self): 41 | """Gets the filename.""" 42 | filename = self.kwargs.get('resumableFilename') 43 | if '/' in filename: 44 | raise Exception('Invalid filename') 45 | return "%s_%s" % ( 46 | self.kwargs.get('resumableTotalSize'), 47 | filename 48 | ) 49 | 50 | @property 51 | def is_complete(self): 52 | """Checks if all chunks are allready stored.""" 53 | if self.storage.exists(self.filename): 54 | return True 55 | return int(self.kwargs.get('resumableTotalSize')) == self.size 56 | 57 | def process_chunk(self, file): 58 | if not self.chunk_exists: 59 | self.storage.save('%s%s%s' % ( 60 | self.filename, 61 | self.chunk_suffix, 62 | self.kwargs.get('resumableChunkNumber').zfill(4) 63 | ), file) 64 | 65 | @property 66 | def size(self): 67 | """Gets chunks size.""" 68 | size = 0 69 | for chunk in self.chunk_names(): 70 | size += self.storage.size(chunk) 71 | return size 72 | -------------------------------------------------------------------------------- /resumable/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/models.py -------------------------------------------------------------------------------- /resumable/static/resumable/js/django-resumable.js: -------------------------------------------------------------------------------- 1 | var DjangoResumable = function (options) { 2 | "use strict"; 3 | var defaults, els; 4 | options = options || {}; 5 | defaults = { 6 | csrfInputName: 'csrfmiddlewaretoken', 7 | urlAttribute: 'data-upload-url', 8 | progressDisplay: 'inline', 9 | errorListClass: 'errorlist', 10 | onFileError: this.onFileError, 11 | onFileAdded: this.onFileAdded, 12 | onFileSuccess: this.onFileSuccess, 13 | onProgress: this.onProgress, 14 | resumableOptions: {} 15 | }; 16 | this.options = this.extend(defaults, options); 17 | this.csrfToken = document.querySelector('input[name=' + this.options.csrfInputName + ']').value; 18 | els = document.querySelectorAll('input[' + this.options.urlAttribute + ']'); 19 | this.each(els, function (el) { 20 | this.initField(el); 21 | }); 22 | }; 23 | 24 | 25 | DjangoResumable.prototype.each = function (elements, fn) { 26 | "use strict"; 27 | var i, l; 28 | for (i = 0, l = elements.length; i < l; i += 1) { 29 | fn.apply(this, [elements[i]]); 30 | } 31 | }; 32 | 33 | 34 | DjangoResumable.prototype.extend = function (target, source) { 35 | "use strict"; 36 | var property; 37 | for (property in source) { 38 | if (source.hasOwnProperty(property)) { 39 | target[property] = source[property]; 40 | } 41 | } 42 | return target; 43 | }; 44 | 45 | 46 | DjangoResumable.prototype.getErrorList = function (el, create) { 47 | "use strict"; 48 | var errorList = el.parentNode.previousSibling; 49 | while (errorList && errorList.tagName === undefined) { 50 | errorList = errorList.previousSibling; 51 | } 52 | if (errorList && !errorList.classList.contains(this.options.errorListClass)) { 53 | if (create === true) { 54 | errorList = document.createElement('ul'); 55 | errorList.classList.add(this.options.errorListClass); 56 | el.parentNode.parentNode.insertBefore(errorList, el.parentNode); 57 | } else { 58 | errorList = null; 59 | } 60 | } 61 | return errorList; 62 | }; 63 | 64 | 65 | DjangoResumable.prototype.getForm = function (el) { 66 | "use strict"; 67 | var parent = el; 68 | while (parent.tagName !== 'FORM') { 69 | parent = parent.parentNode; 70 | } 71 | return parent; 72 | }; 73 | 74 | 75 | DjangoResumable.prototype.initField = function (el) { 76 | "use strict"; 77 | var progress, fileName, filePath, filePathName; 78 | 79 | progress = this.initProgressBar(); 80 | el.parentNode.insertBefore(progress, el.nextSibling); 81 | 82 | filePathName = el.getAttribute('name') + '-path'; 83 | filePath = el.parentNode.querySelector('[name=' + filePathName + ']'); 84 | fileName = el.parentNode.querySelector('label[for=id_' + filePathName + ']'); 85 | 86 | this.initResumable(el, progress, filePath, fileName); 87 | 88 | this.getForm(el).addEventListener('submit', function () { 89 | el.parentNode.removeChild(el); 90 | }); 91 | }; 92 | 93 | 94 | DjangoResumable.prototype.initProgressBar = function () { 95 | "use strict"; 96 | var progress = document.createElement('progress'); 97 | progress.setAttribute('value', '0'); 98 | progress.setAttribute('max', '1'); 99 | progress.style.display = 'none'; 100 | return progress; 101 | }; 102 | 103 | 104 | DjangoResumable.prototype.initResumable = function (el, progress, filePath, fileName) { 105 | "use strict"; 106 | var elements = Array.prototype.slice.call(arguments), 107 | self = this, 108 | opts = { 109 | target: el.getAttribute(this.options.urlAttribute), 110 | query: { 111 | 'csrfmiddlewaretoken': this.csrfToken 112 | } 113 | }; 114 | 115 | opts = this.extend(this.options.resumableOptions, opts); 116 | var r = new Resumable(opts); 117 | r.assignBrowse(el); 118 | this.each(['fileAdded', 'progress', 'fileSuccess', 'fileError'], function (eventType) { 119 | var callback = this.options['on' + eventType.substring(0, 1).toUpperCase() + eventType.substring(1)]; 120 | r.on(eventType, function () { 121 | var args = arguments.length > 0 ? Array.prototype.slice.call(arguments) : []; 122 | callback.apply(self, [r].concat(args).concat(elements)); 123 | }); 124 | }); 125 | return r; 126 | }; 127 | 128 | 129 | DjangoResumable.prototype.onFileError = function (r, file, message, el) { 130 | "use strict"; 131 | console.log(message); 132 | var errorList = this.getErrorList(el, true), 133 | error = document.createElement('li'); 134 | error.innerHTML = message; 135 | if (errorList) { 136 | errorList.appendChild(error); 137 | } 138 | }; 139 | 140 | 141 | DjangoResumable.prototype.onFileAdded = function (r, file, event, el, progress, filePath, fileName) { 142 | "use strict"; 143 | var errorList = this.getErrorList(el); 144 | if (errorList) { 145 | errorList.parentNode.removeChild(errorList); 146 | } 147 | r.upload(); 148 | progress.style.display = this.options.progressDisplay; 149 | }; 150 | 151 | 152 | DjangoResumable.prototype.onFileSuccess = function (r, file, message, el, progress, filePath, fileName) { 153 | "use strict"; 154 | filePath.setAttribute('value', file.size + '_' + file.fileName); 155 | fileName.innerHTML = file.fileName; 156 | progress.style.display = 'none'; 157 | }; 158 | 159 | 160 | DjangoResumable.prototype.onProgress = function (r, el, progress, filePath, fileName) { 161 | "use strict"; 162 | progress.setAttribute('value', r.progress()); 163 | }; 164 | -------------------------------------------------------------------------------- /resumable/static/resumable/js/init.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function () { 2 | "use strict"; 3 | var dj; 4 | if (new Resumable().support) { 5 | dj = new DjangoResumable(); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /resumable/static/resumable/js/resumable.js: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT Licensed 3 | * http://www.23developer.com/opensource 4 | * http://github.com/23/resumable.js 5 | * Steffen Tiedemann Christensen, steffen@23company.com 6 | */ 7 | 8 | (function(){ 9 | "use strict"; 10 | 11 | var Resumable = function(opts){ 12 | if ( !(this instanceof Resumable) ) { 13 | return new Resumable(opts); 14 | } 15 | this.version = 1.0; 16 | // SUPPORTED BY BROWSER? 17 | // Check if these features are support by the browser: 18 | // - File object type 19 | // - Blob object type 20 | // - FileList object type 21 | // - slicing files 22 | this.support = ( 23 | (typeof(File)!=='undefined') 24 | && 25 | (typeof(Blob)!=='undefined') 26 | && 27 | (typeof(FileList)!=='undefined') 28 | && 29 | (!!Blob.prototype.webkitSlice||!!Blob.prototype.mozSlice||!!Blob.prototype.slice||false) 30 | ); 31 | if(!this.support) return(false); 32 | 33 | 34 | // PROPERTIES 35 | var $ = this; 36 | $.files = []; 37 | $.defaults = { 38 | chunkSize:1*1024*1024, 39 | forceChunkSize:false, 40 | simultaneousUploads:3, 41 | fileParameterName:'file', 42 | chunkNumberParameterName: 'resumableChunkNumber', 43 | chunkSizeParameterName: 'resumableChunkSize', 44 | currentChunkSizeParameterName: 'resumableCurrentChunkSize', 45 | totalSizeParameterName: 'resumableTotalSize', 46 | typeParameterName: 'resumableType', 47 | identifierParameterName: 'resumableIdentifier', 48 | fileNameParameterName: 'resumableFilename', 49 | relativePathParameterName: 'resumableRelativePath', 50 | totalChunksParameterName: 'resumableTotalChunks', 51 | throttleProgressCallbacks: 0.5, 52 | query:{}, 53 | headers:{}, 54 | preprocess:null, 55 | method:'multipart', 56 | uploadMethod: 'POST', 57 | testMethod: 'GET', 58 | prioritizeFirstAndLastChunk:false, 59 | target:'/', 60 | testTarget: null, 61 | parameterNamespace:'', 62 | testChunks:true, 63 | generateUniqueIdentifier:null, 64 | getTarget:null, 65 | maxChunkRetries:100, 66 | chunkRetryInterval:undefined, 67 | permanentErrors:[400, 404, 415, 500, 501], 68 | maxFiles:undefined, 69 | withCredentials:false, 70 | xhrTimeout:0, 71 | clearInput:true, 72 | chunkFormat:'blob', 73 | maxFilesErrorCallback:function (files, errorCount) { 74 | var maxFiles = $.getOpt('maxFiles'); 75 | alert('Please upload no more than ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.'); 76 | }, 77 | minFileSize:1, 78 | minFileSizeErrorCallback:function(file, errorCount) { 79 | alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.'); 80 | }, 81 | maxFileSize:undefined, 82 | maxFileSizeErrorCallback:function(file, errorCount) { 83 | alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.'); 84 | }, 85 | fileType: [], 86 | fileTypeErrorCallback: function(file, errorCount) { 87 | alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.'); 88 | } 89 | }; 90 | $.opts = opts||{}; 91 | $.getOpt = function(o) { 92 | var $opt = this; 93 | // Get multiple option if passed an array 94 | if(o instanceof Array) { 95 | var options = {}; 96 | $h.each(o, function(option){ 97 | options[option] = $opt.getOpt(option); 98 | }); 99 | return options; 100 | } 101 | // Otherwise, just return a simple option 102 | if ($opt instanceof ResumableChunk) { 103 | if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } 104 | else { $opt = $opt.fileObj; } 105 | } 106 | if ($opt instanceof ResumableFile) { 107 | if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } 108 | else { $opt = $opt.resumableObj; } 109 | } 110 | if ($opt instanceof Resumable) { 111 | if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } 112 | else { return $opt.defaults[o]; } 113 | } 114 | }; 115 | 116 | // EVENTS 117 | // catchAll(event, ...) 118 | // fileSuccess(file), fileProgress(file), fileAdded(file, event), filesAdded(files, filesSkipped), fileRetry(file), 119 | // fileError(file, message), complete(), progress(), error(message, file), pause() 120 | $.events = []; 121 | $.on = function(event,callback){ 122 | $.events.push(event.toLowerCase(), callback); 123 | }; 124 | $.fire = function(){ 125 | // `arguments` is an object, not array, in FF, so: 126 | var args = []; 127 | for (var i=0; i 0){ 362 | var fileTypeFound = false; 363 | for(var index in o.fileType){ 364 | var extension = '.' + o.fileType[index]; 365 | if(fileName.toLowerCase().indexOf(extension.toLowerCase(), fileName.length - extension.length) !== -1){ 366 | fileTypeFound = true; 367 | break; 368 | } 369 | } 370 | if (!fileTypeFound) { 371 | o.fileTypeErrorCallback(file, errorCount++); 372 | return false; 373 | } 374 | } 375 | 376 | if (typeof(o.minFileSize)!=='undefined' && file.sizeo.maxFileSize) { 381 | o.maxFileSizeErrorCallback(file, errorCount++); 382 | return false; 383 | } 384 | 385 | function addFile(uniqueIdentifier){ 386 | if (!$.getFromUniqueIdentifier(uniqueIdentifier)) {(function(){ 387 | file.uniqueIdentifier = uniqueIdentifier; 388 | var f = new ResumableFile($, file, uniqueIdentifier); 389 | $.files.push(f); 390 | files.push(f); 391 | f.container = (typeof event != 'undefined' ? event.srcElement : null); 392 | window.setTimeout(function(){ 393 | $.fire('fileAdded', f, event) 394 | },0); 395 | })()} else { 396 | filesSkipped.push(file); 397 | }; 398 | decreaseReamining(); 399 | } 400 | // directories have size == 0 401 | var uniqueIdentifier = $h.generateUniqueIdentifier(file, event); 402 | if(uniqueIdentifier && typeof uniqueIdentifier.then === 'function'){ 403 | // Promise or Promise-like object provided as unique identifier 404 | uniqueIdentifier 405 | .then( 406 | function(uniqueIdentifier){ 407 | // unique identifier generation succeeded 408 | addFile(uniqueIdentifier); 409 | }, 410 | function(){ 411 | // unique identifier generation failed 412 | // skip further processing, only decrease file count 413 | decreaseReamining(); 414 | } 415 | ); 416 | }else{ 417 | // non-Promise provided as unique identifier, process synchronously 418 | addFile(uniqueIdentifier); 419 | } 420 | }); 421 | }; 422 | 423 | // INTERNAL OBJECT TYPES 424 | function ResumableFile(resumableObj, file, uniqueIdentifier){ 425 | var $ = this; 426 | $.opts = {}; 427 | $.getOpt = resumableObj.getOpt; 428 | $._prevProgress = 0; 429 | $.resumableObj = resumableObj; 430 | $.file = file; 431 | $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox 432 | $.size = file.size; 433 | $.relativePath = file.relativePath || file.webkitRelativePath || $.fileName; 434 | $.uniqueIdentifier = uniqueIdentifier; 435 | $._pause = false; 436 | $.container = ''; 437 | var _error = uniqueIdentifier !== undefined; 438 | 439 | // Callback when something happens within the chunk 440 | var chunkEvent = function(event, message){ 441 | // event can be 'progress', 'success', 'error' or 'retry' 442 | switch(event){ 443 | case 'progress': 444 | $.resumableObj.fire('fileProgress', $); 445 | break; 446 | case 'error': 447 | $.abort(); 448 | _error = true; 449 | $.chunks = []; 450 | $.resumableObj.fire('fileError', $, message); 451 | break; 452 | case 'success': 453 | if(_error) return; 454 | $.resumableObj.fire('fileProgress', $); // it's at least progress 455 | if($.isComplete()) { 456 | $.resumableObj.fire('fileSuccess', $, message); 457 | } 458 | break; 459 | case 'retry': 460 | $.resumableObj.fire('fileRetry', $); 461 | break; 462 | } 463 | }; 464 | 465 | // Main code to set up a file object with chunks, 466 | // packaged to be able to handle retries if needed. 467 | $.chunks = []; 468 | $.abort = function(){ 469 | // Stop current uploads 470 | var abortCount = 0; 471 | $h.each($.chunks, function(c){ 472 | if(c.status()=='uploading') { 473 | c.abort(); 474 | abortCount++; 475 | } 476 | }); 477 | if(abortCount>0) $.resumableObj.fire('fileProgress', $); 478 | }; 479 | $.cancel = function(){ 480 | // Reset this file to be void 481 | var _chunks = $.chunks; 482 | $.chunks = []; 483 | // Stop current uploads 484 | $h.each(_chunks, function(c){ 485 | if(c.status()=='uploading') { 486 | c.abort(); 487 | $.resumableObj.uploadNextChunk(); 488 | } 489 | }); 490 | $.resumableObj.removeFile($); 491 | $.resumableObj.fire('fileProgress', $); 492 | }; 493 | $.retry = function(){ 494 | $.bootstrap(); 495 | var firedRetry = false; 496 | $.resumableObj.on('chunkingComplete', function(){ 497 | if(!firedRetry) $.resumableObj.upload(); 498 | firedRetry = true; 499 | }); 500 | }; 501 | $.bootstrap = function(){ 502 | $.abort(); 503 | _error = false; 504 | // Rebuild stack of chunks from file 505 | $.chunks = []; 506 | $._prevProgress = 0; 507 | var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor; 508 | var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1); 509 | for (var offset=0; offset0.99999 ? 1 : ret)); 529 | ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused 530 | $._prevProgress = ret; 531 | return(ret); 532 | }; 533 | $.isUploading = function(){ 534 | var uploading = false; 535 | $h.each($.chunks, function(chunk){ 536 | if(chunk.status()=='uploading') { 537 | uploading = true; 538 | return(false); 539 | } 540 | }); 541 | return(uploading); 542 | }; 543 | $.isComplete = function(){ 544 | var outstanding = false; 545 | $h.each($.chunks, function(chunk){ 546 | var status = chunk.status(); 547 | if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) { 548 | outstanding = true; 549 | return(false); 550 | } 551 | }); 552 | return(!outstanding); 553 | }; 554 | $.pause = function(pause){ 555 | if(typeof(pause)==='undefined'){ 556 | $._pause = ($._pause ? false : true); 557 | }else{ 558 | $._pause = pause; 559 | } 560 | }; 561 | $.isPaused = function() { 562 | return $._pause; 563 | }; 564 | 565 | 566 | // Bootstrap and return 567 | $.resumableObj.fire('chunkingStart', $); 568 | $.bootstrap(); 569 | return(this); 570 | } 571 | 572 | 573 | function ResumableChunk(resumableObj, fileObj, offset, callback){ 574 | var $ = this; 575 | $.opts = {}; 576 | $.getOpt = resumableObj.getOpt; 577 | $.resumableObj = resumableObj; 578 | $.fileObj = fileObj; 579 | $.fileObjSize = fileObj.size; 580 | $.fileObjType = fileObj.file.type; 581 | $.offset = offset; 582 | $.callback = callback; 583 | $.lastProgressCallback = (new Date); 584 | $.tested = false; 585 | $.retries = 0; 586 | $.pendingRetry = false; 587 | $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished 588 | 589 | // Computed properties 590 | var chunkSize = $.getOpt('chunkSize'); 591 | $.loaded = 0; 592 | $.startByte = $.offset*chunkSize; 593 | $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize); 594 | if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) { 595 | // The last chunk will be bigger than the chunk size, but less than 2*chunkSize 596 | $.endByte = $.fileObjSize; 597 | } 598 | $.xhr = null; 599 | 600 | // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session 601 | $.test = function(){ 602 | // Set up request and listen for event 603 | $.xhr = new XMLHttpRequest(); 604 | 605 | var testHandler = function(e){ 606 | $.tested = true; 607 | var status = $.status(); 608 | if(status=='success') { 609 | $.callback(status, $.message()); 610 | $.resumableObj.uploadNextChunk(); 611 | } else { 612 | $.send(); 613 | } 614 | }; 615 | $.xhr.addEventListener('load', testHandler, false); 616 | $.xhr.addEventListener('error', testHandler, false); 617 | $.xhr.addEventListener('timeout', testHandler, false); 618 | 619 | // Add data from the query options 620 | var params = []; 621 | var parameterNamespace = $.getOpt('parameterNamespace'); 622 | var customQuery = $.getOpt('query'); 623 | if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); 624 | $h.each(customQuery, function(k,v){ 625 | params.push([encodeURIComponent(parameterNamespace+k), encodeURIComponent(v)].join('=')); 626 | }); 627 | // Add extra data to identify chunk 628 | params = params.concat( 629 | [ 630 | // define key/value pairs for additional parameters 631 | ['chunkNumberParameterName', $.offset + 1], 632 | ['chunkSizeParameterName', $.getOpt('chunkSize')], 633 | ['currentChunkSizeParameterName', $.endByte - $.startByte], 634 | ['totalSizeParameterName', $.fileObjSize], 635 | ['typeParameterName', $.fileObjType], 636 | ['identifierParameterName', $.fileObj.uniqueIdentifier], 637 | ['fileNameParameterName', $.fileObj.fileName], 638 | ['relativePathParameterName', $.fileObj.relativePath], 639 | ['totalChunksParameterName', $.fileObj.chunks.length] 640 | ].filter(function(pair){ 641 | // include items that resolve to truthy values 642 | // i.e. exclude false, null, undefined and empty strings 643 | return $.getOpt(pair[0]); 644 | }) 645 | .map(function(pair){ 646 | // map each key/value pair to its final form 647 | return [ 648 | parameterNamespace + $.getOpt(pair[0]), 649 | encodeURIComponent(pair[1]) 650 | ].join('='); 651 | }) 652 | ); 653 | // Append the relevant chunk and send it 654 | $.xhr.open($.getOpt('testMethod'), $h.getTarget('test', params)); 655 | $.xhr.timeout = $.getOpt('xhrTimeout'); 656 | $.xhr.withCredentials = $.getOpt('withCredentials'); 657 | // Add data from header options 658 | var customHeaders = $.getOpt('headers'); 659 | if(typeof customHeaders === 'function') { 660 | customHeaders = customHeaders($.fileObj, $); 661 | } 662 | $h.each(customHeaders, function(k,v) { 663 | $.xhr.setRequestHeader(k, v); 664 | }); 665 | $.xhr.send(null); 666 | }; 667 | 668 | $.preprocessFinished = function(){ 669 | $.preprocessState = 2; 670 | $.send(); 671 | }; 672 | 673 | // send() uploads the actual data in a POST call 674 | $.send = function(){ 675 | var preprocess = $.getOpt('preprocess'); 676 | if(typeof preprocess === 'function') { 677 | switch($.preprocessState) { 678 | case 0: $.preprocessState = 1; preprocess($); return; 679 | case 1: return; 680 | case 2: break; 681 | } 682 | } 683 | if($.getOpt('testChunks') && !$.tested) { 684 | $.test(); 685 | return; 686 | } 687 | 688 | // Set up request and listen for event 689 | $.xhr = new XMLHttpRequest(); 690 | 691 | // Progress 692 | $.xhr.upload.addEventListener('progress', function(e){ 693 | if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) { 694 | $.callback('progress'); 695 | $.lastProgressCallback = (new Date); 696 | } 697 | $.loaded=e.loaded||0; 698 | }, false); 699 | $.loaded = 0; 700 | $.pendingRetry = false; 701 | $.callback('progress'); 702 | 703 | // Done (either done, failed or retry) 704 | var doneHandler = function(e){ 705 | var status = $.status(); 706 | if(status=='success'||status=='error') { 707 | $.callback(status, $.message()); 708 | $.resumableObj.uploadNextChunk(); 709 | } else { 710 | $.callback('retry', $.message()); 711 | $.abort(); 712 | $.retries++; 713 | var retryInterval = $.getOpt('chunkRetryInterval'); 714 | if(retryInterval !== undefined) { 715 | $.pendingRetry = true; 716 | setTimeout($.send, retryInterval); 717 | } else { 718 | $.send(); 719 | } 720 | } 721 | }; 722 | $.xhr.addEventListener('load', doneHandler, false); 723 | $.xhr.addEventListener('error', doneHandler, false); 724 | $.xhr.addEventListener('timeout', doneHandler, false); 725 | 726 | // Set up the basic query data from Resumable 727 | var query = [ 728 | ['chunkNumberParameterName', $.offset + 1], 729 | ['chunkSizeParameterName', $.getOpt('chunkSize')], 730 | ['currentChunkSizeParameterName', $.endByte - $.startByte], 731 | ['totalSizeParameterName', $.fileObjSize], 732 | ['typeParameterName', $.fileObjType], 733 | ['identifierParameterName', $.fileObj.uniqueIdentifier], 734 | ['fileNameParameterName', $.fileObj.fileName], 735 | ['relativePathParameterName', $.fileObj.relativePath], 736 | ['totalChunksParameterName', $.fileObj.chunks.length], 737 | ].filter(function(pair){ 738 | // include items that resolve to truthy values 739 | // i.e. exclude false, null, undefined and empty strings 740 | return $.getOpt(pair[0]); 741 | }) 742 | .reduce(function(query, pair){ 743 | // assign query key/value 744 | query[$.getOpt(pair[0])] = pair[1]; 745 | return query; 746 | }, {}); 747 | // Mix in custom data 748 | var customQuery = $.getOpt('query'); 749 | if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); 750 | $h.each(customQuery, function(k,v){ 751 | query[k] = v; 752 | }); 753 | 754 | var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice'))); 755 | var bytes = $.fileObj.file[func]($.startByte, $.endByte); 756 | var data = null; 757 | var params = []; 758 | 759 | var parameterNamespace = $.getOpt('parameterNamespace'); 760 | if ($.getOpt('method') === 'octet') { 761 | // Add data from the query options 762 | data = bytes; 763 | $h.each(query, function (k, v) { 764 | params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join('=')); 765 | }); 766 | } else { 767 | // Add data from the query options 768 | data = new FormData(); 769 | $h.each(query, function (k, v) { 770 | data.append(parameterNamespace + k, v); 771 | params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join('=')); 772 | }); 773 | if ($.getOpt('chunkFormat') == 'blob') { 774 | data.append(parameterNamespace + $.getOpt('fileParameterName'), bytes, $.fileObj.fileName); 775 | } 776 | else if ($.getOpt('chunkFormat') == 'base64') { 777 | var fr = new FileReader(); 778 | fr.onload = function (e) { 779 | data.append(parameterNamespace + $.getOpt('fileParameterName'), fr.result); 780 | $.xhr.send(data); 781 | } 782 | fr.readAsDataURL(bytes); 783 | } 784 | } 785 | 786 | var target = $h.getTarget('upload', params); 787 | var method = $.getOpt('uploadMethod'); 788 | 789 | $.xhr.open(method, target); 790 | if ($.getOpt('method') === 'octet') { 791 | $.xhr.setRequestHeader('Content-Type', 'application/octet-stream'); 792 | } 793 | $.xhr.timeout = $.getOpt('xhrTimeout'); 794 | $.xhr.withCredentials = $.getOpt('withCredentials'); 795 | // Add data from header options 796 | var customHeaders = $.getOpt('headers'); 797 | if(typeof customHeaders === 'function') { 798 | customHeaders = customHeaders($.fileObj, $); 799 | } 800 | 801 | $h.each(customHeaders, function(k,v) { 802 | $.xhr.setRequestHeader(k, v); 803 | }); 804 | 805 | if ($.getOpt('chunkFormat') == 'blob') { 806 | $.xhr.send(data); 807 | } 808 | }; 809 | $.abort = function(){ 810 | // Abort and reset 811 | if($.xhr) $.xhr.abort(); 812 | $.xhr = null; 813 | }; 814 | $.status = function(){ 815 | // Returns: 'pending', 'uploading', 'success', 'error' 816 | if($.pendingRetry) { 817 | // if pending retry then that's effectively the same as actively uploading, 818 | // there might just be a slight delay before the retry starts 819 | return('uploading'); 820 | } else if(!$.xhr) { 821 | return('pending'); 822 | } else if($.xhr.readyState<4) { 823 | // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening 824 | return('uploading'); 825 | } else { 826 | if($.xhr.status == 200 || $.xhr.status == 201) { 827 | // HTTP 200, 201 (created) 828 | return('success'); 829 | } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) { 830 | // HTTP 415/500/501, permanent error 831 | return('error'); 832 | } else { 833 | // this should never happen, but we'll reset and queue a retry 834 | // a likely case for this would be 503 service unavailable 835 | $.abort(); 836 | return('pending'); 837 | } 838 | } 839 | }; 840 | $.message = function(){ 841 | return($.xhr ? $.xhr.responseText : ''); 842 | }; 843 | $.progress = function(relative){ 844 | if(typeof(relative)==='undefined') relative = false; 845 | var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1); 846 | if($.pendingRetry) return(0); 847 | if(!$.xhr || !$.xhr.status) factor*=.95; 848 | var s = $.status(); 849 | switch(s){ 850 | case 'success': 851 | case 'error': 852 | return(1*factor); 853 | case 'pending': 854 | return(0*factor); 855 | default: 856 | return($.loaded/($.endByte-$.startByte)*factor); 857 | } 858 | }; 859 | return(this); 860 | } 861 | 862 | // QUEUE 863 | $.uploadNextChunk = function(){ 864 | var found = false; 865 | 866 | // In some cases (such as videos) it's really handy to upload the first 867 | // and last chunk of a file quickly; this let's the server check the file's 868 | // metadata and determine if there's even a point in continuing. 869 | if ($.getOpt('prioritizeFirstAndLastChunk')) { 870 | $h.each($.files, function(file){ 871 | if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) { 872 | file.chunks[0].send(); 873 | found = true; 874 | return(false); 875 | } 876 | if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) { 877 | file.chunks[file.chunks.length-1].send(); 878 | found = true; 879 | return(false); 880 | } 881 | }); 882 | if(found) return(true); 883 | } 884 | 885 | // Now, simply look for the next, best thing to upload 886 | $h.each($.files, function(file){ 887 | if(file.isPaused()===false){ 888 | $h.each(file.chunks, function(chunk){ 889 | if(chunk.status()=='pending' && chunk.preprocessState === 0) { 890 | chunk.send(); 891 | found = true; 892 | return(false); 893 | } 894 | }); 895 | } 896 | if(found) return(false); 897 | }); 898 | if(found) return(true); 899 | 900 | // The are no more outstanding chunks to upload, check is everything is done 901 | var outstanding = false; 902 | $h.each($.files, function(file){ 903 | if(!file.isComplete()) { 904 | outstanding = true; 905 | return(false); 906 | } 907 | }); 908 | if(!outstanding) { 909 | // All chunks have been uploaded, complete 910 | $.fire('complete'); 911 | } 912 | return(false); 913 | }; 914 | 915 | 916 | // PUBLIC METHODS FOR RESUMABLE.JS 917 | $.assignBrowse = function(domNodes, isDirectory){ 918 | if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; 919 | 920 | $h.each(domNodes, function(domNode) { 921 | var input; 922 | if(domNode.tagName==='INPUT' && domNode.type==='file'){ 923 | input = domNode; 924 | } else { 925 | input = document.createElement('input'); 926 | input.setAttribute('type', 'file'); 927 | input.style.display = 'none'; 928 | domNode.addEventListener('click', function(){ 929 | input.style.opacity = 0; 930 | input.style.display='block'; 931 | input.focus(); 932 | input.click(); 933 | input.style.display='none'; 934 | }, false); 935 | domNode.appendChild(input); 936 | } 937 | var maxFiles = $.getOpt('maxFiles'); 938 | if (typeof(maxFiles)==='undefined'||maxFiles!=1){ 939 | input.setAttribute('multiple', 'multiple'); 940 | } else { 941 | input.removeAttribute('multiple'); 942 | } 943 | if(isDirectory){ 944 | input.setAttribute('webkitdirectory', 'webkitdirectory'); 945 | } else { 946 | input.removeAttribute('webkitdirectory'); 947 | } 948 | // When new files are added, simply append them to the overall list 949 | input.addEventListener('change', function(e){ 950 | appendFilesFromFileList(e.target.files,e); 951 | var clearInput = $.getOpt('clearInput'); 952 | if (clearInput) { 953 | e.target.value = ''; 954 | } 955 | }, false); 956 | }); 957 | }; 958 | $.assignDrop = function(domNodes){ 959 | if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; 960 | 961 | $h.each(domNodes, function(domNode) { 962 | domNode.addEventListener('dragover', preventDefault, false); 963 | domNode.addEventListener('dragenter', preventDefault, false); 964 | domNode.addEventListener('drop', onDrop, false); 965 | }); 966 | }; 967 | $.unAssignDrop = function(domNodes) { 968 | if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes]; 969 | 970 | $h.each(domNodes, function(domNode) { 971 | domNode.removeEventListener('dragover', preventDefault); 972 | domNode.removeEventListener('dragenter', preventDefault); 973 | domNode.removeEventListener('drop', onDrop); 974 | }); 975 | }; 976 | $.isUploading = function(){ 977 | var uploading = false; 978 | $h.each($.files, function(file){ 979 | if (file.isUploading()) { 980 | uploading = true; 981 | return(false); 982 | } 983 | }); 984 | return(uploading); 985 | }; 986 | $.upload = function(){ 987 | // Make sure we don't start too many uploads at once 988 | if($.isUploading()) return; 989 | // Kick off the queue 990 | $.fire('uploadStart'); 991 | for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) { 992 | $.uploadNextChunk(); 993 | } 994 | }; 995 | $.pause = function(){ 996 | // Resume all chunks currently being uploaded 997 | $h.each($.files, function(file){ 998 | file.abort(); 999 | }); 1000 | $.fire('pause'); 1001 | }; 1002 | $.cancel = function(){ 1003 | $.fire('beforeCancel'); 1004 | for(var i = $.files.length - 1; i >= 0; i--) { 1005 | $.files[i].cancel(); 1006 | } 1007 | $.fire('cancel'); 1008 | }; 1009 | $.progress = function(){ 1010 | var totalDone = 0; 1011 | var totalSize = 0; 1012 | // Resume all chunks currently being uploaded 1013 | $h.each($.files, function(file){ 1014 | totalDone += file.progress()*file.size; 1015 | totalSize += file.size; 1016 | }); 1017 | return(totalSize>0 ? totalDone/totalSize : 0); 1018 | }; 1019 | $.addFile = function(file, event){ 1020 | appendFilesFromFileList([file], event); 1021 | }; 1022 | $.removeFile = function(file){ 1023 | for(var i = $.files.length - 1; i >= 0; i--) { 1024 | if($.files[i] === file) { 1025 | $.files.splice(i, 1); 1026 | } 1027 | } 1028 | }; 1029 | $.getFromUniqueIdentifier = function(uniqueIdentifier){ 1030 | var ret = false; 1031 | $h.each($.files, function(f){ 1032 | if(f.uniqueIdentifier==uniqueIdentifier) ret = f; 1033 | }); 1034 | return(ret); 1035 | }; 1036 | $.getSize = function(){ 1037 | var totalSize = 0; 1038 | $h.each($.files, function(file){ 1039 | totalSize += file.size; 1040 | }); 1041 | return(totalSize); 1042 | }; 1043 | $.handleDropEvent = function (e) { 1044 | onDrop(e); 1045 | }; 1046 | $.handleChangeEvent = function (e) { 1047 | appendFilesFromFileList(e.target.files, e); 1048 | e.target.value = ''; 1049 | }; 1050 | $.updateQuery = function(query){ 1051 | $.opts.query = query; 1052 | }; 1053 | 1054 | return(this); 1055 | }; 1056 | 1057 | 1058 | // Node.js-style export for Node and Component 1059 | if (typeof module != 'undefined') { 1060 | module.exports = Resumable; 1061 | } else if (typeof define === "function" && define.amd) { 1062 | // AMD/requirejs: Define the module 1063 | define(function(){ 1064 | return Resumable; 1065 | }); 1066 | } else { 1067 | // Browser: Expose to window 1068 | window.Resumable = Resumable; 1069 | } 1070 | 1071 | })(); 1072 | -------------------------------------------------------------------------------- /resumable/templates/resumable/file_input.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resumable/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/__init__.py -------------------------------------------------------------------------------- /resumable/tests/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | from django.conf.urls import url 4 | from django.conf.urls.static import static 5 | from django.views.generic.edit import FormView 6 | from django.forms import Form 7 | from django.core.urlresolvers import reverse 8 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 9 | 10 | from resumable.views import ResumableUploadView 11 | from resumable.fields import ResumableFileField 12 | 13 | 14 | class ResumableForm(Form): 15 | file = ResumableFileField( 16 | allowed_mimes=("audio/ogg",), 17 | upload_url=lambda: reverse('upload'), 18 | chunks_dir=getattr(settings, 'FILE_UPLOAD_TEMP_DIR') 19 | ) 20 | 21 | 22 | class TestFormView(FormView): 23 | form_class = ResumableForm 24 | template_name = 'form.html' 25 | 26 | @property 27 | def success_url(self): 28 | return reverse('form') 29 | 30 | 31 | urlpatterns = staticfiles_urlpatterns() 32 | 33 | 34 | urlpatterns = [ 35 | url('^$', TestFormView.as_view(), name='form'), 36 | url('^upload/$', ResumableUploadView.as_view(), name='upload') 37 | ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 38 | -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/147292_seagull.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/147292_seagull.ogg -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0001: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0001 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0002: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0002 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0003: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0003 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0004: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0004 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0005: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0005 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0006: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0006 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0007: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/147292_seagull.ogg_part_0007 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/49028_craw.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/49028_craw.ogg -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/49028_craw.ogg_part_0001: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/49028_craw.ogg_part_0001 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/49028_craw.ogg_part_0002: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/49028_craw.ogg_part_0002 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/49028_craw.ogg_part_0003: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/49028_craw.ogg_part_0003 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/49028_craw.ogg_part_0004: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeanphix/django-resumable/c8e1e9c93270649c17f8ef617bc5ff082ce7c526/resumable/tests/fixtures/files/chunks/49028_craw.ogg_part_0004 -------------------------------------------------------------------------------- /resumable/tests/fixtures/files/chunks/chunk: -------------------------------------------------------------------------------- 1 | content 2 | -------------------------------------------------------------------------------- /resumable/tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from tempfile import mkdtemp 3 | 4 | 5 | DEBUG = True 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': 'resumable.db', 11 | }, 12 | } 13 | 14 | INSTALLED_APPS = [ 15 | 'django.contrib.staticfiles', 16 | 'resumable', 17 | 'resumable.tests.app', 18 | ] 19 | 20 | STATIC_URL = '/static/' 21 | 22 | STATICFILES_FINDERS = ( 23 | 'django.contrib.staticfiles.finders.FileSystemFinder', 24 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 25 | ) 26 | 27 | SECRET_KEY = 'secret' 28 | 29 | ROOT_URLCONF = 'resumable.tests.app' 30 | 31 | FILE_UPLOAD_TEMP_DIR = mkdtemp() 32 | 33 | MIDDLEWARE_CLASSES = [ 34 | 'django.middleware.csrf.CsrfViewMiddleware', 35 | ] 36 | 37 | TEMPLATES = [ 38 | { 39 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 40 | 'APP_DIRS': True, 41 | }, 42 | ] 43 | -------------------------------------------------------------------------------- /resumable/tests/templates/form.html: -------------------------------------------------------------------------------- 1 | {% load staticfiles %} 2 | 3 | 4 | 5 |
6 |
7 | {% csrf_token %} 8 | {{ form.as_p }} 9 |
10 |

11 |
12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /resumable/tests/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from urllib.parse import urlencode 5 | 6 | from django.conf import settings 7 | from django.test import TestCase 8 | from django.core.files.base import ContentFile 9 | from django.core.files.uploadedfile import UploadedFile 10 | from django.core.files.storage import FileSystemStorage 11 | from django.core.urlresolvers import reverse 12 | from django.core.exceptions import ValidationError 13 | 14 | from resumable.files import ResumableFile 15 | 16 | from .app import ResumableForm 17 | 18 | 19 | TESTS_ROOT = os.path.dirname(__file__) 20 | 21 | 22 | FIXTURES_ROOT = os.path.join(TESTS_ROOT, 'fixtures', 'files') 23 | 24 | 25 | CHUNKS_ROOT = os.path.join(FIXTURES_ROOT, 'chunks') 26 | 27 | 28 | seagull = { 29 | 'resumableTotalSize': '147292', 30 | 'resumableFilename': 'seagull.ogg', 31 | 'resumableChunkNumber': '8', 32 | } 33 | 34 | 35 | craw = { 36 | 'resumableTotalSize': '49028', 37 | 'resumableFilename': 'craw.ogg', 38 | 'resumableChunkNumber': '4', 39 | 'resumableCurrentChunkSize': 18308, 40 | } 41 | 42 | 43 | class BaseTestCase(TestCase): 44 | def setUp(self): 45 | test_storage = FileSystemStorage( 46 | location=getattr(settings, 'FILE_UPLOAD_TEMP_DIR')) 47 | fixtures_storage = FileSystemStorage(location=CHUNKS_ROOT) 48 | 49 | for filename in fixtures_storage.listdir('.')[1]: 50 | test_storage.save( 51 | filename, 52 | fixtures_storage.open(filename) 53 | ) 54 | self.seagull = ResumableFile(test_storage, seagull) 55 | self.craw = ResumableFile(test_storage, craw) 56 | self.storage = test_storage 57 | 58 | def tearDown(self): 59 | for filename in self.storage.listdir('.')[1]: 60 | self.storage.delete(filename) 61 | 62 | 63 | class ResumableFileFieldTest(BaseTestCase): 64 | def test_clean_invalid_mime(self): 65 | form = ResumableForm() 66 | with self.assertRaises(ValidationError): 67 | form.fields.get('file').clean(None, UploadedFile( 68 | file=None, 69 | name="text.txt", 70 | content_type="text/plain" 71 | )) 72 | 73 | def test_clean_valid_mime(self): 74 | form = ResumableForm() 75 | f = UploadedFile( 76 | file=None, 77 | name="sound.ogg", 78 | content_type="audio/ogg" 79 | ) 80 | self.assertEqual(f, form.fields.get('file').clean(None, f)) 81 | 82 | def test_form_upload_file(self): 83 | r = self.client.post(reverse('form'), {'file': open(os.path.join( 84 | FIXTURES_ROOT, '147292_seagull.ogg'), 'rb')}) 85 | self.assertEqual(r.status_code, 302) 86 | 87 | 88 | class ResumableFileTest(BaseTestCase): 89 | def test_chunks_partial(self): 90 | iterations = 0 91 | for chunk in self.seagull.chunks(): 92 | iterations += 1 93 | self.assertEqual(iterations, 7) 94 | 95 | def test_chunks_complete(self): 96 | data = b'' 97 | for chunk in self.craw.chunks(): 98 | data += chunk 99 | self.assertEqual(len(data), 49028) 100 | 101 | def test_chunk_exists_existing(self): 102 | self.assertTrue(self.craw.chunk_exists) 103 | 104 | def test_chunk_exists_missing(self): 105 | self.assertFalse(self.seagull.chunk_exists) 106 | 107 | def test_filename(self): 108 | self.assertEqual(self.seagull.filename, '147292_seagull.ogg') 109 | 110 | def test_is_complete_complete(self): 111 | self.assertTrue(self.craw.is_complete) 112 | 113 | def test_is_complete_partial(self): 114 | self.assertFalse(self.seagull.is_complete) 115 | 116 | def test_process_chunk(self): 117 | self.assertFalse(self.seagull.chunk_exists) 118 | chunk = ContentFile('content') 119 | self.seagull.kwargs['resumableCurrentChunkSize'] = chunk.size 120 | self.seagull.process_chunk(chunk) 121 | self.assertTrue(self.seagull.chunk_exists) 122 | 123 | def test_size_complete(self): 124 | self.assertEqual(self.craw.size, 49028) 125 | 126 | def test_size_partial(self): 127 | self.assertEqual(self.seagull.size, 71680) 128 | 129 | 130 | class ResumableUploadViewTest(BaseTestCase): 131 | def test_get_existing(self): 132 | url = '%s?%s' % (reverse('upload'), urlencode(craw)) 133 | r = self.client.get(url) 134 | self.assertEqual(r.status_code, 200) 135 | 136 | def test_get_missing(self): 137 | url = '%s?%s' % (reverse('upload'), urlencode(seagull)) 138 | r = self.client.get(url) 139 | self.assertEqual(r.status_code, 404) 140 | 141 | def test_post_missing(self): 142 | self.assertFalse(self.seagull.chunk_exists) 143 | path = os.path.join(CHUNKS_ROOT, 'chunk') 144 | size = os.path.getsize(path) 145 | chunk = open(path) 146 | params = dict(seagull, **{ 147 | 'file': chunk, 148 | 'resumableCurrentChunkSize': size, 149 | }) 150 | self.seagull.kwargs['resumableCurrentChunkSize'] = size 151 | self.client.post(reverse('upload'), params) 152 | self.assertTrue(self.seagull.chunk_exists) 153 | -------------------------------------------------------------------------------- /resumable/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | 4 | from django.views.generic import View 5 | from django.http import HttpResponse 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.core.files.storage import FileSystemStorage 8 | 9 | from resumable.files import ResumableFile 10 | 11 | 12 | class ResumableUploadView(View): 13 | def get(self, *args, **kwargs): 14 | """Checks if chunk has allready been sended. 15 | """ 16 | r = ResumableFile(self.storage, self.request.GET) 17 | if not (r.chunk_exists or r.is_complete): 18 | return HttpResponse('chunk not found', status=404) 19 | return HttpResponse('chunk already exists') 20 | 21 | def post(self, *args, **kwargs): 22 | """Saves chunks then checks if the file is complete. 23 | """ 24 | chunk = self.request.FILES.get('file') 25 | r = ResumableFile(self.storage, self.request.POST) 26 | if r.chunk_exists: 27 | return HttpResponse('chunk already exists') 28 | r.process_chunk(chunk) 29 | if r.is_complete: 30 | self.process_file(r.filename, r) 31 | r.delete_chunks() 32 | return HttpResponse() 33 | 34 | def process_file(self, filename, file): 35 | """Process the complete file. 36 | """ 37 | self.storage.save(filename, file) 38 | 39 | @property 40 | def chunks_dir(self): 41 | chunks_dir = getattr(settings, 'FILE_UPLOAD_TEMP_DIR', None) 42 | if not chunks_dir: 43 | raise ImproperlyConfigured( 44 | 'You must set settings.FILE_UPLOAD_TEMP_DIR') 45 | return chunks_dir 46 | 47 | @property 48 | def storage(self): 49 | return FileSystemStorage(location=self.chunks_dir) 50 | -------------------------------------------------------------------------------- /resumable/widgets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import magic 3 | import re 4 | 5 | from django.forms.widgets import FileInput 6 | from django.core.files.storage import FileSystemStorage 7 | from django.core.files.uploadedfile import UploadedFile 8 | from django.template import loader 9 | 10 | 11 | class ResumableFileInput(FileInput): 12 | template_name = 'resumable/file_input.html' 13 | 14 | def __init__(self, *args, **kwargs): 15 | super(ResumableFileInput, self).__init__(*args, **kwargs) 16 | self.filepath = None 17 | self.filename = None 18 | 19 | def filename_input_name(self, name): 20 | """Returns the name of the input[type=hidden] that contains the 21 | uploaded file path 22 | """ 23 | return "%s-path" % name 24 | 25 | def guess_type(self, path): 26 | mime = magic.Magic(mime=True) 27 | return mime.from_file(path) 28 | 29 | def value_from_datadict(self, data, files, name): 30 | filepath = data.get(self.filename_input_name(name)) 31 | storage = self.storage 32 | if filepath is not None and len(filepath) > 0 \ 33 | and storage.exists(filepath): 34 | file = storage.open(filepath) 35 | size = storage.size(filepath) 36 | self.filepath = filepath 37 | self.filename = re.sub('^%s_' % str(size), '', filepath) 38 | return UploadedFile( 39 | file=file, 40 | name=self.filename, 41 | content_type=self.guess_type(file.name), 42 | size=size 43 | ) 44 | return files.get(name, None) 45 | 46 | def render(self, name, value, attrs=None, **kwargs): 47 | attrs = self.build_attrs(attrs) 48 | 49 | if 'required' in attrs: 50 | del attrs['required'] 51 | 52 | context = { 53 | 'filename': self.filename, 54 | 'filename_input_value': self.filepath, 55 | 'filename_input_name': self.filename_input_name(name), 56 | 'file_input_name': name, 57 | 'attrs': attrs, 58 | } 59 | return loader.render_to_string(self.template_name, context) 60 | 61 | @property 62 | def storage(self): 63 | return FileSystemStorage(location=self.chunks_dir) 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from distutils.core import setup 3 | from setuptools import find_packages 4 | 5 | 6 | setup( 7 | name='django-resumable', 8 | version='0.2.0-dev', 9 | author=u'jean-philippe serafin', 10 | author_email='serafinjp@gmail.com', 11 | packages=find_packages(), 12 | include_package_data=True, 13 | url='https://github.com/jeanphix/django-resumable', 14 | license='MIT licence', 15 | description='Django resumable uploads', 16 | long_description=open('README.rst').read(), 17 | install_requires=[ 18 | 'Django>=1.10', 19 | 'python-magic', 20 | ], 21 | classifiers=[ 22 | 'Development Status :: 5 - Production/Stable', 23 | 'Environment :: Web Environment', 24 | 'Framework :: Django', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Natural Language :: English', 28 | 'Programming Language :: Python', 29 | ], 30 | zip_safe=False, 31 | ) 32 | --------------------------------------------------------------------------------