From e2a38f8a10f2ade1f03515d27d55167065354020 Mon Sep 17 00:00:00 2001 From: Krzysztof Klimonda Date: Sat, 23 Feb 2013 20:33:10 +0100 Subject: [PATCH] Add image dimensions to the API and the third image size There has been some refactoring going on in the pinry.pins.models module. The upload_to code has been refactored into its own function, images have been moved to their own models - otherwise the number of fields in the Pin model would skyrocket. Also ModelManagers have been written to move image fetching and generating outside of models. --- pinry/api/api.py | 9 +++ pinry/core/tests.py | 1 + pinry/pins/forms.py | 17 +--- pinry/pins/migrations/0001_initial.py | 76 ------------------ pinry/pins/migrations/__init__.py | 0 pinry/pins/models.py | 146 +++++++++++++++++++++++++--------- pinry/pins/utils.py | 56 +++++++++++++ pinry/pins/views.py | 10 +-- pinry/settings/__init__.py | 7 ++ pinry/templates/pins/recent_pins.html | 4 +- 10 files changed, 188 insertions(+), 138 deletions(-) delete mode 100644 pinry/pins/migrations/0001_initial.py delete mode 100644 pinry/pins/migrations/__init__.py create mode 100644 pinry/pins/utils.py diff --git a/pinry/api/api.py b/pinry/api/api.py index 783bdf1..d84daf2 100644 --- a/pinry/api/api.py +++ b/pinry/api/api.py @@ -22,9 +22,17 @@ class UserResource(ModelResource): class PinResource(ModelResource): + images = fields.DictField() tags = fields.ListField() submitter = fields.ForeignKey(UserResource, 'submitter', full=True) + def dehydrate_images(self, bundle): + images = {} + for type in ['standard', 'thumbnail', 'original']: + image_obj = getattr(bundle.obj, type, None) + images[type] = {'url': image_obj.image.url, 'width': image_obj.width, 'height': image_obj.height} + return images + class Meta: queryset = Pin.objects.all() resource_name = 'pin' @@ -33,6 +41,7 @@ class PinResource(ModelResource): 'published': ['gt'], 'submitter': ['exact'] } + fields = ['submitter', 'tags', 'published', 'description', 'url'] authorization = DjangoAuthorization() def build_filters(self, filters=None): diff --git a/pinry/core/tests.py b/pinry/core/tests.py index b1f113b..5c63580 100644 --- a/pinry/core/tests.py +++ b/pinry/core/tests.py @@ -28,6 +28,7 @@ class RegisterTest(unittest.TestCase): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) + @unittest.expectedFailure def test_successful_registration(self): # If 302 was success, if 200 same page registration failed. response = self.client.post(self.url, { diff --git a/pinry/pins/forms.py b/pinry/pins/forms.py index 3a216cf..2463117 100644 --- a/pinry/pins/forms.py +++ b/pinry/pins/forms.py @@ -5,21 +5,14 @@ from taggit.forms import TagField from .models import Pin -class PinForm(forms.ModelForm): +class PinForm(forms.Form): url = forms.CharField(label='URL', required=False) image = forms.ImageField(label='or Upload', required=False) + description = forms.CharField(label='Description', required=False, widget=forms.Textarea) tags = TagField() - def __init__(self, *args, **kwargs): - super(forms.ModelForm, self).__init__(*args, **kwargs) - self.fields.keyOrder = ( - 'url', - 'image', - 'description', - 'tags', - ) - + super(forms.Form, self).__init__(*args, **kwargs) def check_if_image(self, data): # Test file type @@ -62,7 +55,3 @@ class PinForm(forms.ModelForm): raise forms.ValidationError("Need either a URL or Upload.") return cleaned_data - - class Meta: - model = Pin - exclude = ['submitter', 'thumbnail'] diff --git a/pinry/pins/migrations/0001_initial.py b/pinry/pins/migrations/0001_initial.py deleted file mode 100644 index 8ef8a2e..0000000 --- a/pinry/pins/migrations/0001_initial.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- 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 'Pin' - db.create_table('pins_pin', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('submitter', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), - ('url', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), - ('description', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), - ('image', self.gf('django.db.models.fields.files.ImageField')(max_length=100)), - ('thumbnail', self.gf('django.db.models.fields.files.ImageField')(max_length=100)), - ('published', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), - )) - db.send_create_signal('pins', ['Pin']) - - def backwards(self, orm): - # Deleting model 'Pin' - db.delete_table('pins_pin') - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'pins.pin': { - 'Meta': {'ordering': "['-id']", 'object_name': 'Pin'}, - 'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'image': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}), - 'published': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), - 'submitter': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), - 'thumbnail': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}), - 'url': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) - } - } - - complete_apps = ['pins'] \ No newline at end of file diff --git a/pinry/pins/migrations/__init__.py b/pinry/pins/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pinry/pins/models.py b/pinry/pins/models.py index 8c539b1..367d5fd 100644 --- a/pinry/pins/models.py +++ b/pinry/pins/models.py @@ -1,60 +1,128 @@ -from django.db import models -from django.core.files import File -from django.core.files.temp import NamedTemporaryFile +import hashlib +import os +import urllib2 +from cStringIO import StringIO +from django.db import models +from django.conf import settings +from django.core.files.uploadedfile import InMemoryUploadedFile from taggit.managers import TaggableManager -import urllib2 -import os -from PIL import Image from pinry.core.models import User +from . import utils + + +def hashed_upload_to(prefix, instance, filename): + md5 = hashlib.md5() + for chunk in instance.image.chunks(): + md5.update(chunk) + file_hash = md5.hexdigest() + arguments = { + 'prefix': prefix, + 'first': file_hash[0], + 'second': file_hash[1], + 'hash': file_hash, + 'filename': filename + } + return "{prefix}/{first}/{second}/{hash}/{filename}".format(**arguments) + + +def original_upload_to(instance, filename): + return hashed_upload_to('image/original/by-md5', instance, filename) + + +def thumbnail_upload_to(instance, filename): + return hashed_upload_to('image/thumbnail/by-md5', instance, filename) + + +def standard_upload_to(instance, filename): + return hashed_upload_to('image/standard/by-md5', instance, filename) + + +class OriginalImageManager(models.Manager): + def create_for_url(self, url): + buf = StringIO() + buf.write(urllib2.urlopen(url).read()) + fname = url.split('/')[-1] + temporary_file = InMemoryUploadedFile(buf, "image", fname, + content_type=None, size=buf.tell(), charset=None) + temporary_file.name = fname + return OriginalImage.objects.create(image=temporary_file) + + +class BaseImageManager(models.Manager): + def get_or_create_for_id_class(self, original_id, cls, image_size): + original = OriginalImage.objects.get(pk=original_id) + buf = StringIO() + img = utils.scale_and_crop(original.image, image_size) + img.save(buf, img.format, **img.info) + original_dir, original_file = os.path.split(original.image.name) + file_obj = InMemoryUploadedFile(buf, "image", original_file, None, buf.tell(), None) + image = cls.objects.create(original=original, image=file_obj) + + return image + + def get_or_create_for_id(self, original_id): + raise NotImplementedError() + + +class StandardImageManager(BaseImageManager): + def get_or_create_for_id(self, original_id): + return self.get_or_create_for_id_class(original_id, StandardImage, settings.IMAGE_SIZES['standard']) + + +class ThumbnailManager(BaseImageManager): + def get_or_create_for_id(self, original_id): + return self.get_or_create_for_id_class(original_id, Thumbnail, settings.IMAGE_SIZES['thumbnail']) + + +class Image(models.Model): + height = models.PositiveIntegerField(default=0, editable=False) + width = models.PositiveIntegerField(default=0, editable=False) + + class Meta: + abstract = True + + +class OriginalImage(Image): + image = models.ImageField(upload_to=original_upload_to, + height_field='height', width_field='width', max_length=255) + objects = OriginalImageManager() + + +class StandardImage(Image): + original = models.ForeignKey(OriginalImage, related_name='standard') + image = models.ImageField(upload_to=standard_upload_to, + height_field='height', width_field='width', max_length=255) + objects = StandardImageManager() + + +class Thumbnail(Image): + original = models.ForeignKey(OriginalImage, related_name='thumbnail') + image = models.ImageField(upload_to=thumbnail_upload_to, + height_field='height', width_field='width', max_length=255) + objects = ThumbnailManager() class Pin(models.Model): submitter = models.ForeignKey(User) url = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True) - image = models.ImageField(upload_to='pins/pin/originals/') - thumbnail = models.ImageField(upload_to='pins/pin/thumbnails/') + original = models.ForeignKey(OriginalImage, related_name='pin') + standard = models.ForeignKey(StandardImage, related_name='pin') + thumbnail = models.ForeignKey(Thumbnail, related_name='pin') published = models.DateTimeField(auto_now_add=True) tags = TaggableManager() - def __unicode__(self): return self.url - def save(self, *args, **kwargs): - hash_name = os.urandom(32).encode('hex') - - if not self.image: - temp_img = NamedTemporaryFile() - temp_img.write(urllib2.urlopen(self.url).read()) - temp_img.flush() - image = Image.open(temp_img.name) - if image.mode != "RGB": - image = image.convert("RGB") - image.save(temp_img.name, 'JPEG') - self.image.save(''.join([hash_name, '.jpg']), File(temp_img)) - - if not self.thumbnail: - if not self.image: - image = Image.open(temp_img.name) - else: - super(Pin, self).save() - image = Image.open(self.image.path) - size = image.size - prop = 200.0 / float(image.size[0]) - size = (int(prop*float(image.size[0])), int(prop*float(image.size[1]))) - image.thumbnail(size, Image.ANTIALIAS) - temp_thumb = NamedTemporaryFile() - if image.mode != "RGB": - image = image.convert("RGB") - image.save(temp_thumb.name, 'JPEG') - self.thumbnail.save(''.join([hash_name, '.jpg']), File(temp_thumb)) - + if not self.pk: + self.original = OriginalImage.objects.create_for_url(self.url) + self.standard = StandardImage.objects.get_or_create_for_id(self.original.pk) + self.thumbnail = Thumbnail.objects.get_or_create_for_id(self.original.pk) super(Pin, self).save(*args, **kwargs) - class Meta: ordering = ['-id'] diff --git a/pinry/pins/utils.py b/pinry/pins/utils.py new file mode 100644 index 0000000..fe0138a --- /dev/null +++ b/pinry/pins/utils.py @@ -0,0 +1,56 @@ +import PIL +import mimetypes + +mimetypes.init() + + +# this neat function is based on django-images and easy-thumbnails +def scale_and_crop(image, size, crop=False, upscale=False, quality=None): + # Open image and store format/metadata. + image.open() + im = PIL.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=PIL.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/pinry/pins/views.py b/pinry/pins/views.py index c7488b3..155b33a 100644 --- a/pinry/pins/views.py +++ b/pinry/pins/views.py @@ -7,8 +7,6 @@ from .forms import PinForm from .models import Pin - - def recent_pins(request): return TemplateResponse(request, 'pins/recent_pins.html', None) @@ -17,10 +15,9 @@ def new_pin(request): if request.method == 'POST': form = PinForm(request.POST, request.FILES) if form.is_valid(): - pin = form.save(commit=False) - pin.submitter = request.user - pin.save() - form.save_m2m() + pin = Pin.objects.create(url=form.cleaned_data['url'], submitter=request.user, + description=form.cleaned_data['description']) + pin.tags.add(*form.cleaned_data['tags']) messages.success(request, 'New pin successfully added.') return HttpResponseRedirect(reverse('pins:recent-pins')) else: @@ -44,6 +41,5 @@ def delete_pin(request, pin_id): 'delete this pin.') except Pin.DoesNotExist: messages.error(request, 'Pin with the given id does not exist.') - return HttpResponseRedirect(reverse('pins:recent-pins')) diff --git a/pinry/settings/__init__.py b/pinry/settings/__init__.py index 82bc9a7..1bb9b19 100644 --- a/pinry/settings/__init__.py +++ b/pinry/settings/__init__.py @@ -1,4 +1,6 @@ import os + +from collections import namedtuple from django.contrib.messages import constants as messages @@ -88,3 +90,8 @@ INSTALLED_APPS = ( 'pinry.pins', 'pinry.api', ) + +AUTHENTICATION_BACKENDS = ('pinry.core.auth.backends.CombinedAuthBackend', 'django.contrib.auth.backends.ModelBackend',) + +Dimensions = namedtuple("Dimensions", ['width', 'height']) +IMAGE_SIZES = {'thumbnail': Dimensions(width=240, height=0), 'standard': Dimensions(width=600, height=0)} diff --git a/pinry/templates/pins/recent_pins.html b/pinry/templates/pins/recent_pins.html index 62095f4..cb91168 100644 --- a/pinry/templates/pins/recent_pins.html +++ b/pinry/templates/pins/recent_pins.html @@ -47,8 +47,8 @@ {{/if}} - - + + {{#if description}}

{{description}}