import binascii import hashlib import os from io import BytesIO from django.core.files import File from django.core.files.base import ContentFile from django.core.files.storage import storages 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: # From Django 4.2 use django.core.files.storage.storages in favor # of the deprecated django.core.files.storage.get_storage_class from django.core.files.storage import storages avatar_storage = storages["staticfiles"].__class__ except ImportError: # Backwards compatibility for Django versions prior to 4.2 from django.core.files.storage import get_storage_class avatar_storage = get_storage_class(settings.STATICFILES_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? try: image = Image.open(orig) 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) except IOError: thumb_file = File(orig) thumb = self.avatar.storage.save( self.avatar_name(width, height), 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)