Completed
Pull Request — master (#81)
by
unknown
01:24
created

stdimage.StdImageField   A

Complexity

Total Complexity 19

Size/Duplication

Total Lines 114
Duplicated Lines 0 %
Metric Value
wmc 19
dl 0
loc 114
rs 10

4 Methods

Rating   Name   Duplication   Size   Complexity  
A StdImageFieldFile.delete_variations() 0 4 2
A StdImageFieldFile.delete() 0 3 1
F StdImageField.__init__() 0 45 10
A StdImageField.add_variation() 0 8 2
1
# -*- coding: utf-8 -*-
2
from __future__ import absolute_import, unicode_literals
3
4
import logging
5
import os
6
from io import BytesIO
7
8
from django.core.files.base import ContentFile
9
from django.core.files.storage import default_storage
10
from django.db.models import signals
11
from django.db.models.fields.files import (
12
    ImageField, ImageFieldFile, ImageFileDescriptor
13
)
14
from PIL import Image, ImageOps
15
16
from .validators import MinSizeValidator
17
18
logger = logging.getLogger()
19
20
21
class StdImageFileDescriptor(ImageFileDescriptor):
22
    """The variation property of the field is accessible in instance cases."""
23
24
    def __set__(self, instance, value):
25
        super(StdImageFileDescriptor, self).__set__(instance, value)
26
        self.field.set_variations(instance)
27
28
29
class StdImageFieldFile(ImageFieldFile):
30
    """Like ImageFieldFile but handles variations."""
31
32
    def save(self, name, content, save=True):
33
        super(StdImageFieldFile, self).save(name, content, save)
34
35
        """ Skip rendering if render_variations set False """
36
        if isinstance(self.field.render_variations, bool) \
37
           and not self.field.render_variations:
38
            return
39
40
        self.render_field_variations(self.name, self.field, False, self.storage)
41
42
    @staticmethod
43
    def is_smaller(img, variation):
44
        return img.size[0] > variation['width'] \
45
            or img.size[1] > variation['height']
46
47
    @classmethod
48
    def render_field_variations(cls, file_name, field, replace=False,
49
                                storage=default_storage):
50
        """
51
        Force render field variations. If field's render_variations is false
52
        then render with default render_variation function.
53
        """
54
        if callable(field.render_variations):
55
            render_default = field.render_variations(
56
                file_name=file_name,
57
                variations=field.variations,
58
                storage=storage
59
            )
60
            if not isinstance(render_default, bool):
61
                msg = (
62
                    '"render_variations" callable expects a boolean return'
63
                    '  value, but got %s'
64
                    ) % type(render_default)
65
                raise TypeError(msg)
66
            if not render_default:
67
                return
68
69
        for _, variation in field.variations.items():
70
            cls.render_variation(file_name, variation, replace, storage)
71
72
    @classmethod
73
    def render_variation(cls, file_name, variation, replace=False,
74
                         storage=default_storage):
75
        """Render an image variation and saves it to the storage."""
76
        variation_name = cls.get_variation_name(file_name, variation['name'])
77
        if storage.exists(variation_name):
78
            if replace:
79
                storage.delete(variation_name)
80
                logger.info('File "{}" already exists and has been replaced.')
81
            else:
82
                logger.info('File "{}" already exists.')
83
                return variation_name
84
85
        resample = variation['resample']
86
87
        with storage.open(file_name) as f:
88
            with Image.open(f) as img:
89
                file_format = img.format
90
91
                if cls.is_smaller(img, variation):
92
                    factor = 1
93
                    while img.size[0] / factor \
94
                            > 2 * variation['width'] \
95
                            and img.size[1] * 2 / factor \
96
                            > 2 * variation['height']:
97
                        factor *= 2
98
                    if factor > 1:
99
                        img.thumbnail(
100
                            (int(img.size[0] / factor),
101
                             int(img.size[1] / factor)),
102
                            resample=resample
103
                        )
104
105
                    size = variation['width'], variation['height']
106
                    size = tuple(int(i) if i != float('inf') else i
107
                                 for i in size)
108
                    if variation['crop']:
109
                        img = ImageOps.fit(
110
                            img,
111
                            size,
112
                            method=resample
113
                        )
114
                    else:
115
                        img.thumbnail(
116
                            size,
117
                            resample=resample
118
                        )
119
120
                with BytesIO() as file_buffer:
121
                    img.save(file_buffer, file_format)
122
                    f = ContentFile(file_buffer.getvalue())
123
                    storage.save(variation_name, f)
124
        return variation_name
125
126
    @classmethod
127
    def get_variation_name(cls, file_name, variation_name):
128
        """Return the variation file name based on the variation."""
129
        path, ext = os.path.splitext(file_name)
130
        path, file_name = os.path.split(path)
131
        file_name = '{file_name}.{variation_name}{extension}'.format(**{
132
            'file_name': file_name,
133
            'variation_name': variation_name,
134
            'extension': ext,
135
        })
136
        return os.path.join(path, file_name)
137
138
    def delete(self, save=True):
139
        self.delete_variations()
140
        super(StdImageFieldFile, self).delete(save)
141
142
    def delete_variations(self):
143
        for variation in self.field.variations:
144
            variation_name = self.get_variation_name(self.name, variation)
145
            self.storage.delete(variation_name)
146
147
148
class StdImageField(ImageField):
149
    """
150
    Django ImageField that is able to create different size variations.
151
152
    Extra features are:
153
        - Django-Storages compatible (S3)
154
        - Python 2, 3 and PyPy support
155
        - Django 1.5 and later support
156
        - Resize images to different sizes
157
        - Access thumbnails on model level, no template tags required
158
        - Preserves original image
159
        - Asynchronous rendering (Celery & Co)
160
        - Multi threading and processing for optimum performance
161
        - Restrict accepted image dimensions
162
        - Rename files to a standardized name (using a callable upload_to)
163
164
    :param variations: size variations of the image
165
    """
166
167
    descriptor_class = StdImageFileDescriptor
168
    attr_class = StdImageFieldFile
169
    def_variation = {
170
        'width': float('inf'),
171
        'height': float('inf'),
172
        'crop': False,
173
        'resample': Image.ANTIALIAS
174
    }
175
176
    def __init__(self, verbose_name=None, name=None, variations=None,
177
                 render_variations=True, force_min_size=False,
178
                 *args, **kwargs):
179
        """
180
        Standardized ImageField for Django.
181
182
        Usage: StdImageField(upload_to='PATH',
183
         variations={'thumbnail': {"width", "height", "crop", "resample"}})
184
        :param variations: size variations of the image
185
        :rtype variations: StdImageField
186
        :param render_variations: boolean or callable that returns a boolean.
187
         The callable gets passed the app_name, model, field_name and pk.
188
         Default: True
189
        :rtype render_variations: bool, callable
190
        """
191
        if not variations:
192
            variations = {}
193
        if not isinstance(variations, dict):
194
            msg = ('"variations" expects a dict,'
195
                   ' but got %s') % type(variations)
196
            raise TypeError(msg)
197
        if not (isinstance(render_variations, bool) or
198
                callable(render_variations)):
199
            msg = ('"render_variations" excepts a boolean or callable,'
200
                   ' but got %s') % type(render_variations)
201
            raise TypeError(msg)
202
203
        self._variations = variations
204
        self.force_min_size = force_min_size
205
        self.render_variations = render_variations
206
        self.variations = {}
207
208
        for nm, prm in list(variations.items()):
209
            self.add_variation(nm, prm)
210
211
        if self.variations and self.force_min_size:
212
            self.min_size = (
213
                max(self.variations.values(),
214
                    key=lambda x: x["width"])["width"],
215
                max(self.variations.values(),
216
                    key=lambda x: x["height"])["height"]
217
            )
218
219
        super(StdImageField, self).__init__(verbose_name, name,
220
                                            *args, **kwargs)
221
222
    def add_variation(self, name, params):
223
        variation = self.def_variation.copy()
224
        if isinstance(params, (list, tuple)):
225
            variation.update(dict(zip(("width", "height", "crop"), params)))
226
        else:
227
            variation.update(params)
228
        variation["name"] = name
229
        self.variations[name] = variation
230
231
    def set_variations(self, instance=None, **kwargs):
232
        """
233
        Create a "variation" object as attribute of the ImageField instance.
234
235
        Variation attribute will be of the same class as the original image, so
236
        "path", "url"... properties can be used
237
238
        :param instance: FileField
239
        """
240
        if getattr(instance, self.name):
241
            field = getattr(instance, self.name)
242
            if field._committed:
243
                for name, variation in list(self.variations.items()):
244
                    variation_name = self.attr_class.get_variation_name(
245
                        field.name,
246
                        variation['name']
247
                    )
248
                    variation_field = ImageFieldFile(instance,
249
                                                     self,
250
                                                     variation_name)
251
                    setattr(field, name, variation_field)
252
253
    def contribute_to_class(self, cls, name):
254
        """Generating all operations on specified signals."""
255
        super(StdImageField, self).contribute_to_class(cls, name)
256
        signals.post_init.connect(self.set_variations, sender=cls)
257
258
    def validate(self, value, model_instance):
259
        super(StdImageField, self).validate(value, model_instance)
260
        if self.force_min_size:
261
            MinSizeValidator(self.min_size[0], self.min_size[1])(value)
262