From 641bf19d19fad3c3aa3a1dc00e2fb0bede0c03ae Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Fri, 8 Sep 2017 23:41:41 +0200 Subject: [PATCH 1/3] Monkey patch ClearableFileInput --- README.md | 31 +++---------------------------- s3file/apps.py | 7 ++++--- s3file/forms.py | 26 +++++++++++--------------- setup.py | 2 +- tests/test_apps.py | 9 ++++----- tests/test_forms.py | 19 ++++++++++++------- tests/testapp/forms.py | 5 ----- 7 files changed, 35 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index d12facd..68e98ec 100644 --- a/README.md +++ b/README.md @@ -46,37 +46,12 @@ MIDDLEWARE = ( ## Usage -By default S3File will replace Django's `FileField` widget, -but you can also specify the widget manually and pass custom attributes. +S3File automatically replaces Django's `ClearableFileInput` widget, +you do not need to alter your code at all. -The `FileField`'s widget is only than automatically replaced when the +The `ClearableFileInput` widget is only than automatically replaced when the `DEFAULT_FILE_STORAGE` setting is set to `django-storages`' `S3Boto3Storage`. -### Simple integrations - -**forms.py** - -```python -from django import forms -from django.db import models -from s3file.forms import S3FileInput - - -class ImageModel(models.Model): - file = models.FileField(upload_to='path/to/files') - - -class MyModelForm(forms.ModelForm): - class Meta: - model = ImageModel - fields = ('file',) - widgets = { - 'file': S3FileInput(attrs={'accept': 'image/*'}) - } -``` -**Done!** No really, that's all that needs to be done. - - ### Setting up the AWS S3 bucket ### Upload folder diff --git a/s3file/apps.py b/s3file/apps.py index 8701686..ac0a7cf 100644 --- a/s3file/apps.py +++ b/s3file/apps.py @@ -1,4 +1,5 @@ from django.apps import AppConfig +from django.core.files.storage import default_storage try: from storages.backends.s3boto3 import S3Boto3Storage @@ -11,10 +12,10 @@ class S3FileConfig(AppConfig): verbose_name = 'S3File' def ready(self): - from django.forms import FileField - from django.core.files.storage import default_storage + from django import forms if isinstance(default_storage, S3Boto3Storage): from .forms import S3FileInput - FileField.widget = S3FileInput + forms.ClearableFileInput.__new__ = \ + lambda cls, *args, **kwargs: object.__new__(S3FileInput) diff --git a/s3file/forms.py b/s3file/forms.py index ef210d6..a50c127 100644 --- a/s3file/forms.py +++ b/s3file/forms.py @@ -14,16 +14,8 @@ class S3FileInput(ClearableFileInput): """FileInput that uses JavaScript to directly upload to Amazon S3.""" needs_multipart_form = False - mime_type = None - - def __init__(self, attrs=None): - self.expires = settings.SESSION_COOKIE_AGE - self.upload_path = getattr(settings, 'S3FILE_UPLOAD_PATH', os.path.join('tmp', 's3file')) - super(S3FileInput, self).__init__(attrs=attrs) - try: - self.mime_type = self.attrs['accept'] - except KeyError: - pass + upload_path = getattr(settings, 'S3FILE_UPLOAD_PATH', os.path.join('tmp', 's3file')) + expires = settings.SESSION_COOKIE_AGE @property def bucket_name(self): @@ -35,35 +27,39 @@ def client(self): def build_attrs(self, *args, **kwargs): attrs = super(S3FileInput, self).build_attrs(*args, **kwargs) + + mime_type = attrs.get('accept', None) response = self.client.generate_presigned_post( self.bucket_name, os.path.join(self.upload_folder, '${filename}'), - Conditions=self.get_conditions(), + Conditions=self.get_conditions(mime_type), ExpiresIn=self.expires, ) + defaults = { 'data-fields-%s' % key: value for key, value in response['fields'].items() } defaults['data-url'] = response['url'] defaults.update(attrs) + try: defaults['class'] += ' s3file' except KeyError: defaults['class'] = 's3file' return defaults - def get_conditions(self): + def get_conditions(self, mime_type): conditions = [ {"bucket": self.bucket_name}, ["starts-with", "$key", self.upload_folder], {"success_action_status": "201"}, ] - if self.mime_type: - top_type, sub_type = self.mime_type.split('/', 1) + if mime_type: + top_type, sub_type = mime_type.split('/', 1) if sub_type == '*': conditions.append(["starts-with", "$Content-Type", "%s/" % top_type]) else: - conditions.append({"Content-Type": self.mime_type}) + conditions.append({"Content-Type": mime_type}) else: conditions.append(["starts-with", "$Content-Type", ""]) diff --git a/setup.py b/setup.py index 79409ea..c486b00 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='django-s3file', - version='2.0.0', + version='2.1.0', description='A lightweight file uploader input for Django and Amazon S3', author='codingjoe', url='https://github.com/codingjoe/django-s3file', diff --git a/tests/test_apps.py b/tests/test_apps.py index ff295b2..17e2971 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -1,6 +1,6 @@ import importlib -from django.forms import ClearableFileInput +from django import forms from s3file.apps import S3FileConfig from s3file.forms import S3FileInput @@ -8,10 +8,9 @@ class TestS3FileConfig: def test_ready(self, settings): - app = S3FileConfig('s3file', __import__('tests.testapp')) + app = S3FileConfig('s3file', importlib.import_module('tests.testapp')) app.ready() - forms = importlib.import_module('django.forms') - assert forms.FileField.widget == ClearableFileInput + assert not isinstance(forms.ClearableFileInput(), S3FileInput) settings.DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' app.ready() - assert forms.FileField.widget == S3FileInput + assert isinstance(forms.ClearableFileInput(), S3FileInput) diff --git a/tests/test_forms.py b/tests/test_forms.py index f409c5d..a56ff85 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -2,6 +2,7 @@ import pytest from django.core.files.storage import default_storage +from django.forms import ClearableFileInput from selenium.common.exceptions import NoSuchElementException from selenium.webdriver.support.expected_conditions import staleness_of from selenium.webdriver.support.wait import WebDriverWait @@ -30,6 +31,10 @@ class TestS3FileInput(object): def url(self): return reverse('upload') + @pytest.fixture(autouse=True) + def patch(self): + ClearableFileInput.__new__ = lambda cls, *args, **kwargs: object.__new__(S3FileInput) + @pytest.fixture def freeze(self, monkeypatch): """Freeze datetime and UUID.""" @@ -86,7 +91,7 @@ def test_build_attr(self): assert S3FileInput().build_attrs({'class': 'my-class'})['class'] == 'my-class s3file' def test_get_conditions(self, freeze): - conditions = S3FileInput().get_conditions() + conditions = S3FileInput().get_conditions(None) assert all(condition in conditions for condition in [ {"bucket": 'test-bucket'}, {"success_action_status": "201"}, @@ -96,19 +101,16 @@ def test_get_conditions(self, freeze): def test_accept(self): widget = S3FileInput() - assert widget.mime_type is None assert 'accept' not in widget.render(name='file', value='test.jpg') - assert ["starts-with", "$Content-Type", ""] in widget.get_conditions() + assert ["starts-with", "$Content-Type", ""] in widget.get_conditions(None) widget = S3FileInput(attrs={'accept': 'image/*'}) - assert widget.mime_type == 'image/*' assert 'accept="image/*"' in widget.render(name='file', value='test.jpg') - assert ["starts-with", "$Content-Type", "image/"] in widget.get_conditions() + assert ["starts-with", "$Content-Type", "image/"] in widget.get_conditions('image/*') widget = S3FileInput(attrs={'accept': 'image/jpeg'}) - assert widget.mime_type == 'image/jpeg' assert 'accept="image/jpeg"' in widget.render(name='file', value='test.jpg') - assert {"Content-Type": 'image/jpeg'} in widget.get_conditions() + assert {"Content-Type": 'image/jpeg'} in widget.get_conditions('image/jpeg') def test_no_js_error(self, driver, live_server): driver.get(live_server + self.url) @@ -129,3 +131,6 @@ def test_file_insert(self, request, driver, live_server, upload_file, freeze): with pytest.raises(NoSuchElementException): error = driver.find_element_by_xpath('//body[@JSError]') pytest.fail(error.get_attribute('JSError')) + + def test_media(self): + assert ClearableFileInput().media._js == ['s3file/js/s3file.js'] diff --git a/tests/testapp/forms.py b/tests/testapp/forms.py index 24cc5de..67d050c 100644 --- a/tests/testapp/forms.py +++ b/tests/testapp/forms.py @@ -1,7 +1,5 @@ from django import forms -from s3file.forms import S3FileInput - from .models import FileModel @@ -9,6 +7,3 @@ class UploadForm(forms.ModelForm): class Meta: model = FileModel fields = ('file',) - widgets = { - 'file': S3FileInput - } From c529aac7d82fa198abe5a97c7c0b812ad13a9ab8 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Sat, 9 Sep 2017 11:24:20 +0200 Subject: [PATCH 2/3] Drop Python 2 support --- .travis.yml | 6 +----- s3file/apps.py | 10 +++------- setup.py | 4 ++-- tests/test_forms.py | 5 +++-- tests/test_middleware.py | 2 +- tests/testapp/{storages.py => dummy_storage.py} | 0 tests/testapp/settings.py | 2 +- tox.ini | 2 +- 8 files changed, 12 insertions(+), 19 deletions(-) rename tests/testapp/{storages.py => dummy_storage.py} (100%) diff --git a/.travis.yml b/.travis.yml index 5abde3d..f7fde17 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,6 @@ dist: trusty cache: - apt - pip -services: -- redis addons: firefox: latest apt: @@ -14,13 +12,11 @@ addons: packages: - google-chrome-stable python: -- '2.7' - '3.5' - '3.6' env: global: - - GECKO_DRIVER_VERSION=v0.16.1 - - CHROME_DRIVER_VERSION=2.29 + - CHROME_DRIVER_VERSION=2.32 matrix: - DJANGO=18 - DJANGO=110 diff --git a/s3file/apps.py b/s3file/apps.py index ac0a7cf..df0216d 100644 --- a/s3file/apps.py +++ b/s3file/apps.py @@ -1,10 +1,4 @@ from django.apps import AppConfig -from django.core.files.storage import default_storage - -try: - from storages.backends.s3boto3 import S3Boto3Storage -except ImportError: - from storages.backends.s3boto import S3BotoStorage as S3BotoStorage class S3FileConfig(AppConfig): @@ -12,9 +6,11 @@ class S3FileConfig(AppConfig): verbose_name = 'S3File' def ready(self): - from django import forms + from django.core.files.storage import default_storage + from storages.backends.s3boto3 import S3Boto3Storage if isinstance(default_storage, S3Boto3Storage): + from django import forms from .forms import S3FileInput forms.ClearableFileInput.__new__ = \ diff --git a/setup.py b/setup.py index c486b00..181a983 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='django-s3file', - version='2.1.0', + version='3.0.0', description='A lightweight file uploader input for Django and Amazon S3', author='codingjoe', url='https://github.com/codingjoe/django-s3file', @@ -18,12 +18,12 @@ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Topic :: Software Development', - "Programming Language :: Python :: 2", "Programming Language :: Python :: 3", ], packages=['s3file'], include_package_data=True, install_requires=[ 'django-storages', + 'boto3', ], ) diff --git a/tests/test_forms.py b/tests/test_forms.py index a56ff85..3f74798 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -26,14 +26,15 @@ def wait_for_page_load(driver, timeout=30): ) -class TestS3FileInput(object): +class TestS3FileInput: @property def url(self): return reverse('upload') @pytest.fixture(autouse=True) def patch(self): - ClearableFileInput.__new__ = lambda cls, *args, **kwargs: object.__new__(S3FileInput) + ClearableFileInput.__new__ = \ + lambda cls, *args, **kwargs: object.__new__(S3FileInput) @pytest.fixture def freeze(self, monkeypatch): diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 382e367..2a54faa 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -5,7 +5,7 @@ from s3file.middleware import S3FileMiddleware -class TestS3FileMiddleware(object): +class TestS3FileMiddleware: def test_get_files_from_storage(self): content = b'test_get_files_from_storage' diff --git a/tests/testapp/storages.py b/tests/testapp/dummy_storage.py similarity index 100% rename from tests/testapp/storages.py rename to tests/testapp/dummy_storage.py diff --git a/tests/testapp/settings.py b/tests/testapp/settings.py index 4885f57..8a17763 100644 --- a/tests/testapp/settings.py +++ b/tests/testapp/settings.py @@ -21,7 +21,7 @@ 'tests.testapp', ) -DEFAULT_FILE_STORAGE = 'tests.testapp.storages.DummyS3Boto3Storage' +DEFAULT_FILE_STORAGE = 'tests.testapp.dummy_storage.DummyS3Boto3Storage' MIDDLEWARE = MIDDLEWARE_CLASSES = ( 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/tox.ini b/tox.ini index 9ce0c48..a6a0611 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{27,35,36}-dj{18,110,111,master},qa +envlist = py{35,36}-dj{18,110,111,master},qa [testenv] setenv= DISPLAY=:99.0 From 05c450c734e4aae0d0cc8b0d69f84fdd818ffb94 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Sat, 9 Sep 2017 11:38:18 +0200 Subject: [PATCH 3/3] Update docuementation. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 68e98ec..259c9b1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ A lightweight file upload input for Django and Amazon S3. +Django-S3File allows you to upload files directly AWS S3 effectively +bypassing your application server. This allows you to avoid long running +requests from large file uploads. + [![PyPi Version](https://img.shields.io/pypi/v/django-s3file.svg)](https://pypi.python.org/pypi/django-s3file/) [![Build Status](https://travis-ci.org/codingjoe/django-s3file.svg?branch=master)](https://travis-ci.org/codingjoe/django-s3file) [![Test Coverage](https://coveralls.io/repos/codingjoe/django-s3file/badge.svg?branch=master)](https://coveralls.io/r/codingjoe/django-s3file) @@ -11,8 +15,7 @@ A lightweight file upload input for Django and Amazon S3. * lightweight: less 200 lines * no JavaScript or Python dependencies (no jQuery) -* Python 3 and 2 support -* auto enabled based on your environment +* easy integration * works just like the build-in ## Installation