Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Everything works and tests are passing
- Loading branch information
1 parent
7b79971
commit 581d037
Showing
16 changed files
with
1,118 additions
and
80 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
tests/MEDIA/ | ||
tests/test.db |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
{} | ||
``` | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
__version__ = '0.0.1' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.