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

StdImageFieldFile   A

Complexity

Total Complexity 26

Size/Duplication

Total Lines 118
Duplicated Lines 0 %

Importance

Changes 10
Bugs 3 Features 1
Metric Value
wmc 26
c 10
b 3
f 1
dl 0
loc 118
rs 10

7 Methods

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