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

StdImageFieldFile.get_variation_name()   A

Complexity

Conditions 1

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 11
rs 9.4285
cc 1
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.
53
54
        If field's render_variations is false then render with default
55
        render_variation function.
56
        """
57
        if callable(field.render_variations):
58
            render_default = field.render_variations(
59
                file_name=file_name,
60
                variations=field.variations,
61
                storage=storage
62
            )
63
            if not isinstance(render_default, bool):
64
                msg = (
65
                    '"render_variations" should return a boolean,'
66
                    ' but returned %s'
67
                ) % type(render_default)
68
                raise TypeError(msg)
69
            if not render_default:
70
                return
71
72
        for variation in field.variations.values():
73
            cls.render_variation(file_name, variation, replace, storage)
74
75
    @classmethod
76
    def render_variation(cls, file_name, variation, replace=False,
77
                         storage=default_storage):
78
        """Render an image variation and saves it to the storage."""
79
        variation_name = cls.get_variation_name(file_name, variation['name'])
80
        if storage.exists(variation_name):
81
            if replace:
82
                storage.delete(variation_name)
83
                logger.info('File "{}" already exists and has been replaced.')
84
            else:
85
                logger.info('File "{}" already exists.')
86
                return variation_name
87
88
        resample = variation['resample']
89
90
        with storage.open(file_name) as f:
91
            with Image.open(f) as img:
92
                file_format = img.format
93
94
                if cls.is_smaller(img, variation):
95
                    factor = 1
96
                    while img.size[0] / factor \
97
                            > 2 * variation['width'] \
98
                            and img.size[1] * 2 / factor \
99
                            > 2 * variation['height']:
100
                        factor *= 2
101
                    if factor > 1:
102
                        img.thumbnail(
103
                            (int(img.size[0] / factor),
104
                             int(img.size[1] / factor)),
105
                            resample=resample
106
                        )
107
108
                    size = variation['width'], variation['height']
109
                    size = tuple(int(i) if i != float('inf') else i
110
                                 for i in size)
111
                    if variation['crop']:
112
                        img = ImageOps.fit(
113
                            img,
114
                            size,
115
                            method=resample
116
                        )
117
                    else:
118
                        img.thumbnail(
119
                            size,
120
                            resample=resample
121
                        )
122
123
                with BytesIO() as file_buffer:
124
                    img.save(file_buffer, file_format)
125
                    f = ContentFile(file_buffer.getvalue())
126
                    storage.save(variation_name, f)
127
        return variation_name
128
129
    @classmethod
130
    def get_variation_name(cls, file_name, variation_name):
131
        """Return the variation file name based on the variation."""
132
        path, ext = os.path.splitext(file_name)
133
        path, file_name = os.path.split(path)
134
        file_name = '{file_name}.{variation_name}{extension}'.format(**{
135
            'file_name': file_name,
136
            'variation_name': variation_name,
137
            'extension': ext,
138
        })
139
        return os.path.join(path, file_name)
140
141
    def delete(self, save=True):
142
        self.delete_variations()
143
        super(StdImageFieldFile, self).delete(save)
144
145
    def delete_variations(self):
146
        for variation in self.field.variations:
147
            variation_name = self.get_variation_name(self.name, variation)
148
            self.storage.delete(variation_name)
149
150
151
class StdImageField(ImageField):
152
    """
153
    Django ImageField that is able to create different size variations.
154
155
    Extra features are:
156
        - Django-Storages compatible (S3)
157
        - Python 2, 3 and PyPy support
158
        - Django 1.5 and later support
159
        - Resize images to different sizes
160
        - Access thumbnails on model level, no template tags required
161
        - Preserves original image
162
        - Asynchronous rendering (Celery & Co)
163
        - Multi threading and processing for optimum performance
164
        - Restrict accepted image dimensions
165
        - Rename files to a standardized name (using a callable upload_to)
166
167
    :param variations: size variations of the image
168
    """
169
170
    descriptor_class = StdImageFileDescriptor
171
    attr_class = StdImageFieldFile
172
    def_variation = {
173
        'width': float('inf'),
174
        'height': float('inf'),
175
        'crop': False,
176
        'resample': Image.ANTIALIAS
177
    }
178
179
    def __init__(self, verbose_name=None, name=None, variations=None,
180
                 render_variations=True, force_min_size=False,
181
                 *args, **kwargs):
182
        """
183
        Standardized ImageField for Django.
184
185
        Usage: StdImageField(upload_to='PATH',
186
         variations={'thumbnail': {"width", "height", "crop", "resample"}})
187
        :param variations: size variations of the image
188
        :rtype variations: StdImageField
189
        :param render_variations: boolean or callable that returns a boolean.
190
         The callable gets passed the app_name, model, field_name and pk.
191
         Default: True
192
        :rtype render_variations: bool, callable
193
        """
194
        if not variations:
195
            variations = {}
196
        if not isinstance(variations, dict):
197
            msg = ('"variations" expects a dict,'
198
                   ' but got %s') % type(variations)
199
            raise TypeError(msg)
200
        if not (isinstance(render_variations, bool) or
201
                callable(render_variations)):
202
            msg = ('"render_variations" excepts a boolean or callable,'
203
                   ' but got %s') % type(render_variations)
204
            raise TypeError(msg)
205
206
        self._variations = variations
207
        self.force_min_size = force_min_size
208
        self.render_variations = render_variations
209
        self.variations = {}
210
211
        for nm, prm in list(variations.items()):
212
            self.add_variation(nm, prm)
213
214
        if self.variations and self.force_min_size:
215
            self.min_size = (
216
                max(self.variations.values(),
217
                    key=lambda x: x["width"])["width"],
218
                max(self.variations.values(),
219
                    key=lambda x: x["height"])["height"]
220
            )
221
222
        super(StdImageField, self).__init__(verbose_name, name,
223
                                            *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
        """Generating all operations on specified signals."""
258
        super(StdImageField, self).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(StdImageField, self).validate(value, model_instance)
263
        if self.force_min_size:
264
            MinSizeValidator(self.min_size[0], self.min_size[1])(value)
265