From a59f3cf9eb90017f8f9208443d97f4deff5c3eca Mon Sep 17 00:00:00 2001 From: winkidney Date: Sat, 25 Aug 2018 23:43:52 -0700 Subject: [PATCH] Feature: Built django-images into Pinry --- Pipfile | 2 +- Pipfile.lock | 21 ++- django_images/__init__.py | 0 django_images/migrations/0001_initial.py | 38 ++++ django_images/migrations/__init__.py | 0 django_images/models.py | 132 +++++++++++++ django_images/settings.py | 5 + django_images/south_migrations/0001_initial.py | 65 +++++++ .../0002_change_thumbnail_unique_constraint.py | 45 +++++ django_images/south_migrations/__init__.py | 0 django_images/templatetags/__init__.py | 0 django_images/templatetags/images.py | 8 + django_images/tests.py | 206 +++++++++++++++++++++ django_images/urls.py | 6 + django_images/utils.py | 73 ++++++++ django_images/views.py | 14 ++ 16 files changed, 609 insertions(+), 6 deletions(-) create mode 100644 django_images/__init__.py create mode 100644 django_images/migrations/0001_initial.py create mode 100644 django_images/migrations/__init__.py create mode 100644 django_images/models.py create mode 100644 django_images/settings.py create mode 100644 django_images/south_migrations/0001_initial.py create mode 100644 django_images/south_migrations/0002_change_thumbnail_unique_constraint.py create mode 100644 django_images/south_migrations/__init__.py create mode 100644 django_images/templatetags/__init__.py create mode 100644 django_images/templatetags/images.py create mode 100644 django_images/tests.py create mode 100644 django_images/urls.py create mode 100644 django_images/utils.py create mode 100644 django_images/views.py diff --git a/Pipfile b/Pipfile index a76a21b..23e86c0 100644 --- a/Pipfile +++ b/Pipfile @@ -5,13 +5,13 @@ verify_ssl = true [dev-packages] "flake8" = "*" +qrcode = "*" [packages] django = ">=1.11,<1.12" pillow = "*" requests = "*" django-taggit = "*" -django-images = {git = "https://github.com/winkidney/django-images.git"} django-braces = "*" django-compressor = "*" django-tastypie = ">=0.13.0,<0.14" diff --git a/Pipfile.lock b/Pipfile.lock index 5202859..8522b86 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8fd24fa32e2a3375d13a2df0faa5dee6e50845bff8dd6c0bf10d6ba970a8e7d4" + "sha256": "c632e45ac592ec9c159c042db8c52e221ae133ade94cd11fcfb124a5fd5b9dd0" }, "pipfile-spec": 6, "requires": {}, @@ -59,10 +59,6 @@ "index": "pypi", "version": "==2.2" }, - "django-images": { - "git": "https://github.com/winkidney/django-images.git", - "ref": "5c22e931145d2f924c06fcf5dcf425068cfa0fe9" - }, "django-taggit": { "hashes": [ "sha256:a21cbe7e0879f1364eef1c88a2eda89d593bf000ebf51c3f00423c6927075dce", @@ -274,6 +270,21 @@ "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805" ], "version": "==1.6.0" + }, + "qrcode": { + "hashes": [ + "sha256:037b0db4c93f44586e37f84c3da3f763874fcac85b2974a69a98e399ac78e1bf", + "sha256:de4ffc15065e6ff20a551ad32b6b41264f3c75275675406ddfa8e3530d154be3" + ], + "index": "pypi", + "version": "==6.0" + }, + "six": { + "hashes": [ + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + ], + "version": "==1.11.0" } } } diff --git a/django_images/__init__.py b/django_images/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_images/migrations/0001_initial.py b/django_images/migrations/0001_initial.py new file mode 100644 index 0000000..e3f0f84 --- /dev/null +++ b/django_images/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django_images.models + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Image', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('image', models.ImageField(height_field=b'height', width_field=b'width', max_length=255, upload_to=django_images.models.hashed_upload_to)), + ('height', models.PositiveIntegerField(default=0, editable=False)), + ('width', models.PositiveIntegerField(default=0, editable=False)), + ], + ), + migrations.CreateModel( + name='Thumbnail', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('image', models.ImageField(height_field=b'height', width_field=b'width', max_length=255, upload_to=django_images.models.hashed_upload_to)), + ('size', models.CharField(max_length=100)), + ('height', models.PositiveIntegerField(default=0, editable=False)), + ('width', models.PositiveIntegerField(default=0, editable=False)), + ('original', models.ForeignKey(to='django_images.Image')), + ], + ), + migrations.AlterUniqueTogether( + name='thumbnail', + unique_together=set([('original', 'size')]), + ), + ] diff --git a/django_images/migrations/__init__.py b/django_images/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_images/models.py b/django_images/models.py new file mode 100644 index 0000000..6786af3 --- /dev/null +++ b/django_images/models.py @@ -0,0 +1,132 @@ +import hashlib +import os.path +from io import BytesIO + +from django.db import models +from django.core.files.uploadedfile import InMemoryUploadedFile +from django.core.urlresolvers import reverse +from django.dispatch import receiver +import PIL + +try: + from importlib import import_module +except ImportError: + from django.utils.importlib import import_module + +from . import utils +from .settings import IMAGE_SIZES, IMAGE_PATH, IMAGE_AUTO_DELETE + + +def hashed_upload_to(instance, filename, **kwargs): + image_type = 'original' if isinstance(instance, Image) else 'thumbnail' + prefix = 'image/%s/by-md5/' % (image_type,) + hasher = hashlib.md5() + for chunk in instance.image.chunks(): + hasher.update(chunk) + hash_ = hasher.hexdigest() + base, ext = os.path.splitext(filename) + return '%(prefix)s%(first)s/%(second)s/%(hash)s/%(base)s%(ext)s' % { + 'prefix': prefix, + 'first': hash_[0], + 'second': hash_[1], + 'hash': hash_, + 'base': base, + 'ext': ext, + } + + +if IMAGE_PATH is None: + upload_to = hashed_upload_to +else: + if callable(IMAGE_PATH): + upload_to = IMAGE_PATH + else: + parts = IMAGE_PATH.split('.') + module_name = '.'.join(parts[:-1]) + module = import_module(module_name) + upload_to = getattr(module, parts[-1]) + + +class Image(models.Model): + image = models.ImageField(upload_to=upload_to, + height_field='height', width_field='width', + max_length=255) + height = models.PositiveIntegerField(default=0, editable=False) + width = models.PositiveIntegerField(default=0, editable=False) + + def get_by_size(self, size): + return self.thumbnail_set.get(size=size) + + def get_absolute_url(self, size=None): + if not size: + return self.image.url + try: + return self.get_by_size(size).image.url + except Thumbnail.DoesNotExist: + return reverse('image-thumbnail', args=(self.id, size)) + + +class ThumbnailManager(models.Manager): + def get_or_create_at_size(self, image_id, size): + image = Image.objects.get(id=image_id) + if size not in IMAGE_SIZES: + raise ValueError("Received unknown size: %s" % size) + try: + thumbnail = image.get_by_size(size) + except Thumbnail.DoesNotExist: + img = utils.scale_and_crop(image.image, **IMAGE_SIZES[size]) + # save to memory + buf = BytesIO() + try: + img.save(buf, img.format, **img.info) + except IOError: + if img.info.get('progression'): + orig_MAXBLOCK = PIL.ImageFile.MAXBLOCK + temp_MAXBLOCK = 1048576 + if orig_MAXBLOCK >= temp_MAXBLOCK: + raise + PIL.ImageFile.MAXBLOCK = temp_MAXBLOCK + try: + img.save(buf, img.format, **img.info) + finally: + PIL.ImageFile.MAXBLOCK = orig_MAXBLOCK + else: + raise + # and save to storage + original_dir, original_file = os.path.split(image.image.name) + thumb_file = InMemoryUploadedFile(buf, "image", original_file, + None, buf.tell(), None) + thumbnail, created = image.thumbnail_set.get_or_create( + size=size, defaults={'image': thumb_file}) + return thumbnail + + +class Thumbnail(models.Model): + original = models.ForeignKey(Image) + image = models.ImageField(upload_to=upload_to, + height_field='height', width_field='width', + max_length=255) + size = models.CharField(max_length=100) + height = models.PositiveIntegerField(default=0, editable=False) + width = models.PositiveIntegerField(default=0, editable=False) + + objects = ThumbnailManager() + + class Meta: + unique_together = ('original', 'size') + + def get_absolute_url(self): + return self.image.url + + +@receiver(models.signals.post_save) +def original_changed(sender, instance, created, **kwargs): + if isinstance(instance, Image): + instance.thumbnail_set.all().delete() + + +@receiver(models.signals.post_delete) +def delete_image_files(sender, instance, **kwargs): + if isinstance(instance, (Image, Thumbnail)) and IMAGE_AUTO_DELETE: + if instance.image.storage.exists(instance.image.name): + instance.image.delete(save=False) diff --git a/django_images/settings.py b/django_images/settings.py new file mode 100644 index 0000000..86e0848 --- /dev/null +++ b/django_images/settings.py @@ -0,0 +1,5 @@ +from django.conf import settings + +IMAGE_PATH = getattr(settings, 'IMAGE_PATH', None) +IMAGE_SIZES = getattr(settings, 'IMAGE_SIZES', {}) +IMAGE_AUTO_DELETE = getattr(settings, 'IMAGE_AUTO_DELETE', True) diff --git a/django_images/south_migrations/0001_initial.py b/django_images/south_migrations/0001_initial.py new file mode 100644 index 0000000..04b0587 --- /dev/null +++ b/django_images/south_migrations/0001_initial.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'Image' + db.create_table(u'django_images_image', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('image', self.gf('django.db.models.fields.files.ImageField')(max_length=255)), + ('height', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), + ('width', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), + )) + db.send_create_signal(u'django_images', ['Image']) + + # Adding model 'Thumbnail' + db.create_table(u'django_images_thumbnail', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('original', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['django_images.Image'])), + ('image', self.gf('django.db.models.fields.files.ImageField')(max_length=255)), + ('size', self.gf('django.db.models.fields.CharField')(max_length=100)), + ('height', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), + ('width', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), + )) + db.send_create_signal(u'django_images', ['Thumbnail']) + + # Adding unique constraint on 'Thumbnail', fields ['image', 'size'] + db.create_unique(u'django_images_thumbnail', ['image', 'size']) + + + def backwards(self, orm): + # Removing unique constraint on 'Thumbnail', fields ['image', 'size'] + db.delete_unique(u'django_images_thumbnail', ['image', 'size']) + + # Deleting model 'Image' + db.delete_table(u'django_images_image') + + # Deleting model 'Thumbnail' + db.delete_table(u'django_images_thumbnail') + + + models = { + u'django_images.image': { + 'Meta': {'object_name': 'Image'}, + 'height': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'image': ('django.db.models.fields.files.ImageField', [], {'max_length': '255'}), + 'width': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + u'django_images.thumbnail': { + 'Meta': {'unique_together': "(('image', 'size'),)", 'object_name': 'Thumbnail'}, + 'height': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'image': ('django.db.models.fields.files.ImageField', [], {'max_length': '255'}), + 'original': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['django_images.Image']"}), + 'size': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'width': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + } + } + + complete_apps = ['django_images'] \ No newline at end of file diff --git a/django_images/south_migrations/0002_change_thumbnail_unique_constraint.py b/django_images/south_migrations/0002_change_thumbnail_unique_constraint.py new file mode 100644 index 0000000..e303663 --- /dev/null +++ b/django_images/south_migrations/0002_change_thumbnail_unique_constraint.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Removing unique constraint on 'Thumbnail', fields ['image', 'size'] + db.delete_unique(u'django_images_thumbnail', ['image', 'size']) + + # Adding unique constraint on 'Thumbnail', fields ['original', 'size'] + db.create_unique(u'django_images_thumbnail', ['original_id', 'size']) + + + def backwards(self, orm): + # Removing unique constraint on 'Thumbnail', fields ['original', 'size'] + db.delete_unique(u'django_images_thumbnail', ['original_id', 'size']) + + # Adding unique constraint on 'Thumbnail', fields ['image', 'size'] + db.create_unique(u'django_images_thumbnail', ['image', 'size']) + + + models = { + u'django_images.image': { + 'Meta': {'object_name': 'Image'}, + 'height': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'image': ('django.db.models.fields.files.ImageField', [], {'max_length': '255'}), + 'width': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + u'django_images.thumbnail': { + 'Meta': {'unique_together': "(('original', 'size'),)", 'object_name': 'Thumbnail'}, + 'height': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'image': ('django.db.models.fields.files.ImageField', [], {'max_length': '255'}), + 'original': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['django_images.Image']"}), + 'size': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'width': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + } + } + + complete_apps = ['django_images'] \ No newline at end of file diff --git a/django_images/south_migrations/__init__.py b/django_images/south_migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_images/templatetags/__init__.py b/django_images/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_images/templatetags/images.py b/django_images/templatetags/images.py new file mode 100644 index 0000000..b721e91 --- /dev/null +++ b/django_images/templatetags/images.py @@ -0,0 +1,8 @@ +from django import template + +register = template.Library() + + +@register.filter +def at_size(image, size): + return image.get_absolute_url(size=size) diff --git a/django_images/tests.py b/django_images/tests.py new file mode 100644 index 0000000..f64f14e --- /dev/null +++ b/django_images/tests.py @@ -0,0 +1,206 @@ +import mock +import qrcode +from django.test import TestCase +from django.core.files.images import ImageFile +from django.conf import settings +from django.utils.six import BytesIO +from django.core.urlresolvers import reverse +from django_images.models import Image, Thumbnail +from django_images.templatetags.images import at_size +from django_images.utils import scale_and_crop + + +class ImageModelTest(TestCase): + def setUp(self): + image_obj = BytesIO() + qrcode_obj = qrcode.make('https://mirumee.com/') + qrcode_obj.save(image_obj) + self.image = Image.objects.create(width=370, height=370, + image=ImageFile(image_obj, '01.png')) + + def test_get_by_size(self): + size = list(settings.IMAGE_SIZES.keys())[0] + thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, size) + self.image.get_by_size(size) + + def test_get_absolute_url(self): + url = self.image.get_absolute_url() + self.assertEqual(url, self.image.image.url) + # For thumbnail + size = list(settings.IMAGE_SIZES.keys())[0] + thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, size) + url = self.image.get_absolute_url(size) + self.assertEqual(url, thumb.image.url) + # Fallback on creation url + size = list(settings.IMAGE_SIZES.keys())[1] + url = self.image.get_absolute_url(size) + fallback_url = reverse('image-thumbnail', args=(self.image.id, size)) + self.assertEqual(url, fallback_url) + + +class ThumbnailManagerModelTest(TestCase): + def setUp(self): + image_obj = BytesIO() + qrcode_obj = qrcode.make('https://mirumee.com/') + qrcode_obj.save(image_obj) + self.image = Image.objects.create(width=370, height=370, + image=ImageFile(image_obj, '01.png')) + self.size = list(settings.IMAGE_SIZES.keys())[0] + + def test_unknown_size(self): + self.assertRaises(ValueError, Thumbnail.objects.get_or_create_at_size, + self.image.id, 'foo') + + # TODO: Test the image object and data + def test_create(self): + thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, self.size) + self.assertEqual(self.image.thumbnail_set.count(), 1) + + def test_get(self): + thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, self.size) + thumb2 = Thumbnail.objects.get_or_create_at_size(self.image.id, self.size) + self.assertEqual(thumb.id, thumb2.id) + + +class ThumbnailModelTest(TestCase): + def setUp(self): + image_obj = BytesIO() + qrcode_obj = qrcode.make('https://mirumee.com/') + qrcode_obj.save(image_obj) + self.image = Image.objects.create(width=370, height=370, + image=ImageFile(image_obj, '01.png')) + size = list(settings.IMAGE_SIZES.keys())[0] + self.thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, size) + + def test_get_absolute_url(self): + url = self.thumb.get_absolute_url() + self.assertEqual(url, self.thumb.image.url) + + +class PostSaveSignalOriginalChangedTestCase(TestCase): + def setUp(self): + image_obj = BytesIO() + qrcode_obj = qrcode.make('https://mirumee.com/') + qrcode_obj.save(image_obj) + self.image = Image.objects.create(width=370, height=370, + image=ImageFile(image_obj, '01.png')) + size = list(settings.IMAGE_SIZES.keys())[0] + self.thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, size) + + def test_post_save_signal_original_changed(self): + size = list(settings.IMAGE_SIZES.keys())[0] + thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, size) + self.image.delete() + self.assertFalse(Thumbnail.objects.exists()) + + +class PostDeleteSignalDeleteImageFileTest(TestCase): + def setUp(self): + image_obj = BytesIO() + qrcode_obj = qrcode.make('https://mirumee.com/') + qrcode_obj.save(image_obj) + self.image = Image.objects.create(width=370, height=370, + image=ImageFile(image_obj, '01.png')) + size = list(settings.IMAGE_SIZES.keys())[0] + self.thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, size) + + @mock.patch('django_images.models.IMAGE_AUTO_DELETE', True) + def test_post_delete_signal_delete_image_files_enabled(self): + storage = self.image.image.storage + image_name = self.image.image.name + thumb_name = self.thumb.image.name + self.image.delete() + self.assertFalse(storage.exists(image_name)) + self.assertFalse(storage.exists(thumb_name)) + + @mock.patch('django_images.models.IMAGE_AUTO_DELETE', False) + def test_post_delete_signal_delete_image_files_disabled(self): + storage = self.image.image.storage + image_name = self.image.image.name + thumb_name = self.thumb.image.name + # Delete thumb + self.thumb.delete() + self.assertTrue(storage.exists(image_name)) + self.assertTrue(storage.exists(thumb_name)) + # Delete image + self.image.delete() + self.assertTrue(storage.exists(image_name)) + self.assertTrue(storage.exists(thumb_name)) + + +class AtSizeTemplateTagTest(TestCase): + def setUp(self): + image_obj = BytesIO() + qrcode_obj = qrcode.make('https://mirumee.com/') + qrcode_obj.save(image_obj) + self.image = Image.objects.create(width=370, height=370, + image=ImageFile(image_obj, '01.png')) + size = list(settings.IMAGE_SIZES.keys())[0] + self.thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, size) + + def test_at_size(self): + size = list(settings.IMAGE_SIZES.keys())[0] + url = at_size(self.image, size) + self.assertEqual(url, self.thumb.image.url) + + +class ThumbnailViewTest(TestCase): + def setUp(self): + image_obj = BytesIO() + qrcode_obj = qrcode.make('https://mirumee.com/') + qrcode_obj.save(image_obj) + self.image = Image.objects.create(width=370, height=370, + image=ImageFile(image_obj, '01.png')) + self.size = list(settings.IMAGE_SIZES.keys())[0] + self.thumb = Thumbnail.objects.get_or_create_at_size(self.image.id, self.size) + + def test_redirect(self): + url = reverse('image-thumbnail', args=[self.image.id, self.size]) + response = self.client.get(url) + self.assertRedirects(response, self.thumb.image.url) + + def test_not_found(self): + url = reverse('image-thumbnail', args=['42', self.size]) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_size_not_found(self): + url = reverse('image-thumbnail', args=[self.image.id, '42']) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + +class UtilsScaleAndDropTest(TestCase): + def setUp(self): + image_obj = BytesIO() + qrcode_obj = qrcode.make('https://mirumee.com/') + qrcode_obj.save(image_obj) + self.imagefile = ImageFile(image_obj, '01.png') + + def test_change_size(self): + new_size = (10, 10) + image = scale_and_crop(self.imagefile, new_size) + self.assertEqual(new_size, image.im.size) + + def test_crop(self): + new_size = (10, 10) + image = scale_and_crop(self.imagefile, new_size, crop=True) + self.assertEqual(new_size, image.im.size) + + def test_disabled_upscale(self): + image = scale_and_crop(self.imagefile, (740, 740), upscale=False) + self.assertLess(image.im.size[0], 371) + self.assertLess(image.im.size[1], 371) + + def test_enaabled_upscale(self): + image = scale_and_crop(self.imagefile, (740, 740), upscale=True) + self.assertGreater(image.im.size[0], 371) + self.assertGreater(image.im.size[1], 371) + + def test_not_change_quality(self): + image = scale_and_crop(self.imagefile, (10, 10), quality=None) + self.assertEqual(image.info.get('quality'), None) + + def test_change_quality(self): + image = scale_and_crop(self.imagefile, (10, 10), quality=50) + self.assertEqual(image.info.get('quality'), 50) diff --git a/django_images/urls.py b/django_images/urls.py new file mode 100644 index 0000000..62cabcf --- /dev/null +++ b/django_images/urls.py @@ -0,0 +1,6 @@ +from django.conf.urls import include, url +from . import views + +urlpatterns = [ + url(r'^thumbnail/(?P\d+)/(?P[^/]+)/$', views.thumbnail, name='image-thumbnail'), +] diff --git a/django_images/utils.py b/django_images/utils.py new file mode 100644 index 0000000..955d23f --- /dev/null +++ b/django_images/utils.py @@ -0,0 +1,73 @@ +from PIL import Image + + +# this neat function is based on easy-thumbnails +def scale_and_crop(image, size, crop=False, upscale=False, quality=None): + """ + Resize, crop and/or change quality of an image. + + :param image: Source image file + :param type: :class:`django.core.files.images.ImageFile` + + :param size: Size as width & height, zero as either means unrestricted + :type size: tuple of two int + + :param crop: Truncate image or not + :type crop: bool + + :param upscale: Enable scale up + :type upscale: bool + + :param quality: Value between 1 to 95, or None for keep the same + :type quality: int or NoneType + + :return: Handled image + :rtype: class:`PIL.Image` + """ + # Open image and store format/metadata. + image.open() + im = Image.open(image) + im_format, im_info = im.format, im.info + if quality: + im_info['quality'] = quality + + # Force PIL to load image data. + im.load() + + source_x, source_y = [float(v) for v in im.size] + target_x, target_y = [float(v) for v in size] + + if crop or not target_x or not target_y: + scale = max(target_x / source_x, target_y / source_y) + else: + scale = min(target_x / source_x, target_y / source_y) + + # Handle one-dimensional targets. + if not target_x: + target_x = source_x * scale + elif not target_y: + target_y = source_y * scale + + if scale < 1.0 or (scale > 1.0 and upscale): + im = im.resize((int(source_x * scale), int(source_y * scale)), + resample=Image.ANTIALIAS) + + if crop: + # Use integer values now. + source_x, source_y = im.size + # Difference between new image size and requested size. + diff_x = int(source_x - min(source_x, target_x)) + diff_y = int(source_y - min(source_y, target_y)) + if diff_x or diff_y: + # Center cropping (default). + halfdiff_x, halfdiff_y = diff_x // 2, diff_y // 2 + box = [halfdiff_x, halfdiff_y, + min(source_x, int(target_x) + halfdiff_x), + min(source_y, int(target_y) + halfdiff_y)] + # Finally, crop the image! + im = im.crop(box) + + # Close image and replace format/metadata, as PIL blows this away. + im.format, im.info = im_format, im_info + image.close() + return im diff --git a/django_images/views.py b/django_images/views.py new file mode 100644 index 0000000..ff15b26 --- /dev/null +++ b/django_images/views.py @@ -0,0 +1,14 @@ +from django.http import HttpResponseNotFound +from django.shortcuts import get_object_or_404, redirect + +from . import models +from .settings import IMAGE_SIZES + + +def thumbnail(request, image_id, size): + image = get_object_or_404(models.Image, id=image_id) + if size not in IMAGE_SIZES: + return HttpResponseNotFound() + + return redirect(models.Thumbnail.objects.get_or_create_at_size(image.id, + size))