├── .DS_Store ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── admin_async_upload ├── __init__.py ├── fields.py ├── files.py ├── models.py ├── static │ └── admin_resumable │ │ └── js │ │ └── resumable.js ├── storage.py ├── templates │ └── admin_resumable │ │ ├── admin_file_input.html │ │ └── user_file_input.html ├── urls.py ├── validators.py ├── views.py └── widgets.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 /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataGreed/django-admin-async-upload/d9f0ea2698dbbfaf53edc1c6002f702322dabadc/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # * 2 | !/admin_resumable/** 3 | !/tests/** 4 | /tests/media/ 5 | !/* 6 | !/admin_sync_upload/** 7 | 8 | # *.py[cod] 9 | *.pyc 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Packages 15 | *.egg 16 | *.egg-info 17 | dist 18 | build 19 | eggs 20 | parts 21 | bin 22 | var 23 | sdist 24 | develop-eggs 25 | .installed.cfg 26 | lib 27 | lib64 28 | __pycache__ 29 | 30 | # Installer logs 31 | pip-log.txt 32 | 33 | # Unit test / coverage reports 34 | .coverage 35 | .tox 36 | nosetests.xml 37 | 38 | # Translations 39 | *.mo 40 | 41 | # Mr Developer 42 | .mr.developer.cfg 43 | .project 44 | .pydevproject 45 | 46 | *.log 47 | .venv 48 | .idea 49 | .DS_Store 50 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.5" 5 | 6 | sudo: false 7 | 8 | env: 9 | - TOX_ENV=py27-django1.8 10 | - TOX_ENV=py27-django1.9 11 | - TOX_ENV=py27-django1.10 12 | - TOX_ENV=py27-django1.11 13 | - TOX_ENV=py34-django1.8 14 | - TOX_ENV=py34-django1.9 15 | - TOX_ENV=py34-django1.10 16 | - TOX_ENV=py35-django1.8 17 | - TOX_ENV=py35-django1.9 18 | - TOX_ENV=py35-django1.10 19 | - TOX_ENV=py35-django1.11 20 | 21 | matrix: 22 | fast_finish: true 23 | 24 | before_install: 25 | - "export DISPLAY=:99.0" 26 | - "sh -e /etc/init.d/xvfb start" 27 | - sleep 6 # give xvfb some time to start 28 | 29 | install: 30 | - pip install tox 31 | - pip install "virtualenv<14.0.0" 32 | 33 | script: 34 | - tox -e $TOX_ENV 35 | -------------------------------------------------------------------------------- /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-async-upload 2 | =============================== 3 | 4 | .. image:: https://api.travis-ci.org/jonatron/django-admin-resumable-js.svg?branch=master 5 | :target: https://travis-ci.org/jonatron/django-admin-resumable-js 6 | 7 | django-admin-async-upload is a django app to allow you to upload large files from within the django admin site asynchrously (using ajax), that means that you can add any number of files on the admin page (e.g. through inline models) and continue editing other fields while files are uploading. 8 | 9 | django-admin-async-file-uploads is compatible with django-storages (tested with S3Storage) 10 | 11 | 12 | Screenshot 13 | ---------- 14 | 15 | #TODO: update this screenshot 16 | 17 | .. image:: https://github.com/jonatron/django-admin-resumable-js/raw/master/screenshot.png?raw=true 18 | 19 | 20 | Installation 21 | ------------ 22 | 23 | * pip install django-admin-async-upload 24 | * Add ``admin_async_upload`` to your ``INSTALLED_APPS`` 25 | * Add ``url(r'^admin_async_upload/', include('admin_async_upload.urls')),`` to your urls.py 26 | * Add a model field eg: ``from admin_resumable.models import ResumableFileField`` 27 | 28 | :: 29 | 30 | class Foo(models.Model): 31 | bar = models.CharField(max_length=200) 32 | foo = AsyncFileField() 33 | 34 | 35 | 36 | Optionally: 37 | 38 | * Set ``ADMIN_RESUMABLE_CHUNKSIZE``, default is ``"1*1024*1024"`` 39 | * Set ``ADMIN_RESUMABLE_STORAGE``, default is setting of DEFAULT_FILE_STORAGE and ultimately ``'django.core.files.storage.FileSystemStorage'``. 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/ 40 | * Set ``ADMIN_RESUMABLE_CHUNK_STORAGE``, default is ``'django.core.files.storage.FileSystemStorage'`` . 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/ 41 | * Set ``ADMIN_RESUMABLE_SHOW_THUMB``, default is False. Shows a thumbnail next to the "Currently:" link. 42 | * Set ``ADMIN_SIMULTANEOUS_UPLOADS`` to limit number of simulteneous uploads, dedaults to `3`. If you have broken pipe issues in local development environment, set this value to `1`. 43 | 44 | 45 | Versions 46 | -------- 47 | 48 | 49 | 50 | 51 | 52 | Compatibility 53 | ------------- 54 | 55 | Tested on Django 2.2 running on python 3.6 and 3.7 56 | 57 | Thanks to 58 | --------- 59 | 60 | original django-admin-resumable-js by jonatron https://github.com/jonatron/django-admin-resumable-js 61 | 62 | django-admin-resumable-js fork by roxel https://github.com/roxel/django-admin-resumable-js (django-admin-async-upload is based on this fork 63 | 64 | Resumable.js https://github.com/23/resumable.js 65 | 66 | django-resumable https://github.com/jeanphix/django-resumable 67 | 68 | 69 | -------------------------------------------------------------------------------- /admin_async_upload/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataGreed/django-admin-async-upload/d9f0ea2698dbbfaf53edc1c6002f702322dabadc/admin_async_upload/__init__.py -------------------------------------------------------------------------------- /admin_async_upload/fields.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.forms import fields 3 | 4 | from admin_async_upload.widgets import ResumableAdminWidget 5 | 6 | 7 | class FormResumableFileField(fields.FileField): 8 | widget = ResumableAdminWidget 9 | 10 | def to_python(self, data): 11 | if self.required: 12 | if not data or data == "None": 13 | raise ValidationError(self.error_messages['empty']) 14 | return data 15 | -------------------------------------------------------------------------------- /admin_async_upload/files.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import fnmatch 3 | import tempfile 4 | 5 | from django.core.files import File 6 | from django.utils.functional import cached_property 7 | 8 | from admin_async_upload.storage import ResumableStorage 9 | 10 | 11 | class ResumableFile(object): 12 | """ 13 | Handles file saving and processing. 14 | It must only have access to chunk storage where it saves file chunks. 15 | When all chunks are uploaded it collects and merges them returning temporary file pointer 16 | that can be used to save the complete file to persistent storage. 17 | 18 | Chunk storage should preferably be some local storage to avoid traffic 19 | as files usually must be downloaded to server as chunks and re-uploaded as complete files. 20 | """ 21 | 22 | def __init__(self, field, user, params): 23 | self.field = field 24 | self.user = user 25 | self.params = params 26 | self.chunk_suffix = "_part_" 27 | 28 | @cached_property 29 | def resumable_storage(self): 30 | return ResumableStorage() 31 | 32 | @cached_property 33 | def persistent_storage(self): 34 | return self.resumable_storage.get_persistent_storage() 35 | 36 | @cached_property 37 | def chunk_storage(self): 38 | return ResumableStorage().get_chunk_storage() 39 | 40 | @property 41 | def storage_filename(self): 42 | return self.resumable_storage.full_filename(self.filename, self.upload_to) 43 | 44 | @property 45 | def upload_to(self): 46 | return self.field.upload_to 47 | 48 | @property 49 | def chunk_exists(self): 50 | """ 51 | Checks if the requested chunk exists. 52 | """ 53 | return self.chunk_storage.exists(self.current_chunk_name) and \ 54 | self.chunk_storage.size(self.current_chunk_name) == int(self.params.get('resumableCurrentChunkSize')) 55 | 56 | @property 57 | def chunk_names(self): 58 | """ 59 | Iterates over all stored chunks. 60 | """ 61 | chunks = [] 62 | files = sorted(self.chunk_storage.listdir('')[1]) 63 | for file in files: 64 | if fnmatch.fnmatch(file, '%s%s*' % (self.filename, 65 | self.chunk_suffix)): 66 | chunks.append(file) 67 | return chunks 68 | 69 | @property 70 | def current_chunk_name(self): 71 | # TODO: add user identifier to chunk name 72 | return "%s%s%s" % ( 73 | self.filename, 74 | self.chunk_suffix, 75 | self.params.get('resumableChunkNumber').zfill(4) 76 | ) 77 | 78 | def chunks(self): 79 | """ 80 | Iterates over all stored chunks. 81 | """ 82 | # TODO: add user identifier to chunk name 83 | files = sorted(self.chunk_storage.listdir('')[1]) 84 | for file in files: 85 | if fnmatch.fnmatch(file, '%s%s*' % (self.filename, 86 | self.chunk_suffix)): 87 | yield self.chunk_storage.open(file, 'rb').read() 88 | 89 | def delete_chunks(self): 90 | [self.chunk_storage.delete(chunk) for chunk in self.chunk_names] 91 | 92 | @property 93 | def file(self): 94 | """ 95 | Merges file and returns its file pointer. 96 | """ 97 | if not self.is_complete: 98 | raise Exception('Chunk(s) still missing') 99 | outfile = tempfile.NamedTemporaryFile("w+b") 100 | for chunk in self.chunk_names: 101 | outfile.write(self.chunk_storage.open(chunk).read()) 102 | return outfile 103 | 104 | @property 105 | def filename(self): 106 | """ 107 | Gets the filename. 108 | """ 109 | # TODO: add user identifier to chunk name 110 | filename = self.params.get('resumableFilename') 111 | if '/' in filename: 112 | raise Exception('Invalid filename') 113 | value = "%s_%s" % (self.params.get('resumableTotalSize'), filename) 114 | return value 115 | 116 | @property 117 | def is_complete(self): 118 | """ 119 | Checks if all chunks are already stored. 120 | """ 121 | return int(self.params.get('resumableTotalSize')) == self.size 122 | 123 | def process_chunk(self, file): 124 | """ 125 | Saves chunk to chunk storage. 126 | """ 127 | if self.chunk_storage.exists(self.current_chunk_name): 128 | self.chunk_storage.delete(self.current_chunk_name) 129 | self.chunk_storage.save(self.current_chunk_name, file) 130 | 131 | @property 132 | def size(self): 133 | """ 134 | Gets size of all chunks combined. 135 | """ 136 | size = 0 137 | for chunk in self.chunk_names: 138 | size += self.chunk_storage.size(chunk) 139 | return size 140 | 141 | def collect(self): 142 | actual_filename = self.persistent_storage.save(self.storage_filename, File(self.file)) 143 | self.delete_chunks() 144 | return actual_filename 145 | -------------------------------------------------------------------------------- /admin_async_upload/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from admin_async_upload.widgets import ResumableAdminWidget 3 | from admin_async_upload.fields import FormResumableFileField 4 | 5 | 6 | class AsyncFileField(models.FileField): 7 | 8 | def formfield(self, **kwargs): 9 | defaults = {'form_class': FormResumableFileField} 10 | if self.model and self.name: 11 | defaults['widget'] = ResumableAdminWidget(attrs={ 12 | 'model': self.model, 13 | 'field_name': self.name}) 14 | kwargs.update(defaults) 15 | return super(AsyncFileField, self).formfield(**kwargs) 16 | -------------------------------------------------------------------------------- /admin_async_upload/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 | 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', $, message); 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 | })(); -------------------------------------------------------------------------------- /admin_async_upload/storage.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import posixpath 4 | from django.core.files.storage import get_storage_class 5 | 6 | from django.conf import settings 7 | from django.utils.encoding import force_text, force_str 8 | 9 | 10 | class ResumableStorage(object): 11 | 12 | def __init__(self): 13 | self.persistent_storage_class_name = getattr(settings, 'ADMIN_RESUMABLE_STORAGE', None) or \ 14 | getattr(settings, 'DEFAULT_FILE_STORAGE', 15 | 'django.core.files.storage.FileSystemStorage') 16 | 17 | self.chunk_storage_class_name = getattr( 18 | settings, 19 | 'ADMIN_RESUMABLE_CHUNK_STORAGE', 20 | 'django.core.files.storage.FileSystemStorage' 21 | ) 22 | 23 | def get_chunk_storage(self, *args, **kwargs): 24 | """ 25 | Returns storage class specified in settings as ADMIN_RESUMABLE_CHUNK_STORAGE. 26 | Defaults to django.core.files.storage.FileSystemStorage. 27 | Chunk storage should be highly available for the server as saved chunks must be copied by the server 28 | for saving merged version in persistent storage. 29 | """ 30 | storage_class = get_storage_class(self.chunk_storage_class_name) 31 | return storage_class(*args, **kwargs) 32 | 33 | def get_persistent_storage(self, *args, **kwargs): 34 | """ 35 | Returns storage class specified in settings as ADMIN_RESUMABLE_STORAGE 36 | or DEFAULT_FILE_STORAGE if the former is not found. 37 | 38 | Defaults to django.core.files.storage.FileSystemStorage. 39 | """ 40 | storage_class = get_storage_class(self.persistent_storage_class_name) 41 | return storage_class(*args, **kwargs) 42 | 43 | def full_filename(self, filename, upload_to): 44 | dirname = force_text(datetime.datetime.now().strftime(force_str(upload_to))) 45 | filename = posixpath.join(dirname, filename) 46 | return self.get_persistent_storage().generate_filename(filename) 47 | -------------------------------------------------------------------------------- /admin_async_upload/templates/admin_resumable/admin_file_input.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 118 |
119 |

120 | {% if value %} 121 | {% trans 'Currently' %}: 122 | {% if file_url %} 123 | {{ file_name }} 124 | {% if show_thumb %} 125 | 126 | {% endif %} 127 | {% else %} 128 | {{ value }} 129 | {% endif %} 130 | {{ clear_checkbox }} 131 |
132 | {% trans 'Change' %}: 133 | {% endif %} 134 | 135 | 136 | 137 |

138 | 139 |
140 | 141 | 142 | -------------------------------------------------------------------------------- /admin_async_upload/templates/admin_resumable/user_file_input.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 49 |
50 |

51 | {% if value %} 52 | {% trans 'Change' %}: 53 | {% endif %} 54 | 55 | 56 | 57 |

58 | 59 |
60 | 61 | 62 | -------------------------------------------------------------------------------- /admin_async_upload/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^upload/$', views.admin_resumable, name='admin_resumable'), 7 | ] 8 | -------------------------------------------------------------------------------- /admin_async_upload/validators.py: -------------------------------------------------------------------------------- 1 | from admin_async_upload.storage import ResumableStorage 2 | from os.path import splitext 3 | from django.core.exceptions import ValidationError 4 | from django.utils.translation import ugettext_lazy as _ 5 | 6 | 7 | class StorageFileValidator(object): 8 | """ 9 | Validation of uploaded files. 10 | 11 | Files uploaded using the library are passed to application with their names only. 12 | Any validation must happen either on the client-side or requires upload to be completed 13 | and file saved in application storage. 14 | """ 15 | messages = { 16 | 'file': _(u"File {name} does not exist."), 17 | 'extension': _(u"Extension {extension} not allowed. Allowed extensions are: {allowed_extensions}"), 18 | 'min_size': _(u"File {name} too small ({size} bytes). The minimum file size is {min_size} bytes."), 19 | 'max_size': _(u"File {name} too large ({size} bytes). The maximum file size is {max_size} bytes."), 20 | } 21 | 22 | def __init__(self, min_size=0, max_size=None, allowed_extensions=None): 23 | self.min_size = min_size 24 | self.max_size = max_size 25 | self.allowed_extensions = allowed_extensions or [] 26 | 27 | def get_storage(self): 28 | return ResumableStorage().get_persistent_storage() 29 | 30 | def validate_extension(self, value): 31 | ext = splitext(value)[1].lower() 32 | if self.allowed_extensions and ext not in self.allowed_extensions: 33 | message = self.messages['extension'].format(**{ 34 | 'extension': ext, 35 | 'allowed_extensions': ', '.join(self.allowed_extensions) 36 | }) 37 | raise ValidationError(message) 38 | 39 | def validate_exists(self, value, storage): 40 | if not storage.exists(value): 41 | message = self.messages['file'].format(**{ 42 | 'name': value, 43 | }) 44 | raise ValidationError(message) 45 | 46 | def validate_size(self, value, storage): 47 | size = storage.size(value) 48 | if size > self.max_size: 49 | message = self.messages['max_size'].format(**{ 50 | 'name': value, 51 | 'size': size, 52 | 'max_size': self.max_size, 53 | }) 54 | raise ValidationError(message) 55 | elif size < self.min_size: 56 | message = self.messages['min_size'].format(**{ 57 | 'name': value, 58 | 'size': size, 59 | 'min_size': self.min_size, 60 | }) 61 | raise ValidationError(message) 62 | 63 | def __call__(self, value): 64 | assert type(value) == unicode 65 | storage = self.get_storage() 66 | self.validate_exists(value, storage) 67 | self.validate_extension(value) 68 | self.validate_size(value, storage) 69 | -------------------------------------------------------------------------------- /admin_async_upload/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.http import HttpResponse 4 | from django.utils.functional import cached_property 5 | from django.views.generic import View 6 | from admin_async_upload.files import ResumableFile 7 | 8 | 9 | class UploadView(View): 10 | # inspired by another fork https://github.com/fdemmer/django-admin-resumable-js 11 | 12 | @cached_property 13 | def request_data(self): 14 | return getattr(self.request, self.request.method) 15 | 16 | @cached_property 17 | def model_upload_field(self): 18 | content_type = ContentType.objects.get_for_id(self.request_data['content_type_id']) 19 | return content_type.model_class()._meta.get_field(self.request_data['field_name']) 20 | 21 | def post(self, request, *args, **kwargs): 22 | chunk = request.FILES.get('file') 23 | r = ResumableFile(self.model_upload_field, user=request.user, params=request.POST) 24 | if not r.chunk_exists: 25 | r.process_chunk(chunk) 26 | if r.is_complete: 27 | return HttpResponse(r.collect()) 28 | return HttpResponse('chunk uploaded') 29 | 30 | def get(self, request, *args, **kwargs): 31 | r = ResumableFile(self.model_upload_field, user=request.user, params=request.GET) 32 | if not r.chunk_exists: 33 | return HttpResponse('chunk not found', status=404) 34 | if r.is_complete: 35 | return HttpResponse(r.collect()) 36 | return HttpResponse('chunk exists') 37 | 38 | 39 | admin_resumable = login_required(UploadView.as_view()) 40 | -------------------------------------------------------------------------------- /admin_async_upload/widgets.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db.models.fields.files import FieldFile 4 | from django.forms import FileInput, CheckboxInput, forms 5 | from django.template import loader 6 | from django.templatetags.static import static 7 | from django.utils.safestring import mark_safe 8 | from django.utils.translation import ugettext_lazy 9 | 10 | from admin_async_upload.storage import ResumableStorage 11 | 12 | 13 | class ResumableBaseWidget(FileInput): 14 | template_name = 'admin_resumable/admin_file_input.html' 15 | clear_checkbox_label = ugettext_lazy('Clear') 16 | 17 | def render(self, name, value, attrs=None, **kwargs): 18 | persistent_storage = ResumableStorage().get_persistent_storage() 19 | if value: 20 | if isinstance(value, FieldFile): 21 | value_name = value.name 22 | else: 23 | value_name = value 24 | file_name = value 25 | file_url = mark_safe(persistent_storage.url(value_name)) 26 | 27 | else: 28 | file_name = "" 29 | file_url = "" 30 | 31 | chunk_size = getattr(settings, 'ADMIN_RESUMABLE_CHUNKSIZE', "1*1024*1024") 32 | show_thumb = getattr(settings, 'ADMIN_RESUMABLE_SHOW_THUMB', False) 33 | simultaneous_uploads = getattr(settings, 'ADMIN_SIMULTANEOUS_UPLOADS', 3) 34 | 35 | content_type_id = ContentType.objects.get_for_model(self.attrs['model']).id 36 | 37 | context = { 38 | 'name': name, 39 | 'value': value, 40 | 'id': attrs['id'], 41 | 'chunk_size': chunk_size, 42 | 'show_thumb': show_thumb, 43 | 'field_name': self.attrs['field_name'], 44 | 'content_type_id': content_type_id, 45 | 'file_url': file_url, 46 | 'file_name': file_name, 47 | 'simultaneous_uploads': simultaneous_uploads, 48 | } 49 | 50 | if not self.is_required: 51 | template_with_clear = '%(clear)s ' \ 52 | '' 53 | substitutions = { 54 | 'clear_checkbox_id': attrs['id'] + "-clear-id", 55 | 'clear_checkbox_name': attrs['id'] + "-clear", 56 | 'clear_checkbox_label': self.clear_checkbox_label 57 | } 58 | substitutions['clear'] = CheckboxInput().render( 59 | substitutions['clear_checkbox_name'], 60 | False, 61 | attrs={'id': substitutions['clear_checkbox_id']} 62 | ) 63 | clear_checkbox = mark_safe(template_with_clear % substitutions) 64 | context.update({'clear_checkbox': clear_checkbox}) 65 | return loader.render_to_string(self.template_name, context) 66 | 67 | def value_from_datadict(self, data, files, name): 68 | if not self.is_required and data.get("id_" + name + "-clear"): 69 | return False # False signals to clear any existing value, as opposed to just None 70 | if data.get(name, None) in ['None', 'False']: 71 | return None 72 | return data.get(name, None) 73 | 74 | 75 | class ResumableAdminWidget(ResumableBaseWidget): 76 | @property 77 | def media(self): 78 | js = ["resumable.js"] 79 | return forms.Media(js=[static("admin_resumable/js/%s" % path) for path in js]) 80 | 81 | 82 | class ResumableWidget(ResumableBaseWidget): 83 | template_name = 'admin_resumable/user_file_input.html' 84 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataGreed/django-admin-async-upload/d9f0ea2698dbbfaf53edc1c6002f702322dabadc/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-async-upload', 11 | version='3.0.4', 12 | packages=['admin_async_upload'], 13 | include_package_data=True, 14 | package_data={ 15 | 'admin_async_upload': [ 16 | 'templates/admin_resumable/admin_file_input.html', 17 | 'templates/admin_resumable/user_file_input.html', 18 | 'static/admin_resumable/js/resumable.js', 19 | ] 20 | }, 21 | license='MIT License', 22 | description='A Django app for the uploading of large files from the django admin site.', 23 | long_description=README, 24 | url='https://github.com/jonatron/django-admin-resumable-js', 25 | author='Alexey "DataGreed" Strelkov', 26 | author_email='datagreed@gmail.com', 27 | classifiers=[ 28 | 'Environment :: Web Environment', 29 | 'Framework :: Django', 30 | 'Intended Audience :: Developers', 31 | 'License :: OSI Approved :: MIT License', 32 | 'Operating System :: OS Independent', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 2.7', 35 | 'Topic :: Internet :: WWW/HTTP', 36 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 37 | ], 38 | install_requires=[ 39 | 'Django>=1.8', 40 | ], 41 | tests_require=[ 42 | 'pytest-django', 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DataGreed/django-admin-async-upload/d9f0ea2698dbbfaf53edc1c6002f702322dabadc/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 | admin.site.register(Foo, FooAdmin) 9 | -------------------------------------------------------------------------------- /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_CLASSES=( 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_async_upload', 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_async_upload.models import AsyncFileField 5 | 6 | 7 | # Stolen from the README 8 | class Foo(models.Model): 9 | bar = models.CharField(max_length=200) 10 | foo = AsyncFileField() 11 | bat = AsyncFileField(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/upload/", 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 | upload_filename 80 | ) 81 | f = open(upload_path, 'r') 82 | uploaded_contents = f.read() 83 | assert file_data == uploaded_contents 84 | 85 | 86 | @pytest.mark.django_db 87 | def test_fake_file_upload_incomplete_chunk(admin_user, admin_client): 88 | foo_ct = ContentType.objects.get_for_model(Foo) 89 | clear_uploads() 90 | 91 | payload = client_module.FakePayload() 92 | 93 | def form_value_list(key, value): 94 | return ['--' + client_module.BOUNDARY, 95 | 'Content-Disposition: form-data; name="%s"' % key, 96 | "", 97 | value] 98 | form_vals = [] 99 | file_data = 'foo bar foo bar.' 100 | file_size = str(len(file_data)) 101 | form_vals += form_value_list("resumableChunkNumber", "1") 102 | form_vals += form_value_list("resumableChunkSize", "3") 103 | form_vals += form_value_list("resumableType", "text/plain") 104 | form_vals += form_value_list("resumableIdentifier", file_size + "-foobar") 105 | form_vals += form_value_list("resumableFilename", "foo.bar") 106 | form_vals += form_value_list("resumableTotalChunks", "6") 107 | form_vals += form_value_list("resumableTotalSize", file_size) 108 | form_vals += form_value_list("content_type_id", str(foo_ct.id)) 109 | form_vals += form_value_list("field_name", "foo") 110 | payload.write('\r\n'.join(form_vals + [ 111 | '--' + client_module.BOUNDARY, 112 | 'Content-Disposition: form-data; name="file"; filename=foo.bar', 113 | 'Content-Type: application/octet-stream', 114 | '', 115 | file_data[0:1], 116 | # missing final boundary to simulate failure 117 | ])) 118 | 119 | r = { 120 | 'CONTENT_LENGTH': len(payload), 121 | 'CONTENT_TYPE': client_module.MULTIPART_CONTENT, 122 | 'PATH_INFO': "/admin_resumable/admin_resumable/", 123 | 'REQUEST_METHOD': 'POST', 124 | 'wsgi.input': payload, 125 | } 126 | try: 127 | admin_client.request(**r) 128 | except AttributeError: 129 | pass # we're not worried that this would 500 130 | 131 | get_url = "/admin_resumable/admin_resumable/?" 132 | get_args = { 133 | 'resumableChunkNumber': '1', 134 | 'resumableChunkSize': '3', 135 | 'resumableCurrentChunkSize': '3', 136 | 'resumableTotalSize': file_size, 137 | 'resumableType': "text/plain", 138 | 'resumableIdentifier': file_size + "-foobar", 139 | 'resumableFilename': "foo.bar", 140 | 'resumableRelativePath': "foo.bar", 141 | 'content_type_id': str(foo_ct.id), 142 | 'field_name': "foo", 143 | } 144 | 145 | # we need a fresh client because client.request breaks things 146 | fresh_client = client_module.Client() 147 | fresh_client.login(username=admin_user.username, password='password') 148 | get_response = fresh_client.get(get_url, get_args) 149 | # should be a 404 because we uploaded an incomplete chunk 150 | assert get_response.status_code == 404 151 | 152 | 153 | @pytest.mark.django_db 154 | def test_real_file_upload(admin_user, live_server, driver): 155 | test_file_path = "/tmp/test_small_file.bin" 156 | create_test_file(test_file_path, 5) 157 | 158 | driver.get(live_server.url + '/admin/') 159 | driver.find_element_by_id('id_username').send_keys("admin") 160 | driver.find_element_by_id("id_password").send_keys("password") 161 | driver.find_element_by_xpath('//input[@value="Log in"]').click() 162 | driver.implicitly_wait(2) 163 | driver.get(live_server.url + '/admin/tests/foo/add/') 164 | WebDriverWait(driver, 10).until( 165 | EC.presence_of_element_located((By.ID, "id_bar")) 166 | ) 167 | driver.find_element_by_id("id_bar").send_keys("bat") 168 | driver.find_element_by_id( 169 | 'id_foo_input_file').send_keys(test_file_path) 170 | status_text = driver.find_element_by_id("id_foo_uploaded_status").text 171 | print("status_text", status_text) 172 | i = 0 173 | while i < 5: 174 | if "Uploaded" in status_text: 175 | return # success 176 | time.sleep(1) 177 | i += 1 178 | raise Exception # something went wrong 179 | 180 | 181 | @pytest.mark.django_db 182 | def test_real_file_upload_with_upload_to(admin_user, live_server, driver): 183 | test_file_path = "/tmp/test_small_file.bin" 184 | create_test_file(test_file_path, 5) 185 | 186 | driver.get(live_server.url + '/admin/') 187 | driver.find_element_by_id('id_username').send_keys("admin") 188 | driver.find_element_by_id("id_password").send_keys("password") 189 | driver.find_element_by_xpath('//input[@value="Log in"]').click() 190 | driver.implicitly_wait(2) 191 | driver.get(live_server.url + '/admin/tests/foo/add/') 192 | WebDriverWait(driver, 10).until( 193 | EC.presence_of_element_located((By.ID, "id_bar")) 194 | ) 195 | driver.find_element_by_id("id_bar").send_keys("bat") 196 | driver.find_element_by_id( 197 | 'id_bat_input_file').send_keys(test_file_path) 198 | status_text = driver.find_element_by_id("id_bat_uploaded_status").text 199 | print("status_text", status_text) 200 | i = 0 201 | while i < 5: 202 | if "Uploaded" in status_text: 203 | return # success 204 | time.sleep(1) 205 | i += 1 206 | raise Exception # something went wrong 207 | -------------------------------------------------------------------------------- /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_async_upload.urls')), 9 | url(r'^admin/', include(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,py34,py35}-django{1.8,1.9,1.10,1.11} 4 | 5 | [testenv] 6 | #rsx = report all errors, -s = capture=no, -x = fail fast, --pdb for local testing http://www.linuxcertif.com/man/1/py.test/ 7 | commands = py.test -rsx -s -x 8 | setenv = 9 | PYTHONDONTWRITEBYTECODE=1 10 | deps = 11 | django1.8: Django==1.8.14 12 | django1.9: Django==1.9.9 13 | django1.10: Django==1.10.1 14 | django1.11: Django==1.11 15 | pytest-django==3.1.2 16 | selenium==2.45.0 17 | pyvirtualdisplay 18 | --------------------------------------------------------------------------------