diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml
new file mode 100644
index 0000000..86129be
--- /dev/null
+++ b/.github/workflows/run-tests.yaml
@@ -0,0 +1,33 @@
+name: Run Tests
+
+on: [push, pull_request]
+
+jobs:
+ test:
+ name: Run Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.10"
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+
+ - name: Install Playwright browsers
+ run: python -m playwright install --with-deps chromium
+
+ - name: Run tests
+ run: pytest --tracing=retain-on-failure
+
+ - uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() }}
+ with:
+ name: playwright-traces
+ path: test-results/
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..2c07333
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.11
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index d568f40..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-language: python
-
-python:
- - "3.11"
-
-sudo: false
-
-env:
- - TOXENV=py310-dj42
-
-matrix:
- fast_finish: true
-
-install:
- - pip install tox
-
-script:
- - tox -e $TOX_ENV
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cf4fd78
--- /dev/null
+++ b/README.md
@@ -0,0 +1,70 @@
+# django-resumable-async-upload
+
+django-resumable-async-upload is a django app to allow you to upload large files from within the django admin site asynchronously, 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.
+
+- Currently only tested with the Django default file storage
+
+## Installation
+
+- pip install django-resumable-async-upload
+- Add `django_resumable_async_upload` to your `INSTALLED_APPS`
+- Add `re_path(r"^django_resumable_async_upload/", include("django_resumable_async_upload.urls")),` to your urls.py
+- Add in your models field
+
+```
+from django_resumable_async_upload.models import AsyncFileField
+
+class Foo(models.Model):
+ bar = models.CharField(max_length=200)
+ foo = AsyncFileField()
+```
+
+- Add in your admin form:
+
+```
+from django_resumable_async_upload.fields import FormResumableMultipleFileField
+from django_resumable_async_upload.widgets import ResumableAdminWidget
+
+class MultiUploadForm(forms.ModelForm):
+ files = FormResumableMultipleFileField(
+ required=False,
+ widget=ResumableAdminWidget(attrs={"model": File, "field_name": "file"}),
+ )
+```
+
+Optional Settings:
+
+- Set `ADMIN_RESUMABLE_CHUNKSIZE`, default is `"1*1024*1024"`
+- Set `ADMIN_RESUMABLE_STORAGE`, default is setting of storages 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/
+- 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/
+- Set `ADMIN_RESUMABLE_SHOW_THUMB`, default is False. Shows a thumbnail next to the "Currently:" link.
+- Set `ADMIN_SIMULTANEOUS_UPLOADS` to limit number of simultaneous uploads, defaults to `3`. If you have broken pipe issues in local development environment, set this value to `1`.
+- Set `MEDIA_URL` to where images are stored to be rendered after upload
+
+Optional Param for `AsyncFileField`
+
+- `max_files`, default is None. Configure how many files are allowed to be uploaded to a file input.
+
+## Versions
+
+0.1.0 - inital fork of django-async-upload 4.0.1 with support for Django 4 and later. Includes admin form updates to pause, resume, cancel and track progress of upload. Also supports uploads of multiple files
+
+## Compatibility
+
+Tested on Django 4.2 running on python 3.12
+
+## Thanks to
+
+original django-admin-resumable-js by jonatron https://github.com/jonatron/django-admin-resumable-js
+
+django-admin-resumable-js fork by roxel https://github.com/roxel/django-admin-resumable-js
+
+django-admin-async-upload fork by DataGreed https://github.com/DataGreed/django-admin-async-upload
+
+django-async-upload fork by bit https://github.com/bit/django-async-upload
+
+Resumable.js https://github.com/23/resumable.js
+
+Typescript supported version of resumable.js https://github.com/augustcodes08/resumable-uploads
+
+django-resumable https://github.com/jeanphix/django-resumable
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 9b332a9..0000000
--- a/README.rst
+++ /dev/null
@@ -1,58 +0,0 @@
-django-async-upload
-===============================
-
-django-async-upload is a django app to allow you to upload large files from within the django admin site asynchrously, 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.
-
-django-async-upload is compatible with django-storages (tested with S3Storage)
-
-
-Installation
-------------
-
-* pip install django-async-upload
-* Add ``admin_async_upload`` to your ``INSTALLED_APPS``
-* Add ``url(r'^admin_async_upload/', include('admin_async_upload.urls')),`` to your urls.py
-* Add a model field eg: ``from admin_resumable.models import ResumableFileField``
-
-::
-
- class Foo(models.Model):
- bar = models.CharField(max_length=200)
- foo = AsyncFileField()
-
-
-
-Optionally:
-
-* Set ``ADMIN_RESUMABLE_CHUNKSIZE``, default is ``"1*1024*1024"``
-* 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/
-* 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/
-* Set ``ADMIN_RESUMABLE_SHOW_THUMB``, default is False. Shows a thumbnail next to the "Currently:" link.
-* 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`.
-
-
-Versions
---------
-
-4.0.0 - inital fork of django-admin-async-upload 3.0.4 with support for Django 4 and later
-
-
-Compatibility
--------------
-
-Tested on Django 4.2 running on python 3.11
-
-Thanks to
----------
-
-original django-admin-resumable-js by jonatron https://github.com/jonatron/django-admin-resumable-js
-
-django-admin-resumable-js fork by roxel https://github.com/roxel/django-admin-resumable-js
-
-django-admin-async-upload fork by DataGreed https://github.com/DataGreed/django-admin-async-upload
-
-Resumable.js https://github.com/23/resumable.js
-
-django-resumable https://github.com/jeanphix/django-resumable
-
-
diff --git a/admin_async_upload/fields.py b/admin_async_upload/fields.py
deleted file mode 100644
index 739910b..0000000
--- a/admin_async_upload/fields.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from django.core.exceptions import ValidationError
-from django.forms import fields
-
-from admin_async_upload.widgets import ResumableAdminWidget
-
-
-class FormResumableFileField(fields.FileField):
- widget = ResumableAdminWidget
-
- def to_python(self, data):
- if self.required:
- if not data or data == "None":
- raise ValidationError(self.error_messages['empty'])
- return data
diff --git a/admin_async_upload/models.py b/admin_async_upload/models.py
deleted file mode 100644
index ae2fb38..0000000
--- a/admin_async_upload/models.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from django.db import models
-from admin_async_upload.widgets import ResumableAdminWidget
-from admin_async_upload.fields import FormResumableFileField
-
-
-class AsyncFileField(models.FileField):
-
- def formfield(self, **kwargs):
- defaults = {'form_class': FormResumableFileField}
- if self.model and self.name:
- defaults['widget'] = ResumableAdminWidget(attrs={
- 'model': self.model,
- 'field_name': self.name})
- kwargs.update(defaults)
- return super(AsyncFileField, self).formfield(**kwargs)
diff --git a/admin_async_upload/storage.py b/admin_async_upload/storage.py
deleted file mode 100644
index 494e680..0000000
--- a/admin_async_upload/storage.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import datetime
-
-import posixpath
-from django.core.files.storage import get_storage_class
-
-from django.conf import settings
-from django.utils.encoding import force_str
-
-
-class ResumableStorage(object):
-
- def __init__(self):
- self.persistent_storage_class_name = getattr(settings, 'ADMIN_RESUMABLE_STORAGE', None) or \
- getattr(settings, 'DEFAULT_FILE_STORAGE',
- 'django.core.files.storage.FileSystemStorage')
-
- self.chunk_storage_class_name = getattr(
- settings,
- 'ADMIN_RESUMABLE_CHUNK_STORAGE',
- 'django.core.files.storage.FileSystemStorage'
- )
-
- def get_chunk_storage(self, *args, **kwargs):
- """
- Returns storage class specified in settings as ADMIN_RESUMABLE_CHUNK_STORAGE.
- Defaults to django.core.files.storage.FileSystemStorage.
- Chunk storage should be highly available for the server as saved chunks must be copied by the server
- for saving merged version in persistent storage.
- """
- storage_class = get_storage_class(self.chunk_storage_class_name)
- return storage_class(*args, **kwargs)
-
- def get_persistent_storage(self, *args, **kwargs):
- """
- Returns storage class specified in settings as ADMIN_RESUMABLE_STORAGE
- or DEFAULT_FILE_STORAGE if the former is not found.
-
- Defaults to django.core.files.storage.FileSystemStorage.
- """
- storage_class = get_storage_class(self.persistent_storage_class_name)
- return storage_class(*args, **kwargs)
-
- def full_filename(self, filename, upload_to, instance=None):
- if callable(upload_to):
- filename = upload_to(instance, filename)
- else:
- dirname = force_str(datetime.datetime.now().strftime(force_str(upload_to)))
- filename = posixpath.join(dirname, filename)
- return self.get_persistent_storage().generate_filename(filename)
diff --git a/admin_async_upload/templates/admin_resumable/admin_file_input.html b/admin_async_upload/templates/admin_resumable/admin_file_input.html
deleted file mode 100644
index f985d75..0000000
--- a/admin_async_upload/templates/admin_resumable/admin_file_input.html
+++ /dev/null
@@ -1,142 +0,0 @@
-{% load i18n %}
-
-
-
- {% if value %}
- {% trans 'Currently' %}:
- {% if file_url %}
- {{ file_name }}
- {% if show_thumb %}
-
- {% endif %}
- {% else %}
- {{ value }}
- {% endif %}
- {{ clear_checkbox }}
-
- {% trans 'Change' %}:
- {% endif %}
-
-
-
-
-
-
-
-
diff --git a/admin_async_upload/views.py b/admin_async_upload/views.py
deleted file mode 100644
index 5cdf943..0000000
--- a/admin_async_upload/views.py
+++ /dev/null
@@ -1,39 +0,0 @@
-from django.contrib.auth.decorators import login_required
-from django.contrib.contenttypes.models import ContentType
-from django.http import HttpResponse
-from django.utils.functional import cached_property
-from django.views.generic import View
-from admin_async_upload.files import ResumableFile
-
-
-class UploadView(View):
- # inspired by another fork https://github.com/fdemmer/django-admin-resumable-js
-
- @cached_property
- def request_data(self):
- return getattr(self.request, self.request.method)
-
- @cached_property
- def model_upload_field(self):
- content_type = ContentType.objects.get_for_id(self.request_data['content_type_id'])
- return content_type.model_class()._meta.get_field(self.request_data['field_name'])
-
- def post(self, request, *args, **kwargs):
- chunk = request.FILES.get('file')
- r = ResumableFile(self.model_upload_field, user=request.user, params=request.POST)
- if not r.chunk_exists:
- r.process_chunk(chunk)
- if r.is_complete:
- return HttpResponse(r.collect())
- return HttpResponse('chunk uploaded')
-
- def get(self, request, *args, **kwargs):
- r = ResumableFile(self.model_upload_field, user=request.user, params=request.GET)
- if not r.chunk_exists:
- return HttpResponse('chunk not found', status=204)
- if r.is_complete:
- return HttpResponse(r.collect())
- return HttpResponse('chunk exists')
-
-
-admin_resumable = login_required(UploadView.as_view())
diff --git a/admin_async_upload/widgets.py b/admin_async_upload/widgets.py
deleted file mode 100644
index afcc29e..0000000
--- a/admin_async_upload/widgets.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
-from django.db.models.fields.files import FieldFile
-from django.forms import FileInput, CheckboxInput, forms
-from django.template import loader
-from django.templatetags.static import static
-from django.utils.safestring import mark_safe
-from django.utils.translation import gettext_lazy
-
-from admin_async_upload.storage import ResumableStorage
-
-
-class ResumableBaseWidget(FileInput):
- template_name = 'admin_resumable/admin_file_input.html'
- clear_checkbox_label = gettext_lazy('Clear')
-
- def render(self, name, value, attrs=None, **kwargs):
- persistent_storage = ResumableStorage().get_persistent_storage()
- if value:
- if isinstance(value, FieldFile):
- value_name = value.name
- else:
- value_name = value
- file_name = value
- file_url = mark_safe(persistent_storage.url(value_name))
-
- else:
- file_name = ""
- file_url = ""
-
- chunk_size = getattr(settings, 'ADMIN_RESUMABLE_CHUNKSIZE', "1*1024*1024")
- show_thumb = getattr(settings, 'ADMIN_RESUMABLE_SHOW_THUMB', False)
- simultaneous_uploads = getattr(settings, 'ADMIN_SIMULTANEOUS_UPLOADS', 3)
-
- content_type_id = ContentType.objects.get_for_model(self.attrs['model']).id
-
- context = {
- 'name': name,
- 'value': value,
- 'id': attrs['id'],
- 'chunk_size': chunk_size,
- 'show_thumb': show_thumb,
- 'field_name': self.attrs['field_name'],
- 'content_type_id': content_type_id,
- 'file_url': file_url,
- 'file_name': file_name,
- 'simultaneous_uploads': simultaneous_uploads,
- }
-
- instance = self.attrs.get('instance')
- if instance and instance.pk:
- context['instance_id'] = instance.pk
-
- if not self.is_required:
- template_with_clear = '%(clear)s ' \
- ''
- substitutions = {
- 'clear_checkbox_id': attrs['id'] + "-clear-id",
- 'clear_checkbox_name': attrs['id'] + "-clear",
- 'clear_checkbox_label': self.clear_checkbox_label
- }
- substitutions['clear'] = CheckboxInput().render(
- substitutions['clear_checkbox_name'],
- False,
- attrs={'id': substitutions['clear_checkbox_id']}
- )
- clear_checkbox = mark_safe(template_with_clear % substitutions)
- context.update({'clear_checkbox': clear_checkbox})
- return loader.render_to_string(self.template_name, context)
-
- def value_from_datadict(self, data, files, name):
- if not self.is_required and data.get("id_" + name + "-clear"):
- return False # False signals to clear any existing value, as opposed to just None
- if data.get(name, None) in ['None', 'False']:
- return None
- return data.get(name, None)
-
-
-class ResumableAdminWidget(ResumableBaseWidget):
- @property
- def media(self):
- js = ["resumable.js"]
- return forms.Media(js=[static("admin_resumable/js/%s" % path) for path in js])
-
-
-class ResumableWidget(ResumableBaseWidget):
- template_name = 'admin_resumable/user_file_input.html'
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..f446213
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,33 @@
+[project]
+name = "django-resumable-async-upload"
+version = "0.1.3"
+description = "A Django app for the uploading of large files from the django admin site."
+readme = "README.md"
+authors = [
+ { name = "Paige Williams", email = "pwilliams@ecotrust.org" }
+]
+requires-python = ">=3.11"
+
+dependencies = ["Django>=3.0.14"]
+classifiers=[
+ "Environment :: Web Environment",
+ "Framework :: Django",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Internet :: WWW/HTTP",
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
+]
+
+[project.urls]
+Homepage = "https://github.com/Ecotrust/django-resumable-async-upload"
+Issues = "https://github.com/Ecotrust/django-resumable-async-upload/issues"
+
+[build-system]
+requires = ["uv_build>=0.9.16,<0.10.0"]
+build-backend = "uv_build"
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..83f597c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+django >=4.2.16,<4.3
+pytest
+pytest-django
+playwright
+pytest-playwright
+webdriver-manager
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 0a8df87..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[wheel]
-universal = 1
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 1d2f749..e8ffa28 100644
--- a/setup.py
+++ b/setup.py
@@ -1,44 +1,46 @@
import os
from setuptools import setup
-README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read()
+with open(os.path.join(os.path.dirname(__file__), "README.md")) as f:
+ README = f.read()
# allow setup.py to be run from any path
os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
setup(
- name='django-async-upload',
- version='4.0.1',
- packages=['admin_async_upload'],
+ name="django-resumable-async-upload",
+ version="4.2.0",
+ packages=["admin_resumable_async_upload"],
+ package_dir={"": "src"},
include_package_data=True,
package_data={
- 'admin_async_upload': [
- 'templates/admin_resumable/admin_file_input.html',
- 'templates/admin_resumable/user_file_input.html',
- 'static/admin_resumable/js/resumable.js',
+ "django_resumable_async_upload": [
+ "templates/admin_resumable/*.html",
+ "static/admin_resumable/js/*.js",
]
},
- license='MIT License',
- description='A Django app for the uploading of large files from the django admin site.',
+ license="MIT License",
+ description="A Django app for the uploading of large files from the django admin site.",
long_description=README,
- url='https://github.com/bit/django-async-upload',
- author='j',
- author_email='j@mailb.org',
+ long_description_content_type="text/markdown",
+ url="https://github.com/Ecotrust/django-resumable-async-upload",
+ author="Paige Williams",
+ author_email="pwilliams@ecotrust.org",
classifiers=[
- 'Environment :: Web Environment',
- 'Framework :: Django',
- 'Intended Audience :: Developers',
- 'License :: OSI Approved :: MIT License',
- 'Operating System :: OS Independent',
- 'Programming Language :: Python',
- 'Programming Language :: Python :: 2.7',
- 'Topic :: Internet :: WWW/HTTP',
- 'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
+ "Environment :: Web Environment",
+ "Framework :: Django",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Internet :: WWW/HTTP",
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
],
install_requires=[
- 'Django>=3.0.14',
+ "Django>=3.0.14",
],
tests_require=[
- 'pytest-django',
- ]
+ "pytest-django",
+ ],
)
diff --git a/admin_async_upload/__init__.py b/src/django_resumable_async_upload/__init__.py
similarity index 100%
rename from admin_async_upload/__init__.py
rename to src/django_resumable_async_upload/__init__.py
diff --git a/src/django_resumable_async_upload/fields.py b/src/django_resumable_async_upload/fields.py
new file mode 100644
index 0000000..7b057b4
--- /dev/null
+++ b/src/django_resumable_async_upload/fields.py
@@ -0,0 +1,75 @@
+from django.core.exceptions import ValidationError
+from django.forms import fields
+import json
+
+from django_resumable_async_upload.widgets import ResumableAdminWidget
+
+
+class FormResumableFileField(fields.FileField):
+ widget = ResumableAdminWidget
+
+ def to_python(self, data):
+ if self.required:
+ if not data or data == "None":
+ raise ValidationError(self.error_messages["empty"])
+ return data
+
+
+class FormResumableMultipleFileField(fields.Field):
+ """
+ Form field that handles multiple file uploads via resumable.js.
+ Stores file paths as a JSON array and returns a list of file paths.
+ """
+
+ widget = ResumableAdminWidget
+
+ def to_python(self, data):
+ """Convert JSON string to Python list of file paths."""
+ if not data or data in ["None", "False", None]:
+ return []
+
+ # If already a list, return it
+ if isinstance(data, list):
+ return data
+
+ # Try to parse as JSON
+ try:
+ parsed = json.loads(data)
+ if isinstance(parsed, list):
+ return parsed
+ # If single value, wrap in list
+ return [parsed] if parsed else []
+ except (json.JSONDecodeError, ValueError, TypeError):
+ # Not JSON, treat as single file path
+ return [data] if data else []
+
+ def clean(self, value):
+ """
+ Validate and clean the field value.
+ Converts JSON string to list and validates.
+ """
+ # Convert to Python type (list of file paths)
+ value = self.to_python(value)
+
+ # Run validation
+ self.validate(value)
+
+ # Run any custom validators
+ self.run_validators(value)
+
+ return value
+
+ def prepare_value(self, value):
+ """Convert Python list to JSON string for rendering."""
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ return value
+ if isinstance(value, list):
+ return json.dumps(value)
+ return str(value)
+
+ def validate(self, value):
+ """Validate that all file paths in the list are valid."""
+ if self.required and not value:
+ raise ValidationError(self.error_messages["required"])
diff --git a/admin_async_upload/files.py b/src/django_resumable_async_upload/files.py
similarity index 61%
rename from admin_async_upload/files.py
rename to src/django_resumable_async_upload/files.py
index cfc7e88..f02f119 100644
--- a/admin_async_upload/files.py
+++ b/src/django_resumable_async_upload/files.py
@@ -2,11 +2,11 @@
import fnmatch
import tempfile
-from django.contrib.contenttypes.models import ContentType
from django.core.files import File
from django.utils.functional import cached_property
+from django.conf import settings
-from admin_async_upload.storage import ResumableStorage
+from django_resumable_async_upload.storage import ResumableStorage
class ResumableFile(object):
@@ -25,6 +25,7 @@ def __init__(self, field, user, params):
self.user = user
self.params = params
self.chunk_suffix = "_part_"
+ self.chunk_folder = getattr(settings, "ADMIN_RESUMABLE_CHUNK_FOLDER", "")
@cached_property
def resumable_storage(self):
@@ -45,7 +46,9 @@ def storage_filename(self):
instance = self.field.model.objects.filter(pk=instance_id).first()
else:
instance = None
- return self.resumable_storage.full_filename(self.filename, self.upload_to, instance=instance)
+ return self.resumable_storage.full_filename(
+ self.filename, self.upload_to, instance=instance
+ )
@property
def upload_to(self):
@@ -56,41 +59,52 @@ def chunk_exists(self):
"""
Checks if the requested chunk exists.
"""
- return self.chunk_storage.exists(self.current_chunk_name) and \
- self.chunk_storage.size(self.current_chunk_name) == int(self.params.get('resumableCurrentChunkSize'))
+ return self.chunk_storage.exists(
+ self.current_chunk_name
+ ) and self.chunk_storage.size(self.current_chunk_name) == int(
+ self.params.get("resumableCurrentChunkSize")
+ )
@property
def chunk_names(self):
"""
- Iterates over all stored chunks.
+ Iterates over all stored chunks in the configured chunk folder.
"""
chunks = []
- files = sorted(self.chunk_storage.listdir('')[1])
+ try:
+ files = sorted(self.chunk_storage.listdir(self.chunk_folder)[1])
+ except (FileNotFoundError, OSError):
+ # chunks folder doesn't exist yet
+ return chunks
for file in files:
- if fnmatch.fnmatch(file, '%s%s*' % (self.filename,
- self.chunk_suffix)):
- chunks.append(file)
+ if fnmatch.fnmatch(file, "%s%s*" % (self.filename, self.chunk_suffix)):
+ if self.chunk_folder:
+ chunks.append(self.chunk_folder + "/" + file)
+ else:
+ chunks.append(file)
return chunks
@property
def current_chunk_name(self):
# TODO: add user identifier to chunk name
- return "%s%s%s" % (
+ chunk_name = "%s%s%s" % (
self.filename,
self.chunk_suffix,
- self.params.get('resumableChunkNumber').zfill(4)
+ self.params.get("resumableChunkNumber").zfill(4),
)
+ if self.chunk_folder:
+ return "%s/%s" % (self.chunk_folder, chunk_name)
+ return chunk_name
def chunks(self):
"""
Iterates over all stored chunks.
"""
# TODO: add user identifier to chunk name
- files = sorted(self.chunk_storage.listdir('')[1])
+ files = sorted(self.chunk_storage.listdir("")[1])
for file in files:
- if fnmatch.fnmatch(file, '%s%s*' % (self.filename,
- self.chunk_suffix)):
- yield self.chunk_storage.open(file, 'rb').read()
+ if fnmatch.fnmatch(file, "%s%s*" % (self.filename, self.chunk_suffix)):
+ yield self.chunk_storage.open(file, "rb").read()
def delete_chunks(self):
[self.chunk_storage.delete(chunk) for chunk in self.chunk_names]
@@ -101,7 +115,7 @@ def file(self):
Merges file and returns its file pointer.
"""
if not self.is_complete:
- raise Exception('Chunk(s) still missing')
+ raise Exception("Chunk(s) still missing")
outfile = tempfile.NamedTemporaryFile("w+b")
for chunk in self.chunk_names:
outfile.write(self.chunk_storage.open(chunk).read())
@@ -113,10 +127,10 @@ def filename(self):
Gets the filename.
"""
# TODO: add user identifier to chunk name
- filename = self.params.get('resumableFilename')
- if '/' in filename:
- raise Exception('Invalid filename')
- value = "%s_%s" % (self.params.get('resumableTotalSize'), filename)
+ filename = self.params.get("resumableFilename")
+ if "/" in filename:
+ raise Exception("Invalid filename")
+ value = "%s_%s" % (self.params.get("resumableTotalSize"), filename)
return value
@property
@@ -124,7 +138,7 @@ def is_complete(self):
"""
Checks if all chunks are already stored.
"""
- return int(self.params.get('resumableTotalSize')) == self.size
+ return int(self.params.get("resumableTotalSize")) == self.size
def process_chunk(self, file):
"""
@@ -145,6 +159,12 @@ def size(self):
return size
def collect(self):
- actual_filename = self.persistent_storage.save(self.storage_filename, File(self.file))
+ """
+ Saves the complete file to persistent storage and deletes chunks.
+ Returns the actual filename in persistent storage.
+ """
+ actual_filename = self.persistent_storage.save(
+ self.storage_filename, File(self.file)
+ )
self.delete_chunks()
return actual_filename
diff --git a/src/django_resumable_async_upload/models.py b/src/django_resumable_async_upload/models.py
new file mode 100644
index 0000000..5fae2cc
--- /dev/null
+++ b/src/django_resumable_async_upload/models.py
@@ -0,0 +1,28 @@
+from django.db import models
+from django_resumable_async_upload.widgets import ResumableAdminWidget
+from django_resumable_async_upload.fields import FormResumableFileField
+
+
+class AsyncFileField(models.FileField):
+ def __init__(self, *args, **kwargs):
+ self.max_files = kwargs.pop("max_files", None)
+ super(AsyncFileField, self).__init__(*args, **kwargs)
+
+ def deconstruct(self):
+ name, path, args, kwargs = super(AsyncFileField, self).deconstruct()
+ if self.max_files is not None:
+ kwargs["max_files"] = self.max_files
+ return name, path, args, kwargs
+
+ def formfield(self, **kwargs):
+ defaults = {"form_class": FormResumableFileField}
+ if self.model and self.name:
+ defaults["widget"] = ResumableAdminWidget(
+ attrs={
+ "model": self.model,
+ "field_name": self.name,
+ "max_files": getattr(self, "max_files", None),
+ }
+ )
+ kwargs.update(defaults)
+ return super(AsyncFileField, self).formfield(**kwargs)
diff --git a/src/django_resumable_async_upload/py.typed b/src/django_resumable_async_upload/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/admin_async_upload/static/admin_resumable/js/resumable.js b/src/django_resumable_async_upload/static/admin_resumable/js/resumable.js
similarity index 84%
rename from admin_async_upload/static/admin_resumable/js/resumable.js
rename to src/django_resumable_async_upload/static/admin_resumable/js/resumable.js
index 0f0e66d..5552676 100644
--- a/admin_async_upload/static/admin_resumable/js/resumable.js
+++ b/src/django_resumable_async_upload/static/admin_resumable/js/resumable.js
@@ -1,8 +1,8 @@
/*
* MIT Licensed
-* http://www.23developer.com/opensource
-* http://github.com/23/resumable.js
-* Steffen Tiedemann Christensen, steffen@23company.com
+* https://www.twentythree.com
+* https://github.com/23/resumable.js
+* Steffen Fagerström Christensen, steffen@twentythree.com
*/
(function(){
@@ -48,10 +48,12 @@
fileNameParameterName: 'resumableFilename',
relativePathParameterName: 'resumableRelativePath',
totalChunksParameterName: 'resumableTotalChunks',
+ dragOverClass: 'dragover',
throttleProgressCallbacks: 0.5,
query:{},
headers:{},
preprocess:null,
+ preprocessFile:null,
method:'multipart',
uploadMethod: 'POST',
testMethod: 'GET',
@@ -64,12 +66,13 @@
getTarget:null,
maxChunkRetries:100,
chunkRetryInterval:undefined,
- permanentErrors:[400, 404, 415, 500, 501],
+ permanentErrors:[400, 401, 403, 404, 409, 415, 500, 501],
maxFiles:undefined,
withCredentials:false,
xhrTimeout:0,
clearInput:true,
- chunkFormat:'blob',
+ chunkFormat:'blob',
+ setChunkTypeFromFile:false,
maxFilesErrorCallback:function (files, errorCount) {
var maxFiles = $.getOpt('maxFiles');
alert('Please upload no more than ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.');
@@ -112,6 +115,13 @@
else { return $opt.defaults[o]; }
}
};
+ $.indexOf = function(array, obj) {
+ if (array.indexOf) { return array.indexOf(obj); }
+ for (var i = 0; i < array.length; i++) {
+ if (array[i] === obj) { return i; }
+ }
+ return -1;
+ };
// EVENTS
// catchAll(event, ...)
@@ -160,7 +170,7 @@
if(typeof custom === 'function') {
return custom(file, event);
}
- var relativePath = file.webkitRelativePath||file.fileName||file.name; // Some confusion in different versions of Firefox
+ var relativePath = file.webkitRelativePath||file.relativePath||file.fileName||file.name; // Some confusion in different versions of Firefox
var size = file.size;
return(size + '-' + relativePath.replace(/[^0-9a-zA-Z_-]/img, ''));
},
@@ -202,24 +212,40 @@
var separator = target.indexOf('?') < 0 ? '?' : '&';
var joinedParams = params.join('&');
- return target + separator + joinedParams;
+ if (joinedParams) target = target + separator + joinedParams;
+
+ return target;
}
};
- var onDrop = function(event){
- $h.stopEvent(event);
+ var onDrop = function(e){
+ e.currentTarget.classList.remove($.getOpt('dragOverClass'));
+ $h.stopEvent(e);
//handle dropped things as items if we can (this lets us deal with folders nicer in some cases)
- if (event.dataTransfer && event.dataTransfer.items) {
- loadFiles(event.dataTransfer.items, event);
+ if (e.dataTransfer && e.dataTransfer.items) {
+ loadFiles(e.dataTransfer.items, e);
}
//else handle them as files
- else if (event.dataTransfer && event.dataTransfer.files) {
- loadFiles(event.dataTransfer.files, event);
+ else if (e.dataTransfer && e.dataTransfer.files) {
+ loadFiles(e.dataTransfer.files, e);
}
};
- var preventDefault = function(e) {
+ var onDragLeave = function(e){
+ e.currentTarget.classList.remove($.getOpt('dragOverClass'));
+ };
+ var onDragOverEnter = function(e) {
e.preventDefault();
+ var dt = e.dataTransfer;
+ if ($.indexOf(dt.types, "Files") >= 0) { // only for file drop
+ e.stopPropagation();
+ dt.dropEffect = "copy";
+ dt.effectAllowed = "copy";
+ e.currentTarget.classList.add($.getOpt('dragOverClass'));
+ } else { // not work on IE/Edge....
+ dt.dropEffect = "none";
+ dt.effectAllowed = "none";
+ }
};
/**
@@ -255,8 +281,10 @@
if('function' === typeof item.getAsFile){
// item represents a File object, convert it
item = item.getAsFile();
- item.relativePath = path + item.name;
- items.push(item);
+ if(item instanceof File) {
+ item.relativePath = path + item.name;
+ items.push(item);
+ }
}
cb(); // indicate processing is done
}
@@ -289,20 +317,27 @@
*/
function processDirectory (directory, path, items, cb) {
var dirReader = directory.createReader();
- dirReader.readEntries(function(entries){
- if(!entries.length){
- // empty directory, skip
- return cb();
- }
- // process all conversion callbacks, finally invoke own one
- processCallbacks(
- entries.map(function(entry){
- // bind all properties except for callback
- return processItem.bind(null, entry, path, items);
- }),
- cb
- );
- });
+ var allEntries = [];
+
+ function readEntries () {
+ dirReader.readEntries(function(entries){
+ if (entries.length) {
+ allEntries = allEntries.concat(entries);
+ return readEntries();
+ }
+
+ // process all conversion callbacks, finally invoke own one
+ processCallbacks(
+ allEntries.map(function(entry){
+ // bind all properties except for callback
+ return processItem.bind(null, entry, path, items);
+ }),
+ cb
+ );
+ });
+ }
+
+ readEntries();
}
/**
@@ -319,7 +354,11 @@
processCallbacks(
Array.prototype.map.call(items, function(item){
// bind all properties except for callback
- return processItem.bind(null, item, "", files);
+ var entry = item;
+ if('function' === typeof item.webkitGetAsEntry){
+ entry = item.webkitGetAsEntry();
+ }
+ return processItem.bind(null, entry, "", files);
}),
function(){
if(files.length){
@@ -358,28 +397,40 @@
};
$h.each(fileList, function(file){
var fileName = file.name;
+ var fileType = file.type; // e.g video/mp4
if(o.fileType.length > 0){
var fileTypeFound = false;
for(var index in o.fileType){
- var extension = '.' + o.fileType[index];
- if(fileName.toLowerCase().indexOf(extension.toLowerCase(), fileName.length - extension.length) !== -1){
+ // For good behaviour we do some inital sanitizing. Remove spaces and lowercase all
+ o.fileType[index] = o.fileType[index].replace(/\s/g, '').toLowerCase();
+
+ // Allowing for both [extension, .extension, mime/type, mime/*]
+ var extension = ((o.fileType[index].match(/^[^.][^/]+$/)) ? '.' : '') + o.fileType[index];
+
+ if ((fileName.substr(-1 * extension.length).toLowerCase() === extension) ||
+ //If MIME type, check for wildcard or if extension matches the files tiletype
+ (extension.indexOf('/') !== -1 && (
+ (extension.indexOf('*') !== -1 && fileType.substr(0, extension.indexOf('*')) === extension.substr(0, extension.indexOf('*'))) ||
+ fileType === extension
+ ))
+ ){
fileTypeFound = true;
break;
}
}
if (!fileTypeFound) {
o.fileTypeErrorCallback(file, errorCount++);
- return false;
+ return true;
}
}
if (typeof(o.minFileSize)!=='undefined' && file.sizeo.maxFileSize) {
o.maxFileSizeErrorCallback(file, errorCount++);
- return false;
+ return true;
}
function addFile(uniqueIdentifier){
@@ -434,6 +485,7 @@
$.uniqueIdentifier = uniqueIdentifier;
$._pause = false;
$.container = '';
+ $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished
var _error = uniqueIdentifier !== undefined;
// Callback when something happens within the chunk
@@ -451,7 +503,7 @@
break;
case 'success':
if(_error) return;
- $.resumableObj.fire('fileProgress', $); // it's at least progress
+ $.resumableObj.fire('fileProgress', $, message); // it's at least progress
if($.isComplete()) {
$.resumableObj.fire('fileSuccess', $, message);
}
@@ -507,10 +559,8 @@
var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor;
var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1);
for (var offset=0; offset= $.getOpt('maxChunkRetries')) {
- // HTTP 415/500/501, permanent error
+ // HTTP 400, 404, 409, 415, 500, 501 (permanent error)
return('error');
} else {
// this should never happen, but we'll reset and queue a retry
@@ -844,7 +932,7 @@
if(typeof(relative)==='undefined') relative = false;
var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1);
if($.pendingRetry) return(0);
- if(!$.xhr || !$.xhr.status) factor*=.95;
+ if((!$.xhr || !$.xhr.status) && !$.markComplete) factor*=.95;
var s = $.status();
switch(s){
case 'success':
@@ -884,15 +972,7 @@
// Now, simply look for the next, best thing to upload
$h.each($.files, function(file){
- if(file.isPaused()===false){
- $h.each(file.chunks, function(chunk){
- if(chunk.status()=='pending' && chunk.preprocessState === 0) {
- chunk.send();
- found = true;
- return(false);
- }
- });
- }
+ found = file.upload();
if(found) return(false);
});
if(found) return(true);
@@ -916,7 +996,6 @@
// PUBLIC METHODS FOR RESUMABLE.JS
$.assignBrowse = function(domNodes, isDirectory){
if(typeof(domNodes.length)=='undefined') domNodes = [domNodes];
-
$h.each(domNodes, function(domNode) {
var input;
if(domNode.tagName==='INPUT' && domNode.type==='file'){
@@ -945,6 +1024,19 @@
} else {
input.removeAttribute('webkitdirectory');
}
+ var fileTypes = $.getOpt('fileType');
+ if (typeof (fileTypes) !== 'undefined' && fileTypes.length >= 1) {
+ input.setAttribute('accept', fileTypes.map(function (e) {
+ e = e.replace(/\s/g, '').toLowerCase();
+ if(e.match(/^[^.][^/]+$/)){
+ e = '.' + e;
+ }
+ return e;
+ }).join(','));
+ }
+ else {
+ input.removeAttribute('accept');
+ }
// When new files are added, simply append them to the overall list
input.addEventListener('change', function(e){
appendFilesFromFileList(e.target.files,e);
@@ -959,8 +1051,9 @@
if(typeof(domNodes.length)=='undefined') domNodes = [domNodes];
$h.each(domNodes, function(domNode) {
- domNode.addEventListener('dragover', preventDefault, false);
- domNode.addEventListener('dragenter', preventDefault, false);
+ domNode.addEventListener('dragover', onDragOverEnter, false);
+ domNode.addEventListener('dragenter', onDragOverEnter, false);
+ domNode.addEventListener('dragleave', onDragLeave, false);
domNode.addEventListener('drop', onDrop, false);
});
};
@@ -968,8 +1061,9 @@
if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes];
$h.each(domNodes, function(domNode) {
- domNode.removeEventListener('dragover', preventDefault);
- domNode.removeEventListener('dragenter', preventDefault);
+ domNode.removeEventListener('dragover', onDragOverEnter);
+ domNode.removeEventListener('dragenter', onDragOverEnter);
+ domNode.removeEventListener('dragleave', onDragLeave);
domNode.removeEventListener('drop', onDrop);
});
};
@@ -1019,6 +1113,9 @@
$.addFile = function(file, event){
appendFilesFromFileList([file], event);
};
+ $.addFiles = function(files, event){
+ appendFilesFromFileList(files, event);
+ };
$.removeFile = function(file){
for(var i = $.files.length - 1; i >= 0; i--) {
if($.files[i] === file) {
@@ -1057,7 +1154,9 @@
// Node.js-style export for Node and Component
if (typeof module != 'undefined') {
+ // left here for backwards compatibility
module.exports = Resumable;
+ module.exports.Resumable = Resumable;
} else if (typeof define === "function" && define.amd) {
// AMD/requirejs: Define the module
define(function(){
diff --git a/src/django_resumable_async_upload/storage.py b/src/django_resumable_async_upload/storage.py
new file mode 100644
index 0000000..bd0c47b
--- /dev/null
+++ b/src/django_resumable_async_upload/storage.py
@@ -0,0 +1,103 @@
+import datetime
+
+import posixpath
+
+try:
+ from django.core.files.storage import storages, InvalidStorageError
+except ImportError:
+ # Fallback for older Django versions
+ storages = None
+ InvalidStorageError = None
+
+from django.conf import settings
+from django.utils.encoding import force_str
+
+
+class ResumableStorage(object):
+ def __init__(self):
+ # For backward compatibility, still check old settings
+ self.persistent_storage_name = getattr(
+ settings, "ADMIN_RESUMABLE_STORAGE", None
+ )
+ self.chunk_storage_name = getattr(
+ settings, "ADMIN_RESUMABLE_CHUNK_STORAGE", None
+ )
+
+ def get_chunk_storage(self, *args, **kwargs):
+ """
+ Returns storage class specified in settings as ADMIN_RESUMABLE_CHUNK_STORAGE.
+ Defaults to django.core.files.storage.FileSystemStorage.
+ Chunk storage should be highly available for the server as saved chunks must be copied by the server
+ for saving merged version in persistent storage.
+ """
+ if self.chunk_storage_name:
+ # If a specific storage backend is configured, use it
+ if storages:
+ # Django 4.2+ - check if it's a STORAGES key or class path
+ try:
+ return storages[self.chunk_storage_name]
+ except (KeyError, InvalidStorageError):
+ # Not a STORAGES key, treat as class path
+ from django.core.files.storage import get_storage_class
+
+ storage_class = get_storage_class(self.chunk_storage_name)
+ return storage_class(*args, **kwargs)
+ else:
+ # Older Django - use get_storage_class
+ from django.core.files.storage import get_storage_class
+
+ storage_class = get_storage_class(self.chunk_storage_name)
+ return storage_class(*args, **kwargs)
+ else:
+ # Default to local FileSystemStorage for performance
+ # (chunks should not be written to remote storage like S3)
+ if storages:
+ from django.core.files.storage import FileSystemStorage
+
+ return FileSystemStorage(*args, **kwargs)
+ else:
+ from django.core.files.storage import get_storage_class
+
+ storage_class = get_storage_class(
+ "django.core.files.storage.FileSystemStorage"
+ )
+ return storage_class(*args, **kwargs)
+
+ def get_persistent_storage(self, *args, **kwargs):
+ """
+ Returns storage class specified in settings as ADMIN_RESUMABLE_STORAGE
+ or DEFAULT_FILE_STORAGE if the former is not found.
+
+ Defaults to django.core.files.storage.FileSystemStorage.
+ """
+ if storages:
+ # Django 4.2+ with STORAGES setting
+ if self.persistent_storage_name:
+ # If a specific storage backend is configured, use it
+ from django.core.files.storage import get_storage_class
+
+ storage_class = get_storage_class(self.persistent_storage_name)
+ return storage_class(*args, **kwargs)
+ else:
+ # Use default storage from STORAGES setting
+ return storages["default"]
+ else:
+ # Fallback for older Django versions
+ from django.core.files.storage import get_storage_class
+
+ persistent_storage_class_name = self.persistent_storage_name or getattr(
+ settings,
+ "DEFAULT_FILE_STORAGE",
+ "django.core.files.storage.FileSystemStorage",
+ )
+ storage_class = get_storage_class(persistent_storage_class_name)
+
+ return storage_class(*args, **kwargs)
+
+ def full_filename(self, filename, upload_to, instance=None):
+ if callable(upload_to):
+ filename = upload_to(instance, filename)
+ else:
+ dirname = force_str(datetime.datetime.now().strftime(force_str(upload_to)))
+ filename = posixpath.join(dirname, filename)
+ return self.get_persistent_storage().generate_filename(filename)
diff --git a/src/django_resumable_async_upload/templates/admin_resumable/admin_file_input.html b/src/django_resumable_async_upload/templates/admin_resumable/admin_file_input.html
new file mode 100644
index 0000000..c01806b
--- /dev/null
+++ b/src/django_resumable_async_upload/templates/admin_resumable/admin_file_input.html
@@ -0,0 +1,396 @@
+{% load i18n %}
+
+
+
+
+
+
+ {% if value %} {% trans 'Currently' %}: {% if file_url %}
+ {{ file_name }}
+ {% if show_thumb %}
+
+ {% endif %} {% else %} {{ value }} {% endif %} {{ clear_checkbox }}
+
+ {% trans 'Change' %}: {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin_async_upload/templates/admin_resumable/user_file_input.html b/src/django_resumable_async_upload/templates/admin_resumable/user_file_input.html
similarity index 100%
rename from admin_async_upload/templates/admin_resumable/user_file_input.html
rename to src/django_resumable_async_upload/templates/admin_resumable/user_file_input.html
diff --git a/admin_async_upload/urls.py b/src/django_resumable_async_upload/urls.py
similarity index 50%
rename from admin_async_upload/urls.py
rename to src/django_resumable_async_upload/urls.py
index b1d8903..49534ea 100644
--- a/admin_async_upload/urls.py
+++ b/src/django_resumable_async_upload/urls.py
@@ -3,5 +3,5 @@
from . import views
urlpatterns = [
- path('upload/', views.admin_resumable, name='admin_resumable'),
+ path("upload/", views.admin_resumable, name="admin_resumable"),
]
diff --git a/admin_async_upload/validators.py b/src/django_resumable_async_upload/validators.py
similarity index 54%
rename from admin_async_upload/validators.py
rename to src/django_resumable_async_upload/validators.py
index ea76676..2e38ef5 100644
--- a/admin_async_upload/validators.py
+++ b/src/django_resumable_async_upload/validators.py
@@ -1,4 +1,4 @@
-from admin_async_upload.storage import ResumableStorage
+from django_resumable_async_upload.storage import ResumableStorage
from os.path import splitext
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@@ -12,11 +12,18 @@ class StorageFileValidator(object):
Any validation must happen either on the client-side or requires upload to be completed
and file saved in application storage.
"""
+
messages = {
- 'file': _(u"File {name} does not exist."),
- 'extension': _(u"Extension {extension} not allowed. Allowed extensions are: {allowed_extensions}"),
- 'min_size': _(u"File {name} too small ({size} bytes). The minimum file size is {min_size} bytes."),
- 'max_size': _(u"File {name} too large ({size} bytes). The maximum file size is {max_size} bytes."),
+ "file": _("File {name} does not exist."),
+ "extension": _(
+ "Extension {extension} not allowed. Allowed extensions are: {allowed_extensions}"
+ ),
+ "min_size": _(
+ "File {name} too small ({size} bytes). The minimum file size is {min_size} bytes."
+ ),
+ "max_size": _(
+ "File {name} too large ({size} bytes). The maximum file size is {max_size} bytes."
+ ),
}
def __init__(self, min_size=0, max_size=None, allowed_extensions=None):
@@ -30,34 +37,42 @@ def get_storage(self):
def validate_extension(self, value):
ext = splitext(value)[1].lower()
if self.allowed_extensions and ext not in self.allowed_extensions:
- message = self.messages['extension'].format(**{
- 'extension': ext,
- 'allowed_extensions': ', '.join(self.allowed_extensions)
- })
+ message = self.messages["extension"].format(
+ **{
+ "extension": ext,
+ "allowed_extensions": ", ".join(self.allowed_extensions),
+ }
+ )
raise ValidationError(message)
def validate_exists(self, value, storage):
if not storage.exists(value):
- message = self.messages['file'].format(**{
- 'name': value,
- })
+ message = self.messages["file"].format(
+ **{
+ "name": value,
+ }
+ )
raise ValidationError(message)
def validate_size(self, value, storage):
size = storage.size(value)
if size > self.max_size:
- message = self.messages['max_size'].format(**{
- 'name': value,
- 'size': size,
- 'max_size': self.max_size,
- })
+ message = self.messages["max_size"].format(
+ **{
+ "name": value,
+ "size": size,
+ "max_size": self.max_size,
+ }
+ )
raise ValidationError(message)
elif size < self.min_size:
- message = self.messages['min_size'].format(**{
- 'name': value,
- 'size': size,
- 'min_size': self.min_size,
- })
+ message = self.messages["min_size"].format(
+ **{
+ "name": value,
+ "size": size,
+ "min_size": self.min_size,
+ }
+ )
raise ValidationError(message)
def __call__(self, value):
diff --git a/src/django_resumable_async_upload/views.py b/src/django_resumable_async_upload/views.py
new file mode 100644
index 0000000..1ad00c4
--- /dev/null
+++ b/src/django_resumable_async_upload/views.py
@@ -0,0 +1,79 @@
+from django.contrib.auth.decorators import login_required
+from django.contrib.contenttypes.models import ContentType
+from django.http import HttpResponse, JsonResponse
+from django.utils.functional import cached_property
+from django.views.generic import View
+from django_resumable_async_upload.files import ResumableFile
+from django.core.files.storage import default_storage
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class UploadView(View):
+ """View to handle resumable file uploads via AJAX.
+ Supports POST for uploading chunks, GET for checking chunk existence,
+ and DELETE for removing uploaded files.
+ """
+
+ # inspired by another fork https://github.com/fdemmer/django-admin-resumable-js
+
+ @cached_property
+ def request_data(self):
+ return getattr(self.request, self.request.method)
+
+ @cached_property
+ def model_upload_field(self):
+ content_type = ContentType.objects.get_for_id(
+ self.request_data["content_type_id"]
+ )
+ return content_type.model_class()._meta.get_field(
+ self.request_data["field_name"]
+ )
+
+ def post(self, request, *args, **kwargs):
+ chunk = request.FILES.get("file")
+ r = ResumableFile(
+ self.model_upload_field, user=request.user, params=request.POST
+ )
+ if not r.chunk_exists:
+ r.process_chunk(chunk)
+ if r.is_complete:
+ file_path = r.collect()
+ return HttpResponse(file_path)
+ return HttpResponse("chunk uploaded")
+
+ def get(self, request, *args, **kwargs):
+ r = ResumableFile(
+ self.model_upload_field, user=request.user, params=request.GET
+ )
+ if not r.chunk_exists:
+ return HttpResponse("chunk not found", status=204)
+ if r.is_complete:
+ return HttpResponse(r.collect())
+ return HttpResponse("chunk exists")
+
+ def delete(self, request, *args, **kwargs):
+ """Handle file deletion via DELETE request."""
+ file_path = None
+ try:
+ # Parse the file path from request body
+ body = json.loads(request.body.decode("utf-8"))
+ file_path = body.get("file_path")
+
+ if not file_path:
+ return JsonResponse({"error": "file_path required"}, status=400)
+
+ # Delete from storage
+ if default_storage.exists(file_path):
+ default_storage.delete(file_path)
+ return JsonResponse({"status": "success", "message": "File removed"})
+ except Exception as e:
+ logger.error(f"Failed to delete file: {str(e)}")
+ return JsonResponse(
+ {"error": f"Failed to delete file: {file_path} "}, status=500
+ )
+
+
+admin_resumable = login_required(UploadView.as_view())
diff --git a/src/django_resumable_async_upload/widgets.py b/src/django_resumable_async_upload/widgets.py
new file mode 100644
index 0000000..e585757
--- /dev/null
+++ b/src/django_resumable_async_upload/widgets.py
@@ -0,0 +1,103 @@
+from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
+from django.db.models.fields.files import FieldFile
+from django.forms import FileInput, CheckboxInput, forms
+from django.template import loader
+from django.templatetags.static import static
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy
+
+from django_resumable_async_upload.storage import ResumableStorage
+
+
+class ResumableBaseWidget(FileInput):
+ template_name = "admin_resumable/admin_file_input.html"
+ clear_checkbox_label = gettext_lazy("Clear")
+ allow_multiple_selected = False # Can be overridden per instance
+
+ def __init__(self, attrs=None):
+ super().__init__(attrs)
+
+ if attrs and attrs.get("max_files") != 1:
+ self.allow_multiple_selected = True
+
+ def render(self, name, value, attrs=None, **kwargs):
+ persistent_storage = ResumableStorage().get_persistent_storage()
+ if value:
+ if isinstance(value, FieldFile):
+ value_name = value.name
+ else:
+ value_name = value
+ file_name = value
+ file_url = mark_safe(persistent_storage.url(value_name))
+
+ else:
+ file_name = ""
+ file_url = ""
+
+ chunk_size = getattr(settings, "ADMIN_RESUMABLE_CHUNKSIZE", "1*1024*1024")
+ show_thumb = getattr(settings, "ADMIN_RESUMABLE_SHOW_THUMB", False)
+ simultaneous_uploads = getattr(settings, "ADMIN_SIMULTANEOUS_UPLOADS", 3)
+ media_url = getattr(settings, "MEDIA_URL", None)
+ max_files = self.attrs.get("max_files", None)
+
+ content_type_id = ContentType.objects.get_for_model(self.attrs["model"]).id
+
+ context = {
+ "name": name,
+ "value": value,
+ "id": attrs["id"],
+ "chunk_size": chunk_size,
+ "show_thumb": show_thumb,
+ "field_name": self.attrs["field_name"],
+ "content_type_id": content_type_id,
+ "file_url": file_url,
+ "file_name": file_name,
+ "simultaneous_uploads": simultaneous_uploads,
+ "max_files": max_files,
+ "MEDIA_URL": media_url,
+ }
+
+ instance = self.attrs.get("instance")
+ if instance and instance.pk:
+ context["instance_id"] = instance.pk
+
+ if not self.is_required:
+ template_with_clear = (
+ '%(clear)s '
+ ''
+ )
+ substitutions = {
+ "clear_checkbox_id": attrs["id"] + "-clear-id",
+ "clear_checkbox_name": attrs["id"] + "-clear",
+ "clear_checkbox_label": self.clear_checkbox_label,
+ }
+ substitutions["clear"] = CheckboxInput().render(
+ substitutions["clear_checkbox_name"],
+ False,
+ attrs={"id": substitutions["clear_checkbox_id"]},
+ )
+ clear_checkbox = mark_safe(template_with_clear % substitutions)
+ context.update({"clear_checkbox": clear_checkbox})
+ return loader.render_to_string(self.template_name, context)
+
+ def value_from_datadict(self, data, files, name):
+ if not self.is_required and data.get("id_" + name + "-clear"):
+ return False # False signals to clear any existing value, as opposed to just None
+
+ value = data.get(name, None)
+ if value in ["None", "False", None]:
+ return None
+
+ return value
+
+
+class ResumableAdminWidget(ResumableBaseWidget):
+ @property
+ def media(self):
+ js = ["resumable.js"]
+ return forms.Media(js=[static("admin_resumable/js/%s" % path) for path in js])
+
+
+class ResumableWidget(ResumableBaseWidget):
+ template_name = "admin_resumable/user_file_input.html"
diff --git a/tests/admin.py b/tests/admin.py
index 718291c..cfb6640 100644
--- a/tests/admin.py
+++ b/tests/admin.py
@@ -5,4 +5,5 @@
class FooAdmin(admin.ModelAdmin):
pass
+
admin.site.register(Foo, FooAdmin)
diff --git a/tests/conftest.py b/tests/conftest.py
index f89114c..5d8699b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,36 +1,29 @@
import pytest
import os
-from selenium import webdriver
+import tempfile
-browsers = {
- "firefox": webdriver.Firefox,
- #'PhantomJS': webdriver.PhantomJS,
- #'chrome': webdriver.Chrome,
-}
-browser_options = {"firefox": webdriver.FirefoxOptions()}
-
-browser_options["firefox"].add_argument("--headless")
-
-
-@pytest.fixture(scope="session", params=browsers.keys())
-def driver(request):
- b = browsers[request.param](options=browser_options[request.param])
-
- request.addfinalizer(lambda *args: b.quit())
-
- return b
+# Session-scoped fixture to create a temporary directory for test artifacts
+@pytest.fixture(scope="session")
+def test_temp_dir(tmp_path_factory):
+ """Create a temporary directory for test database and other artifacts."""
+ temp_dir = tmp_path_factory.mktemp("test_artifacts")
+ return temp_dir
def pytest_configure():
import django
from django.conf import settings
+ # Create a temporary directory for the test database
+ test_db_dir = tempfile.mkdtemp(prefix="django_test_")
+ test_db_path = os.path.join(test_db_dir, "test_db.sqlite3")
+
settings.configure(
DEBUG=False,
DEBUG_PROPAGATE_EXCEPTIONS=True,
DATABASES={
- "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}
+ "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": test_db_path}
},
SITE_ID=1,
SECRET_KEY="not very secret in tests",
@@ -38,6 +31,7 @@ def pytest_configure():
USE_L10N=True,
STATIC_URL="/static/",
ROOT_URLCONF="tests.urls",
+ LOGIN_URL="/admin/login/",
TEMPLATE_LOADERS=(
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
@@ -77,15 +71,37 @@ def pytest_configure():
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
- "admin_async_upload",
+ "django_resumable_async_upload",
"tests",
),
PASSWORD_HASHERS=("django.contrib.auth.hashers.MD5PasswordHasher",),
MEDIA_ROOT=os.path.join(os.path.dirname(__file__), "media"),
+ ADMIN_SIMULTANEOUS_UPLOADS=1,
+ # Disable async DB access for Playwright compatibility
+ DJANGO_ALLOW_ASYNC_UNSAFE=True,
)
+
+ # Store the test DB directory for cleanup
+ settings.TEST_DB_DIR = test_db_dir
+
+ # Allow async unsafe operations for Playwright tests
+ os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
+
try:
import django
django.setup()
except AttributeError:
pass
+
+
+def pytest_unconfigure():
+ """Clean up temporary test database directory after all tests."""
+ import shutil
+ from django.conf import settings
+
+ if hasattr(settings, "TEST_DB_DIR") and os.path.exists(settings.TEST_DB_DIR):
+ try:
+ shutil.rmtree(settings.TEST_DB_DIR)
+ except Exception as e:
+ print(f"Warning: Could not clean up test DB directory: {e}")
diff --git a/tests/models.py b/tests/models.py
index 12ca859..41214ae 100644
--- a/tests/models.py
+++ b/tests/models.py
@@ -1,11 +1,11 @@
from django.conf import settings
from django.db import models
-from admin_async_upload.models import AsyncFileField
+from django_resumable_async_upload.models import AsyncFileField
# Stolen from the README
class Foo(models.Model):
bar = models.CharField(max_length=200)
foo = AsyncFileField()
- bat = AsyncFileField(upload_to=settings.MEDIA_ROOT + '/upto/')
+ bat = AsyncFileField(upload_to=settings.MEDIA_ROOT + "/upto/")
diff --git a/tests/test_storage.py b/tests/test_storage.py
new file mode 100644
index 0000000..ebcba1d
--- /dev/null
+++ b/tests/test_storage.py
@@ -0,0 +1,147 @@
+from unittest.mock import Mock, patch
+from django.test import override_settings
+from django.core.files.storage import FileSystemStorage
+
+from django_resumable_async_upload.storage import ResumableStorage
+
+
+class TestResumableStorage:
+ """Tests for ResumableStorage class."""
+
+ def test_init_without_custom_settings(self):
+ """Test initialization with no custom storage settings."""
+ storage = ResumableStorage()
+ assert storage.persistent_storage_name is None
+ assert storage.chunk_storage_name is None
+
+ @override_settings(ADMIN_RESUMABLE_STORAGE="custom.storage.Backend")
+ def test_init_with_custom_persistent_storage(self):
+ """Test initialization with custom persistent storage setting."""
+ storage = ResumableStorage()
+ assert storage.persistent_storage_name == "custom.storage.Backend"
+ assert storage.chunk_storage_name is None
+
+ @override_settings(ADMIN_RESUMABLE_CHUNK_STORAGE="custom.chunk.Storage")
+ def test_init_with_custom_chunk_storage(self):
+ """Test initialization with custom chunk storage setting."""
+ storage = ResumableStorage()
+ assert storage.persistent_storage_name is None
+ assert storage.chunk_storage_name == "custom.chunk.Storage"
+
+ def test_get_chunk_storage_default(self):
+ """Test that chunk storage defaults to FileSystemStorage."""
+ storage = ResumableStorage()
+ chunk_storage = storage.get_chunk_storage()
+ assert isinstance(chunk_storage, FileSystemStorage)
+
+ @override_settings(
+ ADMIN_RESUMABLE_CHUNK_STORAGE="django.core.files.storage.FileSystemStorage"
+ )
+ def test_get_chunk_storage_custom_class_path(self):
+ """Test chunk storage with custom class path."""
+ storage = ResumableStorage()
+ chunk_storage = storage.get_chunk_storage()
+ assert isinstance(chunk_storage, FileSystemStorage)
+
+ def test_get_persistent_storage_default(self):
+ """Test that persistent storage defaults appropriately."""
+ storage = ResumableStorage()
+ persistent_storage = storage.get_persistent_storage()
+ # Should return some storage instance
+ assert hasattr(persistent_storage, "save")
+ assert hasattr(persistent_storage, "delete")
+
+ @override_settings(
+ ADMIN_RESUMABLE_STORAGE="django.core.files.storage.FileSystemStorage"
+ )
+ def test_get_persistent_storage_custom(self):
+ """Test persistent storage with custom setting."""
+ storage = ResumableStorage()
+ persistent_storage = storage.get_persistent_storage()
+ assert isinstance(persistent_storage, FileSystemStorage)
+
+ def test_full_filename_with_string_upload_to(self):
+ """Test full_filename generation with string upload_to."""
+ storage = ResumableStorage()
+ filename = storage.full_filename("test.txt", "uploads/%Y/%m/%d", instance=None)
+
+ # Should contain the filename and have path structure
+ assert "test.txt" in filename
+ assert "/" in filename
+
+ def test_full_filename_with_callable_upload_to(self):
+ """Test full_filename generation with callable upload_to."""
+
+ def custom_upload_to(instance, filename):
+ return f"custom/{filename}"
+
+ storage = ResumableStorage()
+ mock_instance = Mock()
+ filename = storage.full_filename(
+ "test.txt", custom_upload_to, instance=mock_instance
+ )
+
+ assert "custom" in filename
+ assert "test.txt" in filename
+
+ @override_settings(
+ DEFAULT_FILE_STORAGE="django.core.files.storage.FileSystemStorage"
+ )
+ def test_persistent_storage_respects_default_file_storage(self):
+ """Test that persistent storage uses DEFAULT_FILE_STORAGE when ADMIN_RESUMABLE_STORAGE is not set."""
+ storage = ResumableStorage()
+ persistent_storage = storage.get_persistent_storage()
+ assert isinstance(persistent_storage, FileSystemStorage)
+
+ def test_chunk_storage_always_defaults_to_filesystem(self):
+ """
+ Test that chunk storage always defaults to FileSystemStorage,
+ even if default storage is configured differently.
+ This ensures chunks are written locally for performance.
+ """
+ storage = ResumableStorage()
+ chunk_storage = storage.get_chunk_storage()
+ # Should always be FileSystemStorage by default, not following default storage
+ assert isinstance(chunk_storage, FileSystemStorage)
+
+ @patch("django_resumable_async_upload.storage.storages", None)
+ def test_get_chunk_storage_older_django(self):
+ """Test chunk storage behavior with older Django (no storages API)."""
+ storage = ResumableStorage()
+ chunk_storage = storage.get_chunk_storage()
+ assert isinstance(chunk_storage, FileSystemStorage)
+
+ @patch("django_resumable_async_upload.storage.storages", None)
+ def test_get_persistent_storage_older_django(self):
+ """Test persistent storage behavior with older Django (no storages API)."""
+ storage = ResumableStorage()
+ persistent_storage = storage.get_persistent_storage()
+ assert hasattr(persistent_storage, "save")
+
+ def test_multiple_storage_instances_independent(self):
+ """Test that multiple ResumableStorage instances are independent."""
+ storage1 = ResumableStorage()
+ storage2 = ResumableStorage()
+
+ chunk1 = storage1.get_chunk_storage()
+ chunk2 = storage2.get_chunk_storage()
+
+ # Should be different instances
+ assert chunk1 is not chunk2
+
+ def test_storage_methods_exist(self):
+ """Test that storage instances have required methods."""
+ storage = ResumableStorage()
+
+ chunk_storage = storage.get_chunk_storage()
+ assert hasattr(chunk_storage, "save")
+ assert hasattr(chunk_storage, "delete")
+ assert hasattr(chunk_storage, "exists")
+ assert hasattr(chunk_storage, "listdir")
+ assert hasattr(chunk_storage, "size")
+
+ persistent_storage = storage.get_persistent_storage()
+ assert hasattr(persistent_storage, "save")
+ assert hasattr(persistent_storage, "delete")
+ assert hasattr(persistent_storage, "exists")
+ assert hasattr(persistent_storage, "url")
diff --git a/tests/test_uploads.py b/tests/test_uploads.py
index 0235b0b..540e1e0 100644
--- a/tests/test_uploads.py
+++ b/tests/test_uploads.py
@@ -4,10 +4,6 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
-from selenium.webdriver.common.by import By
-from selenium.webdriver.support.ui import WebDriverWait
-from selenium.webdriver.support import expected_conditions as EC
-
import os
import pytest
import time
@@ -51,6 +47,7 @@ def form_value_list(key, value):
file_data = "foo bar foo bar."
file_size = str(len(file_data))
form_vals += form_value_list("resumableChunkNumber", "1")
+ form_vals += form_value_list("resumableCurrentChunkSize", file_size)
form_vals += form_value_list("resumableChunkSize", file_size)
form_vals += form_value_list("resumableType", "text/plain")
form_vals += form_value_list("resumableIdentifier", file_size + "-foobar")
@@ -165,50 +162,442 @@ def form_value_list(key, value):
@pytest.mark.django_db
-def test_real_file_upload(admin_user, live_server, driver):
- test_file_path = "/tmp/test_small_file.bin"
+def test_real_file_upload(admin_user, live_server, page):
+ test_file_path = "/tmp/test_small_file_success.bin"
+ # Clean up any existing test file from prior runs just in case
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
create_test_file(test_file_path, 5)
- driver.get(live_server.url + "/admin/")
- driver.find_element(By.ID, "id_username").send_keys("admin")
- driver.find_element(By.ID, "id_password").send_keys("password")
- driver.find_element(By.XPATH, '//input[@value="Log in"]').click()
- driver.implicitly_wait(2)
- driver.get(live_server.url + "/admin/tests/foo/add/")
- WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.ID, "id_bar")))
- driver.find_element(By.ID, "id_bar").send_keys("bat")
- driver.find_element(By.ID, "id_foo_input_file").send_keys(test_file_path)
- status_text = driver.find_element(By.ID, "id_foo_uploaded_status").text
- print("status_text", status_text)
- i = 0
- while i < 5:
- if "Uploaded" in driver.find_element(By.ID, "id_foo_uploaded_status").text:
- return # success
- time.sleep(1)
- i += 1
- assert False, f"Status text is '{driver.find_element(By.ID, 'id_foo_uploaded_status').text}'; expected 'Uploaded"
+ page.goto(live_server.url + "/admin/")
+
+ # Wait for login page to load and fill in credentials
+ page.wait_for_selector("#id_username")
+ page.fill("#id_username", "admin")
+ page.fill("#id_password", "password")
+ page.click('input[value="Log in"]')
+
+ # Wait for successful login - check that we're no longer on the login page
+ page.wait_for_url(lambda url: "/login/" not in url, timeout=10000)
+
+ # Verify we can see the admin dashboard (session is working)
+ page.wait_for_selector("#content")
+
+ # Add extra wait to ensure session cookie is fully set
+ time.sleep(2)
+
+ page.goto(live_server.url + "/admin/tests/foo/add/")
+ page.wait_for_selector("#id_foo_input_file", timeout=15000)
+ page.set_input_files("#id_foo_input_file", test_file_path)
+
+ try:
+ # Wait for at least one file-status element to appear
+ page.wait_for_selector(".file-status", timeout=15000)
+
+ # Wait for the upload to complete by checking for "Uploaded" or "✓" in the status
+ page.wait_for_function(
+ """
+ () => {
+ const elements = document.querySelectorAll('.file-status');
+ return Array.from(elements).some(elem =>
+ elem.textContent.includes('Uploaded') || elem.textContent.includes('✓')
+ );
+ }
+ """,
+ timeout=20000,
+ )
+
+ # Verify the upload completed successfully
+ status_elements = page.locator(".file-status").all()
+ status_texts = [elem.text_content() for elem in status_elements]
+ assert any("Uploaded" in text or "✓" in text for text in status_texts), (
+ f"No file status contains 'Uploaded' or '✓'. Found: {status_texts}"
+ )
+
+ except Exception as e:
+ # Print page content for debugging
+ print("Page source:", page.content())
+ print("Console logs:", page.evaluate("() => console.log('Debug info')"))
+
+ raise
+ finally:
+ # Clean up test file
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
+
+
+@pytest.mark.django_db
+def test_real_file_upload_multiple(admin_user, live_server, page):
+ test_file_path_1 = "/tmp/test_small_file_1.bin"
+ test_file_path_2 = "/tmp/test_small_file_2.bin"
+ # Clean up any existing test file from prior runs just in case
+ if os.path.exists(test_file_path_1):
+ os.unlink(test_file_path_1)
+ if os.path.exists(test_file_path_2):
+ os.unlink(test_file_path_2)
+ create_test_file(test_file_path_1, 5)
+ create_test_file(test_file_path_2, 5)
+
+ page.goto(live_server.url + "/admin/")
+
+ # Wait for login page and fill credentials
+ page.wait_for_selector("#id_username")
+ page.fill("#id_username", "admin")
+ page.fill("#id_password", "password")
+ page.click('input[value="Log in"]')
+
+ # Wait for successful login
+ page.wait_for_url(lambda url: "/login/" not in url, timeout=10000)
+ page.wait_for_selector("#content")
+ time.sleep(2)
+
+ page.goto(live_server.url + "/admin/tests/foo/add/")
+ page.wait_for_selector("#id_bar")
+ page.fill("#id_bar", "bat")
+
+ # Wait for file input and upload multiple files
+ page.wait_for_selector("#id_foo_input_file")
+ time.sleep(1)
+ page.set_input_files("#id_foo_input_file", [test_file_path_1, test_file_path_2])
+
+ try:
+ # Wait for file-status elements
+ page.wait_for_selector(".file-status", timeout=15000)
+
+ # Wait for uploads to complete
+ page.wait_for_function(
+ """
+ () => {
+ const elements = document.querySelectorAll('.file-status');
+ return Array.from(elements).some(elem =>
+ elem.textContent.includes('Uploaded') || elem.textContent.includes('✓')
+ );
+ }
+ """,
+ timeout=20000,
+ )
+
+ # Verify uploads completed successfully
+ status_elements = page.locator(".file-status").all()
+ status_texts = [elem.text_content() for elem in status_elements]
+
+ assert any("Uploaded" in text or "✓" in text for text in status_texts), (
+ f"No file status contains 'Uploaded' or '✓'. Found: {status_texts}"
+ )
+ assert len(status_elements) == 2, (
+ f"Expected 2 file-status elements, found {len(status_elements)}"
+ )
+
+ except Exception as e:
+ print("Page source:", page.content())
+ raise
+ finally:
+ if os.path.exists(test_file_path_1):
+ os.unlink(test_file_path_1)
+ if os.path.exists(test_file_path_2):
+ os.unlink(test_file_path_2)
+
+
+@pytest.mark.django_db
+def test_real_file_upload_cancel_single_file(admin_user, live_server, page):
+ test_file_path = "/tmp/test_small_file_cancel.bin"
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
+ create_test_file(test_file_path, 5)
+
+ page.goto(live_server.url + "/admin/")
+ page.wait_for_selector("#id_username")
+ page.fill("#id_username", "admin")
+ page.fill("#id_password", "password")
+ page.click('input[value="Log in"]')
+ page.wait_for_url(lambda url: "/login/" not in url, timeout=10000)
+ page.wait_for_selector("#content")
+ time.sleep(2)
+
+ page.goto(live_server.url + "/admin/tests/foo/add/")
+ page.wait_for_selector("#id_foo_input_file", timeout=15000)
+ page.set_input_files("#id_foo_input_file", test_file_path)
+
+ try:
+ # Wait for file-status element
+ page.wait_for_selector(".file-status", timeout=20000)
+
+ # Wait for upload to start
+ page.wait_for_function(
+ """
+ () => {
+ const progress = document.querySelector('.file-progress');
+ return progress && parseFloat(progress.value) > 0;
+ }
+ """,
+ timeout=15000,
+ )
+
+ # Click cancel button
+ cancel_button = page.locator(".file-cancel-btn").first
+ cancel_button.wait_for(state="visible", timeout=10000)
+ cancel_button.click()
+
+ time.sleep(2)
+
+ # Verify cancellation
+ status_elements = page.locator(".file-status").all()
+ status_texts = [elem.text_content() for elem in status_elements]
+
+ assert all("Uploaded" not in text and "✓" not in text for text in status_texts)
+ assert len(status_elements) == 0, (
+ f"Expected 0 file-status elements after cancellation, found {len(status_elements)}"
+ )
+ except Exception as e:
+ print("Page source:", page.content())
+ raise
+ finally:
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
@pytest.mark.django_db
-def test_real_file_upload_with_upload_to(admin_user, live_server, driver):
- test_file_path = "/tmp/test_small_file.bin"
+def test_real_file_upload_cancel_all_files(admin_user, live_server, page):
+ test_file_path = "/tmp/test_large_file_cancel_all.bin"
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
+ create_test_file(test_file_path, 50)
+
+ page.goto(live_server.url + "/admin/")
+ page.wait_for_selector("#id_username")
+ page.fill("#id_username", "admin")
+ page.fill("#id_password", "password")
+ page.click('input[value="Log in"]')
+ page.wait_for_url(lambda url: "/login/" not in url, timeout=10000)
+ page.wait_for_selector("#content")
+ time.sleep(2)
+
+ page.goto(live_server.url + "/admin/tests/foo/add/")
+ page.wait_for_selector("#id_foo_input_file")
+ page.set_input_files("#id_foo_input_file", test_file_path)
+
+ try:
+ # Wait for cancel button to be clickable
+ page.wait_for_selector("#id_foo_cancel", state="visible", timeout=15000)
+ page.wait_for_selector(".file-status", timeout=5000)
+
+ # Click cancel
+ page.click("#id_foo_cancel")
+ time.sleep(2)
+
+ # Verify no status elements remain
+ status_elements = page.locator(".file-status").all()
+ assert len(status_elements) == 0, (
+ f"Expected 0 file-status elements after cancellation, found {len(status_elements)}"
+ )
+
+ # Verify controls are hidden
+ controls_visible = page.locator("#id_foo_controls").is_visible()
+ assert not controls_visible, (
+ "Controls element should be hidden after cancelling all uploads"
+ )
+ except Exception as e:
+ print("Page source:", page.content())
+ raise
+ finally:
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
+
+
+@pytest.mark.django_db
+def test_real_file_upload_pause_resume(admin_user, live_server, page, settings):
+ settings.ADMIN_RESUMABLE_CHUNKSIZE = "100*1024" # 100KB chunks
+ test_file_path = "/tmp/test_large_file_cancel_all.bin"
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
create_test_file(test_file_path, 5)
- driver.get(live_server.url + "/admin/")
- driver.find_element(By.ID, "id_username").send_keys("admin")
- driver.find_element(By.ID, "id_password").send_keys("password")
- driver.find_element(By.XPATH, '//input[@value="Log in"]').click()
- driver.implicitly_wait(2)
- driver.get(live_server.url + "/admin/tests/foo/add/")
- WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.ID, "id_bar")))
- driver.find_element(By.ID, "id_bar").send_keys("bat")
- driver.find_element(By.ID, "id_bat_input_file").send_keys(test_file_path)
- status_text = driver.find_element(By.ID, "id_bat_uploaded_status").text
- print("status_text", status_text)
- i = 0
- while i < 5:
- if "Uploaded" in driver.find_element(By.ID, "id_bat_uploaded_status").text:
- return # success
+ page.goto(live_server.url + "/admin/")
+ page.wait_for_selector("#id_username")
+ page.fill("#id_username", "admin")
+ page.fill("#id_password", "password")
+ page.click('input[value="Log in"]')
+ page.wait_for_url(lambda url: "/login/" not in url, timeout=10000)
+ page.wait_for_selector("#content")
+ time.sleep(2)
+
+ page.goto(live_server.url + "/admin/tests/foo/add/")
+ page.wait_for_selector("#id_foo_input_file")
+ page.set_input_files("#id_foo_input_file", test_file_path)
+
+ try:
+ # Wait for file-status element
+ page.wait_for_selector(".file-status", timeout=15000)
+ page.wait_for_selector("#id_foo_pause", state="visible", timeout=10000)
+
+ # Get initial progress
+ progress_before_pause = float(
+ page.locator(".file-progress").first.get_attribute("value")
+ )
+ assert progress_before_pause > 0, (
+ f"Upload has not started. Progress: {progress_before_pause}"
+ )
+
+ # Pause upload
+ page.click("#id_foo_pause")
time.sleep(1)
- i += 1
- assert False, f"Status text is {driver.find_element(By.ID, 'id_bat_uploaded_status').text}; Expected 'Uploaded'"
+
+ # Get progress after pausing
+ progress_during_pause_1 = float(
+ page.locator(".file-progress").first.get_attribute("value")
+ )
+ time.sleep(5)
+ progress_during_pause_2 = float(
+ page.locator(".file-progress").first.get_attribute("value")
+ )
+
+ # Verify progress hasn't increased while paused
+ assert abs(progress_during_pause_2 - progress_during_pause_1) < 0.05, (
+ f"Upload continued while paused. First: {progress_during_pause_1}, Second: {progress_during_pause_2}"
+ )
+
+ # Resume upload
+ page.click("#id_foo_resume")
+
+ # Wait for completion
+ page.wait_for_function(
+ """
+ () => {
+ const elements = document.querySelectorAll('.file-status');
+ return Array.from(elements).some(elem =>
+ elem.textContent.includes('Uploaded') || elem.textContent.includes('✓')
+ );
+ }
+ """,
+ timeout=20000,
+ )
+
+ progress_after_resume = float(
+ page.locator(".file-progress").first.get_attribute("value")
+ )
+
+ # Verify progress after resume
+ assert progress_after_resume > progress_during_pause_2, (
+ f"Upload did not progress after resume. During pause: {progress_during_pause_2}, After: {progress_after_resume}"
+ )
+ assert progress_after_resume == 1.0, (
+ f"Upload did not complete after resume. Final progress: {progress_after_resume}"
+ )
+
+ except Exception as e:
+ print("Page source:", page.content())
+ raise
+ finally:
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
+
+
+def test_real_file_upload_file_error(admin_user, live_server, page):
+ test_file_path = "/tmp/test_failed_file.bin"
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
+ create_test_file(test_file_path, 5)
+
+ page.goto(live_server.url + "/admin/")
+ page.wait_for_selector("#id_username")
+ page.fill("#id_username", "admin")
+ page.fill("#id_password", "password")
+ page.click('input[value="Log in"]')
+ page.wait_for_url(lambda url: "/login/" not in url, timeout=10000)
+ page.wait_for_selector("#content")
+ time.sleep(2)
+
+ page.goto(live_server.url + "/admin/tests/foo/add/")
+ page.wait_for_selector("#id_bar")
+ page.fill("#id_bar", "bat")
+
+ # Inject JavaScript to mock error response
+ page.evaluate("""
+ (function() {
+ var OriginalXHR = window.XMLHttpRequest;
+ window.XMLHttpRequest = function() {
+ var xhr = new OriginalXHR();
+ var originalOpen = xhr.open;
+ var originalSend = xhr.send;
+
+ xhr.open = function(method, url) {
+ this._method = method;
+ this._url = url;
+ return originalOpen.apply(this, arguments);
+ };
+
+ xhr.send = function(data) {
+ var self = this;
+
+ if (this._method === 'POST' && this._url.includes('admin_resumable')) {
+ setTimeout(function() {
+ Object.defineProperty(self, 'status', {
+ writable: true,
+ configurable: true,
+ value: 500
+ });
+ Object.defineProperty(self, 'readyState', {
+ writable: true,
+ configurable: true,
+ value: 4
+ });
+ Object.defineProperty(self, 'responseText', {
+ writable: true,
+ configurable: true,
+ value: 'Internal Server Error'
+ });
+
+ var event = new Event('load');
+
+ if (self.onreadystatechange) {
+ self.onreadystatechange(event);
+ }
+ if (self.onload) {
+ self.onload(event);
+ }
+
+ self.dispatchEvent(event);
+ }, 100);
+ return;
+ }
+ return originalSend.call(this, data);
+ };
+
+ return xhr;
+ };
+ })();
+ """)
+
+ # Wait for file input and upload
+ page.wait_for_selector("#id_foo_input_file")
+ time.sleep(1)
+ page.set_input_files("#id_foo_input_file", test_file_path)
+
+ try:
+ # Wait for error message in file-status
+ page.wait_for_function(
+ """
+ () => {
+ const elements = document.querySelectorAll('.file-status');
+ return Array.from(elements).some(elem =>
+ elem.textContent.includes('Error')
+ );
+ }
+ """,
+ timeout=20000,
+ )
+
+ # Verify error message is displayed
+ status_elements = page.locator(".file-status").all()
+ status_texts = [elem.text_content() for elem in status_elements]
+
+ assert any("Error" in text for text in status_texts), (
+ f"No file status contains 'Error'. Found: {status_texts}"
+ )
+
+ except Exception as e:
+ print("Page source:", page.content())
+ raise
+ finally:
+ if os.path.exists(test_file_path):
+ os.unlink(test_file_path)
diff --git a/tests/urls.py b/tests/urls.py
index 5cd5888..a23f63a 100644
--- a/tests/urls.py
+++ b/tests/urls.py
@@ -5,14 +5,17 @@
urlpatterns = [
- path('admin_resumable/', include('admin_async_upload.urls')),
- path('admin/', admin.site.urls),
+ path("admin_resumable/", include("django_resumable_async_upload.urls")),
+ path("admin/", admin.site.urls),
]
if settings.DEBUG:
# static files (images, css, javascript, etc.)
urlpatterns += [
- (r'^media/(?P.*)$', django.views.static.serve,
- {'document_root': settings.MEDIA_ROOT})
+ (
+ r"^media/(?P.*)$",
+ django.views.static.serve,
+ {"document_root": settings.MEDIA_ROOT},
+ )
]
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index a6cf219..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,13 +0,0 @@
-[tox]
-envlist = {py310,py311}-django{4.1,4.2}
-
-[testenv]
-#rsx = report all errors, -s = capture=no, -x = fail fast, --pdb for local testing http://www.linuxcertif.com/man/1/py.test/
-commands = py.test -rsx -s -x
-setenv =
- PYTHONDONTWRITEBYTECODE=1
-deps =
- django4.1: Django==4.1.7
- django4.2: Django==4.2.4
- pytest-django==4.5.2
- selenium==4.8.2
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..3a1d793
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,80 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version < '3.12'",
+]
+
+[[package]]
+name = "asgiref"
+version = "3.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
+]
+
+[[package]]
+name = "django"
+version = "5.2.10"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.12'",
+]
+dependencies = [
+ { name = "asgiref", marker = "python_full_version < '3.12'" },
+ { name = "sqlparse", marker = "python_full_version < '3.12'" },
+ { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e6/e5/2671df24bf0ded831768ef79532e5a7922485411a5696f6d979568591a37/django-5.2.10.tar.gz", hash = "sha256:74df100784c288c50a2b5cad59631d71214f40f72051d5af3fdf220c20bdbbbe", size = 10880754, upload-time = "2026-01-06T18:55:26.817Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/de/f1a7cd896daec85832136ab509d9b2a6daed4939dbe26313af3e95fc5f5e/django-5.2.10-py3-none-any.whl", hash = "sha256:cf85067a64250c95d5f9067b056c5eaa80591929f7e16fbcd997746e40d6c45c", size = 8290820, upload-time = "2026-01-06T18:55:20.009Z" },
+]
+
+[[package]]
+name = "django"
+version = "6.0.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+]
+dependencies = [
+ { name = "asgiref", marker = "python_full_version >= '3.12'" },
+ { name = "sqlparse", marker = "python_full_version >= '3.12'" },
+ { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/016f7e55e855ee738a352b05139d4f8b278d0b451bd01ebef07456ef3b0e/django-6.0.1.tar.gz", hash = "sha256:ed76a7af4da21551573b3d9dfc1f53e20dd2e6c7d70a3adc93eedb6338130a5f", size = 11069565, upload-time = "2026-01-06T18:55:53.069Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/b5/814ed98bd21235c116fd3436a7ed44d47560329a6d694ec8aac2982dbb93/django-6.0.1-py3-none-any.whl", hash = "sha256:a92a4ff14f664a896f9849009cb8afaca7abe0d6fc53325f3d1895a15253433d", size = 8338791, upload-time = "2026-01-06T18:55:46.175Z" },
+]
+
+[[package]]
+name = "django-resumable-async-upload"
+version = "0.1.3"
+source = { editable = "." }
+dependencies = [
+ { name = "django", version = "5.2.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+ { name = "django", version = "6.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+]
+
+[package.metadata]
+requires-dist = [{ name = "django", specifier = ">=3.0.14" }]
+
+[[package]]
+name = "sqlparse"
+version = "0.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2025.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
+]