# Based on https://github.com/richardbarran/django-photologue/ # by Richard Barran, BSD-3 licensed import logging import os import random import unicodedata from datetime import datetime from functools import partial from importlib import import_module from inspect import isclass from io import BytesIO import exifread from django.conf import settings from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.core.validators import RegexValidator from django.db import models from django.template.defaultfilters import slugify from django.urls import reverse from django.utils.encoding import filepath_to_uri, force_str, smart_str from django.utils.safestring import mark_safe from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from PIL import Image, ImageFile, ImageFilter logger = logging.getLogger('photologue.models') # Default limit for gallery.latest LATEST_LIMIT = getattr(settings, 'PHOTOLOGUE_GALLERY_LATEST_LIMIT', None) # max_length setting for the ImageModel ImageField IMAGE_FIELD_MAX_LENGTH = getattr(settings, 'PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH', 100) # Modify image file buffer size. ImageFile.MAXBLOCK = getattr(settings, 'PHOTOLOGUE_MAXBLOCK', 256 * 2 ** 10) # 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): fn = unicodedata.normalize('NFKD', force_str(filename)).encode('ascii', 'ignore').decode('ascii') return os.path.join('photos', fn) # Support CACHEDIR.TAG spec for backups for ignoring cache dir. # See http://www.brynosaurus.com/cachedir/spec.html PHOTOLOGUE_CACHEDIRTAG = os.path.join("photos", "cache", "CACHEDIR.TAG") if not default_storage.exists(PHOTOLOGUE_CACHEDIRTAG): default_storage.save(PHOTOLOGUE_CACHEDIRTAG, ContentFile( b"Signature: 8a477f597d28d172789f06886806bc55")) # Exif Orientation values # Value 0thRow 0thColumn # 1 top left # 2 top right # 3 bottom right # 4 bottom left # 5 left top # 6 right top # 7 right bottom # 8 left bottom # Image Orientations (according to EXIF informations) that needs to be # transposed and appropriate action IMAGE_EXIF_ORIENTATION_MAP = { 2: Image.FLIP_LEFT_RIGHT, 3: Image.ROTATE_180, 6: Image.ROTATE_270, 8: Image.ROTATE_90, } # 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')), ) # 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))) size_method_map = {} class TagField(models.CharField): """Tags have been removed from Photologue, but the migrations still refer to them so this Tagfield definition is left here. """ def __init__(self, **kwargs): default_kwargs = {'max_length': 255, 'blank': True} default_kwargs.update(kwargs) super().__init__(**default_kwargs) def get_internal_type(self): return 'CharField' class Gallery(models.Model): date_added = models.DateTimeField(_('date published'), default=now) title = models.CharField(_('title'), max_length=250, unique=True) slug = models.SlugField(_('title slug'), unique=True, max_length=250, help_text=_('A "slug" is a unique URL-friendly title for an object.')) description = models.TextField(_('description'), blank=True) is_public = models.BooleanField(_('is public'), default=True, help_text=_('Public galleries will be displayed ' 'in the default views.')) photos = models.ManyToManyField('photologue.Photo', related_name='galleries', verbose_name=_('photos'), blank=True) class Meta: ordering = ['-date_added'] get_latest_by = 'date_added' verbose_name = _('gallery') verbose_name_plural = _('galleries') def __str__(self): return self.title def get_absolute_url(self): return reverse('photologue: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[:limit] def sample(self, count=None, public=True): """Return a sample of photos, ordered at random.""" if not count: count = 1 if count > self.photo_count(): count = self.photo_count() if public: photo_set = self.public() else: photo_set = self.photos 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.count() photo_count.short_description = _('count') def public(self): """Return a queryset of all the public photos in this gallery.""" return self.photos.filter(is_public=True) 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, help_text=_('Date image was taken; is obtained from the image EXIF data.')) 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) class Meta: abstract = True def exif(self, file=None): try: if file: tags = exifread.process_file(file) else: with self.image.storage.open(self.image.name, 'rb') as file: tags = exifread.process_file(file, details=False) return tags except Exception: 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 mark_safe(''.format(self.get_absolute_url(), func())) else: return mark_safe(''.format(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_str(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) try: return Image.open(self.image.storage.open( self._get_size_filename(size))).size except Exception: return None 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 __getattr__(self, name): global size_method_map if not size_method_map: init_size_method_map() di = size_method_map.get(name, None) if di is not None: result = partial(getattr(self, di['base_name']), di['size']) setattr(self, name, result) return result else: raise AttributeError 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, recreate=False): if self.size_exists(photosize) and not recreate: return try: im = Image.open(self.image.storage.open(self.image.name)) except OSError: 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) # Rotate if found & necessary if 'Image Orientation' in self.exif() and \ self.exif().get('Image Orientation').values[0] in IMAGE_EXIF_ORIENTATION_MAP: im = im.transpose( IMAGE_EXIF_ORIENTATION_MAP[self.EXIF().get('Image Orientation').values[0]]) # Resize/crop image if (im.size != photosize.size and photosize.size != (0, 0)) or recreate: im = self.resize_image(im, photosize) # 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: # Issue #182 - test fix from https://github.com/bashu/django-watermark/issues/31 if im.mode.endswith('A'): im = im.convert(im.mode[:-1]) im.save(buffer, 'JPEG', quality=int(photosize.quality), optimize=True) buffer_contents = ContentFile(buffer.getvalue()) self.image.storage.save(im_filename, buffer_contents) except OSError 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, recreate=False): cache = PhotoSizeCache() if recreate: self.clear_cache() for photosize in cache.sizes.values(): if photosize.pre_cache: self.create_size(photosize, recreate) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._old_image = self.image def save(self, *args, **kwargs): recreate = kwargs.pop('recreate', False) image_has_changed = False if self._get_pk_val() and (self._old_image != self.image): image_has_changed = True # If we have changed the image, we need to clear from the cache all instances of the old # image; clear_cache() works on the current (new) image, and in turn calls several other methods. # Changing them all to act on the old image was a lot of changes, so instead we temporarily swap old # and new images. new_image = self.image self.image = self._old_image self.clear_cache() self.image = new_image # Back to the new image. self._old_image.storage.delete(self._old_image.name) # Delete (old) base image. if self.date_taken is None or image_has_changed: # Attempt to get the date the photo was taken from the EXIF data. try: exif_date = self.exif(self.image.file).get('EXIF DateTimeOriginal', None) if exif_date is not None: d, t = exif_date.values.split() 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 Exception: logger.error('Failed to read EXIF DateTimeOriginal', exc_info=True) super().save(*args, **kwargs) self.pre_cache(recreate) 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().delete() self.image.storage.delete(self.image.name) class Photo(ImageModel): title = models.CharField(_('title'), max_length=250, unique=True) slug = models.SlugField(_('slug'), unique=True, max_length=250, help_text=_('A "slug" is a unique URL-friendly title for an object.')) caption = models.TextField(_('caption'), blank=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.')) class Meta: ordering = ['-date_added'] get_latest_by = 'date_added' verbose_name = _("photo") verbose_name_plural = _("photos") def __str__(self): return self.title def save(self, *args, **kwargs): # If crop_from or effect property has been changed on existing image, # update kwargs to force image recreation in parent class current = Photo.objects.get(pk=self.pk) if self.pk else None if current and (current.crop_from != self.crop_from or current.effect != self.effect): kwargs.update(recreate=True) if self.slug is None: self.slug = slugify(self.title) super().save(*args, **kwargs) def get_absolute_url(self): return reverse('photologue: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.filter(is_public=True) if self not 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.filter(is_public=True) if self not 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 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.')) 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().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().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: __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): global size_method_map size_method_map = {} self.sizes = {} def init_size_method_map(): global size_method_map for size in PhotoSizeCache().sizes.keys(): size_method_map['get_%s_size' % size] = \ {'base_name': '_get_size_size', 'size': size} size_method_map['get_%s_photosize' % size] = \ {'base_name': '_get_size_photosize', 'size': size} size_method_map['get_%s_url' % size] = \ {'base_name': '_get_size_url', 'size': size} size_method_map['get_%s_filename' % size] = \ {'base_name': '_get_size_filename', 'size': size}