From 16622d757a92b004624dce663990c2ae4e87cbcc Mon Sep 17 00:00:00 2001 From: LucasCTN Date: Sun, 29 Apr 2018 20:18:18 -0300 Subject: [PATCH] Initial commit! --- .gitignore | 104 ++++++++++++++++++++++++++++ LICENSE | 21 ++++++ MANIFEST.in | 3 + README.rst | 43 ++++++++++++ remote_image/__init__.py | 0 remote_image/admin.py | 3 + remote_image/apps.py | 5 ++ remote_image/example_forms.py | 13 ++++ remote_image/fields.py | 74 ++++++++++++++++++++ remote_image/migrations/__init__.py | 0 remote_image/tests.py | 63 +++++++++++++++++ setup.py | 33 +++++++++ 12 files changed, 362 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 remote_image/__init__.py create mode 100644 remote_image/admin.py create mode 100644 remote_image/apps.py create mode 100644 remote_image/example_forms.py create mode 100644 remote_image/fields.py create mode 100644 remote_image/migrations/__init__.py create mode 100644 remote_image/tests.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7db61e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,104 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf61c6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Lucas Campos Teixeira e Nascimento + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..d82987c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include README.rst +recursive-include docs * \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..9c04382 --- /dev/null +++ b/README.rst @@ -0,0 +1,43 @@ +===== +Django-remote-image +===== + +Django-remote-image is a Django app that adds a new form field for images. +The default widget is a text input, that accepts a URL of a image. + +The image is downloaded and can be passed to a ``ImageField`` in a model. Pillow needs to be installed. + +It is possible to whitelist and blacklist file extensions. + +Examples are shown below. + +Quick start +----------- + + $ pip install django-remote-image + +Using +----------- +Using the field in a form: + +.. code:: python + import remote_image import RemoteImageField + + class ExampleForm(forms.Form): + image = RemoteImageField() + +Whitelisting file extensions (only the ones in the list will be permitted): + +.. code:: python + import remote_image import RemoteImageField + + class ExampleForm(forms.Form): + image = RemoteImageField(ext_whitelist=['png', 'jpg']) + +Blacklisting file extensions (the ones in the list will be blocked): + +.. code:: python + import remote_image import RemoteImageField + + class ExampleForm(forms.Form): + image = RemoteImageField(ext_blacklist=['png', 'jpg']) \ No newline at end of file diff --git a/remote_image/__init__.py b/remote_image/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/remote_image/admin.py b/remote_image/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/remote_image/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/remote_image/apps.py b/remote_image/apps.py new file mode 100644 index 0000000..5ed7a58 --- /dev/null +++ b/remote_image/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ImageDownloadConfig(AppConfig): + name = 'image_download' diff --git a/remote_image/example_forms.py b/remote_image/example_forms.py new file mode 100644 index 0000000..f3e51a1 --- /dev/null +++ b/remote_image/example_forms.py @@ -0,0 +1,13 @@ +from django import forms +from django.forms import ModelForm + +from .fields import RemoteImageField + +class ExampleForm(forms.Form): + remote_image = RemoteImageField(required=True) + +class ExampleWhitelistedPNGForm(forms.Form): + remote_image = RemoteImageField(required=True, ext_whitelist=['png']) + +class ExampleBlacklistedPNGForm(forms.Form): + remote_image = RemoteImageField(required=True, ext_blacklist=['png']) diff --git a/remote_image/fields.py b/remote_image/fields.py new file mode 100644 index 0000000..1b181c9 --- /dev/null +++ b/remote_image/fields.py @@ -0,0 +1,74 @@ +import copy +import io +import urllib.request + +from django.core import validators +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.forms.fields import ImageField +from django.forms.widgets import URLInput +from django.utils.translation import gettext_lazy as _ +from PIL import Image as ImagePIL + + +class RemoteImageField(ImageField): + widget = URLInput + default_error_messages = { + 'not_whitelisted': _( + "The format of this file is not whitelisted." + ), + 'is_blacklisted': _( + "The format of this file is blacklisted." + ), + } + + def __init__(self, *, ext_whitelist=None, ext_blacklist=None, **kwargs): + self.ext_whitelist = ext_whitelist + self.ext_blacklist = ext_blacklist + super().__init__(**kwargs) + + def to_python(self, url): + data = self.get_remote_image_as_InMemoryUploadedFile(url) + return super().to_python(data) + + def get_remote_image_as_InMemoryUploadedFile(self, url): + image_bytesio = self.download_and_return_BytesIO(url) + return self.BytesIO_to_InMemoryUploadedFile(image_bytesio) + + def download_and_return_BytesIO(self, url): + response = urllib.request.urlopen(url) + img_bytes = response.read() + img_bytesio = io.BytesIO(img_bytes) + return img_bytesio + + def BytesIO_to_InMemoryUploadedFile(self, img_bytesio): + img_length = img_bytesio.getbuffer().nbytes + img_format = self.BytesIO_to_PIL(img_bytesio).format.lower() + img_name = "tempfile." + img_format + img_content_type = "image/" + img_format + + if self.ext_whitelist != None and img_format not in self.ext_whitelist: + raise ValidationError( + self.error_messages['not_whitelisted'], + code='not_whitelisted', + ) + + if self.ext_blacklist != None and img_format in self.ext_blacklist: + raise ValidationError( + self.error_messages['is_blacklisted'], + code='is_blacklisted', + ) + + return InMemoryUploadedFile( + img_bytesio, + field_name='tempfile', + name=img_name, + content_type=img_content_type, + size=img_length, + charset='utf-8', + ) + + def BytesIO_to_PIL(self, img_bytesio): + img_copy = copy.copy(img_bytesio) + return ImagePIL.open(img_copy) diff --git a/remote_image/migrations/__init__.py b/remote_image/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/remote_image/tests.py b/remote_image/tests.py new file mode 100644 index 0000000..1a05dcd --- /dev/null +++ b/remote_image/tests.py @@ -0,0 +1,63 @@ +from io import BytesIO + +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.forms.widgets import URLInput +from django.test import TestCase + +from .example_forms import (ExampleBlacklistedPNGForm, ExampleForm, + ExampleWhitelistedPNGForm) +from .fields import RemoteImageField + + +class RemoteImageTestCase(TestCase): + def setUp(self): + pass + + def test_bytes_length_correct_on_img_download(self): + image_url = "https://upload.wikimedia.org/wikipedia/commons/d/d9/Test.png" + img_bytesio = RemoteImageField().download_and_return_BytesIO(image_url) + img_length = img_bytesio.getbuffer().nbytes + + self.assertEqual(3118, img_length) + + def test_remote_image_field_with_png(self): + image_url = "https://upload.wikimedia.org/wikipedia/commons/d/d9/Test.png" + + data = {"remote_image": image_url} + form = ExampleForm(data) + self.assertTrue(form.is_valid()) + + def test_remote_image_field_with_jpg(self): + image_url = "https://upload.wikimedia.org/wikipedia/commons/5/5b/Name.jpg" + + data = {"remote_image": image_url} + form = ExampleForm(data) + self.assertTrue(form.is_valid()) + + def test_remote_image_field_with_gif(self): + image_url = "https://upload.wikimedia.org/wikipedia/commons/b/bd/Name.gif" + + data = {"remote_image": image_url} + form = ExampleForm(data) + self.assertTrue(form.is_valid()) + + def test_remote_image_field_with_whitelist_working(self): + image_url = "https://upload.wikimedia.org/wikipedia/commons/d/d9/Test.png" + + data = {"remote_image": image_url} + form = ExampleWhitelistedPNGForm(data) + self.assertTrue(form.is_valid()) + + def test_remote_image_field_with_blacklist_working(self): + image_url = "https://upload.wikimedia.org/wikipedia/commons/d/d9/Test.png" + + data = {"remote_image": image_url} + form = ExampleBlacklistedPNGForm(data) + self.assertFalse(form.is_valid()) + + def test_remote_image_field_with_extension_not_on_whitelist(self): + image_url = "https://upload.wikimedia.org/wikipedia/commons/b/bd/Name.gif" + + data = {"remote_image": image_url} + form = ExampleWhitelistedPNGForm(data) + self.assertFalse(form.is_valid()) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..84c34b1 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +import os +from setuptools import find_packages, setup + +with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: + README = readme.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-remote-image', + version='0.1', + packages=find_packages(), + include_package_data=True, + license='MIT License', # example license + description='Custom django form field for downloading images from a URL on a URLInput.', + long_description=README, + url='https://github.com/LucasCTN/django-remote-image', + author='Lucas Nascimento', + author_email='lucascampostn@gmail.com', + 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.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + ], +) \ No newline at end of file