805 lines
26 KiB
Python
805 lines
26 KiB
Python
# Based on https://github.com/richardbarran/django-photologue/
|
|
# by Richard Barran, BSD-3 licensed
|
|
|
|
import logging
|
|
import os
|
|
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.validators import RegexValidator
|
|
from django.core.cache import cache, caches
|
|
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
|
|
|
|
|
|
from django import VERSION
|
|
if VERSION[0] >= 5 : # Gestion du cache
|
|
caches = cache
|
|
|
|
|
|
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)
|
|
|
|
|
|
# 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):
|
|
title = models.CharField(_("title"), max_length=250)
|
|
slug = models.SlugField(
|
|
_("title slug"),
|
|
unique=True,
|
|
max_length=250,
|
|
help_text=_('A "slug" is a unique URL-friendly title for an object.'),
|
|
)
|
|
date_start = models.DateField(
|
|
default=now,
|
|
verbose_name=_("start date"),
|
|
)
|
|
date_end = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_("end date"),
|
|
)
|
|
description = models.TextField(_("description"), blank=True)
|
|
tags = models.ManyToManyField(
|
|
"photologue.Tag",
|
|
related_name="galleries",
|
|
verbose_name=_("tags"),
|
|
blank=True,
|
|
)
|
|
photos = models.ManyToManyField(
|
|
"photologue.Photo",
|
|
related_name="galleries",
|
|
verbose_name=_("photos"),
|
|
blank=True,
|
|
)
|
|
public_token = models.UUIDField(
|
|
_("public token"),
|
|
null=True,
|
|
blank=True,
|
|
unique=True,
|
|
default=None,
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["-date_start"]
|
|
get_latest_by = "date_start"
|
|
verbose_name = _("gallery")
|
|
verbose_name_plural = _("galleries")
|
|
|
|
def __str__(self):
|
|
return f"{ self.title } ({self.date_start})"
|
|
|
|
def get_absolute_url(self):
|
|
return reverse("photologue:pl-gallery", args=[self.slug])
|
|
|
|
def sample(self, public=True):
|
|
"""Return a sample of photos, ordered at random."""
|
|
count = 1
|
|
nb = self.photo_count(public) # Optimisation don't do twice the SQL requests
|
|
if nb < count:
|
|
count = nb
|
|
|
|
if public:
|
|
photo_set = self.photos.filter(is_public=True)
|
|
else:
|
|
photo_set = self.photos
|
|
return photo_set.order_by("?")[:count] # Use native SQL random
|
|
|
|
def photo_count(self, public=True):
|
|
"""Return a count of all the photos in this gallery."""
|
|
if public:
|
|
return self.photos.filter(is_public=True).count()
|
|
else:
|
|
return self.photos.count()
|
|
|
|
def photo_private_count(self):
|
|
"""Return a count of private photos in this gallery."""
|
|
return self.photos.filter(is_public=False).count()
|
|
|
|
photo_count.short_description = _("count")
|
|
photo_private_count.short_description = _("private count")
|
|
|
|
|
|
class ImageModel(models.Model):
|
|
image = models.ImageField(
|
|
_("image"),
|
|
max_length=IMAGE_FIELD_MAX_LENGTH,
|
|
upload_to=get_storage_path,
|
|
width_field="image_width",
|
|
height_field="image_height",
|
|
)
|
|
image_width = models.PositiveIntegerField(editable=False, null=True)
|
|
image_height = models.PositiveIntegerField(editable=False, null=True)
|
|
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(
|
|
'<a href="{}"><img src="{}"></a>'.format(
|
|
self.get_absolute_url(), func()
|
|
)
|
|
)
|
|
else:
|
|
return mark_safe(
|
|
'<a href="{}"><img src="{}"></a>'.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):
|
|
if not self.image:
|
|
return ""
|
|
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:
|
|
# When this attribute error is raised, it's usually because
|
|
# something tried to access a missing attribute on the photo.
|
|
# 99% of the time is due to a typo in the attribute name we are
|
|
# trying to access.
|
|
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))).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)
|
|
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
|
|
# 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)
|
|
# 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) and self.image:
|
|
# 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)
|
|
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)
|
|
owner = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("owner"),
|
|
)
|
|
license = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
verbose_name=_("license"),
|
|
)
|
|
is_public = models.BooleanField(
|
|
_("is public"),
|
|
default=True,
|
|
help_text=_("Public photographs will be displayed in the default views."),
|
|
)
|
|
|
|
class Meta:
|
|
# We do not have a reliable date for ordering, so let's use
|
|
# the title which is incremented by most cameras
|
|
ordering = ["title"]
|
|
get_latest_by = "date_added"
|
|
verbose_name = _("photo")
|
|
verbose_name_plural = _("photos")
|
|
permissions = [
|
|
("can_resolve_censorship", "Can resolve censorship (restore private photos)"),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
def save(self, *args, **kwargs):
|
|
# If crop_from 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):
|
|
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.pk])
|
|
|
|
def public_galleries(self):
|
|
"""Return the public galleries to which this photo belongs."""
|
|
return self.galleries
|
|
|
|
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
|
|
|
|
cached = caches.get("PhotoSizeCache", None)
|
|
if cached is None:
|
|
if not len(self.sizes):
|
|
sizes = PhotoSize.objects.all()
|
|
for size in sizes:
|
|
self.sizes[size.name] = size
|
|
caches.set("PhotoSizeCache", self)
|
|
else:
|
|
self = cached
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
class Tag(models.Model):
|
|
name = models.CharField(
|
|
max_length=250,
|
|
unique=True,
|
|
verbose_name=_("name"),
|
|
)
|
|
slug = models.SlugField(
|
|
unique=True,
|
|
max_length=250,
|
|
verbose_name=_("slug"),
|
|
help_text=_('A "slug" is a unique URL-friendly title for an object.'),
|
|
)
|
|
|
|
class Meta:
|
|
ordering = ["name"]
|
|
verbose_name = _("tag")
|
|
verbose_name_plural = _("tags")
|
|
|
|
def __str__(self):
|
|
return self.name
|