annotate photologue/models.py @ 181:874ecfe6054a

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