|
1 import os |
|
2 import random |
|
3 import shutil |
|
4 import zipfile |
|
5 |
|
6 from datetime import datetime |
|
7 from inspect import isclass |
|
8 |
|
9 from django.db import models |
|
10 from django.db.models.signals import post_init |
|
11 from django.conf import settings |
|
12 from django.core.files.base import ContentFile |
|
13 from django.core.urlresolvers import reverse |
|
14 from django.template.defaultfilters import slugify |
|
15 from django.utils.functional import curry |
|
16 from django.utils.translation import ugettext_lazy as _ |
|
17 |
|
18 # Required PIL classes may or may not be available from the root namespace |
|
19 # depending on the installation method used. |
|
20 try: |
|
21 import Image |
|
22 import ImageFile |
|
23 import ImageFilter |
|
24 import ImageEnhance |
|
25 except ImportError: |
|
26 try: |
|
27 from PIL import Image |
|
28 from PIL import ImageFile |
|
29 from PIL import ImageFilter |
|
30 from PIL import ImageEnhance |
|
31 except ImportError: |
|
32 raise ImportError('Photologue was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.') |
|
33 |
|
34 # attempt to load the django-tagging TagField from default location, |
|
35 # otherwise we substitude a dummy TagField. |
|
36 try: |
|
37 from tagging.fields import TagField |
|
38 tagfield_help_text = _('Separate tags with spaces, put quotes around multiple-word tags.') |
|
39 except ImportError: |
|
40 class TagField(models.CharField): |
|
41 def __init__(self, **kwargs): |
|
42 default_kwargs = {'max_length': 255, 'blank': True} |
|
43 default_kwargs.update(kwargs) |
|
44 super(TagField, self).__init__(**default_kwargs) |
|
45 def get_internal_type(self): |
|
46 return 'CharField' |
|
47 tagfield_help_text = _('Django-tagging was not found, tags will be treated as plain text.') |
|
48 |
|
49 from utils import EXIF |
|
50 from utils.reflection import add_reflection |
|
51 from utils.watermark import apply_watermark |
|
52 |
|
53 # Path to sample image |
|
54 SAMPLE_IMAGE_PATH = getattr(settings, 'SAMPLE_IMAGE_PATH', os.path.join(os.path.dirname(__file__), 'res', 'sample.jpg')) # os.path.join(settings.PROJECT_PATH, 'photologue', 'res', 'sample.jpg' |
|
55 |
|
56 # Modify image file buffer size. |
|
57 ImageFile.MAXBLOCK = getattr(settings, 'PHOTOLOGUE_MAXBLOCK', 256 * 2 ** 10) |
|
58 |
|
59 # Photologue image path relative to media root |
|
60 PHOTOLOGUE_DIR = getattr(settings, 'PHOTOLOGUE_DIR', 'photologue') |
|
61 |
|
62 # Look for user function to define file paths |
|
63 PHOTOLOGUE_PATH = getattr(settings, 'PHOTOLOGUE_PATH', None) |
|
64 if PHOTOLOGUE_PATH is not None: |
|
65 if callable(PHOTOLOGUE_PATH): |
|
66 get_storage_path = PHOTOLOGUE_PATH |
|
67 else: |
|
68 parts = PHOTOLOGUE_PATH.split('.') |
|
69 module_name = '.'.join(parts[:-1]) |
|
70 module = __import__(module_name) |
|
71 get_storage_path = getattr(module, parts[-1]) |
|
72 else: |
|
73 def get_storage_path(instance, filename): |
|
74 return os.path.join(PHOTOLOGUE_DIR, 'photos', filename) |
|
75 |
|
76 # Quality options for JPEG images |
|
77 JPEG_QUALITY_CHOICES = ( |
|
78 (30, _('Very Low')), |
|
79 (40, _('Low')), |
|
80 (50, _('Medium-Low')), |
|
81 (60, _('Medium')), |
|
82 (70, _('Medium-High')), |
|
83 (80, _('High')), |
|
84 (90, _('Very High')), |
|
85 ) |
|
86 |
|
87 # choices for new crop_anchor field in Photo |
|
88 CROP_ANCHOR_CHOICES = ( |
|
89 ('top', _('Top')), |
|
90 ('right', _('Right')), |
|
91 ('bottom', _('Bottom')), |
|
92 ('left', _('Left')), |
|
93 ('center', _('Center (Default)')), |
|
94 ) |
|
95 |
|
96 IMAGE_TRANSPOSE_CHOICES = ( |
|
97 ('FLIP_LEFT_RIGHT', _('Flip left to right')), |
|
98 ('FLIP_TOP_BOTTOM', _('Flip top to bottom')), |
|
99 ('ROTATE_90', _('Rotate 90 degrees counter-clockwise')), |
|
100 ('ROTATE_270', _('Rotate 90 degrees clockwise')), |
|
101 ('ROTATE_180', _('Rotate 180 degrees')), |
|
102 ) |
|
103 |
|
104 WATERMARK_STYLE_CHOICES = ( |
|
105 ('tile', _('Tile')), |
|
106 ('scale', _('Scale')), |
|
107 ) |
|
108 |
|
109 # Prepare a list of image filters |
|
110 filter_names = [] |
|
111 for n in dir(ImageFilter): |
|
112 klass = getattr(ImageFilter, n) |
|
113 if isclass(klass) and issubclass(klass, ImageFilter.BuiltinFilter) and \ |
|
114 hasattr(klass, 'name'): |
|
115 filter_names.append(klass.__name__) |
|
116 IMAGE_FILTERS_HELP_TEXT = _('Chain multiple filters using the following pattern "FILTER_ONE->FILTER_TWO->FILTER_THREE". Image filters will be applied in order. The following filters are available: %s.' % (', '.join(filter_names))) |
|
117 |
|
118 |
|
119 class Gallery(models.Model): |
|
120 date_added = models.DateTimeField(_('date published'), default=datetime.now) |
|
121 title = models.CharField(_('title'), max_length=100, unique=True) |
|
122 title_slug = models.SlugField(_('title slug'), unique=True, |
|
123 help_text=_('A "slug" is a unique URL-friendly title for an object.')) |
|
124 description = models.TextField(_('description'), blank=True) |
|
125 is_public = models.BooleanField(_('is public'), default=True, |
|
126 help_text=_('Public galleries will be displayed in the default views.')) |
|
127 photos = models.ManyToManyField('Photo', related_name='galleries', verbose_name=_('photos'), |
|
128 null=True, blank=True) |
|
129 tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags')) |
|
130 |
|
131 class Meta: |
|
132 ordering = ['-date_added'] |
|
133 get_latest_by = 'date_added' |
|
134 verbose_name = _('gallery') |
|
135 verbose_name_plural = _('galleries') |
|
136 |
|
137 def __unicode__(self): |
|
138 return self.title |
|
139 |
|
140 def __str__(self): |
|
141 return self.__unicode__() |
|
142 |
|
143 def get_absolute_url(self): |
|
144 return reverse('pl-gallery', args=[self.title_slug]) |
|
145 |
|
146 def latest(self, limit=0, public=True): |
|
147 if limit == 0: |
|
148 limit = self.photo_count() |
|
149 if public: |
|
150 return self.public()[:limit] |
|
151 else: |
|
152 return self.photos.all()[:limit] |
|
153 |
|
154 def sample(self, count=0, public=True): |
|
155 if count == 0 or count > self.photo_count(): |
|
156 count = self.photo_count() |
|
157 if public: |
|
158 photo_set = self.public() |
|
159 else: |
|
160 photo_set = self.photos.all() |
|
161 return random.sample(photo_set, count) |
|
162 |
|
163 def photo_count(self, public=True): |
|
164 if public: |
|
165 return self.public().count() |
|
166 else: |
|
167 return self.photos.all().count() |
|
168 photo_count.short_description = _('count') |
|
169 |
|
170 def public(self): |
|
171 return self.photos.filter(is_public=True) |
|
172 |
|
173 |
|
174 class GalleryUpload(models.Model): |
|
175 zip_file = models.FileField(_('images file (.zip)'), upload_to=PHOTOLOGUE_DIR+"/temp", |
|
176 help_text=_('Select a .zip file of images to upload into a new Gallery.')) |
|
177 gallery = models.ForeignKey(Gallery, null=True, blank=True, help_text=_('Select a gallery to add these images to. leave this empty to create a new gallery from the supplied title.')) |
|
178 title = models.CharField(_('title'), max_length=75, help_text=_('All photos in the gallery will be given a title made up of the gallery title + a sequential number.')) |
|
179 caption = models.TextField(_('caption'), blank=True, help_text=_('Caption will be added to all photos.')) |
|
180 description = models.TextField(_('description'), blank=True, help_text=_('A description of this Gallery.')) |
|
181 is_public = models.BooleanField(_('is public'), default=True, help_text=_('Uncheck this to make the uploaded gallery and included photographs private.')) |
|
182 tags = models.CharField(max_length=255, blank=True, help_text=tagfield_help_text, verbose_name=_('tags')) |
|
183 |
|
184 class Meta: |
|
185 verbose_name = _('gallery upload') |
|
186 verbose_name_plural = _('gallery uploads') |
|
187 |
|
188 def save(self, *args, **kwargs): |
|
189 super(GalleryUpload, self).save(*args, **kwargs) |
|
190 gallery = self.process_zipfile() |
|
191 super(GalleryUpload, self).delete() |
|
192 return gallery |
|
193 |
|
194 def process_zipfile(self): |
|
195 if os.path.isfile(self.zip_file.path): |
|
196 # TODO: implement try-except here |
|
197 zip = zipfile.ZipFile(self.zip_file.path) |
|
198 bad_file = zip.testzip() |
|
199 if bad_file: |
|
200 raise Exception('"%s" in the .zip archive is corrupt.' % bad_file) |
|
201 count = 1 |
|
202 if self.gallery: |
|
203 gallery = self.gallery |
|
204 else: |
|
205 gallery = Gallery.objects.create(title=self.title, |
|
206 title_slug=slugify(self.title), |
|
207 description=self.description, |
|
208 is_public=self.is_public, |
|
209 tags=self.tags) |
|
210 from cStringIO import StringIO |
|
211 for filename in zip.namelist(): |
|
212 if filename.startswith('__'): # do not process meta files |
|
213 continue |
|
214 data = zip.read(filename) |
|
215 if len(data): |
|
216 try: |
|
217 # the following is taken from django.newforms.fields.ImageField: |
|
218 # load() is the only method that can spot a truncated JPEG, |
|
219 # but it cannot be called sanely after verify() |
|
220 trial_image = Image.open(StringIO(data)) |
|
221 trial_image.load() |
|
222 # verify() is the only method that can spot a corrupt PNG, |
|
223 # but it must be called immediately after the constructor |
|
224 trial_image = Image.open(StringIO(data)) |
|
225 trial_image.verify() |
|
226 except Exception: |
|
227 # if a "bad" file is found we just skip it. |
|
228 continue |
|
229 while 1: |
|
230 title = ' '.join([self.title, str(count)]) |
|
231 slug = slugify(title) |
|
232 try: |
|
233 p = Photo.objects.get(title_slug=slug) |
|
234 except Photo.DoesNotExist: |
|
235 photo = Photo(title=title, |
|
236 title_slug=slug, |
|
237 caption=self.caption, |
|
238 is_public=self.is_public, |
|
239 tags=self.tags) |
|
240 photo.image.save(filename, ContentFile(data)) |
|
241 gallery.photos.add(photo) |
|
242 count = count + 1 |
|
243 break |
|
244 count = count + 1 |
|
245 zip.close() |
|
246 return gallery |
|
247 |
|
248 |
|
249 class ImageModel(models.Model): |
|
250 image = models.ImageField(_('image'), upload_to=get_storage_path) |
|
251 date_taken = models.DateTimeField(_('date taken'), null=True, blank=True, editable=False) |
|
252 view_count = models.PositiveIntegerField(default=0, editable=False) |
|
253 crop_from = models.CharField(_('crop from'), blank=True, max_length=10, default='center', choices=CROP_ANCHOR_CHOICES) |
|
254 effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name="%(class)s_related", verbose_name=_('effect')) |
|
255 |
|
256 class Meta: |
|
257 abstract = True |
|
258 |
|
259 @property |
|
260 def EXIF(self): |
|
261 try: |
|
262 return EXIF.process_file(open(self.image.path, 'rb')) |
|
263 except: |
|
264 try: |
|
265 return EXIF.process_file(open(self.image.path, 'rb'), details=False) |
|
266 except: |
|
267 return {} |
|
268 |
|
269 def admin_thumbnail(self): |
|
270 func = getattr(self, 'get_admin_thumbnail_url', None) |
|
271 if func is None: |
|
272 return _('An "admin_thumbnail" photo size has not been defined.') |
|
273 else: |
|
274 if hasattr(self, 'get_absolute_url'): |
|
275 return u'<a href="%s"><img src="%s"></a>' % \ |
|
276 (self.get_absolute_url(), func()) |
|
277 else: |
|
278 return u'<a href="%s"><img src="%s"></a>' % \ |
|
279 (self.image.url, func()) |
|
280 admin_thumbnail.short_description = _('Thumbnail') |
|
281 admin_thumbnail.allow_tags = True |
|
282 |
|
283 def cache_path(self): |
|
284 return os.path.join(os.path.dirname(self.image.path), "cache") |
|
285 |
|
286 def cache_url(self): |
|
287 return '/'.join([os.path.dirname(self.image.url), "cache"]) |
|
288 |
|
289 def image_filename(self): |
|
290 return os.path.basename(self.image.path) |
|
291 |
|
292 def _get_filename_for_size(self, size): |
|
293 size = getattr(size, 'name', size) |
|
294 base, ext = os.path.splitext(self.image_filename()) |
|
295 return ''.join([base, '_', size, ext]) |
|
296 |
|
297 def _get_SIZE_photosize(self, size): |
|
298 return PhotoSizeCache().sizes.get(size) |
|
299 |
|
300 def _get_SIZE_size(self, size): |
|
301 photosize = PhotoSizeCache().sizes.get(size) |
|
302 if not self.size_exists(photosize): |
|
303 self.create_size(photosize) |
|
304 return Image.open(self._get_SIZE_filename(size)).size |
|
305 |
|
306 def _get_SIZE_url(self, size): |
|
307 photosize = PhotoSizeCache().sizes.get(size) |
|
308 if not self.size_exists(photosize): |
|
309 self.create_size(photosize) |
|
310 if photosize.increment_count: |
|
311 self.increment_count() |
|
312 return '/'.join([self.cache_url(), self._get_filename_for_size(photosize.name)]) |
|
313 |
|
314 def _get_SIZE_filename(self, size): |
|
315 photosize = PhotoSizeCache().sizes.get(size) |
|
316 return os.path.join(self.cache_path(), |
|
317 self._get_filename_for_size(photosize.name)) |
|
318 |
|
319 def increment_count(self): |
|
320 self.view_count += 1 |
|
321 models.Model.save(self) |
|
322 |
|
323 def add_accessor_methods(self, *args, **kwargs): |
|
324 for size in PhotoSizeCache().sizes.keys(): |
|
325 setattr(self, 'get_%s_size' % size, |
|
326 curry(self._get_SIZE_size, size=size)) |
|
327 setattr(self, 'get_%s_photosize' % size, |
|
328 curry(self._get_SIZE_photosize, size=size)) |
|
329 setattr(self, 'get_%s_url' % size, |
|
330 curry(self._get_SIZE_url, size=size)) |
|
331 setattr(self, 'get_%s_filename' % size, |
|
332 curry(self._get_SIZE_filename, size=size)) |
|
333 |
|
334 def size_exists(self, photosize): |
|
335 func = getattr(self, "get_%s_filename" % photosize.name, None) |
|
336 if func is not None: |
|
337 if os.path.isfile(func()): |
|
338 return True |
|
339 return False |
|
340 |
|
341 def resize_image(self, im, photosize): |
|
342 cur_width, cur_height = im.size |
|
343 new_width, new_height = photosize.size |
|
344 if photosize.crop: |
|
345 ratio = max(float(new_width)/cur_width,float(new_height)/cur_height) |
|
346 x = (cur_width * ratio) |
|
347 y = (cur_height * ratio) |
|
348 xd = abs(new_width - x) |
|
349 yd = abs(new_height - y) |
|
350 x_diff = int(xd / 2) |
|
351 y_diff = int(yd / 2) |
|
352 if self.crop_from == 'top': |
|
353 box = (int(x_diff), 0, int(x_diff+new_width), new_height) |
|
354 elif self.crop_from == 'left': |
|
355 box = (0, int(y_diff), new_width, int(y_diff+new_height)) |
|
356 elif self.crop_from == 'bottom': |
|
357 box = (int(x_diff), int(yd), int(x_diff+new_width), int(y)) # y - yd = new_height |
|
358 elif self.crop_from == 'right': |
|
359 box = (int(xd), int(y_diff), int(x), int(y_diff+new_height)) # x - xd = new_width |
|
360 else: |
|
361 box = (int(x_diff), int(y_diff), int(x_diff+new_width), int(y_diff+new_height)) |
|
362 im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box) |
|
363 else: |
|
364 if not new_width == 0 and not new_height == 0: |
|
365 ratio = min(float(new_width)/cur_width, |
|
366 float(new_height)/cur_height) |
|
367 else: |
|
368 if new_width == 0: |
|
369 ratio = float(new_height)/cur_height |
|
370 else: |
|
371 ratio = float(new_width)/cur_width |
|
372 new_dimensions = (int(round(cur_width*ratio)), |
|
373 int(round(cur_height*ratio))) |
|
374 if new_dimensions[0] > cur_width or \ |
|
375 new_dimensions[1] > cur_height: |
|
376 if not photosize.upscale: |
|
377 return im |
|
378 im = im.resize(new_dimensions, Image.ANTIALIAS) |
|
379 return im |
|
380 |
|
381 def create_size(self, photosize): |
|
382 if self.size_exists(photosize): |
|
383 return |
|
384 if not os.path.isdir(self.cache_path()): |
|
385 os.makedirs(self.cache_path()) |
|
386 try: |
|
387 im = Image.open(self.image.path) |
|
388 except IOError: |
|
389 return |
|
390 # Save the original format |
|
391 im_format = im.format |
|
392 # Apply effect if found |
|
393 if self.effect is not None: |
|
394 im = self.effect.pre_process(im) |
|
395 elif photosize.effect is not None: |
|
396 im = photosize.effect.pre_process(im) |
|
397 # Resize/crop image |
|
398 if im.size != photosize.size and photosize.size != (0, 0): |
|
399 im = self.resize_image(im, photosize) |
|
400 # Apply watermark if found |
|
401 if photosize.watermark is not None: |
|
402 im = photosize.watermark.post_process(im) |
|
403 # Apply effect if found |
|
404 if self.effect is not None: |
|
405 im = self.effect.post_process(im) |
|
406 elif photosize.effect is not None: |
|
407 im = photosize.effect.post_process(im) |
|
408 # Save file |
|
409 im_filename = getattr(self, "get_%s_filename" % photosize.name)() |
|
410 try: |
|
411 if im_format != 'JPEG': |
|
412 try: |
|
413 im.save(im_filename) |
|
414 return |
|
415 except KeyError: |
|
416 pass |
|
417 im.save(im_filename, 'JPEG', quality=int(photosize.quality), optimize=True) |
|
418 except IOError, e: |
|
419 if os.path.isfile(im_filename): |
|
420 os.unlink(im_filename) |
|
421 raise e |
|
422 |
|
423 def remove_size(self, photosize, remove_dirs=True): |
|
424 if not self.size_exists(photosize): |
|
425 return |
|
426 filename = getattr(self, "get_%s_filename" % photosize.name)() |
|
427 if os.path.isfile(filename): |
|
428 os.remove(filename) |
|
429 if remove_dirs: |
|
430 self.remove_cache_dirs() |
|
431 |
|
432 def clear_cache(self): |
|
433 cache = PhotoSizeCache() |
|
434 for photosize in cache.sizes.values(): |
|
435 self.remove_size(photosize, False) |
|
436 self.remove_cache_dirs() |
|
437 |
|
438 def pre_cache(self): |
|
439 cache = PhotoSizeCache() |
|
440 for photosize in cache.sizes.values(): |
|
441 if photosize.pre_cache: |
|
442 self.create_size(photosize) |
|
443 |
|
444 def remove_cache_dirs(self): |
|
445 try: |
|
446 os.removedirs(self.cache_path()) |
|
447 except: |
|
448 pass |
|
449 |
|
450 def save(self, *args, **kwargs): |
|
451 if self.date_taken is None: |
|
452 try: |
|
453 exif_date = self.EXIF.get('EXIF DateTimeOriginal', None) |
|
454 if exif_date is not None: |
|
455 d, t = str.split(exif_date.values) |
|
456 year, month, day = d.split(':') |
|
457 hour, minute, second = t.split(':') |
|
458 self.date_taken = datetime(int(year), int(month), int(day), |
|
459 int(hour), int(minute), int(second)) |
|
460 except: |
|
461 pass |
|
462 if self.date_taken is None: |
|
463 self.date_taken = datetime.now() |
|
464 if self._get_pk_val(): |
|
465 self.clear_cache() |
|
466 super(ImageModel, self).save(*args, **kwargs) |
|
467 self.pre_cache() |
|
468 |
|
469 def delete(self): |
|
470 assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % (self._meta.object_name, self._meta.pk.attname) |
|
471 self.clear_cache() |
|
472 super(ImageModel, self).delete() |
|
473 |
|
474 |
|
475 class Photo(ImageModel): |
|
476 title = models.CharField(_('title'), max_length=100, unique=True) |
|
477 title_slug = models.SlugField(_('slug'), unique=True, |
|
478 help_text=('A "slug" is a unique URL-friendly title for an object.')) |
|
479 caption = models.TextField(_('caption'), blank=True) |
|
480 date_added = models.DateTimeField(_('date added'), default=datetime.now, editable=False) |
|
481 is_public = models.BooleanField(_('is public'), default=True, help_text=_('Public photographs will be displayed in the default views.')) |
|
482 tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags')) |
|
483 |
|
484 class Meta: |
|
485 ordering = ['-date_added'] |
|
486 get_latest_by = 'date_added' |
|
487 verbose_name = _("photo") |
|
488 verbose_name_plural = _("photos") |
|
489 |
|
490 def __unicode__(self): |
|
491 return self.title |
|
492 |
|
493 def __str__(self): |
|
494 return self.__unicode__() |
|
495 |
|
496 def save(self, *args, **kwargs): |
|
497 if self.title_slug is None: |
|
498 self.title_slug = slugify(self.title) |
|
499 super(Photo, self).save(*args, **kwargs) |
|
500 |
|
501 def get_absolute_url(self): |
|
502 return reverse('pl-photo', args=[self.title_slug]) |
|
503 |
|
504 def public_galleries(self): |
|
505 """Return the public galleries to which this photo belongs.""" |
|
506 return self.galleries.filter(is_public=True) |
|
507 |
|
508 def get_previous_in_gallery(self, gallery): |
|
509 try: |
|
510 return self.get_previous_by_date_added(galleries__exact=gallery, |
|
511 is_public=True) |
|
512 except Photo.DoesNotExist: |
|
513 return None |
|
514 |
|
515 def get_next_in_gallery(self, gallery): |
|
516 try: |
|
517 return self.get_next_by_date_added(galleries__exact=gallery, |
|
518 is_public=True) |
|
519 except Photo.DoesNotExist: |
|
520 return None |
|
521 |
|
522 |
|
523 class BaseEffect(models.Model): |
|
524 name = models.CharField(_('name'), max_length=30, unique=True) |
|
525 description = models.TextField(_('description'), blank=True) |
|
526 |
|
527 class Meta: |
|
528 abstract = True |
|
529 |
|
530 def sample_dir(self): |
|
531 return os.path.join(settings.MEDIA_ROOT, PHOTOLOGUE_DIR, 'samples') |
|
532 |
|
533 def sample_url(self): |
|
534 return settings.MEDIA_URL + '/'.join([PHOTOLOGUE_DIR, 'samples', '%s %s.jpg' % (self.name.lower(), 'sample')]) |
|
535 |
|
536 def sample_filename(self): |
|
537 return os.path.join(self.sample_dir(), '%s %s.jpg' % (self.name.lower(), 'sample')) |
|
538 |
|
539 def create_sample(self): |
|
540 if not os.path.isdir(self.sample_dir()): |
|
541 os.makedirs(self.sample_dir()) |
|
542 try: |
|
543 im = Image.open(SAMPLE_IMAGE_PATH) |
|
544 except IOError: |
|
545 raise IOError('Photologue was unable to open the sample image: %s.' % SAMPLE_IMAGE_PATH) |
|
546 im = self.process(im) |
|
547 im.save(self.sample_filename(), 'JPEG', quality=90, optimize=True) |
|
548 |
|
549 def admin_sample(self): |
|
550 return u'<img src="%s">' % self.sample_url() |
|
551 admin_sample.short_description = 'Sample' |
|
552 admin_sample.allow_tags = True |
|
553 |
|
554 def pre_process(self, im): |
|
555 return im |
|
556 |
|
557 def post_process(self, im): |
|
558 return im |
|
559 |
|
560 def process(self, im): |
|
561 im = self.pre_process(im) |
|
562 im = self.post_process(im) |
|
563 return im |
|
564 |
|
565 def __unicode__(self): |
|
566 return self.name |
|
567 |
|
568 def __str__(self): |
|
569 return self.__unicode__() |
|
570 |
|
571 def save(self, *args, **kwargs): |
|
572 try: |
|
573 os.remove(self.sample_filename()) |
|
574 except: |
|
575 pass |
|
576 models.Model.save(self, *args, **kwargs) |
|
577 self.create_sample() |
|
578 for size in self.photo_sizes.all(): |
|
579 size.clear_cache() |
|
580 # try to clear all related subclasses of ImageModel |
|
581 for prop in [prop for prop in dir(self) if prop[-8:] == '_related']: |
|
582 for obj in getattr(self, prop).all(): |
|
583 obj.clear_cache() |
|
584 obj.pre_cache() |
|
585 |
|
586 def delete(self): |
|
587 try: |
|
588 os.remove(self.sample_filename()) |
|
589 except: |
|
590 pass |
|
591 models.Model.delete(self) |
|
592 |
|
593 |
|
594 class PhotoEffect(BaseEffect): |
|
595 """ A pre-defined effect to apply to photos """ |
|
596 transpose_method = models.CharField(_('rotate or flip'), max_length=15, blank=True, choices=IMAGE_TRANSPOSE_CHOICES) |
|
597 color = models.FloatField(_('color'), default=1.0, help_text=_("A factor of 0.0 gives a black and white image, a factor of 1.0 gives the original image.")) |
|
598 brightness = models.FloatField(_('brightness'), default=1.0, help_text=_("A factor of 0.0 gives a black image, a factor of 1.0 gives the original image.")) |
|
599 contrast = models.FloatField(_('contrast'), default=1.0, help_text=_("A factor of 0.0 gives a solid grey image, a factor of 1.0 gives the original image.")) |
|
600 sharpness = models.FloatField(_('sharpness'), default=1.0, help_text=_("A factor of 0.0 gives a blurred image, a factor of 1.0 gives the original image.")) |
|
601 filters = models.CharField(_('filters'), max_length=200, blank=True, help_text=_(IMAGE_FILTERS_HELP_TEXT)) |
|
602 reflection_size = models.FloatField(_('size'), default=0, help_text=_("The height of the reflection as a percentage of the orignal image. A factor of 0.0 adds no reflection, a factor of 1.0 adds a reflection equal to the height of the orignal image.")) |
|
603 reflection_strength = models.FloatField(_('strength'), default=0.6, help_text=_("The initial opacity of the reflection gradient.")) |
|
604 background_color = models.CharField(_('color'), max_length=7, default="#FFFFFF", help_text=_("The background color of the reflection gradient. Set this to match the background color of your page.")) |
|
605 |
|
606 class Meta: |
|
607 verbose_name = _("photo effect") |
|
608 verbose_name_plural = _("photo effects") |
|
609 |
|
610 def pre_process(self, im): |
|
611 if self.transpose_method != '': |
|
612 method = getattr(Image, self.transpose_method) |
|
613 im = im.transpose(method) |
|
614 if im.mode != 'RGB' and im.mode != 'RGBA': |
|
615 return im |
|
616 for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: |
|
617 factor = getattr(self, name.lower()) |
|
618 if factor != 1.0: |
|
619 im = getattr(ImageEnhance, name)(im).enhance(factor) |
|
620 for name in self.filters.split('->'): |
|
621 image_filter = getattr(ImageFilter, name.upper(), None) |
|
622 if image_filter is not None: |
|
623 try: |
|
624 im = im.filter(image_filter) |
|
625 except ValueError: |
|
626 pass |
|
627 return im |
|
628 |
|
629 def post_process(self, im): |
|
630 if self.reflection_size != 0.0: |
|
631 im = add_reflection(im, bgcolor=self.background_color, amount=self.reflection_size, opacity=self.reflection_strength) |
|
632 return im |
|
633 |
|
634 |
|
635 class Watermark(BaseEffect): |
|
636 image = models.ImageField(_('image'), upload_to=PHOTOLOGUE_DIR+"/watermarks") |
|
637 style = models.CharField(_('style'), max_length=5, choices=WATERMARK_STYLE_CHOICES, default='scale') |
|
638 opacity = models.FloatField(_('opacity'), default=1, help_text=_("The opacity of the overlay.")) |
|
639 |
|
640 class Meta: |
|
641 verbose_name = _('watermark') |
|
642 verbose_name_plural = _('watermarks') |
|
643 |
|
644 def post_process(self, im): |
|
645 mark = Image.open(self.image.path) |
|
646 return apply_watermark(im, mark, self.style, self.opacity) |
|
647 |
|
648 |
|
649 class PhotoSize(models.Model): |
|
650 name = models.CharField(_('name'), max_length=20, unique=True, help_text=_('Photo size name should contain only letters, numbers and underscores. Examples: "thumbnail", "display", "small", "main_page_widget".')) |
|
651 width = models.PositiveIntegerField(_('width'), default=0, help_text=_('If width is set to "0" the image will be scaled to the supplied height.')) |
|
652 height = models.PositiveIntegerField(_('height'), default=0, help_text=_('If height is set to "0" the image will be scaled to the supplied width')) |
|
653 quality = models.PositiveIntegerField(_('quality'), choices=JPEG_QUALITY_CHOICES, default=70, help_text=_('JPEG image quality.')) |
|
654 upscale = models.BooleanField(_('upscale images?'), default=False, help_text=_('If selected the image will be scaled up if necessary to fit the supplied dimensions. Cropped sizes will be upscaled regardless of this setting.')) |
|
655 crop = models.BooleanField(_('crop to fit?'), default=False, help_text=_('If selected the image will be scaled and cropped to fit the supplied dimensions.')) |
|
656 pre_cache = models.BooleanField(_('pre-cache?'), default=False, help_text=_('If selected this photo size will be pre-cached as photos are added.')) |
|
657 increment_count = models.BooleanField(_('increment view count?'), default=False, help_text=_('If selected the image\'s "view_count" will be incremented when this photo size is displayed.')) |
|
658 effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name='photo_sizes', verbose_name=_('photo effect')) |
|
659 watermark = models.ForeignKey('Watermark', null=True, blank=True, related_name='photo_sizes', verbose_name=_('watermark image')) |
|
660 |
|
661 class Meta: |
|
662 ordering = ['width', 'height'] |
|
663 verbose_name = _('photo size') |
|
664 verbose_name_plural = _('photo sizes') |
|
665 |
|
666 def __unicode__(self): |
|
667 return self.name |
|
668 |
|
669 def __str__(self): |
|
670 return self.__unicode__() |
|
671 |
|
672 def clear_cache(self): |
|
673 for cls in ImageModel.__subclasses__(): |
|
674 for obj in cls.objects.all(): |
|
675 obj.remove_size(self) |
|
676 if self.pre_cache: |
|
677 obj.create_size(self) |
|
678 PhotoSizeCache().reset() |
|
679 |
|
680 def save(self, *args, **kwargs): |
|
681 if self.crop is True: |
|
682 if self.width == 0 or self.height == 0: |
|
683 raise ValueError("PhotoSize width and/or height can not be zero if crop=True.") |
|
684 super(PhotoSize, self).save(*args, **kwargs) |
|
685 PhotoSizeCache().reset() |
|
686 self.clear_cache() |
|
687 |
|
688 def delete(self): |
|
689 assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % (self._meta.object_name, self._meta.pk.attname) |
|
690 self.clear_cache() |
|
691 super(PhotoSize, self).delete() |
|
692 |
|
693 def _get_size(self): |
|
694 return (self.width, self.height) |
|
695 def _set_size(self, value): |
|
696 self.width, self.height = value |
|
697 size = property(_get_size, _set_size) |
|
698 |
|
699 |
|
700 class PhotoSizeCache(object): |
|
701 __state = {"sizes": {}} |
|
702 |
|
703 def __init__(self): |
|
704 self.__dict__ = self.__state |
|
705 if not len(self.sizes): |
|
706 sizes = PhotoSize.objects.all() |
|
707 for size in sizes: |
|
708 self.sizes[size.name] = size |
|
709 |
|
710 def reset(self): |
|
711 self.sizes = {} |
|
712 |
|
713 |
|
714 # Set up the accessor methods |
|
715 def add_methods(sender, instance, signal, *args, **kwargs): |
|
716 """ Adds methods to access sized images (urls, paths) |
|
717 |
|
718 after the Photo model's __init__ function completes, |
|
719 this method calls "add_accessor_methods" on each instance. |
|
720 """ |
|
721 if hasattr(instance, 'add_accessor_methods'): |
|
722 instance.add_accessor_methods() |
|
723 |
|
724 # connect the add_accessor_methods function to the post_init signal |
|
725 post_init.connect(add_methods) |