import os import random import zipfile from datetime import datetime from inspect import isclass import warnings import logging from io import BytesIO try: from importlib import import_module except ImportError: # Compatibility with Python 2.6. from django.utils.importlib import import_module import django from django.utils.timezone import now from django.db import models from django.db.models.signals import post_init, post_save, pre_delete from django.conf import settings from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.core.urlresolvers import reverse from django.core.exceptions import ValidationError from django.template.defaultfilters import slugify from django.utils.encoding import force_text, smart_str, filepath_to_uri from django.utils.functional import curry from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import python_2_unicode_compatible from django.core.validators import RegexValidator from django.contrib import messages from django.contrib.sites.models import Site from functions.signal_handlers import post_save_handler, file_cleanup # Required PIL classes may or may not be available from the root namespace # depending on the installation method used. try: from PIL import Image from PIL import ImageFile from PIL import ImageFilter from PIL import ImageEnhance except ImportError: try: import Image import ImageFile import ImageFilter import ImageEnhance except ImportError: raise ImportError( 'Photologue was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.') from sortedm2m.fields import SortedManyToManyField from model_utils.managers import PassThroughManager from hvad.models import TranslatableModel, TranslatedFields, TranslationManager # attempt to load the django-tagging TagField from default location, # otherwise we substitude a dummy TagField. try: from tagging.fields import TagField tagfield_help_text = _('Separate tags with spaces, put quotes around multiple-word tags.') except ImportError: class TagField(models.CharField): def __init__(self, **kwargs): default_kwargs = {'max_length': 255, 'blank': True} default_kwargs.update(kwargs) super(TagField, self).__init__(**default_kwargs) def get_internal_type(self): return 'CharField' tagfield_help_text = _('Django-tagging was not found, tags will be treated as plain text.') # Tell South how to handle this custom field. if django.VERSION[:2] < (1, 7): from south.modelsinspector import add_introspection_rules add_introspection_rules([], ["^photologue\.models\.TagField"]) from .utils import EXIF from .utils.reflection import add_reflection from .utils.watermark import apply_watermark from .managers import GalleryQuerySet, PhotoQuerySet, PhotologueManager from functions.url_utils import slugify, unique_slug from functions.model_utils import base_concrete_model logger = logging.getLogger('photologue.models') # Default limit for gallery.latest LATEST_LIMIT = getattr(settings, 'PHOTOLOGUE_GALLERY_LATEST_LIMIT', None) # Number of random images from the gallery to display. SAMPLE_SIZE = getattr(settings, 'PHOTOLOGUE_GALLERY_SAMPLE_SIZE', 5) # max_length setting for the ImageModel ImageField IMAGE_FIELD_MAX_LENGTH = getattr(settings, 'PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH', 100) # Path to sample image SAMPLE_IMAGE_PATH = getattr(settings, 'PHOTOLOGUE_SAMPLE_IMAGE_PATH', os.path.join( os.path.dirname(__file__), 'res', 'sample.jpg')) # os.path.join(settings.PROJECT_PATH, 'photologue', 'res', 'sample.jpg' # Modify image file buffer size. ImageFile.MAXBLOCK = getattr(settings, 'PHOTOLOGUE_MAXBLOCK', 256 * 2 ** 10) # Photologue image path relative to media root PHOTOLOGUE_DIR = getattr(settings, 'PHOTOLOGUE_DIR', 'photologue') # Look for user function to define file paths PHOTOLOGUE_PATH = getattr(settings, 'PHOTOLOGUE_PATH', None) if PHOTOLOGUE_PATH is not None: if callable(PHOTOLOGUE_PATH): get_storage_path = PHOTOLOGUE_PATH else: parts = PHOTOLOGUE_PATH.split('.') module_name = '.'.join(parts[:-1]) module = import_module(module_name) get_storage_path = getattr(module, parts[-1]) else: def get_storage_path(instance, filename): return os.path.join(PHOTOLOGUE_DIR, 'photos', filename) # Quality options for JPEG images JPEG_QUALITY_CHOICES = ( (30, _('Very Low')), (40, _('Low')), (50, _('Medium-Low')), (60, _('Medium')), (70, _('Medium-High')), (80, _('High')), (90, _('Very High')), ) # choices for new crop_anchor field in Photo CROP_ANCHOR_CHOICES = ( ('top', _('Top')), ('right', _('Right')), ('bottom', _('Bottom')), ('left', _('Left')), ('center', _('Center (Default)')), ) IMAGE_TRANSPOSE_CHOICES = ( ('FLIP_LEFT_RIGHT', _('Flip left to right')), ('FLIP_TOP_BOTTOM', _('Flip top to bottom')), ('ROTATE_90', _('Rotate 90 degrees counter-clockwise')), ('ROTATE_270', _('Rotate 90 degrees clockwise')), ('ROTATE_180', _('Rotate 180 degrees')), ) WATERMARK_STYLE_CHOICES = ( ('tile', _('Tile')), ('scale', _('Scale')), ) # Prepare a list of image filters filter_names = [] for n in dir(ImageFilter): klass = getattr(ImageFilter, n) if isclass(klass) and issubclass(klass, ImageFilter.BuiltinFilter) and \ hasattr(klass, 'name'): filter_names.append(klass.__name__) 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))) class UserMark(models.Model): user = models.ForeignKey('accounts.User', related_name='marks') top = models.PositiveSmallIntegerField() left = models.PositiveSmallIntegerField() height = models.PositiveSmallIntegerField() width = models.PositiveSmallIntegerField() @python_2_unicode_compatible class Gallery(TranslatableModel): translations = TranslatedFields( title=models.CharField(_('title'), max_length=200), description=models.TextField(_('description'), blank=True) ) date_added = models.DateTimeField(_('date published'), default=now) slug = models.SlugField(_('title slug'), unique=True, help_text=_('A "slug" is a unique URL-friendly title for an object.'), max_length=200) is_public = models.BooleanField(_('is public'), default=True, help_text=_('Public galleries will be displayed ' 'in the default views.')) photos = SortedManyToManyField('Photo', related_name='galleries', verbose_name=_('photos'), null=True, blank=True, #sorted=True, #sort_value_field_name='sort_value' ) tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags')) sites = models.ManyToManyField(Site, verbose_name=_(u'sites'), blank=True, null=True) objects = PhotologueManager() class Meta: ordering = ['-date_added'] get_latest_by = 'date_added' verbose_name = _('gallery') verbose_name_plural = _('galleries') def __str__(self): return self.lazy_translation_getter('title', self.pk) def admin_url(self): return '/admin/photogallery/gallery/%s'%self.slug def translation_model(self): return self._meta.translations_model def generate_unique_slug(self): """ Create a unique slug by passing the result of get_slug() to utils.urls.unique_slug, which appends an index if necessary. """ # For custom content types, use the ``Page`` instance for # slug lookup. concrete_model = base_concrete_model(Photo, self) slug_qs = concrete_model.objects.exclude(id=self.id) return unique_slug(slug_qs, "slug", self.get_slug()) def get_slug(self): """ Allows subclasses to implement their own slug creation logic. """ return slugify(self.get_available_title()) def get_available_title(self): #print self.lazy_translation_getter('main_title', self.pk) return u'%s'%self.lazy_translation_getter('title', self.pk) def save(self, *args, **kwargs): if not self.slug: self.slug = self.generate_unique_slug() super(Gallery, self).save(*args, **kwargs) def get_absolute_url(self): return reverse('pl-gallery', args=[self.slug]) def latest(self, limit=LATEST_LIMIT, public=True): if not limit: limit = self.photo_count() if public: return self.public()[:limit] else: return self.photos.filter(sites__id=settings.SITE_ID)[:limit] def sample(self, count=None, public=True): """Return a sample of photos, ordered at random. If the 'count' is not specified, it will return a number of photos limited by the GALLERY_SAMPLE_SIZE setting. """ if not count: count = SAMPLE_SIZE if count > self.photo_count(): count = self.photo_count() if public: photo_set = self.public() else: photo_set = self.photos.filter(sites__id=settings.SITE_ID) return random.sample(set(photo_set), count) def photo_count(self, public=True): """Return a count of all the photos in this gallery.""" if public: return self.public().count() else: return self.photos.filter(sites__id=settings.SITE_ID).count() photo_count.short_description = _('count') def public(self): """Return a queryset of all the public photos in this gallery.""" return self.photos.is_public().filter(sites__id=settings.SITE_ID) def orphaned_photos(self): """ Return all photos that belong to this gallery but don't share the gallery's site. """ return self.photos.filter(is_public=True)\ .exclude(sites__id__in=self.sites.all()) @property def title_slug(self): warnings.warn( DeprecationWarning("`title_slug` field in Gallery is being renamed to `slug`. Update your code.")) return self.slug class GalleryUpload(models.Model): zip_file = models.FileField(_('images file (.zip)'), upload_to=os.path.join(PHOTOLOGUE_DIR, 'temp'), help_text=_('Select a .zip file of images to upload into a new Gallery.')) title = models.CharField(_('title'), null=True, blank=True, max_length=50, help_text=_('All uploaded photos will be given a title made up of this title + a ' 'sequential number.')) gallery = models.ForeignKey(Gallery, verbose_name=_('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.')) caption = models.TextField(_('caption'), blank=True, help_text=_('Caption will be added to all photos.')) description = models.TextField(_('description'), blank=True, help_text=_('A description of this Gallery.')) is_public = models.BooleanField(_('is public'), default=True, help_text=_('Uncheck this to make the uploaded ' 'gallery and included photographs private.')) tags = models.CharField(max_length=255, blank=True, help_text=tagfield_help_text, verbose_name=_('tags')) class Meta: verbose_name = _('gallery upload') verbose_name_plural = _('gallery uploads') def save(self, *args, **kwargs): super(GalleryUpload, self).save(*args, **kwargs) gallery = self.process_zipfile() super(GalleryUpload, self).delete() return gallery def clean(self): if self.title: try: Gallery.objects.get(title=self.title) raise ValidationError(_('A gallery with that title already exists.')) except Gallery.DoesNotExist: pass if not self.gallery and not self.title: raise ValidationError(_('Select an existing gallery or enter a new gallery name.')) def process_zipfile(self): if default_storage.exists(self.zip_file.name): # TODO: implement try-except here zip = zipfile.ZipFile(default_storage.open(self.zip_file.name)) bad_file = zip.testzip() if bad_file: zip.close() raise Exception('"%s" in the .zip archive is corrupt.' % bad_file) count = 1 current_site = Site.objects.get(id=settings.SITE_ID) if self.gallery: logger.debug('Using pre-existing gallery.') gallery = self.gallery else: logger.debug(force_text('Creating new gallery "{0}".').format(self.title)) gallery = Gallery.objects.create(title=self.title, slug=slugify(self.title), description=self.description, is_public=self.is_public, tags=self.tags) gallery.sites.add(current_site) for filename in sorted(zip.namelist()): logger.debug('Reading file "{0}".'.format(filename)) if filename.startswith('__') or filename.startswith('.'): logger.debug('Ignoring file "{0}".'.format(filename)) continue if os.path.dirname(filename): logger.warning('Ignoring file "{0}" as it is in a subfolder; all images should be in the top ' 'folder of the zip.'.format(filename)) if getattr(self, 'request', None): messages.warning(self.request, _('Ignoring file "{filename}" as it is in a subfolder; all images should ' 'be in the top folder of the zip.').format(filename=filename), fail_silently=True) continue data = zip.read(filename) if not len(data): logger.debug('File "{0}" is empty.'.format(filename)) continue title = ' '.join([gallery.title, str(count)]) slug = slugify(title) try: Photo.objects.get(slug=slug) logger.warning('Did not create photo "{0}" with slug "{1}" as a photo with that ' 'slug already exists.'.format(filename, slug)) if getattr(self, 'request', None): messages.warning(self.request, _('Did not create photo "%(filename)s" with slug "{1}" as a photo with that ' 'slug already exists.').format(filename, slug), fail_silently=True) continue except Photo.DoesNotExist: pass photo = Photo(title=title, slug=slug, caption=self.caption, is_public=self.is_public, tags=self.tags) # Basic check that we have a valid image. try: file = BytesIO(data) opened = Image.open(file) opened.verify() except Exception: # Pillow (or PIL) doesn't recognize it as an image. # If a "bad" file is found we just skip it. # But we do flag this both in the logs and to the user. logger.error('Could not process file "{0}" in the .zip archive.'.format( filename)) if getattr(self, 'request', None): messages.warning(self.request, _('Could not process file "{0}" in the .zip archive.').format( filename, fail_silently=True)) continue contentfile = ContentFile(data) photo.image.save(filename, contentfile) photo.save() photo.sites.add(current_site) gallery.photos.add(photo) count = count + 1 zip.close() return gallery class ImageModel(models.Model): image = models.ImageField(_('image'), max_length=IMAGE_FIELD_MAX_LENGTH, upload_to=get_storage_path) date_taken = models.DateTimeField(_('date taken'), null=True, blank=True, editable=False) view_count = models.PositiveIntegerField(_('view count'), default=0, editable=False) crop_from = models.CharField(_('crop from'), blank=True, max_length=10, default='center', choices=CROP_ANCHOR_CHOICES) effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name="%(class)s_related", verbose_name=_('effect')) class Meta: abstract = True @property def EXIF(self): try: f = self.image.storage.open(self.image.name, 'rb') tags = EXIF.process_file(f) f.close() return tags except: try: f = self.image.storage.open(self.image.name, 'rb') tags = EXIF.process_file(f, details=False) f.close() return tags except: return {} def admin_thumbnail(self): func = getattr(self, 'get_admin_thumbnail_url', None) if func is None: return _('An "admin_thumbnail" photo size has not been defined.') else: if hasattr(self, 'get_absolute_url'): return u'' % \ (self.get_absolute_url(), func()) else: return u'' % \ (self.image.url, func()) admin_thumbnail.short_description = _('Thumbnail') admin_thumbnail.allow_tags = True def cache_path(self): return os.path.join(os.path.dirname(self.image.name), "cache") def cache_url(self): return '/'.join([os.path.dirname(self.image.url), "cache"]) def image_filename(self): return os.path.basename(force_text(self.image.name)) def _get_filename_for_size(self, size): size = getattr(size, 'name', size) base, ext = os.path.splitext(self.image_filename()) return ''.join([base, '_', size, ext]) def _get_SIZE_photosize(self, size): return PhotoSizeCache().sizes.get(size) def _get_SIZE_size(self, size): photosize = PhotoSizeCache().sizes.get(size) if not self.size_exists(photosize): self.create_size(photosize) return Image.open(self.image.storage.open( self._get_SIZE_filename(size))).size def _get_SIZE_url(self, size): photosize = PhotoSizeCache().sizes.get(size) if not self.size_exists(photosize): self.create_size(photosize) if photosize.increment_count: self.increment_count() return '/'.join([ self.cache_url(), filepath_to_uri(self._get_filename_for_size(photosize.name))]) def _get_SIZE_filename(self, size): photosize = PhotoSizeCache().sizes.get(size) return smart_str(os.path.join(self.cache_path(), self._get_filename_for_size(photosize.name))) def increment_count(self): self.view_count += 1 models.Model.save(self) def add_accessor_methods(self, *args, **kwargs): for size in PhotoSizeCache().sizes.keys(): setattr(self, 'get_%s_size' % size, curry(self._get_SIZE_size, size=size)) setattr(self, 'get_%s_photosize' % size, curry(self._get_SIZE_photosize, size=size)) setattr(self, 'get_%s_url' % size, curry(self._get_SIZE_url, size=size)) setattr(self, 'get_%s_filename' % size, curry(self._get_SIZE_filename, size=size)) def size_exists(self, photosize): func = getattr(self, "get_%s_filename" % photosize.name, None) if func is not None: if self.image.storage.exists(func()): return True return False def resize_image(self, im, photosize): cur_width, cur_height = im.size new_width, new_height = photosize.size if photosize.crop: ratio = max(float(new_width) / cur_width, float(new_height) / cur_height) x = (cur_width * ratio) y = (cur_height * ratio) xd = abs(new_width - x) yd = abs(new_height - y) x_diff = int(xd / 2) y_diff = int(yd / 2) if self.crop_from == 'top': box = (int(x_diff), 0, int(x_diff + new_width), new_height) elif self.crop_from == 'left': box = (0, int(y_diff), new_width, int(y_diff + new_height)) elif self.crop_from == 'bottom': # y - yd = new_height box = (int(x_diff), int(yd), int(x_diff + new_width), int(y)) elif self.crop_from == 'right': # x - xd = new_width box = (int(xd), int(y_diff), int(x), int(y_diff + new_height)) else: box = (int(x_diff), int(y_diff), int(x_diff + new_width), int(y_diff + new_height)) im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box) else: if not new_width == 0 and not new_height == 0: ratio = min(float(new_width) / cur_width, float(new_height) / cur_height) else: if new_width == 0: ratio = float(new_height) / cur_height else: ratio = float(new_width) / cur_width new_dimensions = (int(round(cur_width * ratio)), int(round(cur_height * ratio))) if new_dimensions[0] > cur_width or \ new_dimensions[1] > cur_height: if not photosize.upscale: return im im = im.resize(new_dimensions, Image.ANTIALIAS) return im def create_size(self, photosize): if self.size_exists(photosize): return try: im = Image.open(self.image.storage.open(self.image.name)) except IOError: return # Save the original format im_format = im.format # Apply effect if found if self.effect is not None: im = self.effect.pre_process(im) elif photosize.effect is not None: im = photosize.effect.pre_process(im) # Resize/crop image if im.size != photosize.size and photosize.size != (0, 0): im = self.resize_image(im, photosize) # Apply watermark if found if photosize.watermark is not None: im = photosize.watermark.post_process(im) # Apply effect if found if self.effect is not None: im = self.effect.post_process(im) elif photosize.effect is not None: im = photosize.effect.post_process(im) # Save file im_filename = getattr(self, "get_%s_filename" % photosize.name)() try: buffer = BytesIO() if im_format != 'JPEG': im.save(buffer, im_format) else: im.save(buffer, 'JPEG', quality=int(photosize.quality), optimize=True) buffer_contents = ContentFile(buffer.getvalue()) self.image.storage.save(im_filename, buffer_contents) except IOError as e: if self.image.storage.exists(im_filename): self.image.storage.delete(im_filename) raise e def remove_size(self, photosize, remove_dirs=True): if not self.size_exists(photosize): return filename = getattr(self, "get_%s_filename" % photosize.name)() if self.image.storage.exists(filename): self.image.storage.delete(filename) def clear_cache(self): cache = PhotoSizeCache() for photosize in cache.sizes.values(): self.remove_size(photosize, False) def pre_cache(self): cache = PhotoSizeCache() for photosize in cache.sizes.values(): if photosize.pre_cache: self.create_size(photosize) def save(self, *args, **kwargs): if self.date_taken is None: try: exif_date = self.EXIF.get('EXIF DateTimeOriginal', None) if exif_date is not None: d, t = str.split(exif_date.values) year, month, day = d.split(':') hour, minute, second = t.split(':') self.date_taken = datetime(int(year), int(month), int(day), int(hour), int(minute), int(second)) except: pass if self.date_taken is None: self.date_taken = now() if self._get_pk_val(): self.clear_cache() super(ImageModel, self).save(*args, **kwargs) self.pre_cache() def delete(self): assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % ( self._meta.object_name, self._meta.pk.attname) self.clear_cache() # Files associated to a FileField have to be manually deleted: # https://docs.djangoproject.com/en/dev/releases/1.3/#deleting-a-model-doesn-t-delete-associated-files # http://haineault.com/blog/147/ # The data loss scenarios mentioned in the docs hopefully do not apply # to Photologue! super(ImageModel, self).delete() self.image.storage.delete(self.image.name) @python_2_unicode_compatible class Photo(TranslatableModel, ImageModel): translations = TranslatedFields( caption=models.TextField(_('caption'), blank=True), title=models.CharField(_('title'), max_length=200) ) slug = models.SlugField(_('slug'), unique=True, help_text=_('A "slug" is a unique URL-friendly title for an object.'), max_length=200) sort = models.PositiveIntegerField(verbose_name="Sort", null=True, default=10, db_index=True) date_added = models.DateTimeField(_('date added'), default=now) is_public = models.BooleanField(_('is public'), default=True, help_text=_('Public photographs will be displayed in the default views.')) tags = TagField(help_text=tagfield_help_text, verbose_name=_('tags')) sites = models.ManyToManyField(Site, verbose_name=_(u'sites'), blank=True, null=True) users = models.ManyToManyField(UserMark, null=True) objects = PhotologueManager() class Meta: ordering = ['sort'] get_latest_by = 'date_added' verbose_name = _("photo") verbose_name_plural = _("photos") def __str__(self): return str(self.id) def translation_model(self): return self._meta.translations_model def admin_url(self): return '/admin/photogallery/photo/%s/'%self.slug def get_delete_url(self): return '/admin/photogallery/photo/delete/%s/'%self.pk def generate_unique_slug(self): """ Create a unique slug by passing the result of get_slug() to utils.urls.unique_slug, which appends an index if necessary. """ # For custom content types, use the ``Page`` instance for # slug lookup. concrete_model = base_concrete_model(Photo, self) slug_qs = concrete_model.objects.exclude(id=self.id) return unique_slug(slug_qs, "slug", self.get_slug()) def get_slug(self): """ Allows subclasses to implement their own slug creation logic. """ return slugify(self.get_available_title()) def get_available_title(self): #print self.lazy_translation_getter('main_title', self.pk) return u'%s'%self.lazy_translation_getter('title', self.pk) def save(self, *args, **kwargs): if not self.slug: self.slug = self.generate_unique_slug() super(Photo, self).save(*args, **kwargs) def get_absolute_url(self): return reverse('pl-photo', args=[self.slug]) def public_galleries(self): """Return the public galleries to which this photo belongs.""" return self.galleries.filter(is_public=True) def get_previous_in_gallery(self, gallery): """Find the neighbour of this photo in the supplied gallery. We assume that the gallery and all its photos are on the same site. """ if not self.is_public: raise ValueError('Cannot determine neighbours of a non-public photo.') photos = gallery.photos.is_public() if not self in photos: raise ValueError('Photo does not belong to gallery.') previous = None for photo in photos: if photo == self: return previous previous = photo def get_next_in_gallery(self, gallery): """Find the neighbour of this photo in the supplied gallery. We assume that the gallery and all its photos are on the same site. """ if not self.is_public: raise ValueError('Cannot determine neighbours of a non-public photo.') photos = gallery.photos.is_public() if not self in photos: raise ValueError('Photo does not belong to gallery.') matched = False for photo in photos: if matched: return photo if photo == self: matched = True return None @property def title_slug(self): warnings.warn( DeprecationWarning("`title_slug` field in Photo is being renamed to `slug`. Update your code.")) return self.slug @python_2_unicode_compatible class BaseEffect(models.Model): name = models.CharField(_('name'), max_length=30, unique=True) description = models.TextField(_('description'), blank=True) class Meta: abstract = True def sample_dir(self): return os.path.join(PHOTOLOGUE_DIR, 'samples') def sample_url(self): return settings.MEDIA_URL + '/'.join([PHOTOLOGUE_DIR, 'samples', '%s %s.jpg' % (self.name.lower(), 'sample')]) def sample_filename(self): return os.path.join(self.sample_dir(), '%s %s.jpg' % (self.name.lower(), 'sample')) def create_sample(self): try: im = Image.open(SAMPLE_IMAGE_PATH) except IOError: raise IOError( 'Photologue was unable to open the sample image: %s.' % SAMPLE_IMAGE_PATH) im = self.process(im) buffer = BytesIO() im.save(buffer, 'JPEG', quality=90, optimize=True) buffer_contents = ContentFile(buffer.getvalue()) default_storage.save(self.sample_filename(), buffer_contents) def admin_sample(self): return u'' % self.sample_url() admin_sample.short_description = 'Sample' admin_sample.allow_tags = True def pre_process(self, im): return im def post_process(self, im): return im def process(self, im): im = self.pre_process(im) im = self.post_process(im) return im def __str__(self): return self.name def save(self, *args, **kwargs): try: default_storage.delete(self.sample_filename()) except: pass models.Model.save(self, *args, **kwargs) self.create_sample() for size in self.photo_sizes.all(): size.clear_cache() # try to clear all related subclasses of ImageModel for prop in [prop for prop in dir(self) if prop[-8:] == '_related']: for obj in getattr(self, prop).all(): obj.clear_cache() obj.pre_cache() def delete(self): try: default_storage.delete(self.sample_filename()) except: pass models.Model.delete(self) class PhotoEffect(BaseEffect): """ A pre-defined effect to apply to photos """ transpose_method = models.CharField(_('rotate or flip'), max_length=15, blank=True, choices=IMAGE_TRANSPOSE_CHOICES) 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.")) 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.")) 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.")) 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.")) filters = models.CharField(_('filters'), max_length=200, blank=True, help_text=_(IMAGE_FILTERS_HELP_TEXT)) 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.")) reflection_strength = models.FloatField(_('strength'), default=0.6, help_text=_("The initial opacity of the reflection gradient.")) 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.")) class Meta: verbose_name = _("photo effect") verbose_name_plural = _("photo effects") def pre_process(self, im): if self.transpose_method != '': method = getattr(Image, self.transpose_method) im = im.transpose(method) if im.mode != 'RGB' and im.mode != 'RGBA': return im for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']: factor = getattr(self, name.lower()) if factor != 1.0: im = getattr(ImageEnhance, name)(im).enhance(factor) for name in self.filters.split('->'): image_filter = getattr(ImageFilter, name.upper(), None) if image_filter is not None: try: im = im.filter(image_filter) except ValueError: pass return im def post_process(self, im): if self.reflection_size != 0.0: im = add_reflection(im, bgcolor=self.background_color, amount=self.reflection_size, opacity=self.reflection_strength) return im class Watermark(BaseEffect): image = models.ImageField(_('image'), upload_to=PHOTOLOGUE_DIR + "/watermarks") style = models.CharField(_('style'), max_length=5, choices=WATERMARK_STYLE_CHOICES, default='scale') opacity = models.FloatField(_('opacity'), default=1, help_text=_("The opacity of the overlay.")) class Meta: verbose_name = _('watermark') verbose_name_plural = _('watermarks') def delete(self): assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % ( self._meta.object_name, self._meta.pk.attname) super(Watermark, self).delete() self.image.storage.delete(self.image.name) def post_process(self, im): mark = Image.open(self.image.storage.open(self.image.name)) return apply_watermark(im, mark, self.style, self.opacity) @python_2_unicode_compatible class PhotoSize(models.Model): """About the Photosize name: it's used to create get_PHOTOSIZE_url() methods, so the name has to follow the same restrictions as any Python method name, e.g. no spaces or non-ascii characters.""" name = models.CharField(_('name'), max_length=40, unique=True, help_text=_( 'Photo size name should contain only letters, numbers and underscores. Examples: "thumbnail", "display", "small", "main_page_widget".'), validators=[RegexValidator(regex='^[a-z0-9_]+$', message='Use only plain lowercase letters (ASCII), numbers and underscores.' )] ) width = models.PositiveIntegerField(_('width'), default=0, help_text=_('If width is set to "0" the image will be scaled to the supplied height.')) height = models.PositiveIntegerField(_('height'), default=0, help_text=_('If height is set to "0" the image will be scaled to the supplied width')) quality = models.PositiveIntegerField(_('quality'), choices=JPEG_QUALITY_CHOICES, default=70, help_text=_('JPEG image quality.')) 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.')) crop = models.BooleanField(_('crop to fit?'), default=False, help_text=_('If selected the image will be scaled and cropped to fit the supplied dimensions.')) pre_cache = models.BooleanField(_('pre-cache?'), default=False, help_text=_('If selected this photo size will be pre-cached as photos are added.')) 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.')) effect = models.ForeignKey('PhotoEffect', null=True, blank=True, related_name='photo_sizes', verbose_name=_('photo effect')) watermark = models.ForeignKey('Watermark', null=True, blank=True, related_name='photo_sizes', verbose_name=_('watermark image')) class Meta: ordering = ['width', 'height'] verbose_name = _('photo size') verbose_name_plural = _('photo sizes') def __str__(self): return self.name def clear_cache(self): for cls in ImageModel.__subclasses__(): for obj in cls.objects.all(): obj.remove_size(self) if self.pre_cache: obj.create_size(self) PhotoSizeCache().reset() def clean(self): if self.crop is True: if self.width == 0 or self.height == 0: raise ValidationError( _("Can only crop photos if both width and height dimensions are set.")) def save(self, *args, **kwargs): super(PhotoSize, self).save(*args, **kwargs) PhotoSizeCache().reset() self.clear_cache() def delete(self): assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % ( self._meta.object_name, self._meta.pk.attname) self.clear_cache() super(PhotoSize, self).delete() def _get_size(self): return (self.width, self.height) def _set_size(self, value): self.width, self.height = value size = property(_get_size, _set_size) class PhotoSizeCache(object): __state = {"sizes": {}} def __init__(self): self.__dict__ = self.__state if not len(self.sizes): sizes = PhotoSize.objects.all() for size in sizes: self.sizes[size.name] = size def reset(self): self.sizes = {} # Set up the accessor methods def add_methods(sender, instance, signal, *args, **kwargs): """ Adds methods to access sized images (urls, paths) after the Photo model's __init__ function completes, this method calls "add_accessor_methods" on each instance. """ if hasattr(instance, 'add_accessor_methods'): instance.add_accessor_methods() post_init.connect(add_methods) def add_default_site(instance, created, **kwargs): """ Called via Django's signals when an instance is created. In case PHOTOLOGUE_MULTISITE is False, the current site (i.e. ``settings.SITE_ID``) will always be added to the site relations if none are present. """ if not created: return if getattr(settings, 'PHOTOLOGUE_MULTISITE', False): return if instance.sites.exists(): return instance.sites.add(Site.objects.get_current()) post_save.connect(add_default_site, sender=Gallery) post_save.connect(add_default_site, sender=Photo) pre_delete.connect(file_cleanup, sender=Photo) post_save.connect(post_save_handler, sender=Photo) post_save.connect(post_save_handler, sender=Gallery)