You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1108 lines
44 KiB
1108 lines
44 KiB
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
|
|
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
|
|
|
|
# Required PIL classes may or may not be available from the root namespace
|
|
# depending on the installation method used.
|
|
try:
|
|
import Image
|
|
import ImageFile
|
|
import ImageFilter
|
|
import ImageEnhance
|
|
except ImportError:
|
|
try:
|
|
from PIL import Image
|
|
from PIL import ImageFile
|
|
from PIL import ImageFilter
|
|
from PIL 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)
|
|
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'<a href="%s"><img src="%s"></a>' % \
|
|
(self.get_absolute_url(), func())
|
|
else:
|
|
return u'<a href="%s"><img src="%s"></a>' % \
|
|
(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(
|
|
title = models.CharField(_('title'),
|
|
max_length=200),
|
|
caption = models.TextField(_('caption'),
|
|
blank=True)
|
|
)
|
|
|
|
slug = models.SlugField(_('slug'),
|
|
unique=True,
|
|
help_text=_('A "slug" is a unique URL-friendly title for an object.'),
|
|
max_length=200)
|
|
|
|
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 = ['-date_added']
|
|
get_latest_by = 'date_added'
|
|
verbose_name = _("photo")
|
|
verbose_name_plural = _("photos")
|
|
|
|
def __str__(self):
|
|
return self.get_available_title()
|
|
|
|
def translation_model(self):
|
|
return self._meta.translations_model
|
|
|
|
def admin_url(self):
|
|
return '/admin/photogallery/photo/%s'%self.slug
|
|
|
|
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'<img src="%s">' % 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)
|
|
|
|
|
|
from django.db.models.signals import post_save
|
|
from functions.signal_handlers import post_save_handler
|
|
|
|
post_save.connect(post_save_handler, sender=Photo)
|
|
post_save.connect(post_save_handler, sender=Gallery) |