web/lib/photologue/models.py
changeset 5 10b1f6d8a5d2
equal deleted inserted replaced
4:b77683731f25 5:10b1f6d8a5d2
       
     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)