From 56d1139d7192f1c5509f6037f1740cf11bace5fe Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Thu, 27 Oct 2011 00:58:47 -0700 Subject: [PATCH] Added ImageField Support Thanks to @wpjunior for the patch Closes [#298] --- docs/changelog.rst | 1 + mongoengine/base.py | 1 + mongoengine/fields.py | 217 ++++++++++++++++++++++++++++++++++++++---- setup.py | 2 +- tests/fields.py | 71 +++++++++++++- tests/mongoengine.png | Bin 0 -> 8313 bytes 6 files changed, 269 insertions(+), 23 deletions(-) create mode 100644 tests/mongoengine.png diff --git a/docs/changelog.rst b/docs/changelog.rst index bd598a8a..43e900e4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,7 @@ Changelog Changes in dev ============== +- Added ImageField - requires PIL - Fixed Reference Fields can be None in get_or_create / queries - Fixed accessing pk on an embedded document - Fixed calling a queryset after drop_collection now recreates the collection diff --git a/mongoengine/base.py b/mongoengine/base.py index ed14c745..20388e91 100644 --- a/mongoengine/base.py +++ b/mongoengine/base.py @@ -19,6 +19,7 @@ class NotRegistered(Exception): class InvalidDocumentError(Exception): pass + class ValidationError(Exception): pass diff --git a/mongoengine/fields.py b/mongoengine/fields.py index a1638bf0..5700fe41 100644 --- a/mongoengine/fields.py +++ b/mongoengine/fields.py @@ -1,3 +1,14 @@ +import datetime +import time +import decimal +import gridfs +import pymongo +import pymongo.binary +import pymongo.dbref +import pymongo.son +import re +import uuid + from base import (BaseField, ComplexBaseField, ObjectIdField, ValidationError, get_document) from queryset import DO_NOTHING @@ -5,15 +16,17 @@ from document import Document, EmbeddedDocument from connection import _get_db from operator import itemgetter -import re -import pymongo -import pymongo.dbref -import pymongo.son -import pymongo.binary -import datetime, time -import decimal -import gridfs -import uuid + +try: + from PIL import Image, ImageOps +except ImportError: + Image = None + ImageOps = None + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO __all__ = ['StringField', 'IntField', 'FloatField', 'BooleanField', @@ -21,7 +34,7 @@ __all__ = ['StringField', 'IntField', 'FloatField', 'BooleanField', 'ObjectIdField', 'ReferenceField', 'ValidationError', 'MapField', 'DecimalField', 'ComplexDateTimeField', 'URLField', 'GenericReferenceField', 'FileField', 'BinaryField', - 'SortedListField', 'EmailField', 'GeoPointField', + 'SortedListField', 'EmailField', 'GeoPointField', 'ImageField', 'SequenceField', 'UUIDField', 'GenericEmbeddedDocumentField'] RECURSIVE_REFERENCE_CONSTANT = 'self' @@ -784,10 +797,12 @@ class GridFSProxy(object): .. versionadded:: 0.4 .. versionchanged:: 0.5 - added optional size param to read + .. versionchanged:: 0.6 - added collection name param """ - def __init__(self, grid_id=None, key=None, instance=None): - self.fs = gridfs.GridFS(_get_db()) # Filesystem instance + def __init__(self, grid_id=None, key=None, + instance=None, collection_name='fs'): + self.fs = gridfs.GridFS(_get_db(), collection_name) # Filesystem instance self.newfile = None # Used for partial writes self.grid_id = grid_id # Store GridFS id for file self.gridout = None @@ -878,9 +893,11 @@ class FileField(BaseField): .. versionadded:: 0.4 .. versionchanged:: 0.5 added optional size param for read """ + proxy_class = GridFSProxy - def __init__(self, **kwargs): + def __init__(self, collection_name="fs", **kwargs): super(FileField, self).__init__(**kwargs) + self.collection_name = collection_name def __get__(self, instance, owner): if instance is None: @@ -889,12 +906,13 @@ class FileField(BaseField): # Check if a file already exists for this model grid_file = instance._data.get(self.name) self.grid_file = grid_file - if isinstance(self.grid_file, GridFSProxy): + if isinstance(self.grid_file, self.proxy_class): if not self.grid_file.key: self.grid_file.key = self.name self.grid_file.instance = instance return self.grid_file - return GridFSProxy(key=self.name, instance=instance) + return self.proxy_class(key=self.name, instance=instance, + collection_name=self.collection_name) def __set__(self, instance, value): key = self.name @@ -911,7 +929,8 @@ class FileField(BaseField): grid_file.put(value) else: # Create a new proxy object as we don't already have one - instance._data[key] = GridFSProxy(key=key, instance=instance) + instance._data[key] = self.proxy_class(key=key, instance=instance, + collection_name=self.collection_name) instance._data[key].put(value) else: instance._data[key] = value @@ -920,20 +939,180 @@ class FileField(BaseField): def to_mongo(self, value): # Store the GridFS file id in MongoDB - if isinstance(value, GridFSProxy) and value.grid_id is not None: + if isinstance(value, self.proxy_class) and value.grid_id is not None: return value.grid_id return None def to_python(self, value): if value is not None: - return GridFSProxy(value) + return self.proxy_class(value, + collection_name=self.collection_name) def validate(self, value): if value.grid_id is not None: - assert isinstance(value, GridFSProxy) + assert isinstance(value, self.proxy_class) assert isinstance(value.grid_id, pymongo.objectid.ObjectId) +class ImageGridFsProxy(GridFSProxy): + """ + Proxy for ImageField + + versionadded: 0.6 + """ + def put(self, file_obj, **kwargs): + """ + Insert a image in database + applying field properties (size, thumbnail_size) + """ + field = self.instance._fields[self.key] + + try: + img = Image.open(file_obj) + except: + raise ValidationError('Invalid image') + + if (field.size and (img.size[0] > field.size['width'] or + img.size[1] > field.size['height'])): + size = field.size + + if size['force']: + img = ImageOps.fit(img, + (size['width'], + size['height']), + Image.ANTIALIAS) + else: + img.thumbnail((size['width'], + size['height']), + Image.ANTIALIAS) + + thumbnail = None + if field.thumbnail_size: + size = field.thumbnail_size + + if size['force']: + thumbnail = ImageOps.fit(img, + (size['width'], + size['height']), + Image.ANTIALIAS) + else: + thumbnail = img.copy() + thumbnail.thumbnail((size['width'], + size['height']), + Image.ANTIALIAS) + + if thumbnail: + thumb_id = self._put_thumbnail(thumbnail, + img.format) + else: + thumb_id = None + + w, h = img.size + + io = StringIO() + img.save(io, img.format) + io.seek(0) + + return super(ImageGridFsProxy, self).put(io, + width=w, + height=h, + format=img.format, + thumbnail_id=thumb_id, + **kwargs) + + def delete(self, *args, **kwargs): + #deletes thumbnail + out = self.get() + if out and out.thumbnail_id: + self.fs.delete(out.thumbnail_id) + + return super(ImageGridFsProxy, self).delete(*args, **kwargs) + + def _put_thumbnail(self, thumbnail, format, **kwargs): + w, h = thumbnail.size + + io = StringIO() + thumbnail.save(io, format) + io.seek(0) + + return self.fs.put(io, width=w, + height=h, + format=format, + **kwargs) + @property + def size(self): + """ + return a width, height of image + """ + out = self.get() + if out: + return out.width, out.height + + @property + def format(self): + """ + return format of image + ex: PNG, JPEG, GIF, etc + """ + out = self.get() + if out: + return out.format + + @property + def thumbnail(self): + """ + return a gridfs.grid_file.GridOut + representing a thumbnail of Image + """ + out = self.get() + if out and out.thumbnail_id: + return self.fs.get(out.thumbnail_id) + + def write(self, *args, **kwargs): + raise RuntimeError("Please use \"put\" method instead") + + def writelines(self, *args, **kwargs): + raise RuntimeError("Please use \"put\" method instead") + + +class ImproperlyConfigured(Exception): + pass + + +class ImageField(FileField): + """ + A Image File storage field. + + @size (width, height, force): + max size to store images, if larger will be automatically resized + ex: size=(800, 600, True) + + @thumbnail (width, height, force): + size to generate a thumbnail + + .. versionadded:: 0.6 + """ + proxy_class = ImageGridFsProxy + + def __init__(self, size=None, thumbnail_size=None, + collection_name='images', **kwargs): + if not Image: + raise ImproperlyConfigured("PIL library was not found") + + params_size = ('width', 'height', 'force') + extra_args = dict(size=size, thumbnail_size=thumbnail_size) + for att_name, att in extra_args.items(): + if att and (isinstance(att, tuple) or isinstance(att, list)): + setattr(self, att_name, dict( + map(None, params_size, att))) + else: + setattr(self, att_name, None) + + super(ImageField, self).__init__( + collection_name=collection_name, + **kwargs) + + class GeoPointField(BaseField): """A list storing a latitude and longitude. diff --git a/setup.py b/setup.py index d4dd5abd..b0c29bf0 100644 --- a/setup.py +++ b/setup.py @@ -47,5 +47,5 @@ setup(name='mongoengine', classifiers=CLASSIFIERS, install_requires=['pymongo'], test_suite='tests', - tests_require=['blinker', 'django>=1.3'] + tests_require=['blinker', 'django>=1.3', 'PIL'] ) diff --git a/tests/fields.py b/tests/fields.py index 80a343e3..20cdf197 100644 --- a/tests/fields.py +++ b/tests/fields.py @@ -1,12 +1,16 @@ -import unittest import datetime -from decimal import Decimal +import os +import unittest import uuid +from decimal import Decimal + from mongoengine import * from mongoengine.connection import _get_db from mongoengine.base import _document_registry, NotRegistered +TEST_IMAGE_PATH = os.path.join(os.path.dirname(__file__), 'mongoengine.png') + class FieldTest(unittest.TestCase): @@ -1011,7 +1015,7 @@ class FieldTest(unittest.TestCase): self.assertEqual(obj, me) obj, created = Product.objects.get_or_create(company=None) - + self.assertEqual(created, False) self.assertEqual(obj, me) @@ -1392,6 +1396,67 @@ class FieldTest(unittest.TestCase): TestFile.drop_collection() + def test_image_field(self): + + class TestImage(Document): + image = ImageField() + + TestImage.drop_collection() + + t = TestImage() + t.image.put(open(TEST_IMAGE_PATH, 'r')) + t.save() + + t = TestImage.objects.first() + + self.assertEquals(t.image.format, 'PNG') + + w, h = t.image.size + self.assertEquals(w, 371) + self.assertEquals(h, 76) + + t.image.delete() + + def test_image_field_resize(self): + + class TestImage(Document): + image = ImageField(size=(185, 37)) + + TestImage.drop_collection() + + t = TestImage() + t.image.put(open(TEST_IMAGE_PATH, 'r')) + t.save() + + t = TestImage.objects.first() + + self.assertEquals(t.image.format, 'PNG') + w, h = t.image.size + + self.assertEquals(w, 185) + self.assertEquals(h, 37) + + t.image.delete() + + def test_image_field_thumbnail(self): + + class TestImage(Document): + image = ImageField(thumbnail_size=(92, 18)) + + TestImage.drop_collection() + + t = TestImage() + t.image.put(open(TEST_IMAGE_PATH, 'r')) + t.save() + + t = TestImage.objects.first() + + self.assertEquals(t.image.thumbnail.format, 'PNG') + self.assertEquals(t.image.thumbnail.width, 92) + self.assertEquals(t.image.thumbnail.height, 18) + + t.image.delete() + def test_geo_indexes(self): """Ensure that indexes are created automatically for GeoPointFields. """ diff --git a/tests/mongoengine.png b/tests/mongoengine.png new file mode 100644 index 0000000000000000000000000000000000000000..56acb96dbebb029feb884d3ffa4f9f001a0b28cb GIT binary patch literal 8313 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 DWUWgQ literal 0 HcmV?d00001