/
models.py
401 lines (361 loc) · 13.1 KB
/
models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
"""
Enables the user to add an "Image" plugin that displays an image
using the HTML <img> tag.
"""
from cms.models import CMSPlugin
from cms.models.fields import PageField
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from djangocms_attributes_field.fields import AttributesField
from easy_thumbnails.files import get_thumbnailer
from filer.fields.image import FilerImageField
from filer.models import ThumbnailOption
# add setting for picture alignment, renders a class or inline styles
# depending on your template setup
def get_alignment():
alignment = getattr(
settings,
'DJANGOCMS_PICTURE_ALIGN',
(
('left', _('Align left')),
('right', _('Align right')),
('center', _('Align center')),
)
)
return alignment
# Add additional choices through the ``settings.py``.
def get_templates():
choices = [
('default', _('Default')),
]
choices += getattr(
settings,
'DJANGOCMS_PICTURE_TEMPLATES',
[],
)
return choices
# use golden ration as default (https://en.wikipedia.org/wiki/Golden_ratio)
PICTURE_RATIO = getattr(settings, 'DJANGOCMS_PICTURE_RATIO', 1.6180)
# required for backwards compability
PICTURE_ALIGNMENT = get_alignment()
LINK_TARGET = (
('_blank', _('Open in new window')),
('_self', _('Open in same window')),
('_parent', _('Delegate to parent')),
('_top', _('Delegate to top')),
)
RESPONSIVE_IMAGE_CHOICES = (
('inherit', _('Let settings.DJANGOCMS_PICTURE_RESPONSIVE_IMAGES decide')),
('yes', _('Yes')),
('no', _('No')),
)
class AbstractPicture(CMSPlugin):
"""
Renders an image with the option of adding a link
"""
template = models.CharField(
verbose_name=_('Template'),
choices=get_templates(),
default=get_templates()[0][0],
max_length=255,
)
picture = FilerImageField(
verbose_name=_('Image'),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='+',
)
external_picture = models.URLField(
verbose_name=_('External image'),
blank=True,
null=True,
max_length=255,
help_text=_(
'If provided, overrides the embedded image. '
'Certain options such as cropping are not applicable to external images.'
)
)
width = models.PositiveIntegerField(
verbose_name=_('Width'),
blank=True,
null=True,
help_text=_(
'The image width as number in pixels. '
'Example: "720" and not "720px".'
),
)
height = models.PositiveIntegerField(
verbose_name=_('Height'),
blank=True,
null=True,
help_text=_(
'The image height as number in pixels. '
'Example: "720" and not "720px".'
),
)
alignment = models.CharField(
verbose_name=_('Alignment'),
choices=get_alignment(),
blank=True,
max_length=255,
help_text=_('Aligns the image according to the selected option.'),
)
caption_text = models.TextField(
verbose_name=_('Caption text'),
blank=True,
null=True,
help_text=_('Provide a description, attribution, copyright or other information.')
)
attributes = AttributesField(
verbose_name=_('Attributes'),
blank=True,
excluded_keys=['src', 'width', 'height'],
)
# link models
link_url = models.URLField(
verbose_name=_('External URL'),
blank=True,
null=True,
max_length=2040,
help_text=_('Wraps the image in a link to an external URL.'),
)
link_page = PageField(
verbose_name=_('Internal URL'),
blank=True,
null=True,
on_delete=models.SET_NULL,
help_text=_('Wraps the image in a link to an internal (page) URL.'),
)
link_target = models.CharField(
verbose_name=_('Link target'),
choices=LINK_TARGET,
blank=True,
max_length=255,
)
link_attributes = AttributesField(
verbose_name=_('Link attributes'),
blank=True,
excluded_keys=['href', 'target'],
)
# cropping models
# active per default
use_automatic_scaling = models.BooleanField(
verbose_name=_('Automatic scaling'),
blank=True,
default=True,
help_text=_('Uses the placeholder dimensions to automatically calculate the size.'),
)
# ignores all other cropping options
# throws validation error if other cropping options are selected
use_no_cropping = models.BooleanField(
verbose_name=_('Use original image'),
blank=True,
default=False,
help_text=_('Outputs the raw image without cropping.'),
)
# upscale and crop work together
# throws validation error if other cropping options are selected
use_crop = models.BooleanField(
verbose_name=_('Crop image'),
blank=True,
default=False,
help_text=_('Crops the image according to the thumbnail settings provided in the template.'),
)
use_upscale = models.BooleanField(
verbose_name=_('Upscale image'),
blank=True,
default=False,
help_text=_('Upscales the image to the size of the thumbnail settings in the template.')
)
use_responsive_image = models.CharField(
verbose_name=_('Use responsive image'),
max_length=7,
choices=RESPONSIVE_IMAGE_CHOICES,
default=RESPONSIVE_IMAGE_CHOICES[0][0],
help_text=_(
'Uses responsive image technique to choose better image to display based upon screen viewport. '
'This configuration only applies to uploaded images (external pictures will not be affected). '
)
)
# overrides all other options
# throws validation error if other cropping options are selected
thumbnail_options = models.ForeignKey(
ThumbnailOption,
verbose_name=_('Thumbnail options'),
blank=True,
null=True,
help_text=_('Overrides width, height, and crop; scales up to the provided preset dimensions.'),
on_delete=models.CASCADE,
)
# Add an app namespace to related_name to avoid field name clashes
# with any other plugins that have a field with the same name as the
# lowercase of the class name of this model.
# https://github.com/divio/django-cms/issues/5030
cmsplugin_ptr = models.OneToOneField(
CMSPlugin,
related_name='%(app_label)s_%(class)s',
parent_link=True,
on_delete=models.CASCADE,
)
class Meta:
abstract = True
def __str__(self):
if self.picture and self.picture.label:
return self.picture.label
return str(self.pk)
def get_short_description(self):
if self.external_picture:
return self.external_picture
if self.picture and self.picture.label:
return self.picture.label
return gettext('<file is missing>')
def copy_relations(self, oldinstance):
# Because we have a ForeignKey, it's required to copy over
# the reference from the instance to the new plugin.
self.picture = oldinstance.picture
def get_size(self, width=None, height=None):
crop = self.use_crop
upscale = self.use_upscale
# use field thumbnail settings
if self.thumbnail_options:
width = self.thumbnail_options.width
height = self.thumbnail_options.height
crop = self.thumbnail_options.crop
upscale = self.thumbnail_options.upscale
elif not self.use_automatic_scaling:
width = self.width
height = self.height
if self.picture:
# calculate height when not given according to the
# golden ratio or fallback to the picture size
if crop:
if not height and width:
if self.picture.width > self.picture.height:
height = width / PICTURE_RATIO
else:
height = width * PICTURE_RATIO
elif not width and height:
if self.picture.width > self.picture.height:
width = height * PICTURE_RATIO
else:
width = height / PICTURE_RATIO
width = width or self.picture.width
height = height or self.picture.height
# ensure width and height are int
width = int(width) if width is not None else width
height = int(height) if height is not None else height
options = {
'size': (width, height),
'crop': crop,
'upscale': upscale,
}
return options
def get_link(self):
if self.link_url:
return self.link_url
elif self.link_page_id:
return self.link_page.get_absolute_url(language=self.language)
elif self.external_picture:
return self.external_picture
return False
def clean(self):
# there can be only one link type
if self.link_url and self.link_page_id:
raise ValidationError(
gettext(
'You have given both external and internal links. '
'Only one option is allowed.'
)
)
# you shall only set one image kind
if not self.picture and not self.external_picture:
raise ValidationError(
gettext(
'You need to add either an image, '
'or a URL linking to an external image.'
)
)
# certain cropping options do not work together, the following
# list defines the disallowed options used in the ``clean`` method
invalid_option_pairs = [
('use_automatic_scaling', 'use_no_cropping'),
('use_automatic_scaling', 'thumbnail_options'),
('use_no_cropping', 'use_crop'),
('use_no_cropping', 'use_upscale'),
('use_no_cropping', 'thumbnail_options'),
('thumbnail_options', 'use_crop'),
('thumbnail_options', 'use_upscale'),
]
# invalid_option_pairs
invalid_option_pair = None
for pair in invalid_option_pairs:
if getattr(self, pair[0]) and getattr(self, pair[1]):
invalid_option_pair = pair
break
if invalid_option_pair:
message = gettext(
'Invalid cropping settings. '
'You cannot combine "{field_a}" with "{field_b}".'
)
message = message.format(
field_a=self._meta.get_field(invalid_option_pair[0]).verbose_name,
field_b=self._meta.get_field(invalid_option_pair[1]).verbose_name,
)
raise ValidationError(message)
@property
def is_responsive_image(self):
if self.external_picture:
return False
if self.use_responsive_image == 'inherit':
return getattr(settings, 'DJANGOCMS_PICTURE_RESPONSIVE_IMAGES', False)
return self.use_responsive_image == 'yes'
@property
def img_srcset_data(self):
if not (self.picture and self.is_responsive_image):
return None
srcset = []
thumbnailer = get_thumbnailer(self.picture)
picture_options = self.get_size(self.width, self.height)
picture_width = picture_options['size'][0]
thumbnail_options = {'crop': picture_options['crop']}
breakpoints = getattr(
settings,
'DJANGOCMS_PICTURE_RESPONSIVE_IMAGES_VIEWPORT_BREAKPOINTS',
[576, 768, 992],
)
for size in filter(lambda x: x < picture_width, breakpoints):
thumbnail_options['size'] = (size, size)
srcset.append((int(size), thumbnailer.get_thumbnail(thumbnail_options)))
return srcset
@property
def img_src(self):
# we want the external picture to take priority by design
# please open a ticket if you disagree for an open discussion
if self.external_picture:
return self.external_picture
# picture can be empty, for example when the image is removed from filer
# in this case we want to return an empty string to avoid #69
elif not self.picture:
return ''
# return the original, unmodified picture
elif self.use_no_cropping:
return self.picture.url
picture_options = self.get_size(
width=self.width or 0,
height=self.height or 0,
)
thumbnail_options = {
'size': picture_options['size'],
'crop': picture_options['crop'],
'upscale': picture_options['upscale'],
'subject_location': self.picture.subject_location,
}
thumbnailer = get_thumbnailer(self.picture)
return thumbnailer.get_thumbnail(thumbnail_options).url
class Picture(AbstractPicture):
class Meta:
abstract = False