Skip to content

Commit

Permalink
Everything works and tests are passing
Browse files Browse the repository at this point in the history
  • Loading branch information
flashingpumpkin committed Jan 11, 2013
1 parent 7b79971 commit 581d037
Show file tree
Hide file tree
Showing 16 changed files with 1,118 additions and 80 deletions.
1 change: 1 addition & 0 deletions .gitignore
@@ -1 +1,2 @@
tests/MEDIA/
tests/test.db
15 changes: 15 additions & 0 deletions Makefile
@@ -0,0 +1,15 @@
pandoc:
pandoc README.md --to rst > docs/source/readme.rst

rtd:
cd docs && make clean && DJANGO_SETTINGS_MODULE=tests.settings make html

docs: pandoc rtd

publish: docs
pandoc README.md --to rst > README.rst
python setup.py sdist
python setup.py upload
rm README.rst

.PHONY: docs
84 changes: 84 additions & 0 deletions README.md
@@ -0,0 +1,84 @@
# django-croppy



`django-croppy` enables creating custom crops of images by
specifying a name, coordinates, width and height of the crop.

`django-croppy` provides a custom model field responsible for
creating and deleting crops. Crops are stored as serialized JSON data
on the same model as the image field via [django-jsonfield](http://pypi.python.org/pypi/django-jsonfield/).

`django-croppy` is useful if you want to manually curate the crop
size and location instead of relying on generic cropping like
[django-imagekit](http://pypi.python.org/pypi/django-imagekit/) provides.

`django-croppy` makes use of image processors provided by
`django-imagekit`.

## Usage

First, create your model with a crop field. You can specify a custom
location where to save crops to with the ``upload_to`` parameter:


```python
from django.db import models
from croppy.fields import CropField

def upload_to(instance, image, crop_name):
filename, ext = os.path.splitext(os.path.split(image.name)[-1])
return os.path.join('crops', '{}-{}{}'.format(filename, crop_name, ext))

class Image(models.Model):
image = models.ImageField()
crops = CropField(upload_to = upload_to)
```

The created `crops` field allows you to create, delete and inspect
crops.


```python
$ cd tests && source test.sh && django-admin.py syncdb --settings tests.settings
...
$ django-admin.py shell --settings tests.settings
...
>>> from tests.app import tests
>>> image = tests.get_image('test.tiff')
>>> image
<Image: Image object>
>>> image.image
<ImageFieldFile: images/test.tiff>
>>> image.image.path
u'/home/alen/projects/django-croppy/tests/test-media/images/test.tiff'

>>> # Inspect the crop data
>>> image.crops.data
{}

>>> # Create a new crop called 'rect' at position 0/0
>>> # with a width of 100px and a height of 50px
>>> image.crops.create('rect', (0, 0, 100, 50))

>>> # Inspect the crop data
>>> image.crops.data
{'rect': {'y': 0, 'width': 100, 'height': 50, 'filename': 'crops/test-rect.tiff', 'x': 0}}

>>> # Inspect the crop
>>> image.crops.rect.name
'crops/test-rect.tiff'
>>> image.crops.rect.path
u'/home/alen/projects/django-croppy/tests/test-media/crops/test-rect.tiff'
>>> image.crops.rect.url
'/test-media/crops/test-rect.tiff'

>>> # Save the data to database
>>> image.save()

>>> # Delete the crop
>>> image.crops.delete('rect')
>>> image.crops.data
{}
```

1 change: 1 addition & 0 deletions croppy/__init__.py
@@ -0,0 +1 @@
__version__ = '0.0.1'
255 changes: 229 additions & 26 deletions croppy/fields.py
@@ -1,40 +1,243 @@
from django.db import models
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.fields.files import ImageField, ImageFieldFile

import json
from django.core.exceptions import ValidationError
from django.core.files.storage import DefaultStorage
from django.db.models.fields import TextField
from django.db.models.fields.files import ImageFieldFile
from imagekit.generators import SpecFileGenerator
from imagekit.processors.crop import Crop
import jsonfield
import os


def upload_to(instance, image, crop_name):
"""
Default function to specify a location to save crops to.
:param instance: The model instance this crop field belongs to.
:param image: The image instance this crop field operates on.
:param crop_name: The crop name used when :attr:`CropFieldDescriptor.crop` was
called.
"""
filename, ext = os.path.splitext(os.path.split(image.name)[-1])
return os.path.join('crops', '{}-{}{}'.format(filename, crop_name, ext))

class CropFieldFile(ImageFieldFile):
"""
:attr:`CropFieldFile` objects are attached to a model's crop descriptor for each
specified crop spec.
"""

def delete(self, save=True):
"""
Overriding delete method to not reset the model instance with an empty
field. This would override all our other crops and the JSON metadata.
.. note:: This method should not be called directly, rather use
:attr:`CropFieldDescriptor.delete`.
:param save: When true, the instance is saved to the database after
the crop was deleted.
"""
if hasattr(self, '_file'):
self.close()
del self.file

self.storage.delete(self.name)

if save:
self.instance.save()

self._commited = False

class Crop(object):
pass
class CropFieldDescriptor(object):
"""
Crop descriptors are created by :attr:`CropField` and allow for creating,
inspecting and deleting crops.
"""
def __init__(self, instance, field, data):
self.instance = instance
self.field = field
self.image = getattr(instance, field.image_field)

self._data = {}
self.data = data

class CropsDescriptor(object):
def __init__(self, data):
self.data = data
self._post_init()
def create(self, name, spec, save=True):
"""
Create a new crop with the provided spec. For example the following code
creates a crop of the original image starting at the X/Y coordinates of
0/0 and having a width of 100 pixels and a height of 150 pixels::
>>> image.crops.create('thumbnail', (0, 0, 100, 150))
:param name: Crop name. This must be unique and is also used to generate
the filename.
:param spec: 4-tuple containing ``(x, y, width, height)``
:param save: Boolean, if specified the model is saved back to DB after the crop.
"""

self.validate_name(name)
(x, y, width, height) = spec
spec = {name: dict(x=x, y=y, width=width, height=height)}

processors = [Crop(**spec[name])]

filename = self.get_filename(name)

def create(self, name, (x, y, width, height)):
self._create_accessor(name, 'asdf')
self.data[name] = 'asdf'
spec[name]['filename'] = filename

def _post_init(self):
for (key, value) in self.data.iteritems():
self._create_accessor(key, value)
generator = SpecFileGenerator(processors, storage=self.field.storage)

generator.generate_file(filename, self.image)

self.data = dict(self.data, **spec)

if save:
self.instance.save()

def _create_accessor(self, name, value):
setattr(self, name, Crop())

def delete(self, name, save=True):
"""
Delete the named crop. If save is specified, the model instance is saved
after the deletion.
class CropsField(jsonfield.JSONField):
__metaclass__ = models.SubfieldBase
:param name: Crop name
:param save: Boolean, whether to save the model instance or not after
deleting the file.
"""
crop = getattr(self, name)
delattr(self, name)
del self._data[name]
crop.delete(save)

def validate_name(self, name):
"""
Makes sure that the crop name does not clash with any methods or
attributes on :attr:`CropFieldDescriptor`.
Raises :attr:`django.core.exceptions.ValidationError` in case there is
a clash.
"""

if hasattr(self, name) and not isinstance(getattr(self, name), CropFieldFile):
raise ValidationError(
"Cannot override existing attribute '{}' with crop file.".format(
name))

def get_filename(self, name):
"""
Delegate filename creation to :attr:`field.upload_to`.
"""
return self.field.upload_to(self.instance, self.image, name)

@property
def data(self):
"""
Return the raw picture data as a dictionary including path, coordinates, e.g.:
>>> model.crops.data
{
'thumbnail': {
'x': 0, 'y': 0,
'width': 100, 'height': 100,
'name': 'crops/image_thumbnail.png'
},
'skyscraper': {
'x': 0, 'y': 0,
'width': 100, height: 600,
'name': 'crops/image_skyscraper.png'
}
}
"""
return self._data

@data.setter
def data(self, value):
"""
Sets the data attribute and generates :attr:`CropFieldFiles`
for convenience methods and attributes like :attr:`CropFieldFiles.delete`,
:attr:`CropFieldFiles.url`, :attr:`CropFieldFiles.path`, etc.
"""
for name, spec in value.iteritems():
self.validate_name(name)
self._data[name] = spec

if hasattr(self, name):
continue

setattr(self, name, CropFieldFile(
self.instance,
self.field,
spec['filename']))


class CropFieldCreator(object):
"""
Instead of using a metaclass for our custom field, we follow kind of
follow :attr:`django.db.fields.files` in how to set up custom field
attributes on model objects.
"""

def __init__(self, field):
self.field = field

def __get__(self, instance, cls):
if instance is None:
return self.field
return instance.__dict__[self.field.name]


def __set__(self, instance, data):
"""
Turn data from string into Python and store the CropFieldDescriptor
on the instance
"""
instance.__dict__[self.field.name] = CropFieldDescriptor(instance, self.field,
self.field.to_python(data))



class CropField(TextField):
"""
Creates custom crops from a specified model field::
class MyModel(models.Model):
my_image = models.ImageField()
my_crops = CropField('my_image')
"""

def __init__(self, image_field, *args, **kwargs):
"""
Custom field to generate crops of custom sizes in custom locations.
:param image_field: The name of the image field this cropper operates on.
:param storage: A custom storage backend to use.
:param upload_to: A custom function to generate crop filenames. Must take
three attributes, ``instance``, ``image`` and ``crop_name``. See
:attr:`upload_to`.
"""
self.image_field = image_field
super(CropsField, self).__init__(*args, **kwargs)

self.json_field = jsonfield.JSONField(*args, **kwargs)
kwargs['default'] = self.json_field.default

# Storage is required to make ImageFieldFile work
self.storage = kwargs.pop('storage', DefaultStorage())
self.upload_to = kwargs.pop('upload_to', upload_to)

super(CropField, self).__init__(*args, **kwargs)

def contribute_to_class(self, cls, name):
super(CropField, self).contribute_to_class(cls, name)
setattr(cls, name, CropFieldCreator(self))

def get_db_prep_value(self, value, connection=None, prepared=None):
return self.json_field.get_db_prep_value(value.data, connection, prepared)

def to_python(self, value):
data = super(CropsField, self).to_python(value)
return CropsDescriptor(data)
return self.json_field.to_python(value)




def get_db_prep_value(self, value, connection, prepared=False):
return super(CropsField, self).get_db_prep_value(value.data, connection, prepared = False)
16 changes: 0 additions & 16 deletions croppy/tests.py

This file was deleted.

0 comments on commit 581d037

Please sign in to comment.