├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── admin_resumable ├── __init__.py ├── fields.py ├── files.py ├── static │ └── admin_resumable │ │ └── js │ │ └── resumable.js ├── templates │ └── admin_resumable │ │ └── file_input.html ├── tests.py ├── urls.py └── views.py ├── screenshot.png ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── admin.py ├── conftest.py ├── media │ └── hello.world ├── models.py ├── test_uploads.py └── urls.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !/admin_resumable/** 3 | !/tests/** 4 | /tests/media/ 5 | !/* 6 | 7 | *.py[cod] 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Packages 13 | *.egg 14 | *.egg-info 15 | dist 16 | build 17 | eggs 18 | parts 19 | bin 20 | var 21 | sdist 22 | develop-eggs 23 | .installed.cfg 24 | lib 25 | lib64 26 | __pycache__ 27 | 28 | # Installer logs 29 | pip-log.txt 30 | 31 | # Unit test / coverage reports 32 | .coverage 33 | .tox 34 | nosetests.xml 35 | 36 | # Translations 37 | *.mo 38 | 39 | # Mr Developer 40 | .mr.developer.cfg 41 | .project 42 | .pydevproject 43 | 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | matrix: 4 | include: 5 | - python: 2.7 6 | dist: trusty 7 | sudo: false 8 | env: TOX_ENV=py27-django1.11 9 | - python: 3.5 10 | dist: trusty 11 | sudo: false 12 | env: 13 | - TOX_ENV=py35-django1.11 14 | - python: 3.5 15 | dist: trusty 16 | sudo: false 17 | env: 18 | - TOX_ENV=py35-django2.0 19 | - python: 3.5 20 | dist: trusty 21 | sudo: false 22 | env: 23 | - TOX_ENV=py35-django2.1 24 | - python: 3.6 25 | dist: trusty 26 | sudo: false 27 | env: 28 | - TOX_ENV=py36-django1.11 29 | - python: 3.6 30 | dist: trusty 31 | sudo: false 32 | env: 33 | - TOX_ENV=py36-django2.0 34 | - python: 3.6 35 | dist: trusty 36 | sudo: false 37 | env: 38 | - TOX_ENV=py36-django2.1 39 | - python: 3.7 40 | dist: xenial 41 | sudo: true 42 | env: 43 | - TOX_ENV=py37-django2.0 44 | - python: 3.7 45 | dist: xenial 46 | sudo: true 47 | env: 48 | - TOX_ENV=py37-django2.1 49 | 50 | env: 51 | global: 52 | - MOZ_HEADLESS=1 53 | 54 | addons: 55 | firefox: latest 56 | 57 | before_install: 58 | - wget https://github.com/mozilla/geckodriver/releases/download/v0.20.1/geckodriver-v0.20.1-linux64.tar.gz 59 | - mkdir geckodriver 60 | - tar -xzf geckodriver-v0.20.1-linux64.tar.gz -C geckodriver 61 | - export PATH=$PATH:$PWD/geckodriver 62 | 63 | install: 64 | - pip install tox 65 | - pip install virtualenv 66 | 67 | script: 68 | - tox -e $TOX_ENV 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 jonatron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include admin_resumable/templates/admin_resumable/file_input.html 3 | include admin_resumable/static/admin_resumable/js/resumable.js -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-admin-resumable-js 2 | ========================= 3 | 4 | Abandoned Notice 5 | ---------------- 6 | 7 | This app is now abandoned. Please see https://github.com/DataGreed/django-admin-async-upload for an updated fork. 8 | 9 | 10 | Summary 11 | ------- 12 | 13 | .. image:: https://api.travis-ci.org/jonatron/django-admin-resumable-js.svg?branch=master 14 | :target: https://travis-ci.org/jonatron/django-admin-resumable-js 15 | 16 | django-admin-resumable-js is a django app to allow you to upload large files from within the django admin site. 17 | 18 | Screenshot 19 | ---------- 20 | 21 | .. image:: https://github.com/jonatron/django-admin-resumable-js/raw/master/screenshot.png?raw=true 22 | 23 | 24 | Installation 25 | ------------ 26 | 27 | * pip install django-admin-resumable-js 28 | * Add ``admin_resumable`` to your ``INSTALLED_APPS`` 29 | * Add ``url(r'^admin_resumable/', include('admin_resumable.urls')),`` to your urls.py 30 | * Add a model field eg: ``from admin_resumable.fields import ModelAdminResumableFileField`` 31 | 32 | :: 33 | 34 | class Foo(models.Model): 35 | bar = models.CharField(max_length=200) 36 | foo = ModelAdminResumableFileField() 37 | 38 | 39 | 40 | Optionally: 41 | 42 | * Set ``ADMIN_RESUMABLE_SUBDIR``, default is ``'admin_uploaded'`` 43 | * Use upload_to instead of ADMIN_RESUMABLE_SUBDIR 44 | * Set ``ADMIN_RESUMABLE_CHUNKSIZE``, default is ``"1*1024*1024"`` 45 | * Set ``ADMIN_RESUMABLE_STORAGE``, default is ``'django.core.files.storage.FileSystemStorage'`` (must be a subclass of ``django.core.files.storage.FileSystemStorage``, or accept the ``location`` init parameter). If you don't want the default FileSystemStorage behaviour of creating new files on the server with filenames appended with _1, _2, etc for consecutive uploads of the same file, then you could use this to set your storage class to something like https://djangosnippets.org/snippets/976/ 46 | * Set ``ADMIN_RESUMABLE_SHOW_THUMB``, default is False. Shows a thumbnail next to the "Currently:" link. 47 | 48 | 49 | Versions 50 | -------- 51 | 52 | 1.0: First PyPI release 53 | 54 | 1.1: Bug fix [1] 55 | 56 | 1.2: Django 1.9 Compatibility 57 | 58 | 2.0: Added upload_to 59 | 60 | 3.0: New Django/Python compatibility 61 | 62 | 63 | [1] Django silently truncates incomplete chunks, due to the way the multipart 64 | parser works: https://github.com/django/django/blob/master/django/http/multipartparser.py 65 | This could result in a file being unable to be uploaded, or a corrupt file, 66 | depending on the situation. 67 | 68 | 69 | Compatibility 70 | ------------- 71 | 72 | 1.2: 73 | {py27,py32,py33,py34,py35}-django{1.6,1.7,1.8,1.9}. 74 | python 3.2 and 3.3 supported up to django 1.8. 75 | 76 | 2.0: 77 | {py27,py34,py35}-django{1.8,1.9,1.10,1.11} 78 | 79 | 3.0: 80 | {py27,py35,py36,py37}-django{1.11,2.0,2.1} 81 | python 2.7 supported up to django 1.11 82 | 83 | 84 | Running Tests Locally 85 | ------------- 86 | 87 | Incomplete notes 88 | [on ubuntu] 89 | 90 | :: 91 | 92 | sudo apt-get install python3-venv 93 | python3 -m venv venv 94 | source venv/bin/activate 95 | pip install pytest 96 | pip install tox 97 | tox 98 | 99 | Thanks to 100 | --------- 101 | 102 | Resumable.js https://github.com/23/resumable.js 103 | 104 | django-resumable https://github.com/jeanphix/django-resumable 105 | -------------------------------------------------------------------------------- /admin_resumable/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonatron/django-admin-resumable-js/2f71fcc2076b551b3a3c0f3a531e199c21edbb59/admin_resumable/__init__.py -------------------------------------------------------------------------------- /admin_resumable/fields.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.forms.widgets import FileInput 4 | from django.forms import forms 5 | from django.db import models 6 | from django.templatetags.static import static 7 | from django.template import loader 8 | from django.forms.fields import FileField 9 | from django.forms.widgets import CheckboxInput 10 | from django.core.exceptions import ValidationError 11 | from django.conf import settings 12 | from django.utils.translation import ugettext_lazy 13 | from django.utils.safestring import mark_safe 14 | from django.contrib.contenttypes.models import ContentType 15 | 16 | from .views import get_storage 17 | 18 | 19 | def get_upload_to(ct_id, field_name): 20 | ct = ContentType.objects.get_for_id(ct_id) 21 | model_cls = ct.model_class() 22 | field = model_cls._meta.get_field(field_name) 23 | return field.orig_upload_to 24 | 25 | 26 | class ResumableWidget(FileInput): 27 | template_name = 'admin_resumable/file_input.html' 28 | clear_checkbox_label = ugettext_lazy('Clear') 29 | 30 | def render(self, name, value, attrs=None, **kwargs): 31 | upload_to = get_upload_to( 32 | self.attrs['content_type_id'], self.attrs['field_name']) 33 | storage = get_storage(upload_to) 34 | if value: 35 | file_name = os.path.basename(value.name) 36 | file_url = storage.url(file_name) 37 | else: 38 | file_url = "" 39 | 40 | chunkSize = getattr(settings, 'ADMIN_RESUMABLE_CHUNKSIZE', "1*1024*1024") 41 | show_thumb = getattr(settings, 'ADMIN_RESUMABLE_SHOW_THUMB', False) 42 | context = {'name': name, 43 | 'value': value, 44 | 'id': attrs['id'], 45 | 'chunkSize': chunkSize, 46 | 'show_thumb': show_thumb, 47 | 'field_name': self.attrs['field_name'], 48 | 'content_type_id': self.attrs['content_type_id'], 49 | 'file_url': file_url} 50 | 51 | if not self.is_required: 52 | template_with_clear = '%(clear)s ' 53 | substitutions = {} 54 | substitutions['clear_checkbox_id'] = attrs['id'] + "-clear-id" 55 | substitutions['clear_checkbox_name'] = attrs['id'] + "-clear" 56 | substitutions['clear_checkbox_label'] = self.clear_checkbox_label 57 | substitutions['clear'] = CheckboxInput().render( 58 | substitutions['clear_checkbox_name'], 59 | False, 60 | attrs={'id': substitutions['clear_checkbox_id']} 61 | ) 62 | clear_checkbox = mark_safe(template_with_clear % substitutions) 63 | context.update({'clear_checkbox': clear_checkbox}) 64 | return loader.render_to_string(self.template_name, context) 65 | 66 | def value_from_datadict(self, data, files, name): 67 | if not self.is_required and data.get("id_" + name + "-clear"): 68 | return False # False signals to clear any existing value, as opposed to just None 69 | if data.get(name, None) in ['None', 'False']: 70 | return None 71 | return data.get(name, None) 72 | 73 | 74 | class AdminResumableWidget(ResumableWidget): 75 | @property 76 | def media(self): 77 | js = ["resumable.js"] 78 | return forms.Media(js=[static("admin_resumable/js/%s" % path) for path in js]) 79 | 80 | 81 | class FormResumableFileField(FileField): 82 | widget = ResumableWidget 83 | 84 | 85 | class FormAdminResumableFileField(FileField): 86 | widget = AdminResumableWidget 87 | 88 | def to_python(self, data): 89 | if self.required: 90 | if not data or data == "None": 91 | raise ValidationError(self.error_messages['empty']) 92 | return data 93 | 94 | 95 | class ModelAdminResumableFileField(models.FileField): 96 | def __init__(self, verbose_name=None, name=None, upload_to='', 97 | storage=None, **kwargs): 98 | self.orig_upload_to = upload_to 99 | super(ModelAdminResumableFileField, self).__init__( 100 | verbose_name, name, 'unused', **kwargs) 101 | 102 | def formfield(self, **kwargs): 103 | content_type_id = ContentType.objects.get_for_model(self.model).id 104 | defaults = { 105 | 'form_class': FormAdminResumableFileField, 106 | 'widget': AdminResumableWidget(attrs={ 107 | 'content_type_id': content_type_id, 108 | 'field_name': self.name}) 109 | } 110 | kwargs.update(defaults) 111 | return super(ModelAdminResumableFileField, self).formfield(**kwargs) 112 | -------------------------------------------------------------------------------- /admin_resumable/files.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import fnmatch 3 | 4 | from django.core.files.base import File 5 | 6 | 7 | class ResumableFile(object): 8 | def __init__(self, storage, kwargs): 9 | self.storage = storage 10 | self.kwargs = kwargs 11 | self.chunk_suffix = "_part_" 12 | 13 | @property 14 | def chunk_exists(self): 15 | """Checks if the requested chunk exists. 16 | """ 17 | return self.storage.exists(self.current_chunk_name) and \ 18 | self.storage.size(self.current_chunk_name) == int(self.kwargs.get('resumableCurrentChunkSize')) 19 | 20 | @property 21 | def chunk_names(self): 22 | """Iterates over all stored chunks. 23 | """ 24 | chunks = [] 25 | files = sorted(self.storage.listdir('')[1]) 26 | for f in files: 27 | if f.startswith('{}{}'.format( 28 | self.filename, self.chunk_suffix)): 29 | chunks.append(f) 30 | return chunks 31 | 32 | @property 33 | def current_chunk_name(self): 34 | return "%s%s%s" % ( 35 | self.filename, 36 | self.chunk_suffix, 37 | self.kwargs.get('resumableChunkNumber').zfill(4) 38 | ) 39 | 40 | def chunks(self): 41 | """Iterates over all stored chunks. 42 | """ 43 | files = sorted(self.storage.listdir('')[1]) 44 | for f in files: 45 | if f.startswith('{}{}'.format( 46 | self.filename, self.chunk_suffix)): 47 | yield self.storage.open(f, 'rb').read() 48 | 49 | def delete_chunks(self): 50 | [self.storage.delete(chunk) for chunk in self.chunk_names] 51 | 52 | @property 53 | def file(self): 54 | """Gets the complete file. 55 | """ 56 | if not self.is_complete: 57 | raise Exception('Chunk(s) still missing') 58 | 59 | return self 60 | 61 | @property 62 | def filename(self): 63 | """Gets the filename.""" 64 | filename = self.kwargs.get('resumableFilename') 65 | if '/' in filename: 66 | raise Exception('Invalid filename') 67 | return "%s_%s" % ( 68 | self.kwargs.get('resumableTotalSize'), 69 | filename 70 | ) 71 | 72 | @property 73 | def is_complete(self): 74 | """Checks if all chunks are already stored. 75 | """ 76 | print("resumableTotalSize",int(self.kwargs.get('resumableTotalSize')),": size",self.size) 77 | return int(self.kwargs.get('resumableTotalSize')) == self.size 78 | 79 | def process_chunk(self, file): 80 | if self.storage.exists(self.current_chunk_name): 81 | self.storage.delete(self.current_chunk_name) 82 | self.storage.save(self.current_chunk_name, file) 83 | 84 | @property 85 | def size(self): 86 | """Gets chunks size. 87 | """ 88 | size = 0 89 | for chunk in self.chunk_names: 90 | size += self.storage.size(chunk) 91 | return size 92 | -------------------------------------------------------------------------------- /admin_resumable/static/admin_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 | throttleProgressCallbacks:0.5, 43 | query:{}, 44 | headers:{}, 45 | preprocess:null, 46 | method:'multipart', 47 | uploadMethod: 'POST', 48 | testMethod: 'GET', 49 | prioritizeFirstAndLastChunk:false, 50 | target:'/', 51 | parameterNamespace:'', 52 | testChunks:true, 53 | generateUniqueIdentifier:null, 54 | getTarget:null, 55 | maxChunkRetries:undefined, 56 | chunkRetryInterval:undefined, 57 | permanentErrors:[400, 404, 415, 500, 501], 58 | maxFiles:undefined, 59 | withCredentials:false, 60 | xhrTimeout:0, 61 | maxFilesErrorCallback:function (files, errorCount) { 62 | var maxFiles = $.getOpt('maxFiles'); 63 | alert('Please upload ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.'); 64 | }, 65 | minFileSize:1, 66 | minFileSizeErrorCallback:function(file, errorCount) { 67 | alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.'); 68 | }, 69 | maxFileSize:undefined, 70 | maxFileSizeErrorCallback:function(file, errorCount) { 71 | alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.'); 72 | }, 73 | fileType: [], 74 | fileTypeErrorCallback: function(file, errorCount) { 75 | alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.'); 76 | } 77 | }; 78 | $.opts = opts||{}; 79 | $.getOpt = function(o) { 80 | var $opt = this; 81 | // Get multiple option if passed an array 82 | if(o instanceof Array) { 83 | var options = {}; 84 | $h.each(o, function(option){ 85 | options[option] = $opt.getOpt(option); 86 | }); 87 | return options; 88 | } 89 | // Otherwise, just return a simple option 90 | if ($opt instanceof ResumableChunk) { 91 | if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } 92 | else { $opt = $opt.fileObj; } 93 | } 94 | if ($opt instanceof ResumableFile) { 95 | if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } 96 | else { $opt = $opt.resumableObj; } 97 | } 98 | if ($opt instanceof Resumable) { 99 | if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } 100 | else { return $opt.defaults[o]; } 101 | } 102 | }; 103 | 104 | // EVENTS 105 | // catchAll(event, ...) 106 | // fileSuccess(file), fileProgress(file), fileAdded(file, event), fileRetry(file), fileError(file, message), 107 | // complete(), progress(), error(message, file), pause() 108 | $.events = []; 109 | $.on = function(event,callback){ 110 | $.events.push(event.toLowerCase(), callback); 111 | }; 112 | $.fire = function(){ 113 | // `arguments` is an object, not array, in FF, so: 114 | var args = []; 115 | for (var i=0; i0){ 293 | //add these results to the array of all the new stuff 294 | for (var i=0; i 0){ 367 | var fileTypeFound = false; 368 | for(var index in o.fileType){ 369 | var extension = '.' + o.fileType[index]; 370 | if(fileName.indexOf(extension, fileName.length - extension.length) !== -1){ 371 | fileTypeFound = true; 372 | break; 373 | } 374 | } 375 | if (!fileTypeFound) { 376 | o.fileTypeErrorCallback(file, errorCount++); 377 | return false; 378 | } 379 | } 380 | 381 | if (typeof(o.minFileSize)!=='undefined' && file.sizeo.maxFileSize) { 386 | o.maxFileSizeErrorCallback(file, errorCount++); 387 | return false; 388 | } 389 | 390 | function addFile(uniqueIdentifier){ 391 | if (!$.getFromUniqueIdentifier(uniqueIdentifier)) {(function(){ 392 | file.uniqueIdentifier = uniqueIdentifier; 393 | var f = new ResumableFile($, file, uniqueIdentifier); 394 | $.files.push(f); 395 | files.push(f); 396 | f.container = (typeof event != 'undefined' ? event.srcElement : null); 397 | window.setTimeout(function(){ 398 | $.fire('fileAdded', f, event) 399 | },0); 400 | })()}; 401 | } 402 | // directories have size == 0 403 | var uniqueIdentifier = $h.generateUniqueIdentifier(file) 404 | if(uniqueIdentifier && typeof uniqueIdentifier.done === 'function' && typeof uniqueIdentifier.fail === 'function'){ 405 | uniqueIdentifier 406 | .done(function(uniqueIdentifier){ 407 | addFile(uniqueIdentifier); 408 | }) 409 | .fail(function(){ 410 | addFile(); 411 | }); 412 | }else{ 413 | addFile(uniqueIdentifier); 414 | } 415 | 416 | }); 417 | window.setTimeout(function(){ 418 | $.fire('filesAdded', files) 419 | },0); 420 | }; 421 | 422 | // INTERNAL OBJECT TYPES 423 | function ResumableFile(resumableObj, file, uniqueIdentifier){ 424 | var $ = this; 425 | $.opts = {}; 426 | $.getOpt = resumableObj.getOpt; 427 | $._prevProgress = 0; 428 | $.resumableObj = resumableObj; 429 | $.file = file; 430 | $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox 431 | $.size = file.size; 432 | $.relativePath = file.webkitRelativePath || file.relativePath || $.fileName; 433 | $.uniqueIdentifier = uniqueIdentifier; 434 | $._pause = false; 435 | $.container = ''; 436 | var _error = uniqueIdentifier !== undefined; 437 | 438 | // Callback when something happens within the chunk 439 | var chunkEvent = function(event, message){ 440 | // event can be 'progress', 'success', 'error' or 'retry' 441 | switch(event){ 442 | case 'progress': 443 | $.resumableObj.fire('fileProgress', $); 444 | break; 445 | case 'error': 446 | $.abort(); 447 | _error = true; 448 | $.chunks = []; 449 | $.resumableObj.fire('fileError', $, message); 450 | break; 451 | case 'success': 452 | if(_error) return; 453 | $.resumableObj.fire('fileProgress', $); // it's at least progress 454 | if($.isComplete()) { 455 | $.resumableObj.fire('fileSuccess', $, message); 456 | } 457 | break; 458 | case 'retry': 459 | $.resumableObj.fire('fileRetry', $); 460 | break; 461 | } 462 | }; 463 | 464 | // Main code to set up a file object with chunks, 465 | // packaged to be able to handle retries if needed. 466 | $.chunks = []; 467 | $.abort = function(){ 468 | // Stop current uploads 469 | var abortCount = 0; 470 | $h.each($.chunks, function(c){ 471 | if(c.status()=='uploading') { 472 | c.abort(); 473 | abortCount++; 474 | } 475 | }); 476 | if(abortCount>0) $.resumableObj.fire('fileProgress', $); 477 | }; 478 | $.cancel = function(){ 479 | // Reset this file to be void 480 | var _chunks = $.chunks; 481 | $.chunks = []; 482 | // Stop current uploads 483 | $h.each(_chunks, function(c){ 484 | if(c.status()=='uploading') { 485 | c.abort(); 486 | $.resumableObj.uploadNextChunk(); 487 | } 488 | }); 489 | $.resumableObj.removeFile($); 490 | $.resumableObj.fire('fileProgress', $); 491 | }; 492 | $.retry = function(){ 493 | $.bootstrap(); 494 | var firedRetry = false; 495 | $.resumableObj.on('chunkingComplete', function(){ 496 | if(!firedRetry) $.resumableObj.upload(); 497 | firedRetry = true; 498 | }); 499 | }; 500 | $.bootstrap = function(){ 501 | $.abort(); 502 | _error = false; 503 | // Rebuild stack of chunks from file 504 | $.chunks = []; 505 | $._prevProgress = 0; 506 | var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor; 507 | var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1); 508 | for (var offset=0; offset0.99999 ? 1 : ret)); 528 | ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused 529 | $._prevProgress = ret; 530 | return(ret); 531 | }; 532 | $.isUploading = function(){ 533 | var uploading = false; 534 | $h.each($.chunks, function(chunk){ 535 | if(chunk.status()=='uploading') { 536 | uploading = true; 537 | return(false); 538 | } 539 | }); 540 | return(uploading); 541 | }; 542 | $.isComplete = function(){ 543 | var outstanding = false; 544 | $h.each($.chunks, function(chunk){ 545 | var status = chunk.status(); 546 | if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) { 547 | outstanding = true; 548 | return(false); 549 | } 550 | }); 551 | return(!outstanding); 552 | }; 553 | $.pause = function(pause){ 554 | if(typeof(pause)==='undefined'){ 555 | $._pause = ($._pause ? false : true); 556 | }else{ 557 | $._pause = pause; 558 | } 559 | }; 560 | $.isPaused = function() { 561 | return $._pause; 562 | }; 563 | 564 | 565 | // Bootstrap and return 566 | $.resumableObj.fire('chunkingStart', $); 567 | $.bootstrap(); 568 | return(this); 569 | } 570 | 571 | 572 | function ResumableChunk(resumableObj, fileObj, offset, callback){ 573 | var $ = this; 574 | $.opts = {}; 575 | $.getOpt = resumableObj.getOpt; 576 | $.resumableObj = resumableObj; 577 | $.fileObj = fileObj; 578 | $.fileObjSize = fileObj.size; 579 | $.fileObjType = fileObj.file.type; 580 | $.offset = offset; 581 | $.callback = callback; 582 | $.lastProgressCallback = (new Date); 583 | $.tested = false; 584 | $.retries = 0; 585 | $.pendingRetry = false; 586 | $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished 587 | 588 | // Computed properties 589 | var chunkSize = $.getOpt('chunkSize'); 590 | $.loaded = 0; 591 | $.startByte = $.offset*chunkSize; 592 | $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize); 593 | if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) { 594 | // The last chunk will be bigger than the chunk size, but less than 2*chunkSize 595 | $.endByte = $.fileObjSize; 596 | } 597 | $.xhr = null; 598 | 599 | // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session 600 | $.test = function(){ 601 | // Set up request and listen for event 602 | $.xhr = new XMLHttpRequest(); 603 | 604 | var testHandler = function(e){ 605 | $.tested = true; 606 | var status = $.status(); 607 | if(status=='success') { 608 | $.callback(status, $.message()); 609 | $.resumableObj.uploadNextChunk(); 610 | } else { 611 | $.send(); 612 | } 613 | }; 614 | $.xhr.addEventListener('load', testHandler, false); 615 | $.xhr.addEventListener('error', testHandler, false); 616 | $.xhr.addEventListener('timeout', testHandler, false); 617 | 618 | // Add data from the query options 619 | var params = []; 620 | var parameterNamespace = $.getOpt('parameterNamespace'); 621 | var customQuery = $.getOpt('query'); 622 | if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); 623 | $h.each(customQuery, function(k,v){ 624 | params.push([encodeURIComponent(parameterNamespace+k), encodeURIComponent(v)].join('=')); 625 | }); 626 | // Add extra data to identify chunk 627 | params.push([parameterNamespace+'resumableChunkNumber', encodeURIComponent($.offset+1)].join('=')); 628 | params.push([parameterNamespace+'resumableChunkSize', encodeURIComponent($.getOpt('chunkSize'))].join('=')); 629 | params.push([parameterNamespace+'resumableCurrentChunkSize', encodeURIComponent($.endByte - $.startByte)].join('=')); 630 | params.push([parameterNamespace+'resumableTotalSize', encodeURIComponent($.fileObjSize)].join('=')); 631 | params.push([parameterNamespace+'resumableType', encodeURIComponent($.fileObjType)].join('=')); 632 | params.push([parameterNamespace+'resumableIdentifier', encodeURIComponent($.fileObj.uniqueIdentifier)].join('=')); 633 | params.push([parameterNamespace+'resumableFilename', encodeURIComponent($.fileObj.fileName)].join('=')); 634 | params.push([parameterNamespace+'resumableRelativePath', encodeURIComponent($.fileObj.relativePath)].join('=')); 635 | params.push([parameterNamespace+'resumableTotalChunks', encodeURIComponent($.fileObj.chunks.length)].join('=')); 636 | // Append the relevant chunk and send it 637 | $.xhr.open($.getOpt('testMethod'), $h.getTarget(params)); 638 | $.xhr.timeout = $.getOpt('xhrTimeout'); 639 | $.xhr.withCredentials = $.getOpt('withCredentials'); 640 | // Add data from header options 641 | $h.each($.getOpt('headers'), function(k,v) { 642 | $.xhr.setRequestHeader(k, v); 643 | }); 644 | $.xhr.send(null); 645 | }; 646 | 647 | $.preprocessFinished = function(){ 648 | $.preprocessState = 2; 649 | $.send(); 650 | }; 651 | 652 | // send() uploads the actual data in a POST call 653 | $.send = function(){ 654 | var preprocess = $.getOpt('preprocess'); 655 | if(typeof preprocess === 'function') { 656 | switch($.preprocessState) { 657 | case 0: $.preprocessState = 1; preprocess($); return; 658 | case 1: return; 659 | case 2: break; 660 | } 661 | } 662 | if($.getOpt('testChunks') && !$.tested) { 663 | $.test(); 664 | return; 665 | } 666 | 667 | // Set up request and listen for event 668 | $.xhr = new XMLHttpRequest(); 669 | 670 | // Progress 671 | $.xhr.upload.addEventListener('progress', function(e){ 672 | if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) { 673 | $.callback('progress'); 674 | $.lastProgressCallback = (new Date); 675 | } 676 | $.loaded=e.loaded||0; 677 | }, false); 678 | $.loaded = 0; 679 | $.pendingRetry = false; 680 | $.callback('progress'); 681 | 682 | // Done (either done, failed or retry) 683 | var doneHandler = function(e){ 684 | var status = $.status(); 685 | if(status=='success'||status=='error') { 686 | $.callback(status, $.message()); 687 | $.resumableObj.uploadNextChunk(); 688 | } else { 689 | $.callback('retry', $.message()); 690 | $.abort(); 691 | $.retries++; 692 | var retryInterval = $.getOpt('chunkRetryInterval'); 693 | if(retryInterval !== undefined) { 694 | $.pendingRetry = true; 695 | setTimeout($.send, retryInterval); 696 | } else { 697 | $.send(); 698 | } 699 | } 700 | }; 701 | $.xhr.addEventListener('load', doneHandler, false); 702 | $.xhr.addEventListener('error', doneHandler, false); 703 | $.xhr.addEventListener('timeout', doneHandler, false); 704 | 705 | // Set up the basic query data from Resumable 706 | var query = { 707 | resumableChunkNumber: $.offset+1, 708 | resumableChunkSize: $.getOpt('chunkSize'), 709 | resumableCurrentChunkSize: $.endByte - $.startByte, 710 | resumableTotalSize: $.fileObjSize, 711 | resumableType: $.fileObjType, 712 | resumableIdentifier: $.fileObj.uniqueIdentifier, 713 | resumableFilename: $.fileObj.fileName, 714 | resumableRelativePath: $.fileObj.relativePath, 715 | resumableTotalChunks: $.fileObj.chunks.length 716 | }; 717 | // Mix in custom data 718 | var customQuery = $.getOpt('query'); 719 | if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); 720 | $h.each(customQuery, function(k,v){ 721 | query[k] = v; 722 | }); 723 | 724 | var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice'))), 725 | bytes = $.fileObj.file[func]($.startByte,$.endByte), 726 | data = null, 727 | target = $.getOpt('target'); 728 | 729 | var parameterNamespace = $.getOpt('parameterNamespace'); 730 | if ($.getOpt('method') === 'octet') { 731 | // Add data from the query options 732 | data = bytes; 733 | var params = []; 734 | $h.each(query, function(k,v){ 735 | params.push([encodeURIComponent(parameterNamespace+k), encodeURIComponent(v)].join('=')); 736 | }); 737 | target = $h.getTarget(params); 738 | } else { 739 | // Add data from the query options 740 | data = new FormData(); 741 | $h.each(query, function(k,v){ 742 | data.append(parameterNamespace+k,v); 743 | }); 744 | data.append(parameterNamespace+$.getOpt('fileParameterName'), bytes); 745 | } 746 | 747 | var method = $.getOpt('uploadMethod'); 748 | $.xhr.open(method, target); 749 | if ($.getOpt('method') === 'octet') { 750 | $.xhr.setRequestHeader('Content-Type', 'binary/octet-stream'); 751 | } 752 | $.xhr.timeout = $.getOpt('xhrTimeout'); 753 | $.xhr.withCredentials = $.getOpt('withCredentials'); 754 | // Add data from header options 755 | $h.each($.getOpt('headers'), function(k,v) { 756 | $.xhr.setRequestHeader(k, v); 757 | }); 758 | $.xhr.send(data); 759 | }; 760 | $.abort = function(){ 761 | // Abort and reset 762 | if($.xhr) $.xhr.abort(); 763 | $.xhr = null; 764 | }; 765 | $.status = function(){ 766 | // Returns: 'pending', 'uploading', 'success', 'error' 767 | if($.pendingRetry) { 768 | // if pending retry then that's effectively the same as actively uploading, 769 | // there might just be a slight delay before the retry starts 770 | return('uploading'); 771 | } else if(!$.xhr) { 772 | return('pending'); 773 | } else if($.xhr.readyState<4) { 774 | // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening 775 | return('uploading'); 776 | } else { 777 | if($.xhr.status == 200 || $.xhr.status == 201) { 778 | // HTTP 200 or 201 (created) perfect 779 | return('success'); 780 | } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) { 781 | // HTTP 415/500/501, permanent error 782 | return('error'); 783 | } else { 784 | // this should never happen, but we'll reset and queue a retry 785 | // a likely case for this would be 503 service unavailable 786 | $.abort(); 787 | return('pending'); 788 | } 789 | } 790 | }; 791 | $.message = function(){ 792 | return($.xhr ? $.xhr.responseText : ''); 793 | }; 794 | $.progress = function(relative){ 795 | if(typeof(relative)==='undefined') relative = false; 796 | var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1); 797 | if($.pendingRetry) return(0); 798 | var s = $.status(); 799 | switch(s){ 800 | case 'success': 801 | case 'error': 802 | return(1*factor); 803 | case 'pending': 804 | return(0*factor); 805 | default: 806 | return($.loaded/($.endByte-$.startByte)*factor); 807 | } 808 | }; 809 | return(this); 810 | } 811 | 812 | // QUEUE 813 | $.uploadNextChunk = function(){ 814 | var found = false; 815 | 816 | // In some cases (such as videos) it's really handy to upload the first 817 | // and last chunk of a file quickly; this let's the server check the file's 818 | // metadata and determine if there's even a point in continuing. 819 | if ($.getOpt('prioritizeFirstAndLastChunk')) { 820 | $h.each($.files, function(file){ 821 | if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) { 822 | file.chunks[0].send(); 823 | found = true; 824 | return(false); 825 | } 826 | if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) { 827 | file.chunks[file.chunks.length-1].send(); 828 | found = true; 829 | return(false); 830 | } 831 | }); 832 | if(found) return(true); 833 | } 834 | 835 | // Now, simply look for the next, best thing to upload 836 | $h.each($.files, function(file){ 837 | if(file.isPaused()===false){ 838 | $h.each(file.chunks, function(chunk){ 839 | if(chunk.status()=='pending' && chunk.preprocessState === 0) { 840 | chunk.send(); 841 | found = true; 842 | return(false); 843 | } 844 | }); 845 | } 846 | if(found) return(false); 847 | }); 848 | if(found) return(true); 849 | 850 | // The are no more outstanding chunks to upload, check is everything is done 851 | var outstanding = false; 852 | $h.each($.files, function(file){ 853 | if(!file.isComplete()) { 854 | outstanding = true; 855 | return(false); 856 | } 857 | }); 858 | if(!outstanding) { 859 | // All chunks have been uploaded, complete 860 | $.fire('complete'); 861 | } 862 | return(false); 863 | }; 864 | 865 | 866 | // PUBLIC METHODS FOR RESUMABLE.JS 867 | $.assignBrowse = function(domNodes, isDirectory){ 868 | if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; 869 | 870 | $h.each(domNodes, function(domNode) { 871 | var input; 872 | if(domNode.tagName==='INPUT' && domNode.type==='file'){ 873 | input = domNode; 874 | } else { 875 | input = document.createElement('input'); 876 | input.setAttribute('type', 'file'); 877 | input.style.display = 'none'; 878 | domNode.addEventListener('click', function(){ 879 | input.style.opacity = 0; 880 | input.style.display='block'; 881 | input.focus(); 882 | input.click(); 883 | input.style.display='none'; 884 | }, false); 885 | domNode.appendChild(input); 886 | } 887 | var maxFiles = $.getOpt('maxFiles'); 888 | if (typeof(maxFiles)==='undefined'||maxFiles!=1){ 889 | input.setAttribute('multiple', 'multiple'); 890 | } else { 891 | input.removeAttribute('multiple'); 892 | } 893 | if(isDirectory){ 894 | input.setAttribute('webkitdirectory', 'webkitdirectory'); 895 | } else { 896 | input.removeAttribute('webkitdirectory'); 897 | } 898 | // When new files are added, simply append them to the overall list 899 | input.addEventListener('change', function(e){ 900 | appendFilesFromFileList(e.target.files,e); 901 | e.target.value = ''; 902 | }, false); 903 | }); 904 | }; 905 | $.assignDrop = function(domNodes){ 906 | if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; 907 | 908 | $h.each(domNodes, function(domNode) { 909 | domNode.addEventListener('dragover', preventDefault, false); 910 | domNode.addEventListener('dragenter', preventDefault, false); 911 | domNode.addEventListener('drop', onDrop, false); 912 | }); 913 | }; 914 | $.unAssignDrop = function(domNodes) { 915 | if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes]; 916 | 917 | $h.each(domNodes, function(domNode) { 918 | domNode.removeEventListener('dragover', preventDefault); 919 | domNode.removeEventListener('dragenter', preventDefault); 920 | domNode.removeEventListener('drop', onDrop); 921 | }); 922 | }; 923 | $.isUploading = function(){ 924 | var uploading = false; 925 | $h.each($.files, function(file){ 926 | if (file.isUploading()) { 927 | uploading = true; 928 | return(false); 929 | } 930 | }); 931 | return(uploading); 932 | }; 933 | $.upload = function(){ 934 | // Make sure we don't start too many uploads at once 935 | if($.isUploading()) return; 936 | // Kick off the queue 937 | $.fire('uploadStart'); 938 | for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) { 939 | $.uploadNextChunk(); 940 | } 941 | }; 942 | $.pause = function(){ 943 | // Resume all chunks currently being uploaded 944 | $h.each($.files, function(file){ 945 | file.abort(); 946 | }); 947 | $.fire('pause'); 948 | }; 949 | $.cancel = function(){ 950 | for(var i = $.files.length - 1; i >= 0; i--) { 951 | $.files[i].cancel(); 952 | } 953 | $.fire('cancel'); 954 | }; 955 | $.progress = function(){ 956 | var totalDone = 0; 957 | var totalSize = 0; 958 | // Resume all chunks currently being uploaded 959 | $h.each($.files, function(file){ 960 | totalDone += file.progress()*file.size; 961 | totalSize += file.size; 962 | }); 963 | return(totalSize>0 ? totalDone/totalSize : 0); 964 | }; 965 | $.addFile = function(file, event){ 966 | appendFilesFromFileList([file], event); 967 | }; 968 | $.removeFile = function(file){ 969 | for(var i = $.files.length - 1; i >= 0; i--) { 970 | if($.files[i] === file) { 971 | $.files.splice(i, 1); 972 | } 973 | } 974 | }; 975 | $.getFromUniqueIdentifier = function(uniqueIdentifier){ 976 | var ret = false; 977 | $h.each($.files, function(f){ 978 | if(f.uniqueIdentifier==uniqueIdentifier) ret = f; 979 | }); 980 | return(ret); 981 | }; 982 | $.getSize = function(){ 983 | var totalSize = 0; 984 | $h.each($.files, function(file){ 985 | totalSize += file.size; 986 | }); 987 | return(totalSize); 988 | }; 989 | 990 | return(this); 991 | }; 992 | 993 | 994 | // Node.js-style export for Node and Component 995 | if (typeof module != 'undefined') { 996 | module.exports = Resumable; 997 | } else if (typeof define === "function" && define.amd) { 998 | // AMD/requirejs: Define the module 999 | define(function(){ 1000 | return Resumable; 1001 | }); 1002 | } else { 1003 | // Browser: Expose to window 1004 | window.Resumable = Resumable; 1005 | } 1006 | 1007 | })(); 1008 | -------------------------------------------------------------------------------- /admin_resumable/templates/admin_resumable/file_input.html: -------------------------------------------------------------------------------- 1 | 46 |
47 |

48 | {% if value %} 49 | Currently: 50 | {% if file_url %} 51 | {{ file_url }} 52 | {% if show_thumb %} 53 | 54 | {% endif %} 55 | {% else %} 56 | {{ value }} 57 | {% endif %} 58 | {{ clear_checkbox }} 59 |
60 | Change: 61 | {% endif %} 62 | 63 | 64 | 65 |

66 | 67 |
68 | 69 | 70 | -------------------------------------------------------------------------------- /admin_resumable/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /admin_resumable/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^admin_resumable/$', views.admin_resumable, name='admin_resumable'), 7 | ] 8 | -------------------------------------------------------------------------------- /admin_resumable/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.core.files.storage import get_storage_class 6 | from django.contrib.admin.views.decorators import staff_member_required 7 | from django.contrib.contenttypes.models import ContentType 8 | from django.http import HttpResponse 9 | 10 | from admin_resumable.files import ResumableFile 11 | 12 | 13 | def ensure_dir(f): 14 | d = os.path.dirname(f) 15 | if not os.path.exists(d): 16 | os.makedirs(d) 17 | 18 | 19 | def get_chunks_subdir(): 20 | return getattr(settings, 'ADMIN_RESUMABLE_SUBDIR', 'admin_uploaded/') 21 | 22 | 23 | def get_chunks_dir(): 24 | chunks_subdir = get_chunks_subdir() 25 | media_root = getattr(settings, 'MEDIA_ROOT', None) 26 | if not media_root: 27 | raise ImproperlyConfigured( 28 | 'You must set settings.MEDIA_ROOT') 29 | chunks_dir = os.path.join(media_root, chunks_subdir) 30 | ensure_dir(chunks_dir) 31 | return chunks_dir 32 | 33 | 34 | def get_storage(upload_to): 35 | """ 36 | Looks at the ADMIN_RESUMABLE_STORAGE setting and returns 37 | an instance of the storage class specified. 38 | 39 | Defaults to django.core.files.storage.FileSystemStorage. 40 | 41 | Any custom storage class used here must either be a subclass of 42 | django.core.files.storage.FileSystemStorage, or accept a location 43 | init parameter. 44 | """ 45 | if upload_to: 46 | location = settings.MEDIA_ROOT + upload_to 47 | url_path = settings.MEDIA_URL + upload_to 48 | ensure_dir(location) 49 | else: 50 | url_path = settings.MEDIA_URL + get_chunks_subdir() 51 | location = get_chunks_dir() 52 | storage_class_name = getattr( 53 | settings, 54 | 'ADMIN_RESUMABLE_STORAGE', 55 | 'django.core.files.storage.FileSystemStorage' 56 | ) 57 | return get_storage_class(storage_class_name)( 58 | location=location, base_url=url_path) 59 | 60 | 61 | def get_upload_to(request): 62 | if request.method == 'POST': 63 | ct_id = request.POST['content_type_id'] 64 | field_name = request.POST['field_name'] 65 | else: 66 | ct_id = request.GET['content_type_id'] 67 | field_name = request.GET['field_name'] 68 | 69 | ct = ContentType.objects.get_for_id(ct_id) 70 | model_cls = ct.model_class() 71 | field = model_cls._meta.get_field(field_name) 72 | return field.orig_upload_to 73 | 74 | 75 | @staff_member_required 76 | def admin_resumable(request): 77 | upload_to = get_upload_to(request) 78 | storage = get_storage(upload_to) 79 | if request.method == 'POST': 80 | chunk = request.FILES.get('file') 81 | r = ResumableFile(storage, request.POST) 82 | if not r.chunk_exists: 83 | r.process_chunk(chunk) 84 | if r.is_complete: 85 | actual_filename = storage.save(r.filename, r.file) 86 | r.delete_chunks() 87 | return HttpResponse(storage.url(actual_filename)) 88 | return HttpResponse('chunk uploaded') 89 | elif request.method == 'GET': 90 | r = ResumableFile(storage, request.GET) 91 | if not r.chunk_exists: 92 | return HttpResponse('chunk not found', status=404) 93 | if r.is_complete: 94 | actual_filename = storage.save(r.filename, r.file) 95 | r.delete_chunks() 96 | return HttpResponse(storage.url(actual_filename)) 97 | return HttpResponse('chunk exists') 98 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonatron/django-admin-resumable-js/2f71fcc2076b551b3a3c0f3a531e199c21edbb59/screenshot.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read() 5 | 6 | # allow setup.py to be run from any path 7 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 8 | 9 | setup( 10 | name='django-admin-resumable-js', 11 | version='3.0', 12 | packages=['admin_resumable'], 13 | include_package_data=True, 14 | package_data={ 15 | 'admin_resumable': [ 16 | 'templates/admin_resumable/file_input.html', 17 | 'static/admin_resumable/js/resumable.js', 18 | ] 19 | }, 20 | license='MIT License', 21 | description='A Django app for the uploading of large files from the django admin site.', 22 | long_description=README, 23 | url='https://github.com/jonatron/django-admin-resumable-js', 24 | author='Jonatron', 25 | author_email='jon4tron@gmail.com', 26 | classifiers=[ 27 | 'Environment :: Web Environment', 28 | 'Framework :: Django', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: MIT License', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Topic :: Internet :: WWW/HTTP', 35 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 36 | ], 37 | ) 38 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonatron/django-admin-resumable-js/2f71fcc2076b551b3a3c0f3a531e199c21edbb59/tests/__init__.py -------------------------------------------------------------------------------- /tests/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Foo 3 | 4 | 5 | class FooAdmin(admin.ModelAdmin): 6 | pass 7 | 8 | 9 | admin.site.register(Foo, FooAdmin) 10 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import os 3 | from selenium import webdriver 4 | from pyvirtualdisplay import Display 5 | 6 | browsers = { 7 | 'firefox': webdriver.Firefox, 8 | #'PhantomJS': webdriver.PhantomJS, 9 | #'chrome': webdriver.Chrome, 10 | } 11 | 12 | 13 | @pytest.fixture(scope='session', 14 | params=browsers.keys()) 15 | def driver(request): 16 | display = Display(visible=0, size=(1024, 768)) 17 | display.start() 18 | b = browsers[request.param]() 19 | 20 | request.addfinalizer(lambda *args: b.quit()) 21 | 22 | return b 23 | 24 | 25 | def pytest_configure(): 26 | import django 27 | from django.conf import settings 28 | 29 | settings.configure( 30 | DEBUG=False, 31 | DEBUG_PROPAGATE_EXCEPTIONS=True, 32 | DATABASES={ 33 | 'default': { 34 | 'ENGINE': 'django.db.backends.sqlite3', 35 | 'NAME': ':memory:' 36 | } 37 | }, 38 | SITE_ID=1, 39 | SECRET_KEY='not very secret in tests', 40 | USE_I18N=True, 41 | USE_L10N=True, 42 | STATIC_URL='/static/', 43 | ROOT_URLCONF='tests.urls', 44 | TEMPLATE_LOADERS=( 45 | 'django.template.loaders.filesystem.Loader', 46 | 'django.template.loaders.app_directories.Loader', 47 | ), 48 | TEMPLATES=[ 49 | { 50 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 51 | 'DIRS': [ 52 | ], 53 | 'APP_DIRS': True, 54 | 'OPTIONS': { 55 | 'context_processors': [ 56 | 'django.contrib.auth.context_processors.auth', 57 | 'django.template.context_processors.debug', 58 | 'django.template.context_processors.i18n', 59 | 'django.template.context_processors.media', 60 | 'django.template.context_processors.static', 61 | 'django.template.context_processors.tz', 62 | 'django.contrib.messages.context_processors.messages', 63 | ], 64 | }, 65 | }, 66 | ], 67 | MIDDLEWARE=( 68 | 'django.middleware.common.CommonMiddleware', 69 | 'django.contrib.sessions.middleware.SessionMiddleware', 70 | 'django.middleware.csrf.CsrfViewMiddleware', 71 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 72 | 'django.contrib.messages.middleware.MessageMiddleware', 73 | ), 74 | INSTALLED_APPS=( 75 | 'django.contrib.admin', 76 | 'django.contrib.auth', 77 | 'django.contrib.contenttypes', 78 | 'django.contrib.sessions', 79 | 'django.contrib.sites', 80 | 'django.contrib.messages', 81 | 'django.contrib.staticfiles', 82 | 83 | 'admin_resumable', 84 | 'tests', 85 | ), 86 | PASSWORD_HASHERS=( 87 | 'django.contrib.auth.hashers.MD5PasswordHasher', 88 | ), 89 | MEDIA_ROOT=os.path.join(os.path.dirname(__file__), 'media') 90 | ) 91 | try: 92 | import django 93 | django.setup() 94 | except AttributeError: 95 | pass 96 | -------------------------------------------------------------------------------- /tests/media/hello.world: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | from admin_resumable.fields import ModelAdminResumableFileField 5 | 6 | 7 | # Stolen from the README 8 | class Foo(models.Model): 9 | bar = models.CharField(max_length=200) 10 | foo = ModelAdminResumableFileField() 11 | bat = ModelAdminResumableFileField(upload_to=settings.MEDIA_ROOT + '/upto/') 12 | -------------------------------------------------------------------------------- /tests/test_uploads.py: -------------------------------------------------------------------------------- 1 | from .models import Foo 2 | 3 | from django.test import client as client_module 4 | from django.conf import settings 5 | from django.contrib.contenttypes.models import ContentType 6 | 7 | from selenium.webdriver.common.by import By 8 | from selenium.webdriver.support.ui import WebDriverWait 9 | from selenium.webdriver.support import expected_conditions as EC 10 | 11 | import os 12 | import pytest 13 | import time 14 | 15 | 16 | def create_test_file(file_path, size_in_megabytes): 17 | with open(file_path, 'wb') as bigfile: 18 | bigfile.seek(size_in_megabytes * 1024 * 1024) 19 | bigfile.write(b'0') 20 | 21 | 22 | def clear_uploads(): 23 | upload_path = os.path.join(settings.MEDIA_ROOT, 'admin_uploaded') 24 | if not os.path.exists(upload_path): 25 | return 26 | for the_file in os.listdir(upload_path): 27 | file_path = os.path.join(upload_path, the_file) 28 | try: 29 | if os.path.isfile(file_path): 30 | os.unlink(file_path) 31 | except Exception as e: 32 | print(e) 33 | 34 | 35 | @pytest.mark.django_db 36 | def test_fake_file_upload(admin_user, admin_client): 37 | foo_ct = ContentType.objects.get_for_model(Foo) 38 | clear_uploads() 39 | 40 | payload = client_module.FakePayload() 41 | 42 | def form_value_list(key, value): 43 | return ['--' + client_module.BOUNDARY, 44 | 'Content-Disposition: form-data; name="%s"' % key, 45 | "", 46 | value] 47 | form_vals = [] 48 | file_data = 'foo bar foo bar.' 49 | file_size = str(len(file_data)) 50 | form_vals += form_value_list("resumableChunkNumber", "1") 51 | form_vals += form_value_list("resumableChunkSize", file_size) 52 | form_vals += form_value_list("resumableType", "text/plain") 53 | form_vals += form_value_list("resumableIdentifier", file_size + "-foobar") 54 | form_vals += form_value_list("resumableFilename", "foo.bar") 55 | form_vals += form_value_list("resumableTotalChunks", "1") 56 | form_vals += form_value_list("resumableTotalSize", file_size) 57 | form_vals += form_value_list("content_type_id", str(foo_ct.id)) 58 | form_vals += form_value_list("field_name", "foo") 59 | payload.write('\r\n'.join(form_vals + [ 60 | '--' + client_module.BOUNDARY, 61 | 'Content-Disposition: form-data; name="file"; filename=foo.bar', 62 | 'Content-Type: application/octet-stream', 63 | '', 64 | file_data, 65 | '--' + client_module.BOUNDARY + '--\r\n' 66 | ])) 67 | 68 | r = { 69 | 'CONTENT_LENGTH': len(payload), 70 | 'CONTENT_TYPE': client_module.MULTIPART_CONTENT, 71 | 'PATH_INFO': "/admin_resumable/admin_resumable/", 72 | 'REQUEST_METHOD': 'POST', 73 | 'wsgi.input': payload, 74 | } 75 | response = admin_client.request(**r) 76 | assert response.status_code == 200 77 | upload_filename = file_size + "_foo.bar" 78 | upload_path = os.path.join(settings.MEDIA_ROOT, 79 | 'admin_uploaded', 80 | upload_filename 81 | ) 82 | f = open(upload_path, 'r') 83 | uploaded_contents = f.read() 84 | assert file_data == uploaded_contents 85 | 86 | 87 | @pytest.mark.django_db 88 | def test_fake_file_upload_incomplete_chunk(admin_user, admin_client): 89 | foo_ct = ContentType.objects.get_for_model(Foo) 90 | clear_uploads() 91 | 92 | payload = client_module.FakePayload() 93 | 94 | def form_value_list(key, value): 95 | return ['--' + client_module.BOUNDARY, 96 | 'Content-Disposition: form-data; name="%s"' % key, 97 | "", 98 | value] 99 | form_vals = [] 100 | file_data = 'foo bar foo bar.' 101 | file_size = str(len(file_data)) 102 | form_vals += form_value_list("resumableChunkNumber", "1") 103 | form_vals += form_value_list("resumableChunkSize", "3") 104 | form_vals += form_value_list("resumableType", "text/plain") 105 | form_vals += form_value_list("resumableIdentifier", file_size + "-foobar") 106 | form_vals += form_value_list("resumableFilename", "foo.bar") 107 | form_vals += form_value_list("resumableTotalChunks", "6") 108 | form_vals += form_value_list("resumableTotalSize", file_size) 109 | form_vals += form_value_list("content_type_id", str(foo_ct.id)) 110 | form_vals += form_value_list("field_name", "foo") 111 | payload.write('\r\n'.join(form_vals + [ 112 | '--' + client_module.BOUNDARY, 113 | 'Content-Disposition: form-data; name="file"; filename=foo.bar', 114 | 'Content-Type: application/octet-stream', 115 | '', 116 | file_data[0:1], 117 | # missing final boundary to simulate failure 118 | ])) 119 | 120 | r = { 121 | 'CONTENT_LENGTH': len(payload), 122 | 'CONTENT_TYPE': client_module.MULTIPART_CONTENT, 123 | 'PATH_INFO': "/admin_resumable/admin_resumable/", 124 | 'REQUEST_METHOD': 'POST', 125 | 'wsgi.input': payload, 126 | } 127 | try: 128 | admin_client.request(**r) 129 | except AttributeError: 130 | pass # we're not worried that this would 500 131 | 132 | get_url = "/admin_resumable/admin_resumable/?" 133 | get_args = { 134 | 'resumableChunkNumber': '1', 135 | 'resumableChunkSize': '3', 136 | 'resumableCurrentChunkSize': '3', 137 | 'resumableTotalSize': file_size, 138 | 'resumableType': "text/plain", 139 | 'resumableIdentifier': file_size + "-foobar", 140 | 'resumableFilename': "foo.bar", 141 | 'resumableRelativePath': "foo.bar", 142 | 'content_type_id': str(foo_ct.id), 143 | 'field_name': "foo", 144 | } 145 | 146 | # we need a fresh client because client.request breaks things 147 | fresh_client = client_module.Client() 148 | fresh_client.login(username=admin_user.username, password='password') 149 | get_response = fresh_client.get(get_url, get_args) 150 | # should be a 404 because we uploaded an incomplete chunk 151 | assert get_response.status_code == 404 152 | 153 | 154 | @pytest.mark.django_db 155 | def test_real_file_upload(admin_user, live_server, driver): 156 | test_file_path = "/tmp/test_small_file.bin" 157 | create_test_file(test_file_path, 5) 158 | 159 | driver.get(live_server.url + '/admin/') 160 | driver.find_element_by_id('id_username').send_keys("admin") 161 | driver.find_element_by_id("id_password").send_keys("password") 162 | driver.find_element_by_xpath('//input[@value="Log in"]').click() 163 | driver.implicitly_wait(2) 164 | driver.get(live_server.url + '/admin/tests/foo/add/') 165 | WebDriverWait(driver, 10).until( 166 | EC.presence_of_element_located((By.ID, "id_bar")) 167 | ) 168 | driver.find_element_by_id("id_bar").send_keys("bat") 169 | driver.find_element_by_id( 170 | 'id_foo_input_file').send_keys(test_file_path) 171 | i = 0 172 | while i < 5: 173 | status_text = driver.find_element_by_id("id_foo_uploaded_status").text 174 | print("status_text", status_text) 175 | if "Uploaded" in status_text: 176 | return # success 177 | time.sleep(1) 178 | i += 1 179 | raise Exception # something went wrong 180 | 181 | 182 | @pytest.mark.django_db 183 | def test_real_file_upload_with_upload_to(admin_user, live_server, driver): 184 | test_file_path = "/tmp/test_[small]_file.bin" 185 | create_test_file(test_file_path, 5) 186 | 187 | driver.get(live_server.url + '/admin/') 188 | driver.find_element_by_id('id_username').send_keys("admin") 189 | driver.find_element_by_id("id_password").send_keys("password") 190 | driver.find_element_by_xpath('//input[@value="Log in"]').click() 191 | driver.implicitly_wait(2) 192 | driver.get(live_server.url + '/admin/tests/foo/add/') 193 | WebDriverWait(driver, 10).until( 194 | EC.presence_of_element_located((By.ID, "id_bar")) 195 | ) 196 | driver.find_element_by_id("id_bar").send_keys("bat") 197 | driver.find_element_by_id( 198 | 'id_bat_input_file').send_keys(test_file_path) 199 | i = 0 200 | while i < 5: 201 | status_text = driver.find_element_by_id("id_bat_uploaded_status").text 202 | print("status_text", status_text) 203 | if "Uploaded" in status_text and "5242881_test_" in status_text: 204 | return # success 205 | time.sleep(1) 206 | i += 1 207 | raise Exception # something went wrong 208 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.conf.urls import include, url 3 | from django.contrib import admin 4 | from django.conf import settings 5 | 6 | 7 | urlpatterns = [ 8 | url(r'^admin_resumable/', include('admin_resumable.urls')), 9 | url(r'^admin/', admin.site.urls), 10 | ] 11 | 12 | 13 | if settings.DEBUG: 14 | # static files (images, css, javascript, etc.) 15 | urlpatterns += [ 16 | (r'^media/(?P.*)$', django.views.static.serve, 17 | {'document_root': settings.MEDIA_ROOT}) 18 | ] 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py27,py35,py36,py37}-django{1.11,2.0,2.1} 4 | 5 | [testenv] 6 | # passenv required for local testing visible display 7 | passenv = * 8 | #rsx = report all errors, -s = capture=no, -x = fail fast, --pdb for local testing http://www.linuxcertif.com/man/1/py.test/ 9 | commands = py.test -rsx -s -x 10 | setenv = 11 | PYTHONDONTWRITEBYTECODE=1 12 | deps = 13 | django1.11: Django==1.11.14 14 | django2.0: Django==2.0.7 15 | django2.1: Django==2.1 16 | pytest-django==3.1.2 17 | selenium==3.13.0 18 | pyvirtualdisplay 19 | --------------------------------------------------------------------------------