StdImageField   A
last analyzed

Complexity

Total Complexity 19

Size/Duplication

Total Lines 113
Duplicated Lines 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
wmc 19
c 4
b 0
f 0
dl 0
loc 113
rs 10

5 Methods

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