import binascii import hashlib import os from contextlib import closing from io import BytesIO from django.core.files import File from django.core.files.base import ContentFile from django.db import models from django.db.models import signals from django.utils.encoding import force_bytes, force_str from django.utils.module_loading import import_string from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from PIL import Image, ImageOps from avatar.conf import settings from avatar.utils import get_username, invalidate_cache try: # Django 4.2+ from django.core.files.storage import storages avatar_storage = storages[settings.AVATAR_STORAGE_ALIAS] except ImportError: from django.core.files.storage import get_storage_class avatar_storage = get_storage_class(settings.AVATAR_STORAGE)() def avatar_path_handler( instance=None, filename=None, width=None, height=None, ext=None ): tmppath = [settings.AVATAR_STORAGE_DIR] if settings.AVATAR_HASH_USERDIRNAMES: tmp = hashlib.md5(force_bytes(get_username(instance.user))).hexdigest() tmppath.extend(tmp[0:2]) if settings.AVATAR_EXPOSE_USERNAMES: tmppath.append(get_username(instance.user)) else: tmppath.append(force_str(instance.user.pk)) if not filename: # Filename already stored in database filename = instance.avatar.name if ext: (root, oldext) = os.path.splitext(filename) filename = root + "." + ext.lower() else: # File doesn't exist yet (root, oldext) = os.path.splitext(filename) if settings.AVATAR_HASH_FILENAMES: if settings.AVATAR_RANDOMIZE_HASHES: root = binascii.hexlify(os.urandom(16)).decode("ascii") else: root = hashlib.md5(force_bytes(root)).hexdigest() if ext: filename = root + "." + ext.lower() else: filename = root + oldext.lower() if width or height: tmppath.extend(["resized", str(width), str(height)]) tmppath.append(os.path.basename(filename)) return os.path.join(*tmppath) avatar_file_path = import_string(settings.AVATAR_PATH_HANDLER) def find_extension(format): format = format.lower() if format == "jpeg": format = "jpg" return format class AvatarField(models.ImageField): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.max_length = 1024 self.upload_to = avatar_file_path self.storage = avatar_storage self.blank = True def deconstruct(self): name, path, args, kwargs = super().deconstruct() return name, path, (), {} class Avatar(models.Model): user = models.ForeignKey( getattr(settings, "AUTH_USER_MODEL", "auth.User"), verbose_name=_("user"), on_delete=models.CASCADE, ) primary = models.BooleanField( verbose_name=_("primary"), default=False, ) avatar = AvatarField(verbose_name=_("avatar")) date_uploaded = models.DateTimeField( verbose_name=_("uploaded at"), default=now, ) class Meta: app_label = "avatar" verbose_name = _("avatar") verbose_name_plural = _("avatars") def __str__(self): return _("Avatar for %s") % self.user def save(self, *args, **kwargs): avatars = Avatar.objects.filter(user=self.user) if self.pk: avatars = avatars.exclude(pk=self.pk) if settings.AVATAR_MAX_AVATARS_PER_USER > 1: if self.primary: avatars = avatars.filter(primary=True) avatars.update(primary=False) else: avatars.delete() super().save(*args, **kwargs) def thumbnail_exists(self, width, height=None): return self.avatar.storage.exists(self.avatar_name(width, height)) def transpose_image(self, image): EXIF_ORIENTATION = 0x0112 exif_code = image.getexif().get(EXIF_ORIENTATION, 1) if exif_code and exif_code != 1: image = ImageOps.exif_transpose(image) return image def create_thumbnail(self, width, height=None, quality=None): if height is None: height = width # invalidate the cache of the thumbnail with the given size first invalidate_cache(self.user, width, height) try: orig = self.avatar.storage.open(self.avatar.name, "rb") except IOError: return # What should we do here? Render a "sorry, didn't work" img? with closing(orig): try: image = Image.open(orig) except IOError: thumb_file = File(orig) else: image = self.transpose_image(image) quality = quality or settings.AVATAR_THUMB_QUALITY w, h = image.size if w != width or h != height: ratioReal = 1.0 * w / h ratioWant = 1.0 * width / height if ratioReal > ratioWant: diff = int((w - (h * ratioWant)) / 2) image = image.crop((diff, 0, w - diff, h)) elif ratioReal < ratioWant: diff = int((h - (w / ratioWant)) / 2) image = image.crop((0, diff, w, h - diff)) if settings.AVATAR_THUMB_FORMAT == "JPEG" and image.mode == "RGBA": image = image.convert("RGB") elif image.mode not in (settings.AVATAR_THUMB_MODES): image = image.convert(settings.AVATAR_THUMB_MODES[0]) image = image.resize((width, height), settings.AVATAR_RESIZE_METHOD) thumb = BytesIO() image.save(thumb, settings.AVATAR_THUMB_FORMAT, quality=quality) thumb_file = ContentFile(thumb.getvalue()) else: thumb_file = File(orig) thumb_name = self.avatar_name(width, height) thumb = self.avatar.storage.save(thumb_name, thumb_file) invalidate_cache(self.user, width, height) def avatar_url(self, width, height=None): return self.avatar.storage.url(self.avatar_name(width, height)) def get_absolute_url(self): return self.avatar_url(settings.AVATAR_DEFAULT_SIZE) def avatar_name(self, width, height=None): if height is None: height = width ext = find_extension(settings.AVATAR_THUMB_FORMAT) return avatar_file_path(instance=self, width=width, height=height, ext=ext) def invalidate_avatar_cache(sender, instance, **kwargs): if hasattr(instance, "user"): invalidate_cache(instance.user) def create_default_thumbnails(sender, instance, created=False, **kwargs): invalidate_avatar_cache(sender, instance) if created: for size in settings.AVATAR_AUTO_GENERATE_SIZES: if isinstance(size, int): instance.create_thumbnail(size, size) else: # Size is specified with height and width. instance.create_thumbnail(size[0], size[1]) def remove_avatar_images(instance=None, delete_main_avatar=True, **kwargs): base_filepath = instance.avatar.name path, filename = os.path.split(base_filepath) # iterate through resized avatars directories and delete resized avatars resized_path = os.path.join(path, "resized") resized_widths, _ = instance.avatar.storage.listdir(resized_path) for width in resized_widths: resized_width_path = os.path.join(resized_path, width) resized_heights, _ = instance.avatar.storage.listdir(resized_width_path) for height in resized_heights: if instance.thumbnail_exists(width, height): instance.avatar.storage.delete(instance.avatar_name(width, height)) if delete_main_avatar: if instance.avatar.storage.exists(instance.avatar.name): instance.avatar.storage.delete(instance.avatar.name) signals.post_save.connect(create_default_thumbnails, sender=Avatar) signals.post_delete.connect(invalidate_avatar_cache, sender=Avatar) if settings.AVATAR_CLEANUP_DELETED: signals.post_delete.connect(remove_avatar_images, sender=Avatar)