diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index ee8fec6..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.travis.yml b/.travis.yml index 049a55c..dccb8b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,34 +1,40 @@ language: python python: - - "3.5" + - "3.6" + - "3.7" + - "3.8" + - "3.9" + - "3.10" sudo: false env: - - TOX_ENV=py27-django1.8 - - TOX_ENV=py27-django1.9 - - TOX_ENV=py27-django1.10 - - TOX_ENV=py27-django1.11 - - TOX_ENV=py34-django1.8 - - TOX_ENV=py34-django1.9 - - TOX_ENV=py34-django1.10 - - TOX_ENV=py35-django1.8 - - TOX_ENV=py35-django1.9 - - TOX_ENV=py35-django1.10 - - TOX_ENV=py35-django1.11 + - TOX_ENV=py36-django3.0.14 + - TOX_ENV=py36-django3.1.14 + - TOX_ENV=py36-django3.2.18 + - TOX_ENV=py37-django3.0.14 + - TOX_ENV=py37-django3.1.14 + - TOX_ENV=py37-django3.2.18 + - TOX_ENV=py38-django3.0.14 + - TOX_ENV=py38-django3.1.14 + - TOX_ENV=py38-django3.2.18 + - TOX_ENV=py39-django3.0.14 + - TOX_ENV=py39-django3.1.14 + - TOX_ENV=py39-django3.2.18 + - TOX_ENV=py38-django4.0.14 + - TOX_ENV=py38-django4.1.7 + - TOX_ENV=py39-django4.0.10 + - TOX_ENV=py39-django4.1.7 + - TOX_ENV=py310-django4.0.10 + - TOX_ENV=py310-django4.1.7 + matrix: fast_finish: true -before_install: - - "export DISPLAY=:99.0" - - "sh -e /etc/init.d/xvfb start" - - sleep 6 # give xvfb some time to start - install: - pip install tox - - pip install "virtualenv<14.0.0" script: - tox -e $TOX_ENV diff --git a/admin_async_upload/files.py b/admin_async_upload/files.py index d284d77..64ddb4d 100644 --- a/admin_async_upload/files.py +++ b/admin_async_upload/files.py @@ -2,6 +2,7 @@ import fnmatch import tempfile +from django.contrib.contenttypes.models import ContentType from django.core.files import File from django.utils.functional import cached_property @@ -39,7 +40,8 @@ def chunk_storage(self): @property def storage_filename(self): - return self.resumable_storage.full_filename(self.filename, self.upload_to) + instance = self.field.model.objects.filter(pk=self.params.get("instance_id")).first() + return self.resumable_storage.full_filename(self.filename, self.upload_to, instance=instance) @property def upload_to(self): diff --git a/admin_async_upload/storage.py b/admin_async_upload/storage.py index e4c9e26..494e680 100644 --- a/admin_async_upload/storage.py +++ b/admin_async_upload/storage.py @@ -4,7 +4,7 @@ from django.core.files.storage import get_storage_class from django.conf import settings -from django.utils.encoding import force_text, force_str +from django.utils.encoding import force_str class ResumableStorage(object): @@ -40,7 +40,10 @@ def get_persistent_storage(self, *args, **kwargs): storage_class = get_storage_class(self.persistent_storage_class_name) return storage_class(*args, **kwargs) - def full_filename(self, filename, upload_to): - dirname = force_text(datetime.datetime.now().strftime(force_str(upload_to))) - filename = posixpath.join(dirname, filename) + 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 index 68d463a..f985d75 100644 --- a/admin_async_upload/templates/admin_resumable/admin_file_input.html +++ b/admin_async_upload/templates/admin_resumable/admin_file_input.html @@ -30,7 +30,8 @@ query: { csrfmiddlewaretoken: $("input[name='csrfmiddlewaretoken']").val(), field_name: '{{ field_name }}', {# FIXME: this probably should be checked at run time for added inlines #} - content_type_id: '{{ content_type_id }}' {# FIXME: this probably should be checked at run time for added inlines #} + content_type_id: '{{ content_type_id }}', {# FIXME: this probably should be checked at run time for added inlines #} + instance_id: '{{ instance_id }}' }, simultaneousUploads: {{ simultaneous_uploads }}, //3 is better, 1 is used for local testing; }); diff --git a/admin_async_upload/urls.py b/admin_async_upload/urls.py index dbcae4d..b1d8903 100644 --- a/admin_async_upload/urls.py +++ b/admin_async_upload/urls.py @@ -1,7 +1,7 @@ -from django.conf.urls import url +from django.urls import path from . import views urlpatterns = [ - url(r'^upload/$', views.admin_resumable, name='admin_resumable'), + path('upload/', views.admin_resumable, name='admin_resumable'), ] diff --git a/admin_async_upload/validators.py b/admin_async_upload/validators.py index 81b501a..ea76676 100644 --- a/admin_async_upload/validators.py +++ b/admin_async_upload/validators.py @@ -1,7 +1,7 @@ from admin_async_upload.storage import ResumableStorage from os.path import splitext from django.core.exceptions import ValidationError -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class StorageFileValidator(object): diff --git a/admin_async_upload/widgets.py b/admin_async_upload/widgets.py index 3e356a7..b6a98ff 100644 --- a/admin_async_upload/widgets.py +++ b/admin_async_upload/widgets.py @@ -5,14 +5,14 @@ from django.template import loader from django.templatetags.static import static from django.utils.safestring import mark_safe -from django.utils.translation import ugettext_lazy +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 = ugettext_lazy('Clear') + clear_checkbox_label = gettext_lazy('Clear') def render(self, name, value, attrs=None, **kwargs): persistent_storage = ResumableStorage().get_persistent_storage() @@ -33,6 +33,8 @@ def render(self, name, value, attrs=None, **kwargs): simultaneous_uploads = getattr(settings, 'ADMIN_SIMULTANEOUS_UPLOADS', 3) content_type_id = ContentType.objects.get_for_model(self.attrs['model']).id + instance = self.attrs.get('instance') + instance_id = instance.pk if instance else None context = { 'name': name, @@ -42,6 +44,7 @@ def render(self, name, value, attrs=None, **kwargs): 'show_thumb': show_thumb, 'field_name': self.attrs['field_name'], 'content_type_id': content_type_id, + 'instance_id': instance_id, 'file_url': file_url, 'file_name': file_name, 'simultaneous_uploads': simultaneous_uploads, diff --git a/setup.py b/setup.py index 2aabd7b..088f556 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', ], install_requires=[ - 'Django>=1.8', + 'Django>=3.0.14', ], tests_require=[ 'pytest-django', diff --git a/tests/conftest.py b/tests/conftest.py index 74a9f83..f89114c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,21 +1,21 @@ import pytest import os from selenium import webdriver -from pyvirtualdisplay import Display browsers = { - 'firefox': webdriver.Firefox, + "firefox": webdriver.Firefox, #'PhantomJS': webdriver.PhantomJS, #'chrome': webdriver.Chrome, } +browser_options = {"firefox": webdriver.FirefoxOptions()} -@pytest.fixture(scope='session', - params=browsers.keys()) +browser_options["firefox"].add_argument("--headless") + + +@pytest.fixture(scope="session", params=browsers.keys()) def driver(request): - display = Display(visible=0, size=(1024, 768)) - display.start() - b = browsers[request.param]() + b = browsers[request.param](options=browser_options[request.param]) request.addfinalizer(lambda *args: b.quit()) @@ -30,66 +30,62 @@ def pytest_configure(): DEBUG=False, DEBUG_PROPAGATE_EXCEPTIONS=True, DATABASES={ - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:' - } + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} }, SITE_ID=1, - SECRET_KEY='not very secret in tests', + SECRET_KEY="not very secret in tests", USE_I18N=True, USE_L10N=True, - STATIC_URL='/static/', - ROOT_URLCONF='tests.urls', + STATIC_URL="/static/", + ROOT_URLCONF="tests.urls", TEMPLATE_LOADERS=( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", ), TEMPLATES=[ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", ], }, }, ], - MIDDLEWARE_CLASSES=( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', + MIDDLEWARE=( + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ), INSTALLED_APPS=( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'admin_async_upload', - 'tests', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + "admin_async_upload", + "tests", ), - PASSWORD_HASHERS=( - 'django.contrib.auth.hashers.MD5PasswordHasher', - ), - MEDIA_ROOT=os.path.join(os.path.dirname(__file__), 'media') + PASSWORD_HASHERS=("django.contrib.auth.hashers.MD5PasswordHasher",), + MEDIA_ROOT=os.path.join(os.path.dirname(__file__), "media"), ) try: import django + django.setup() except AttributeError: pass diff --git a/tests/test_uploads.py b/tests/test_uploads.py index 41f1ae5..0235b0b 100644 --- a/tests/test_uploads.py +++ b/tests/test_uploads.py @@ -14,13 +14,13 @@ def create_test_file(file_path, size_in_megabytes): - with open(file_path, 'wb') as bigfile: + with open(file_path, "wb") as bigfile: bigfile.seek(size_in_megabytes * 1024 * 1024) - bigfile.write(b'0') + bigfile.write(b"0") def clear_uploads(): - upload_path = os.path.join(settings.MEDIA_ROOT, 'admin_uploaded') + upload_path = os.path.join(settings.MEDIA_ROOT, "admin_uploaded") if not os.path.exists(upload_path): return for the_file in os.listdir(upload_path): @@ -40,12 +40,15 @@ def test_fake_file_upload(admin_user, admin_client): payload = client_module.FakePayload() def form_value_list(key, value): - return ['--' + client_module.BOUNDARY, - 'Content-Disposition: form-data; name="%s"' % key, - "", - value] + return [ + "--" + client_module.BOUNDARY, + 'Content-Disposition: form-data; name="%s"' % key, + "", + value, + ] + form_vals = [] - file_data = 'foo bar foo bar.' + file_data = "foo bar foo bar." file_size = str(len(file_data)) form_vals += form_value_list("resumableChunkNumber", "1") form_vals += form_value_list("resumableChunkSize", file_size) @@ -56,29 +59,32 @@ def form_value_list(key, value): form_vals += form_value_list("resumableTotalSize", file_size) form_vals += form_value_list("content_type_id", str(foo_ct.id)) form_vals += form_value_list("field_name", "foo") - payload.write('\r\n'.join(form_vals + [ - '--' + client_module.BOUNDARY, - 'Content-Disposition: form-data; name="file"; filename=foo.bar', - 'Content-Type: application/octet-stream', - '', - file_data, - '--' + client_module.BOUNDARY + '--\r\n' - ])) + payload.write( + "\r\n".join( + form_vals + + [ + "--" + client_module.BOUNDARY, + 'Content-Disposition: form-data; name="file"; filename=foo.bar', + "Content-Type: application/octet-stream", + "", + file_data, + "--" + client_module.BOUNDARY + "--\r\n", + ] + ) + ) r = { - 'CONTENT_LENGTH': len(payload), - 'CONTENT_TYPE': client_module.MULTIPART_CONTENT, - 'PATH_INFO': "/admin_resumable/upload/", - 'REQUEST_METHOD': 'POST', - 'wsgi.input': payload, + "CONTENT_LENGTH": len(payload), + "CONTENT_TYPE": client_module.MULTIPART_CONTENT, + "PATH_INFO": "/admin_resumable/upload/", + "REQUEST_METHOD": "POST", + "wsgi.input": payload, } response = admin_client.request(**r) assert response.status_code == 200 upload_filename = file_size + "_foo.bar" - upload_path = os.path.join(settings.MEDIA_ROOT, - upload_filename - ) - f = open(upload_path, 'r') + upload_path = os.path.join(settings.MEDIA_ROOT, upload_filename) + f = open(upload_path, "r") uploaded_contents = f.read() assert file_data == uploaded_contents @@ -91,12 +97,15 @@ def test_fake_file_upload_incomplete_chunk(admin_user, admin_client): payload = client_module.FakePayload() def form_value_list(key, value): - return ['--' + client_module.BOUNDARY, - 'Content-Disposition: form-data; name="%s"' % key, - "", - value] + return [ + "--" + client_module.BOUNDARY, + 'Content-Disposition: form-data; name="%s"' % key, + "", + value, + ] + form_vals = [] - file_data = 'foo bar foo bar.' + file_data = "foo bar foo bar." file_size = str(len(file_data)) form_vals += form_value_list("resumableChunkNumber", "1") form_vals += form_value_list("resumableChunkSize", "3") @@ -107,21 +116,26 @@ def form_value_list(key, value): form_vals += form_value_list("resumableTotalSize", file_size) form_vals += form_value_list("content_type_id", str(foo_ct.id)) form_vals += form_value_list("field_name", "foo") - payload.write('\r\n'.join(form_vals + [ - '--' + client_module.BOUNDARY, - 'Content-Disposition: form-data; name="file"; filename=foo.bar', - 'Content-Type: application/octet-stream', - '', - file_data[0:1], - # missing final boundary to simulate failure - ])) + payload.write( + "\r\n".join( + form_vals + + [ + "--" + client_module.BOUNDARY, + 'Content-Disposition: form-data; name="file"; filename=foo.bar', + "Content-Type: application/octet-stream", + "", + file_data[0:1], + # missing final boundary to simulate failure + ] + ) + ) r = { - 'CONTENT_LENGTH': len(payload), - 'CONTENT_TYPE': client_module.MULTIPART_CONTENT, - 'PATH_INFO': "/admin_resumable/admin_resumable/", - 'REQUEST_METHOD': 'POST', - 'wsgi.input': payload, + "CONTENT_LENGTH": len(payload), + "CONTENT_TYPE": client_module.MULTIPART_CONTENT, + "PATH_INFO": "/admin_resumable/admin_resumable/", + "REQUEST_METHOD": "POST", + "wsgi.input": payload, } try: admin_client.request(**r) @@ -130,21 +144,21 @@ def form_value_list(key, value): get_url = "/admin_resumable/admin_resumable/?" get_args = { - 'resumableChunkNumber': '1', - 'resumableChunkSize': '3', - 'resumableCurrentChunkSize': '3', - 'resumableTotalSize': file_size, - 'resumableType': "text/plain", - 'resumableIdentifier': file_size + "-foobar", - 'resumableFilename': "foo.bar", - 'resumableRelativePath': "foo.bar", - 'content_type_id': str(foo_ct.id), - 'field_name': "foo", + "resumableChunkNumber": "1", + "resumableChunkSize": "3", + "resumableCurrentChunkSize": "3", + "resumableTotalSize": file_size, + "resumableType": "text/plain", + "resumableIdentifier": file_size + "-foobar", + "resumableFilename": "foo.bar", + "resumableRelativePath": "foo.bar", + "content_type_id": str(foo_ct.id), + "field_name": "foo", } # we need a fresh client because client.request breaks things fresh_client = client_module.Client() - fresh_client.login(username=admin_user.username, password='password') + fresh_client.login(username=admin_user.username, password="password") get_response = fresh_client.get(get_url, get_args) # should be a 404 because we uploaded an incomplete chunk assert get_response.status_code == 404 @@ -155,27 +169,24 @@ def test_real_file_upload(admin_user, live_server, driver): test_file_path = "/tmp/test_small_file.bin" 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.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 + 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 status_text: + if "Uploaded" in driver.find_element(By.ID, "id_foo_uploaded_status").text: return # success time.sleep(1) i += 1 - raise Exception # something went wrong + assert False, f"Status text is '{driver.find_element(By.ID, 'id_foo_uploaded_status').text}'; expected 'Uploaded" @pytest.mark.django_db @@ -183,24 +194,21 @@ def test_real_file_upload_with_upload_to(admin_user, live_server, driver): test_file_path = "/tmp/test_small_file.bin" 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.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 + 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 status_text: + if "Uploaded" in driver.find_element(By.ID, "id_bat_uploaded_status").text: return # success time.sleep(1) i += 1 - raise Exception # something went wrong + assert False, f"Status text is {driver.find_element(By.ID, 'id_bat_uploaded_status').text}; Expected 'Uploaded'" diff --git a/tests/urls.py b/tests/urls.py index 90d8cab..5cd5888 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,12 +1,12 @@ import django -from django.conf.urls import include, url +from django.urls import include, path from django.contrib import admin from django.conf import settings urlpatterns = [ - url(r'^admin_resumable/', include('admin_async_upload.urls')), - url(r'^admin/', include(admin.site.urls)), + path('admin_resumable/', include('admin_async_upload.urls')), + path('admin/', admin.site.urls), ] diff --git a/tox.ini b/tox.ini index 16b2576..7bac18f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - {py27,py34,py35}-django{1.8,1.9,1.10,1.11} + {py36,py37,py38,py39}-django{3.0,3.1,3.2},{py38,py39,py310}-django{4.0,4.1} [testenv] #rsx = report all errors, -s = capture=no, -x = fail fast, --pdb for local testing http://www.linuxcertif.com/man/1/py.test/ @@ -8,10 +8,10 @@ commands = py.test -rsx -s -x setenv = PYTHONDONTWRITEBYTECODE=1 deps = - django1.8: Django==1.8.14 - django1.9: Django==1.9.9 - django1.10: Django==1.10.1 - django1.11: Django==1.11 - pytest-django==3.1.2 - selenium==2.45.0 - pyvirtualdisplay + django3.0: Django==3.0.14 + django3.1: Django==3.1.14 + django3.2: Django==3.2.18 + django4.0: Django==4.0.10 + django4.1: Django==4.1.7 + pytest-django==4.5.2 + selenium==4.8.2