comparison photologue/models.py @ 71:e2868ad47a1e

For Django 1.4, using the new manage.py.
author Brian Neal <bgneal@gmail.com>
date Sat, 14 Apr 2012 16:40:29 -0500
parents madeira/photologue/models.py@63e4211628e1
children dcd097a59223
comparison
equal deleted inserted replaced
70:f26cdda0ad8b 71:e2868ad47a1e
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):
189 super(GalleryUpload, self).save()
190 self.process_zipfile()
191 super(GalleryUpload, self).delete()
192
193 def process_zipfile(self):
194 if os.path.isfile(self.zip_file.path):
195 # TODO: implement try-except here
196 zip = zipfile.ZipFile(self.zip_file.path)
197 bad_file = zip.testzip()
198 if bad_file:
199 raise Exception('"%s" in the .zip archive is corrupt.' % bad_file)
200 count = 1
201 if self.gallery:
202 gallery = self.gallery
203 else:
204 gallery = Gallery.objects.create(title=self.title,
205 title_slug=slugify(self.title),
206 description=self.description,
207 is_public=self.is_public,
208 tags=self.tags)
209 from cStringIO import StringIO
210 for filename in zip.namelist():
211 if filename.startswith('__'): # do not process meta files
212 continue
213 data = zip.read(filename)
214 if len(data):
215 try:
216 # the following is taken from django.newforms.fields.ImageField:
217 # load() is the only method that can spot a truncated JPEG,
218 # but it cannot be called sanely after verify()
219 trial_image = Image.open(StringIO(data))
220 trial_image.load()
221 # verify() is the only method that can spot a corrupt PNG,
222 # but it must be called immediately after the constructor
223 trial_image = Image.open(StringIO(data))
224 trial_image.verify()
225 except Exception:
226 # if a "bad" file is found we just skip it.
227 continue
228 while 1:
229 title = ' '.join([self.title, str(count)])
230 slug = slugify(title)
231 try:
232 p = Photo.objects.get(title_slug=slug)
233 except Photo.DoesNotExist:
234 photo = Photo(title=title, title_slug=slug,
235 caption=self.caption,
236 is_public=self.is_public,
237 tags=self.tags)
238 photo.image.save(filename, ContentFile(data))
239 gallery.photos.add(photo)
240 count = count + 1
241 break
242 count = count + 1
243 zip.close()
244
245
246 class ImageModel(models.Model):
247 image = models.ImageField(_('image'), upload_to=get_storage_path)
248 date_taken = models.DateTimeField(_('date taken'), null=True, blank=True, editable=False)
249 view_count = models.PositiveIntegerField(default=0, editable=False)
250 crop_from = models.CharField(_('crop from'), blank=True, max_length=10, default='center', choices=CROP_ANCHOR_CHOICES)
251 effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name="%(class)s_related", verbose_name=_('effect'))
252
253 class Meta:
254 abstract = True
255
256 @property
257 def EXIF(self):
258 try:
259 return EXIF.process_file(open(self.image.path, 'rb'))
260 except:
261 try:
262 return EXIF.process_file(open(self.image.path, 'rb'), details=False)
263 except:
264 return {}
265
266 def admin_thumbnail(self):
267 func = getattr(self, 'get_admin_thumbnail_url', None)
268 if func is None:
269 return _('An "admin_thumbnail" photo size has not been defined.')
270 else:
271 if hasattr(self, 'get_absolute_url'):
272 return u'<a href="%s"><img src="%s"></a>' % \
273 (self.get_absolute_url(), func())
274 else:
275 return u'<a href="%s"><img src="%s"></a>' % \
276 (self.image.url, func())
277 admin_thumbnail.short_description = _('Thumbnail')
278 admin_thumbnail.allow_tags = True
279
280 def cache_path(self):
281 return os.path.join(os.path.dirname(self.image.path), "cache")
282
283 def cache_url(self):
284 return '/'.join([os.path.dirname(self.image.url), "cache"])
285
286 def image_filename(self):
287 return os.path.basename(self.image.path)
288
289 def _get_filename_for_size(self, size):
290 size = getattr(size, 'name', size)
291 base, ext = os.path.splitext(self.image_filename())
292 return ''.join([base, '_', size, ext])
293
294 def _get_SIZE_photosize(self, size):
295 return PhotoSizeCache().sizes.get(size)
296
297 def _get_SIZE_size(self, size):
298 photosize = PhotoSizeCache().sizes.get(size)
299 if not self.size_exists(photosize):
300 self.create_size(photosize)
301 return Image.open(self._get_SIZE_filename(size)).size
302
303 def _get_SIZE_url(self, size):
304 photosize = PhotoSizeCache().sizes.get(size)
305 if not self.size_exists(photosize):
306 self.create_size(photosize)
307 if photosize.increment_count:
308 self.view_count += 1
309 self.save(update=True)
310 return '/'.join([self.cache_url(), self._get_filename_for_size(photosize.name)])
311
312 def _get_SIZE_filename(self, size):
313 photosize = PhotoSizeCache().sizes.get(size)
314 return os.path.join(self.cache_path(),
315 self._get_filename_for_size(photosize.name))
316
317 def add_accessor_methods(self, *args, **kwargs):
318 for size in PhotoSizeCache().sizes.keys():
319 setattr(self, 'get_%s_size' % size,
320 curry(self._get_SIZE_size, size=size))
321 setattr(self, 'get_%s_photosize' % size,
322 curry(self._get_SIZE_photosize, size=size))
323 setattr(self, 'get_%s_url' % size,
324 curry(self._get_SIZE_url, size=size))
325 setattr(self, 'get_%s_filename' % size,
326 curry(self._get_SIZE_filename, size=size))
327
328 def size_exists(self, photosize):
329 func = getattr(self, "get_%s_filename" % photosize.name, None)
330 if func is not None:
331 if os.path.isfile(func()):
332 return True
333 return False
334
335 def resize_image(self, im, photosize):
336 cur_width, cur_height = im.size
337 new_width, new_height = photosize.size
338 if photosize.crop:
339 ratio = max(float(new_width)/cur_width,float(new_height)/cur_height)
340 x = (cur_width * ratio)
341 y = (cur_height * ratio)
342 xd = abs(new_width - x)
343 yd = abs(new_height - y)
344 x_diff = int(xd / 2)
345 y_diff = int(yd / 2)
346 if self.crop_from == 'top':
347 box = (int(x_diff), 0, int(x_diff+new_width), new_height)
348 elif self.crop_from == 'left':
349 box = (0, int(y_diff), new_width, int(y_diff+new_height))
350 elif self.crop_from == 'bottom':
351 box = (int(x_diff), int(yd), int(x_diff+new_width), int(y)) # y - yd = new_height
352 elif self.crop_from == 'right':
353 box = (int(xd), int(y_diff), int(x), int(y_diff+new_height)) # x - xd = new_width
354 else:
355 box = (int(x_diff), int(y_diff), int(x_diff+new_width), int(y_diff+new_height))
356 im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box)
357 else:
358 if not new_width == 0 and not new_height == 0:
359 ratio = min(float(new_width)/cur_width,
360 float(new_height)/cur_height)
361 else:
362 if new_width == 0:
363 ratio = float(new_height)/cur_height
364 else:
365 ratio = float(new_width)/cur_width
366 new_dimensions = (int(round(cur_width*ratio)),
367 int(round(cur_height*ratio)))
368 if new_dimensions[0] > cur_width or \
369 new_dimensions[1] > cur_height:
370 if not photosize.upscale:
371 return im
372 im = im.resize(new_dimensions, Image.ANTIALIAS)
373 return im
374
375 def create_size(self, photosize):
376 if self.size_exists(photosize):
377 return
378 if not os.path.isdir(self.cache_path()):
379 os.makedirs(self.cache_path())
380 try:
381 im = Image.open(self.image.path)
382 except IOError:
383 return
384 # Apply effect if found
385 if self.effect is not None:
386 im = self.effect.pre_process(im)
387 elif photosize.effect is not None:
388 im = photosize.effect.pre_process(im)
389 # Resize/crop image
390 if im.size != photosize.size:
391 im = self.resize_image(im, photosize)
392 # Apply watermark if found
393 if photosize.watermark is not None:
394 im = photosize.watermark.post_process(im)
395 # Apply effect if found
396 if self.effect is not None:
397 im = self.effect.post_process(im)
398 elif photosize.effect is not None:
399 im = photosize.effect.post_process(im)
400 # Save file
401 im_filename = getattr(self, "get_%s_filename" % photosize.name)()
402 try:
403 if im.format == 'JPEG':
404 im.save(im_filename, 'JPEG', quality=int(photosize.quality), optimize=True)
405 else:
406 im.save(im_filename)
407 except IOError, e:
408 if os.path.isfile(im_filename):
409 os.unlink(im_filename)
410 raise e
411
412 def remove_size(self, photosize, remove_dirs=True):
413 if not self.size_exists(photosize):
414 return
415 filename = getattr(self, "get_%s_filename" % photosize.name)()
416 if os.path.isfile(filename):
417 os.remove(filename)
418 if remove_dirs:
419 self.remove_cache_dirs()
420
421 def clear_cache(self):
422 cache = PhotoSizeCache()
423 for photosize in cache.sizes.values():
424 self.remove_size(photosize, False)
425 self.remove_cache_dirs()
426
427 def pre_cache(self):
428 cache = PhotoSizeCache()
429 for photosize in cache.sizes.values():
430 if photosize.pre_cache:
431 self.create_size(photosize)
432
433 def remove_cache_dirs(self):
434 try:
435 os.removedirs(self.cache_path())
436 except:
437 pass
438
439 def save(self, update=False):
440 if update:
441 models.Model.save(self)
442 return
443 if self.date_taken is None:
444 try:
445 exif_date = self.EXIF.get('EXIF DateTimeOriginal', None)
446 if exif_date is not None:
447 d, t = str.split(exif_date.values)
448 year, month, day = d.split(':')
449 hour, minute, second = t.split(':')
450 self.date_taken = datetime(int(year), int(month), int(day),
451 int(hour), int(minute), int(second))
452 except:
453 pass
454 if self.date_taken is None:
455 self.date_taken = datetime.now()
456 if self._get_pk_val():
457 self.clear_cache()
458 super(ImageModel, self).save()
459 self.pre_cache()
460
461 def delete(self):
462 self.clear_cache()
463 super(ImageModel, self).delete()
464
465
466 class Photo(ImageModel):
467 title = models.CharField(_('title'), max_length=100, unique=True)
468 title_slug = models.SlugField(_('slug'), unique=True,
469 help_text=('A "slug" is a unique URL-friendly title for an object.'))
470 caption = models.TextField(_('caption'), blank=True)
471 date_added = models.DateTimeField(_('date added'), default=datetime.now, editable=False)
472 is_public = models.BooleanField(_('is public'), default=True, help_text=_('Public photographs will be displayed in the default views.'))
473 tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags'))
474
475 class Meta:
476 ordering = ['-date_added']
477 get_latest_by = 'date_added'
478 verbose_name = _("photo")
479 verbose_name_plural = _("photos")
480
481 def __unicode__(self):
482 return self.title
483
484 def __str__(self):
485 return self.__unicode__()
486
487 def save(self, update=False):
488 if self.title_slug is None:
489 self.title_slug = slugify(self.title)
490 super(Photo, self).save(update)
491
492 def get_absolute_url(self):
493 return reverse('pl-photo', args=[self.title_slug])
494
495 def public_galleries(self):
496 """Return the public galleries to which this photo belongs."""
497 return self.galleries.filter(is_public=True)
498
499
500 class BaseEffect(models.Model):
501 name = models.CharField(_('name'), max_length=30, unique=True)
502 description = models.TextField(_('description'), blank=True)
503
504 class Meta:
505 abstract = True
506
507 def sample_dir(self):
508 return os.path.join(settings.MEDIA_ROOT, PHOTOLOGUE_DIR, 'samples')
509
510 def sample_url(self):
511 return settings.MEDIA_URL + '/'.join([PHOTOLOGUE_DIR, 'samples', '%s %s.jpg' % (self.name.lower(), 'sample')])
512
513 def sample_filename(self):
514 return os.path.join(self.sample_dir(), '%s %s.jpg' % (self.name.lower(), 'sample'))
515
516 def create_sample(self):
517 if not os.path.isdir(self.sample_dir()):
518 os.makedirs(self.sample_dir())
519 try:
520 im = Image.open(SAMPLE_IMAGE_PATH)
521 except IOError:
522 raise IOError('Photologue was unable to open the sample image: %s.' % SAMPLE_IMAGE_PATH)
523 im = self.process(im)
524 im.save(self.sample_filename(), 'JPEG', quality=90, optimize=True)
525
526 def admin_sample(self):
527 return u'<img src="%s">' % self.sample_url()
528 admin_sample.short_description = 'Sample'
529 admin_sample.allow_tags = True
530
531 def pre_process(self, im):
532 return im
533
534 def post_process(self, im):
535 return im
536
537 def process(self, im):
538 im = self.pre_process(im)
539 im = self.post_process(im)
540 return im
541
542 def __unicode__(self):
543 return self.name
544
545 def __str__(self):
546 return self.__unicode__()
547
548 def save(self):
549 try:
550 os.remove(self.sample_filename())
551 except:
552 pass
553 models.Model.save(self)
554 self.create_sample()
555 for size in self.photo_sizes.all():
556 size.clear_cache()
557 # try to clear all related subclasses of ImageModel
558 for prop in [prop for prop in dir(self) if prop[-8:] == '_related']:
559 for obj in getattr(self, prop).all():
560 obj.clear_cache()
561 obj.pre_cache()
562
563 def delete(self):
564 try:
565 os.remove(self.sample_filename())
566 except:
567 pass
568 super(PhotoEffect, self).delete()
569
570
571 class PhotoEffect(BaseEffect):
572 """ A pre-defined effect to apply to photos """
573 transpose_method = models.CharField(_('rotate or flip'), max_length=15, blank=True, choices=IMAGE_TRANSPOSE_CHOICES)
574 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."))
575 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."))
576 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."))
577 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."))
578 filters = models.CharField(_('filters'), max_length=200, blank=True, help_text=_(IMAGE_FILTERS_HELP_TEXT))
579 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."))
580 reflection_strength = models.FloatField(_('strength'), default=0.6, help_text="The initial opacity of the reflection gradient.")
581 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.")
582
583 class Meta:
584 verbose_name = _("photo effect")
585 verbose_name_plural = _("photo effects")
586
587 def pre_process(self, im):
588 if self.transpose_method != '':
589 method = getattr(Image, self.transpose_method)
590 im = im.transpose(method)
591 if im.mode != 'RGB' and im.mode != 'RGBA':
592 return im
593 for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']:
594 factor = getattr(self, name.lower())
595 if factor != 1.0:
596 im = getattr(ImageEnhance, name)(im).enhance(factor)
597 for name in self.filters.split('->'):
598 image_filter = getattr(ImageFilter, name.upper(), None)
599 if image_filter is not None:
600 try:
601 im = im.filter(image_filter)
602 except ValueError:
603 pass
604 return im
605
606 def post_process(self, im):
607 if self.reflection_size != 0.0:
608 im = add_reflection(im, bgcolor=self.background_color, amount=self.reflection_size, opacity=self.reflection_strength)
609 return im
610
611
612 class Watermark(BaseEffect):
613 image = models.ImageField(_('image'), upload_to=PHOTOLOGUE_DIR+"/watermarks")
614 style = models.CharField(_('style'), max_length=5, choices=WATERMARK_STYLE_CHOICES, default='scale')
615 opacity = models.FloatField(_('opacity'), default=1, help_text=_("The opacity of the overlay."))
616
617 class Meta:
618 verbose_name = _('watermark')
619 verbose_name_plural = _('watermarks')
620
621 def post_process(self, im):
622 mark = Image.open(self.image.path)
623 return apply_watermark(im, mark, self.style, self.opacity)
624
625
626 class PhotoSize(models.Model):
627 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".'))
628 width = models.PositiveIntegerField(_('width'), default=0, help_text=_('If width is set to "0" the image will be scaled to the supplied height.'))
629 height = models.PositiveIntegerField(_('height'), default=0, help_text=_('If height is set to "0" the image will be scaled to the supplied width'))
630 quality = models.PositiveIntegerField(_('quality'), choices=JPEG_QUALITY_CHOICES, default=70, help_text=_('JPEG image quality.'))
631 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.'))
632 crop = models.BooleanField(_('crop to fit?'), default=False, help_text=_('If selected the image will be scaled and cropped to fit the supplied dimensions.'))
633 pre_cache = models.BooleanField(_('pre-cache?'), default=False, help_text=_('If selected this photo size will be pre-cached as photos are added.'))
634 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.'))
635 effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name='photo_sizes', verbose_name=_('photo effect'))
636 watermark = models.ForeignKey('Watermark', null=True, blank=True, related_name='photo_sizes', verbose_name=_('watermark image'))
637
638 class Meta:
639 ordering = ['width', 'height']
640 verbose_name = _('photo size')
641 verbose_name_plural = _('photo sizes')
642
643 def __unicode__(self):
644 return self.name
645
646 def __str__(self):
647 return self.__unicode__()
648
649 def clear_cache(self):
650 for cls in ImageModel.__subclasses__():
651 for obj in cls.objects.all():
652 obj.remove_size(self)
653 if self.pre_cache:
654 obj.create_size(self)
655 PhotoSizeCache().reset()
656
657 def save(self):
658 if self.width + self.height <= 0:
659 raise ValueError(_('A PhotoSize must have a positive height or width.'))
660 super(PhotoSize, self).save()
661 PhotoSizeCache().reset()
662 self.clear_cache()
663
664 def delete(self):
665 self.clear_cache()
666 super(PhotoSize, self).delete()
667
668 def _get_size(self):
669 return (self.width, self.height)
670 def _set_size(self, value):
671 self.width, self.height = value
672 size = property(_get_size, _set_size)
673
674
675 class PhotoSizeCache(object):
676 __state = {"sizes": {}}
677
678 def __init__(self):
679 self.__dict__ = self.__state
680 if not len(self.sizes):
681 sizes = PhotoSize.objects.all()
682 for size in sizes:
683 self.sizes[size.name] = size
684
685 def reset(self):
686 self.sizes = {}
687
688
689 # Set up the accessor methods
690 def add_methods(sender, instance, signal, *args, **kwargs):
691 """ Adds methods to access sized images (urls, paths)
692
693 after the Photo model's __init__ function completes,
694 this method calls "add_accessor_methods" on each instance.
695 """
696 if hasattr(instance, 'add_accessor_methods'):
697 instance.add_accessor_methods()
698
699 # connect the add_accessor_methods function to the post_init signal
700 post_init.connect(add_methods)