annotate photologue/models.py @ 94:dcd097a59223

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