annotate mysite/photologue/models.py @ 88:7245c769e31e django1.3

Close this branch. I'm not sure if I merged it correctly to the default branch, because the graphlog doesn't look right. But the changes were made to default somehow. So closing this off to prevent future confusion.
author Brian Neal <bgneal@gmail.com>
date Sat, 13 Apr 2013 18:08:19 -0500
parents 0dcfcdf50c62
children
rev   line source
bgneal@1 1 import os
bgneal@1 2 import random
bgneal@1 3 import shutil
bgneal@1 4 import zipfile
bgneal@1 5
bgneal@1 6 from datetime import datetime
bgneal@1 7 from inspect import isclass
bgneal@1 8
bgneal@1 9 from django.db import models
bgneal@1 10 from django.db.models.signals import post_init
bgneal@1 11 from django.conf import settings
bgneal@1 12 from django.core.files.base import ContentFile
bgneal@1 13 from django.core.urlresolvers import reverse
bgneal@1 14 from django.template.defaultfilters import slugify
bgneal@1 15 from django.utils.functional import curry
bgneal@1 16 from django.utils.translation import ugettext_lazy as _
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@1 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@1 310 return '/'.join([self.cache_url(), self._get_filename_for_size(photosize.name)])
bgneal@1 311
bgneal@1 312 def _get_SIZE_filename(self, size):
bgneal@1 313 photosize = PhotoSizeCache().sizes.get(size)
bgneal@1 314 return os.path.join(self.cache_path(),
bgneal@1 315 self._get_filename_for_size(photosize.name))
bgneal@1 316
bgneal@1 317 def add_accessor_methods(self, *args, **kwargs):
bgneal@1 318 for size in PhotoSizeCache().sizes.keys():
bgneal@1 319 setattr(self, 'get_%s_size' % size,
bgneal@1 320 curry(self._get_SIZE_size, size=size))
bgneal@1 321 setattr(self, 'get_%s_photosize' % size,
bgneal@1 322 curry(self._get_SIZE_photosize, size=size))
bgneal@1 323 setattr(self, 'get_%s_url' % size,
bgneal@1 324 curry(self._get_SIZE_url, size=size))
bgneal@1 325 setattr(self, 'get_%s_filename' % size,
bgneal@1 326 curry(self._get_SIZE_filename, size=size))
bgneal@1 327
bgneal@1 328 def size_exists(self, photosize):
bgneal@1 329 func = getattr(self, "get_%s_filename" % photosize.name, None)
bgneal@1 330 if func is not None:
bgneal@1 331 if os.path.isfile(func()):
bgneal@1 332 return True
bgneal@1 333 return False
bgneal@1 334
bgneal@1 335 def resize_image(self, im, photosize):
bgneal@1 336 cur_width, cur_height = im.size
bgneal@1 337 new_width, new_height = photosize.size
bgneal@1 338 if photosize.crop:
bgneal@1 339 ratio = max(float(new_width)/cur_width,float(new_height)/cur_height)
bgneal@1 340 x = (cur_width * ratio)
bgneal@1 341 y = (cur_height * ratio)
bgneal@1 342 xd = abs(new_width - x)
bgneal@1 343 yd = abs(new_height - y)
bgneal@1 344 x_diff = int(xd / 2)
bgneal@1 345 y_diff = int(yd / 2)
bgneal@1 346 if self.crop_from == 'top':
bgneal@1 347 box = (int(x_diff), 0, int(x_diff+new_width), new_height)
bgneal@1 348 elif self.crop_from == 'left':
bgneal@1 349 box = (0, int(y_diff), new_width, int(y_diff+new_height))
bgneal@1 350 elif self.crop_from == 'bottom':
bgneal@1 351 box = (int(x_diff), int(yd), int(x_diff+new_width), int(y)) # y - yd = new_height
bgneal@1 352 elif self.crop_from == 'right':
bgneal@1 353 box = (int(xd), int(y_diff), int(x), int(y_diff+new_height)) # x - xd = new_width
bgneal@1 354 else:
bgneal@1 355 box = (int(x_diff), int(y_diff), int(x_diff+new_width), int(y_diff+new_height))
bgneal@1 356 im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box)
bgneal@1 357 else:
bgneal@1 358 if not new_width == 0 and not new_height == 0:
bgneal@1 359 ratio = min(float(new_width)/cur_width,
bgneal@1 360 float(new_height)/cur_height)
bgneal@1 361 else:
bgneal@1 362 if new_width == 0:
bgneal@1 363 ratio = float(new_height)/cur_height
bgneal@1 364 else:
bgneal@1 365 ratio = float(new_width)/cur_width
bgneal@1 366 new_dimensions = (int(round(cur_width*ratio)),
bgneal@1 367 int(round(cur_height*ratio)))
bgneal@1 368 if new_dimensions[0] > cur_width or \
bgneal@1 369 new_dimensions[1] > cur_height:
bgneal@1 370 if not photosize.upscale:
bgneal@1 371 return im
bgneal@1 372 im = im.resize(new_dimensions, Image.ANTIALIAS)
bgneal@1 373 return im
bgneal@1 374
bgneal@1 375 def create_size(self, photosize):
bgneal@1 376 if self.size_exists(photosize):
bgneal@1 377 return
bgneal@1 378 if not os.path.isdir(self.cache_path()):
bgneal@1 379 os.makedirs(self.cache_path())
bgneal@1 380 try:
bgneal@1 381 im = Image.open(self.image.path)
bgneal@1 382 except IOError:
bgneal@1 383 return
bgneal@1 384 # Apply effect if found
bgneal@1 385 if self.effect is not None:
bgneal@1 386 im = self.effect.pre_process(im)
bgneal@1 387 elif photosize.effect is not None:
bgneal@1 388 im = photosize.effect.pre_process(im)
bgneal@1 389 # Resize/crop image
bgneal@1 390 if im.size != photosize.size:
bgneal@1 391 im = self.resize_image(im, photosize)
bgneal@1 392 # Apply watermark if found
bgneal@1 393 if photosize.watermark is not None:
bgneal@1 394 im = photosize.watermark.post_process(im)
bgneal@1 395 # Apply effect if found
bgneal@1 396 if self.effect is not None:
bgneal@1 397 im = self.effect.post_process(im)
bgneal@1 398 elif photosize.effect is not None:
bgneal@1 399 im = photosize.effect.post_process(im)
bgneal@1 400 # Save file
bgneal@1 401 im_filename = getattr(self, "get_%s_filename" % photosize.name)()
bgneal@1 402 try:
bgneal@1 403 if im.format == 'JPEG':
bgneal@1 404 im.save(im_filename, 'JPEG', quality=int(photosize.quality), optimize=True)
bgneal@1 405 else:
bgneal@1 406 im.save(im_filename)
bgneal@1 407 except IOError, e:
bgneal@1 408 if os.path.isfile(im_filename):
bgneal@1 409 os.unlink(im_filename)
bgneal@1 410 raise e
bgneal@1 411
bgneal@1 412 def remove_size(self, photosize, remove_dirs=True):
bgneal@1 413 if not self.size_exists(photosize):
bgneal@1 414 return
bgneal@1 415 filename = getattr(self, "get_%s_filename" % photosize.name)()
bgneal@1 416 if os.path.isfile(filename):
bgneal@1 417 os.remove(filename)
bgneal@1 418 if remove_dirs:
bgneal@1 419 self.remove_cache_dirs()
bgneal@1 420
bgneal@1 421 def clear_cache(self):
bgneal@1 422 cache = PhotoSizeCache()
bgneal@1 423 for photosize in cache.sizes.values():
bgneal@1 424 self.remove_size(photosize, False)
bgneal@1 425 self.remove_cache_dirs()
bgneal@1 426
bgneal@1 427 def pre_cache(self):
bgneal@1 428 cache = PhotoSizeCache()
bgneal@1 429 for photosize in cache.sizes.values():
bgneal@1 430 if photosize.pre_cache:
bgneal@1 431 self.create_size(photosize)
bgneal@1 432
bgneal@1 433 def remove_cache_dirs(self):
bgneal@1 434 try:
bgneal@1 435 os.removedirs(self.cache_path())
bgneal@1 436 except:
bgneal@1 437 pass
bgneal@1 438
bgneal@1 439 def save(self, update=False):
bgneal@1 440 if update:
bgneal@1 441 models.Model.save(self)
bgneal@1 442 return
bgneal@1 443 if self.date_taken is None:
bgneal@1 444 try:
bgneal@1 445 exif_date = self.EXIF.get('EXIF DateTimeOriginal', None)
bgneal@1 446 if exif_date is not None:
bgneal@1 447 d, t = str.split(exif_date.values)
bgneal@1 448 year, month, day = d.split(':')
bgneal@1 449 hour, minute, second = t.split(':')
bgneal@1 450 self.date_taken = datetime(int(year), int(month), int(day),
bgneal@1 451 int(hour), int(minute), int(second))
bgneal@1 452 except:
bgneal@1 453 pass
bgneal@1 454 if self.date_taken is None:
bgneal@1 455 self.date_taken = datetime.now()
bgneal@1 456 if self._get_pk_val():
bgneal@1 457 self.clear_cache()
bgneal@1 458 super(ImageModel, self).save()
bgneal@1 459 self.pre_cache()
bgneal@1 460
bgneal@1 461 def delete(self):
bgneal@1 462 self.clear_cache()
bgneal@1 463 super(ImageModel, self).delete()
bgneal@1 464
bgneal@1 465
bgneal@1 466 class Photo(ImageModel):
bgneal@1 467 title = models.CharField(_('title'), max_length=100, unique=True)
bgneal@1 468 title_slug = models.SlugField(_('slug'), unique=True,
bgneal@1 469 help_text=('A "slug" is a unique URL-friendly title for an object.'))
bgneal@1 470 caption = models.TextField(_('caption'), blank=True)
bgneal@1 471 date_added = models.DateTimeField(_('date added'), default=datetime.now, editable=False)
bgneal@1 472 is_public = models.BooleanField(_('is public'), default=True, help_text=_('Public photographs will be displayed in the default views.'))
bgneal@1 473 tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags'))
bgneal@1 474
bgneal@1 475 class Meta:
bgneal@1 476 ordering = ['-date_added']
bgneal@1 477 get_latest_by = 'date_added'
bgneal@1 478 verbose_name = _("photo")
bgneal@1 479 verbose_name_plural = _("photos")
bgneal@1 480
bgneal@1 481 def __unicode__(self):
bgneal@1 482 return self.title
bgneal@1 483
bgneal@1 484 def __str__(self):
bgneal@1 485 return self.__unicode__()
bgneal@1 486
bgneal@1 487 def save(self, update=False):
bgneal@1 488 if self.title_slug is None:
bgneal@1 489 self.title_slug = slugify(self.title)
bgneal@1 490 super(Photo, self).save(update)
bgneal@1 491
bgneal@1 492 def get_absolute_url(self):
bgneal@1 493 return reverse('pl-photo', args=[self.title_slug])
bgneal@1 494
bgneal@1 495 def public_galleries(self):
bgneal@1 496 """Return the public galleries to which this photo belongs."""
bgneal@1 497 return self.galleries.filter(is_public=True)
bgneal@1 498
bgneal@1 499
bgneal@1 500 class BaseEffect(models.Model):
bgneal@1 501 name = models.CharField(_('name'), max_length=30, unique=True)
bgneal@1 502 description = models.TextField(_('description'), blank=True)
bgneal@1 503
bgneal@1 504 class Meta:
bgneal@1 505 abstract = True
bgneal@1 506
bgneal@1 507 def sample_dir(self):
bgneal@1 508 return os.path.join(settings.MEDIA_ROOT, PHOTOLOGUE_DIR, 'samples')
bgneal@1 509
bgneal@1 510 def sample_url(self):
bgneal@1 511 return settings.MEDIA_URL + '/'.join([PHOTOLOGUE_DIR, 'samples', '%s %s.jpg' % (self.name.lower(), 'sample')])
bgneal@1 512
bgneal@1 513 def sample_filename(self):
bgneal@1 514 return os.path.join(self.sample_dir(), '%s %s.jpg' % (self.name.lower(), 'sample'))
bgneal@1 515
bgneal@1 516 def create_sample(self):
bgneal@1 517 if not os.path.isdir(self.sample_dir()):
bgneal@1 518 os.makedirs(self.sample_dir())
bgneal@1 519 try:
bgneal@1 520 im = Image.open(SAMPLE_IMAGE_PATH)
bgneal@1 521 except IOError:
bgneal@1 522 raise IOError('Photologue was unable to open the sample image: %s.' % SAMPLE_IMAGE_PATH)
bgneal@1 523 im = self.process(im)
bgneal@1 524 im.save(self.sample_filename(), 'JPEG', quality=90, optimize=True)
bgneal@1 525
bgneal@1 526 def admin_sample(self):
bgneal@1 527 return u'<img src="%s">' % self.sample_url()
bgneal@1 528 admin_sample.short_description = 'Sample'
bgneal@1 529 admin_sample.allow_tags = True
bgneal@1 530
bgneal@1 531 def pre_process(self, im):
bgneal@1 532 return im
bgneal@1 533
bgneal@1 534 def post_process(self, im):
bgneal@1 535 return im
bgneal@1 536
bgneal@1 537 def process(self, im):
bgneal@1 538 im = self.pre_process(im)
bgneal@1 539 im = self.post_process(im)
bgneal@1 540 return im
bgneal@1 541
bgneal@1 542 def __unicode__(self):
bgneal@1 543 return self.name
bgneal@1 544
bgneal@1 545 def __str__(self):
bgneal@1 546 return self.__unicode__()
bgneal@1 547
bgneal@1 548 def save(self):
bgneal@1 549 try:
bgneal@1 550 os.remove(self.sample_filename())
bgneal@1 551 except:
bgneal@1 552 pass
bgneal@1 553 models.Model.save(self)
bgneal@1 554 self.create_sample()
bgneal@1 555 for size in self.photo_sizes.all():
bgneal@1 556 size.clear_cache()
bgneal@1 557 # try to clear all related subclasses of ImageModel
bgneal@1 558 for prop in [prop for prop in dir(self) if prop[-8:] == '_related']:
bgneal@1 559 for obj in getattr(self, prop).all():
bgneal@1 560 obj.clear_cache()
bgneal@1 561 obj.pre_cache()
bgneal@1 562
bgneal@1 563 def delete(self):
bgneal@1 564 try:
bgneal@1 565 os.remove(self.sample_filename())
bgneal@1 566 except:
bgneal@1 567 pass
bgneal@1 568 super(PhotoEffect, self).delete()
bgneal@1 569
bgneal@1 570
bgneal@1 571 class PhotoEffect(BaseEffect):
bgneal@1 572 """ A pre-defined effect to apply to photos """
bgneal@1 573 transpose_method = models.CharField(_('rotate or flip'), max_length=15, blank=True, choices=IMAGE_TRANSPOSE_CHOICES)
bgneal@1 574 color = models.FloatField(_('color'), default=1.0, help_text=_("A factor of 0.0 gives a black and white image, a factor of 1.0 gives the original image."))
bgneal@1 575 brightness = models.FloatField(_('brightness'), default=1.0, help_text=_("A factor of 0.0 gives a black image, a factor of 1.0 gives the original image."))
bgneal@1 576 contrast = models.FloatField(_('contrast'), default=1.0, help_text=_("A factor of 0.0 gives a solid grey image, a factor of 1.0 gives the original image."))
bgneal@1 577 sharpness = models.FloatField(_('sharpness'), default=1.0, help_text=_("A factor of 0.0 gives a blurred image, a factor of 1.0 gives the original image."))
bgneal@1 578 filters = models.CharField(_('filters'), max_length=200, blank=True, help_text=_(IMAGE_FILTERS_HELP_TEXT))
bgneal@1 579 reflection_size = models.FloatField(_('size'), default=0, help_text=_("The height of the reflection as a percentage of the orignal image. A factor of 0.0 adds no reflection, a factor of 1.0 adds a reflection equal to the height of the orignal image."))
bgneal@1 580 reflection_strength = models.FloatField(_('strength'), default=0.6, help_text="The initial opacity of the reflection gradient.")
bgneal@1 581 background_color = models.CharField(_('color'), max_length=7, default="#FFFFFF", help_text="The background color of the reflection gradient. Set this to match the background color of your page.")
bgneal@1 582
bgneal@1 583 class Meta:
bgneal@1 584 verbose_name = _("photo effect")
bgneal@1 585 verbose_name_plural = _("photo effects")
bgneal@1 586
bgneal@1 587 def pre_process(self, im):
bgneal@1 588 if self.transpose_method != '':
bgneal@1 589 method = getattr(Image, self.transpose_method)
bgneal@1 590 im = im.transpose(method)
bgneal@1 591 if im.mode != 'RGB' and im.mode != 'RGBA':
bgneal@1 592 return im
bgneal@1 593 for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']:
bgneal@1 594 factor = getattr(self, name.lower())
bgneal@1 595 if factor != 1.0:
bgneal@1 596 im = getattr(ImageEnhance, name)(im).enhance(factor)
bgneal@1 597 for name in self.filters.split('->'):
bgneal@1 598 image_filter = getattr(ImageFilter, name.upper(), None)
bgneal@1 599 if image_filter is not None:
bgneal@1 600 try:
bgneal@1 601 im = im.filter(image_filter)
bgneal@1 602 except ValueError:
bgneal@1 603 pass
bgneal@1 604 return im
bgneal@1 605
bgneal@1 606 def post_process(self, im):
bgneal@1 607 if self.reflection_size != 0.0:
bgneal@1 608 im = add_reflection(im, bgcolor=self.background_color, amount=self.reflection_size, opacity=self.reflection_strength)
bgneal@1 609 return im
bgneal@1 610
bgneal@1 611
bgneal@1 612 class Watermark(BaseEffect):
bgneal@1 613 image = models.ImageField(_('image'), upload_to=PHOTOLOGUE_DIR+"/watermarks")
bgneal@1 614 style = models.CharField(_('style'), max_length=5, choices=WATERMARK_STYLE_CHOICES, default='scale')
bgneal@1 615 opacity = models.FloatField(_('opacity'), default=1, help_text=_("The opacity of the overlay."))
bgneal@1 616
bgneal@1 617 class Meta:
bgneal@1 618 verbose_name = _('watermark')
bgneal@1 619 verbose_name_plural = _('watermarks')
bgneal@1 620
bgneal@1 621 def post_process(self, im):
bgneal@1 622 mark = Image.open(self.image.path)
bgneal@1 623 return apply_watermark(im, mark, self.style, self.opacity)
bgneal@1 624
bgneal@1 625
bgneal@1 626 class PhotoSize(models.Model):
bgneal@1 627 name = models.CharField(_('name'), max_length=20, unique=True, help_text=_('Photo size name should contain only letters, numbers and underscores. Examples: "thumbnail", "display", "small", "main_page_widget".'))
bgneal@1 628 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 629 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 630 quality = models.PositiveIntegerField(_('quality'), choices=JPEG_QUALITY_CHOICES, default=70, help_text=_('JPEG image quality.'))
bgneal@1 631 upscale = models.BooleanField(_('upscale images?'), default=False, help_text=_('If selected the image will be scaled up if necessary to fit the supplied dimensions. Cropped sizes will be upscaled regardless of this setting.'))
bgneal@1 632 crop = models.BooleanField(_('crop to fit?'), default=False, help_text=_('If selected the image will be scaled and cropped to fit the supplied dimensions.'))
bgneal@1 633 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 634 increment_count = models.BooleanField(_('increment view count?'), default=False, help_text=_('If selected the image\'s "view_count" will be incremented when this photo size is displayed.'))
bgneal@1 635 effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name='photo_sizes', verbose_name=_('photo effect'))
bgneal@1 636 watermark = models.ForeignKey('Watermark', null=True, blank=True, related_name='photo_sizes', verbose_name=_('watermark image'))
bgneal@1 637
bgneal@1 638 class Meta:
bgneal@1 639 ordering = ['width', 'height']
bgneal@1 640 verbose_name = _('photo size')
bgneal@1 641 verbose_name_plural = _('photo sizes')
bgneal@1 642
bgneal@1 643 def __unicode__(self):
bgneal@1 644 return self.name
bgneal@1 645
bgneal@1 646 def __str__(self):
bgneal@1 647 return self.__unicode__()
bgneal@1 648
bgneal@1 649 def clear_cache(self):
bgneal@1 650 for cls in ImageModel.__subclasses__():
bgneal@1 651 for obj in cls.objects.all():
bgneal@1 652 obj.remove_size(self)
bgneal@1 653 if self.pre_cache:
bgneal@1 654 obj.create_size(self)
bgneal@1 655 PhotoSizeCache().reset()
bgneal@1 656
bgneal@1 657 def save(self):
bgneal@1 658 if self.width + self.height <= 0:
bgneal@1 659 raise ValueError(_('A PhotoSize must have a positive height or width.'))
bgneal@1 660 super(PhotoSize, self).save()
bgneal@1 661 PhotoSizeCache().reset()
bgneal@1 662 self.clear_cache()
bgneal@1 663
bgneal@1 664 def delete(self):
bgneal@1 665 self.clear_cache()
bgneal@1 666 super(PhotoSize, self).delete()
bgneal@1 667
bgneal@1 668 def _get_size(self):
bgneal@1 669 return (self.width, self.height)
bgneal@1 670 def _set_size(self, value):
bgneal@1 671 self.width, self.height = value
bgneal@1 672 size = property(_get_size, _set_size)
bgneal@1 673
bgneal@1 674
bgneal@1 675 class PhotoSizeCache(object):
bgneal@1 676 __state = {"sizes": {}}
bgneal@1 677
bgneal@1 678 def __init__(self):
bgneal@1 679 self.__dict__ = self.__state
bgneal@1 680 if not len(self.sizes):
bgneal@1 681 sizes = PhotoSize.objects.all()
bgneal@1 682 for size in sizes:
bgneal@1 683 self.sizes[size.name] = size
bgneal@1 684
bgneal@1 685 def reset(self):
bgneal@1 686 self.sizes = {}
bgneal@1 687
bgneal@1 688
bgneal@1 689 # Set up the accessor methods
bgneal@1 690 def add_methods(sender, instance, signal, *args, **kwargs):
bgneal@1 691 """ Adds methods to access sized images (urls, paths)
bgneal@1 692
bgneal@1 693 after the Photo model's __init__ function completes,
bgneal@1 694 this method calls "add_accessor_methods" on each instance.
bgneal@1 695 """
bgneal@1 696 if hasattr(instance, 'add_accessor_methods'):
bgneal@1 697 instance.add_accessor_methods()
bgneal@1 698
bgneal@1 699 # connect the add_accessor_methods function to the post_init signal
bgneal@1 700 post_init.connect(add_methods)