rectangular avatars (#214)

* Support for rectangular avatars

* fix tests

* Add rectangle size test

* Update documentation and changelog

* add test for exposing username (or not)

* add primary_avatar_tag tests

* make rebuild_avatars, remove_avatar_images work with rectangles + tests

* Python 2 => 3

* fix tests

Co-authored-by: Karl Moritz Hermann <karlmoritz.hermann@gmail.com>
This commit is contained in:
Johannes Wilm 2022-08-15 10:08:35 +02:00 committed by GitHub
parent 8017d6fc4c
commit 99a979b057
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 317 additions and 135 deletions

1
.gitignore vendored
View file

@ -13,3 +13,4 @@ htmlcov/
*.sqlite3 *.sqlite3
test_proj/media test_proj/media
.python-version .python-version
/test-media/

View file

@ -2,6 +2,7 @@ Changelog
========= =========
* Unreleased * Unreleased
* Allowed for rectangular avatars. Custom avatar tag templates now require the specification of both a ``width`` and ``height`` attribute instead of ``size``.
* Made ``True`` the default value of ``AVATAR_CLEANUP_DELETED``. (Set to ``False`` to obtain previous behavior). * Made ``True`` the default value of ``AVATAR_CLEANUP_DELETED``. (Set to ``False`` to obtain previous behavior).
* 6.0.1 (August 12, 2022) * 6.0.1 (August 12, 2022)

View file

@ -30,7 +30,7 @@ class AvatarAdmin(admin.ModelAdmin):
get_avatar.allow_tags = True get_avatar.allow_tags = True
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
super(AvatarAdmin, self).save_model(request, obj, form, change) super().save_model(request, obj, form, change)
avatar_updated.send(sender=Avatar, user=request.user, avatar=obj) avatar_updated.send(sender=Avatar, user=request.user, avatar=obj)

View file

@ -10,12 +10,12 @@ from avatar.conf import settings
from avatar.models import Avatar from avatar.models import Avatar
def avatar_img(avatar, size): def avatar_img(avatar, width, height):
if not avatar.thumbnail_exists(size): if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(size) avatar.create_thumbnail(width, height)
return mark_safe( return mark_safe(
'<img src="%s" alt="%s" width="%s" height="%s" />' '<img src="%s" alt="%s" width="%s" height="%s" />'
% (avatar.avatar_url(size), str(avatar), size, size) % (avatar.avatar_url(width, height), str(avatar), width, height)
) )
@ -24,7 +24,7 @@ class UploadAvatarForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user") self.user = kwargs.pop("user")
super(UploadAvatarForm, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def clean_avatar(self): def clean_avatar(self):
data = self.cleaned_data["avatar"] data = self.cleaned_data["avatar"]
@ -73,22 +73,25 @@ class UploadAvatarForm(forms.Form):
class PrimaryAvatarForm(forms.Form): class PrimaryAvatarForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
kwargs.pop("user") kwargs.pop("user")
size = kwargs.pop("size", settings.AVATAR_DEFAULT_SIZE) width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
avatars = kwargs.pop("avatars") avatars = kwargs.pop("avatars")
super(PrimaryAvatarForm, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
self.fields["choice"] = forms.ChoiceField( self.fields["choice"] = forms.ChoiceField(
label=_("Choices"), choices=choices, widget=widgets.RadioSelect choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
widget=widgets.RadioSelect,
) )
class DeleteAvatarForm(forms.Form): class DeleteAvatarForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
kwargs.pop("user") kwargs.pop("user")
size = kwargs.pop("size", settings.AVATAR_DEFAULT_SIZE) width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
avatars = kwargs.pop("avatars") avatars = kwargs.pop("avatars")
super(DeleteAvatarForm, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
self.fields["choices"] = forms.MultipleChoiceField( self.fields["choices"] = forms.MultipleChoiceField(
label=_("Choices"), choices=choices, widget=widgets.CheckboxSelectMultiple label=_("Choices"),
choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
widget=widgets.CheckboxSelectMultiple,
) )

View file

@ -1,7 +1,7 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from avatar.conf import settings from avatar.conf import settings
from avatar.models import Avatar from avatar.models import Avatar, remove_avatar_images
class Command(BaseCommand): class Command(BaseCommand):
@ -12,10 +12,15 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
for avatar in Avatar.objects.all(): for avatar in Avatar.objects.all():
if settings.AVATAR_CLEANUP_DELETED:
remove_avatar_images(avatar, delete_main_avatar=False)
for size in settings.AVATAR_AUTO_GENERATE_SIZES: for size in settings.AVATAR_AUTO_GENERATE_SIZES:
if options["verbosity"] != 0: if options["verbosity"] != 0:
self.stdout.write( self.stdout.write(
"Rebuilding Avatar id=%s at size %s." % (avatar.id, size) "Rebuilding Avatar id=%s at size %s." % (avatar.id, size)
) )
if isinstance(size, int):
avatar.create_thumbnail(size) avatar.create_thumbnail(size, size)
else:
# Size is specified with height and width.
avatar.create_thumbnail(size[0], size[1])

View file

@ -20,7 +20,9 @@ from avatar.utils import force_bytes, get_username, invalidate_cache
avatar_storage = get_storage_class(settings.AVATAR_STORAGE)() avatar_storage = get_storage_class(settings.AVATAR_STORAGE)()
def avatar_path_handler(instance=None, filename=None, size=None, ext=None): def avatar_path_handler(
instance=None, filename=None, width=None, height=None, ext=None
):
tmppath = [settings.AVATAR_STORAGE_DIR] tmppath = [settings.AVATAR_STORAGE_DIR]
if settings.AVATAR_HASH_USERDIRNAMES: if settings.AVATAR_HASH_USERDIRNAMES:
tmp = hashlib.md5(force_bytes(get_username(instance.user))).hexdigest() tmp = hashlib.md5(force_bytes(get_username(instance.user))).hexdigest()
@ -38,7 +40,7 @@ def avatar_path_handler(instance=None, filename=None, size=None, ext=None):
# only enabled if AVATAR_HASH_FILENAMES is true, we can trust # only enabled if AVATAR_HASH_FILENAMES is true, we can trust
# it won't conflict with another filename # it won't conflict with another filename
(root, oldext) = os.path.splitext(filename) (root, oldext) = os.path.splitext(filename)
filename = root + "." + ext filename = root + "." + ext.lower()
else: else:
# File doesn't exist yet # File doesn't exist yet
if settings.AVATAR_HASH_FILENAMES: if settings.AVATAR_HASH_FILENAMES:
@ -48,8 +50,8 @@ def avatar_path_handler(instance=None, filename=None, size=None, ext=None):
else: else:
filename = hashlib.md5(force_bytes(filename)).hexdigest() filename = hashlib.md5(force_bytes(filename)).hexdigest()
filename = filename + ext filename = filename + ext
if size: if width or height:
tmppath.extend(["resized", str(size)]) tmppath.extend(["resized", str(width), str(height)])
tmppath.append(os.path.basename(filename)) tmppath.append(os.path.basename(filename))
return os.path.join(*tmppath) return os.path.join(*tmppath)
@ -116,8 +118,8 @@ class Avatar(models.Model):
avatars.delete() avatars.delete()
super(Avatar, self).save(*args, **kwargs) super(Avatar, self).save(*args, **kwargs)
def thumbnail_exists(self, size): def thumbnail_exists(self, width, height=None):
return self.avatar.storage.exists(self.avatar_name(size)) return self.avatar.storage.exists(self.avatar_name(width, height))
def transpose_image(self, image): def transpose_image(self, image):
""" """
@ -144,9 +146,11 @@ class Avatar(models.Model):
image = image.transpose(getattr(Image, method)) image = image.transpose(getattr(Image, method))
return image return image
def create_thumbnail(self, size, quality=None): 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 the cache of the thumbnail with the given size first
invalidate_cache(self.user, size) invalidate_cache(self.user, width, height)
try: try:
orig = self.avatar.storage.open(self.avatar.name, "rb") orig = self.avatar.storage.open(self.avatar.name, "rb")
except IOError: except IOError:
@ -156,37 +160,45 @@ class Avatar(models.Model):
image = self.transpose_image(image) image = self.transpose_image(image)
quality = quality or settings.AVATAR_THUMB_QUALITY quality = quality or settings.AVATAR_THUMB_QUALITY
w, h = image.size w, h = image.size
if w != size or h != size: if w != width or h != height:
if w > h: ratioReal = 1.0 * w / h
diff = int((w - h) / 2) ratioWant = 1.0 * width / height
if ratioReal > ratioWant:
diff = int((w - (h * ratioWant)) / 2)
image = image.crop((diff, 0, w - diff, h)) image = image.crop((diff, 0, w - diff, h))
else: elif ratioReal < ratioWant:
diff = int((h - w) / 2) diff = int((h - (w / ratioWant)) / 2)
image = image.crop((0, diff, w, h - diff)) image = image.crop((0, diff, w, h - diff))
if settings.AVATAR_THUMB_FORMAT == "JPEG" and image.mode == "RGBA": if settings.AVATAR_THUMB_FORMAT == "JPEG" and image.mode == "RGBA":
image = image.convert("RGB") image = image.convert("RGB")
elif image.mode not in (settings.AVATAR_THUMB_MODES): elif image.mode not in (settings.AVATAR_THUMB_MODES):
image = image.convert(settings.AVATAR_THUMB_MODES[0]) image = image.convert(settings.AVATAR_THUMB_MODES[0])
image = image.resize((size, size), settings.AVATAR_RESIZE_METHOD) image = image.resize((width, height), settings.AVATAR_RESIZE_METHOD)
thumb = BytesIO() thumb = BytesIO()
image.save(thumb, settings.AVATAR_THUMB_FORMAT, quality=quality) image.save(thumb, settings.AVATAR_THUMB_FORMAT, quality=quality)
thumb_file = ContentFile(thumb.getvalue()) thumb_file = ContentFile(thumb.getvalue())
else: else:
thumb_file = File(orig) thumb_file = File(orig)
thumb = self.avatar.storage.save(self.avatar_name(size), thumb_file) thumb = self.avatar.storage.save(
self.avatar_name(width, height), thumb_file
)
except IOError: except IOError:
thumb_file = File(orig) thumb_file = File(orig)
thumb = self.avatar.storage.save(self.avatar_name(size), thumb_file) thumb = self.avatar.storage.save(
self.avatar_name(width, height), thumb_file
)
def avatar_url(self, size): def avatar_url(self, width, height=None):
return self.avatar.storage.url(self.avatar_name(size)) return self.avatar.storage.url(self.avatar_name(width, height))
def get_absolute_url(self): def get_absolute_url(self):
return self.avatar_url(settings.AVATAR_DEFAULT_SIZE) return self.avatar_url(settings.AVATAR_DEFAULT_SIZE)
def avatar_name(self, size): def avatar_name(self, width, height=None):
if height is None:
height = width
ext = find_extension(settings.AVATAR_THUMB_FORMAT) ext = find_extension(settings.AVATAR_THUMB_FORMAT)
return avatar_file_path(instance=self, size=size, ext=ext) return avatar_file_path(instance=self, width=width, height=height, ext=ext)
def invalidate_avatar_cache(sender, instance, **kwargs): def invalidate_avatar_cache(sender, instance, **kwargs):
@ -198,19 +210,28 @@ def create_default_thumbnails(sender, instance, created=False, **kwargs):
invalidate_avatar_cache(sender, instance) invalidate_avatar_cache(sender, instance)
if created: if created:
for size in settings.AVATAR_AUTO_GENERATE_SIZES: for size in settings.AVATAR_AUTO_GENERATE_SIZES:
instance.create_thumbnail(size) 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, **kwargs): def remove_avatar_images(instance=None, delete_main_avatar=True, **kwargs):
base_filepath = instance.avatar.name base_filepath = instance.avatar.name
path, filename = os.path.split(base_filepath) path, filename = os.path.split(base_filepath)
# iterate through resized avatars directories and delete resized avatars # iterate through resized avatars directories and delete resized avatars
resized_sizes, _ = instance.avatar.storage.listdir(os.path.join(path, "resized")) resized_path = os.path.join(path, "resized")
for size in resized_sizes: resized_widths, _ = instance.avatar.storage.listdir(resized_path)
if instance.thumbnail_exists(size): for width in resized_widths:
instance.avatar.storage.delete(instance.avatar_name(size)) resized_width_path = os.path.join(resized_path, width)
if instance.avatar.storage.exists(instance.avatar.name): resized_heights, _ = instance.avatar.storage.listdir(resized_width_path)
instance.avatar.storage.delete(instance.avatar.name) 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_save.connect(create_default_thumbnails, sender=Avatar)

View file

@ -24,7 +24,7 @@ class DefaultAvatarProvider(object):
""" """
@classmethod @classmethod
def get_avatar_url(cls, user, size): def get_avatar_url(cls, user, width, height):
return get_default_avatar_url() return get_default_avatar_url()
@ -34,10 +34,10 @@ class PrimaryAvatarProvider(object):
""" """
@classmethod @classmethod
def get_avatar_url(cls, user, size): def get_avatar_url(cls, user, width, height):
avatar = get_primary_avatar(user, size) avatar = get_primary_avatar(user, width, height)
if avatar: if avatar:
return avatar.avatar_url(size) return avatar.avatar_url(width, height)
class GravatarAvatarProvider(object): class GravatarAvatarProvider(object):
@ -46,8 +46,8 @@ class GravatarAvatarProvider(object):
""" """
@classmethod @classmethod
def get_avatar_url(cls, user, size): def get_avatar_url(cls, user, width, _height):
params = {"s": str(size)} params = {"s": str(width)}
if settings.AVATAR_GRAVATAR_DEFAULT: if settings.AVATAR_GRAVATAR_DEFAULT:
params["d"] = settings.AVATAR_GRAVATAR_DEFAULT params["d"] = settings.AVATAR_GRAVATAR_DEFAULT
if settings.AVATAR_GRAVATAR_FORCEDEFAULT: if settings.AVATAR_GRAVATAR_FORCEDEFAULT:
@ -68,11 +68,11 @@ class FacebookAvatarProvider(object):
""" """
@classmethod @classmethod
def get_avatar_url(cls, user, size): def get_avatar_url(cls, user, width, height):
fb_id = get_facebook_id(user) fb_id = get_facebook_id(user)
if fb_id: if fb_id:
url = "https://graph.facebook.com/{fb_id}/picture?type=square&width={size}&height={size}" url = "https://graph.facebook.com/{fb_id}/picture?type=square&width={width}&height={height}"
return url.format(fb_id=fb_id, size=size) return url.format(fb_id=fb_id, width=width, height=height)
class InitialsAvatarProvider(object): class InitialsAvatarProvider(object):
@ -82,13 +82,13 @@ class InitialsAvatarProvider(object):
""" """
@classmethod @classmethod
def get_avatar_url(cls, user, size): def get_avatar_url(cls, user, width, _height):
initials = user.first_name[:1] + user.last_name[:1] initials = user.first_name[:1] + user.last_name[:1]
if not initials: if not initials:
initials = user.username[:1] initials = user.username[:1]
initials = initials.upper() initials = initials.upper()
context = { context = {
"fontsize": (size * 1.1) / 2, "fontsize": (width * 1.1) / 2,
"initials": initials, "initials": initials,
"hue": user.pk % 360, "hue": user.pk % 360,
"saturation": "65%", "saturation": "65%",

View file

@ -1 +1 @@
<img src="{{ url }}" width="{{ size }}" height="{{ size }}" {% for key, value in kwargs.items %}{{key}}="{{value}}" {% endfor %}/> <img src="{{ url }}" width="{{ width }}" height="{{ height }}" {% for key, value in kwargs.items %}{{key}}="{{value}}" {% endfor %}/>

View file

@ -13,17 +13,21 @@ register = template.Library()
@cache_result() @cache_result()
@register.simple_tag @register.simple_tag
def avatar_url(user, size=settings.AVATAR_DEFAULT_SIZE): def avatar_url(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
if height is None:
height = width
for provider_path in settings.AVATAR_PROVIDERS: for provider_path in settings.AVATAR_PROVIDERS:
provider = import_string(provider_path) provider = import_string(provider_path)
avatar_url = provider.get_avatar_url(user, size) avatar_url = provider.get_avatar_url(user, width, height)
if avatar_url: if avatar_url:
return avatar_url return avatar_url
@cache_result() @cache_result()
@register.simple_tag @register.simple_tag
def avatar(user, size=settings.AVATAR_DEFAULT_SIZE, **kwargs): def avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None, **kwargs):
if height is None:
height = width
if not isinstance(user, get_user_model()): if not isinstance(user, get_user_model()):
try: try:
user = get_user(user) user = get_user(user)
@ -31,7 +35,7 @@ def avatar(user, size=settings.AVATAR_DEFAULT_SIZE, **kwargs):
alt = str(user) alt = str(user)
else: else:
alt = _("User Avatar") alt = _("User Avatar")
url = avatar_url(user, size) url = avatar_url(user, width, height)
except get_user_model().DoesNotExist: except get_user_model().DoesNotExist:
url = get_default_avatar_url() url = get_default_avatar_url()
alt = _("Default Avatar") alt = _("Default Avatar")
@ -40,13 +44,14 @@ def avatar(user, size=settings.AVATAR_DEFAULT_SIZE, **kwargs):
alt = str(user) alt = str(user)
else: else:
alt = _("User Avatar") alt = _("User Avatar")
url = avatar_url(user, size) url = avatar_url(user, width, height)
kwargs.update({"alt": alt}) kwargs.update({"alt": alt})
context = { context = {
"user": user, "user": user,
"alt": alt, "alt": alt,
"size": size, "width": width,
"height": height,
"kwargs": kwargs, "kwargs": kwargs,
} }
template_name = "avatar/avatar_tag.html" template_name = "avatar/avatar_tag.html"
@ -69,55 +74,44 @@ def has_avatar(user):
@cache_result() @cache_result()
@register.simple_tag @register.simple_tag
def primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE): def primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
""" """
This tag tries to get the default avatar for a user without doing any db This tag tries to get the default avatar for a user without doing any db
requests. It achieve this by linking to a special view that will do all the requests. It achieve this by linking to a special view that will do all the
work for us. If that special view is then cached by a CDN for instance, work for us. If that special view is then cached by a CDN for instance,
we will avoid many db calls. we will avoid many db calls.
""" """
alt = str(user) kwargs = {"width": width}
url = reverse("avatar_render_primary", kwargs={"user": user, "size": size}) if settings.AVATAR_EXPOSE_USERNAMES:
return """<img src="%s" alt="%s" width="%s" height="%s" />""" % ( alt = str(user)
kwargs["user"] = user
else:
alt = _("User Avatar")
kwargs["user"] = user.id
if height is None:
height = width
else:
kwargs["height"] = height
url = reverse("avatar_render_primary", kwargs=kwargs)
return """<img src="%s" width="%s" height="%s" alt="%s" />""" % (
url, url,
width,
height,
alt, alt,
size,
size,
) )
@cache_result() @cache_result()
@register.simple_tag @register.simple_tag
def render_avatar(avatar, size=settings.AVATAR_DEFAULT_SIZE): def render_avatar(avatar, width=settings.AVATAR_DEFAULT_SIZE, height=None):
if not avatar.thumbnail_exists(size): if height is None:
avatar.create_thumbnail(size) height = width
if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(width, height)
return """<img src="%s" alt="%s" width="%s" height="%s" />""" % ( return """<img src="%s" alt="%s" width="%s" height="%s" />""" % (
avatar.avatar_url(size), avatar.avatar_url(width, height),
str(avatar), str(avatar),
size, width,
size, height,
) )
@register.tag
def primary_avatar_object(parser, token):
split = token.split_contents()
if len(split) == 4:
return UsersAvatarObjectNode(split[1], split[3])
raise template.TemplateSyntaxError("%r tag takes three arguments." % split[0])
class UsersAvatarObjectNode(template.Node):
def __init__(self, user, key):
self.user = template.Variable(user)
self.key = key
def render(self, context):
user = self.user.resolve(context)
key = self.key
avatar = Avatar.objects.filter(user=user, primary=True)
if avatar:
context[key] = avatar[0]
else:
context[key] = None
return str()

View file

@ -7,7 +7,12 @@ urlpatterns = [
re_path(r"^change/$", views.change, name="avatar_change"), re_path(r"^change/$", views.change, name="avatar_change"),
re_path(r"^delete/$", views.delete, name="avatar_delete"), re_path(r"^delete/$", views.delete, name="avatar_delete"),
re_path( re_path(
r"^render_primary/(?P<user>[\w\d\@\.\-_]+)/(?P<size>[\d]+)/$", r"^render_primary/(?P<user>[\w\d\@\.\-_]+)/(?P<width>[\d]+)/$",
views.render_primary,
name="avatar_render_primary",
),
re_path(
r"^render_primary/(?P<user>[\w\d\@\.\-_]+)/(?P<width>[\d]+)/(?P<height>[\d]+)/$",
views.render_primary, views.render_primary,
name="avatar_render_primary", name="avatar_render_primary",
), ),

View file

@ -18,18 +18,23 @@ def get_username(user):
return user.username return user.username
def get_user(username): def get_user(userdescriptor):
"""Return user from a username/ish identifier""" """Return user from a username/ID/ish identifier"""
return get_user_model().objects.get_by_natural_key(username) User = get_user_model()
if userdescriptor.isdigit():
user = User.objects.filter(id=int(userdescriptor)).first()
if user:
return user
return User.objects.get_by_natural_key(userdescriptor)
def get_cache_key(user_or_username, size, prefix): def get_cache_key(user_or_username, width, height, prefix):
""" """
Returns a cache key consisten of a username and image size. Returns a cache key consisten of a username and image size.
""" """
if isinstance(user_or_username, get_user_model()): if isinstance(user_or_username, get_user_model()):
user_or_username = get_username(user_or_username) user_or_username = get_username(user_or_username)
key = "%s_%s_%s" % (prefix, user_or_username, size) key = "%s_%s_%s_%s" % (prefix, user_or_username, width, height or width)
return "%s_%s" % ( return "%s_%s" % (
slugify(key)[:100], slugify(key)[:100],
hashlib.md5(force_bytes(key)).hexdigest(), hashlib.md5(force_bytes(key)).hexdigest(),
@ -43,8 +48,8 @@ def cache_set(key, value):
def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE): def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
""" """
Decorator to cache the result of functions that take a ``user`` and a Decorator to cache the result of functions that take a ``user``, a
``size`` value. ``width`` and a ``height`` value.
""" """
if not settings.AVATAR_CACHE_ENABLED: if not settings.AVATAR_CACHE_ENABLED:
@ -54,13 +59,13 @@ def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
return decorator return decorator
def decorator(func): def decorator(func):
def cached_func(user, size=None, **kwargs): def cached_func(user, width=None, height=None, **kwargs):
prefix = func.__name__ prefix = func.__name__
cached_funcs.add(prefix) cached_funcs.add(prefix)
key = get_cache_key(user, size or default_size, prefix=prefix) key = get_cache_key(user, width or default_size, height, prefix=prefix)
result = cache.get(key) result = cache.get(key)
if result is None: if result is None:
result = func(user, size or default_size, **kwargs) result = func(user, width or default_size, height, **kwargs)
cache_set(key, result) cache_set(key, result)
return result return result
@ -69,16 +74,20 @@ def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
return decorator return decorator
def invalidate_cache(user, size=None): def invalidate_cache(user, width=None, height=None):
""" """
Function to be called when saving or changing an user's avatars. Function to be called when saving or changing a user's avatars.
""" """
sizes = set(settings.AVATAR_AUTO_GENERATE_SIZES) sizes = set(settings.AVATAR_AUTO_GENERATE_SIZES)
if size is not None: if width is not None:
sizes.add(size) sizes.add((width, height or width))
for prefix in cached_funcs: for prefix in cached_funcs:
for size in sizes: for size in sizes:
cache.delete(get_cache_key(user, size, prefix)) if isinstance(size, int):
cache.delete(get_cache_key(user, size, size, prefix))
else:
# Size is specified with height and width.
cache.delete(get_cache_key(user, size[0], size[1], prefix))
def get_default_avatar_url(): def get_default_avatar_url():
@ -101,7 +110,7 @@ def get_default_avatar_url():
return "%s%s" % (base_url, settings.AVATAR_DEFAULT_URL) return "%s%s" % (base_url, settings.AVATAR_DEFAULT_URL)
def get_primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE): def get_primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
User = get_user_model() User = get_user_model()
if not isinstance(user, User): if not isinstance(user, User):
try: try:
@ -117,6 +126,6 @@ def get_primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE):
except IndexError: except IndexError:
avatar = None avatar = None
if avatar: if avatar:
if not avatar.thumbnail_exists(size): if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(size) avatar.create_thumbnail(width, height)
return avatar return avatar

View file

@ -173,16 +173,27 @@ def delete(request, extra_context=None, next_override=None, *args, **kwargs):
return render(request, template_name, context) return render(request, template_name, context)
def render_primary(request, user=None, size=settings.AVATAR_DEFAULT_SIZE): def render_primary(request, user=None, width=settings.AVATAR_DEFAULT_SIZE, height=None):
size = int(size) if height is None:
avatar = get_primary_avatar(user, size=size) height = width
width = int(width)
height = int(height)
avatar = get_primary_avatar(user, width=width, height=height)
if width == 0 and height == 0:
avatar = get_primary_avatar(
user,
width=settings.AVATAR_DEFAULT_SIZE,
height=settings.AVATAR_DEFAULT_SIZE,
)
else:
avatar = get_primary_avatar(user, width=width, height=height)
if avatar: if avatar:
# FIXME: later, add an option to render the resized avatar dynamically # FIXME: later, add an option to render the resized avatar dynamically
# instead of redirecting to an already created static file. This could # instead of redirecting to an already created static file. This could
# be useful in certain situations, particulary if there is a CDN and # be useful in certain situations, particulary if there is a CDN and
# we want to minimize the storage usage on our static server, letting # we want to minimize the storage usage on our static server, letting
# the CDN store those files instead # the CDN store those files instead
url = avatar.avatar_url(size) url = avatar.avatar_url(width, height)
else: else:
url = get_default_avatar_url() url = get_default_avatar_url()

View file

@ -69,6 +69,10 @@ that are required. A minimal integration can work like this:
{% avatar user 65 %} {% avatar user 65 %}
Or specify a width and height (in pixels) explicitly::
{% avatar user 65 50 %}
Example for customize the attribute of the HTML ``img`` tag:: Example for customize the attribute of the HTML ``img`` tag::
{% avatar user 65 class="img-circle img-responsive" id="user_avatar" %} {% avatar user 65 class="img-circle img-responsive" id="user_avatar" %}
@ -106,9 +110,10 @@ appear on the site. Listed below are those settings:
.. py:data:: AVATAR_AUTO_GENERATE_SIZES .. py:data:: AVATAR_AUTO_GENERATE_SIZES
An iterable of integers representing the sizes of avatars to generate on An iterable of integers and/or sequences in the format ``(width, height)``
upload. This can save rendering time later on if you pre-generate the representing the sizes of avatars to generate on upload. This can save
resized versions. Defaults to ``(80,)`` rendering time later on if you pre-generate the resized versions. Defaults
to ``(80,)``.
.. py:data:: AVATAR_CACHE_ENABLED .. py:data:: AVATAR_CACHE_ENABLED
@ -180,7 +185,7 @@ appear on the site. Listed below are those settings:
) )
If you want to implement your own provider, it must provide a class method If you want to implement your own provider, it must provide a class method
``get_avatar_url(user, size)``. ``get_avatar_url(user, width, height)``.
.. py:class:: avatar.providers.PrimaryAvatarProvider .. py:class:: avatar.providers.PrimaryAvatarProvider

View file

@ -57,4 +57,7 @@ STATIC_URL = "/site_media/static/"
AVATAR_ALLOWED_FILE_EXTS = (".jpg", ".png") AVATAR_ALLOWED_FILE_EXTS = (".jpg", ".png")
AVATAR_MAX_SIZE = 1024 * 1024 AVATAR_MAX_SIZE = 1024 * 1024
AVATAR_MAX_AVATARS_PER_USER = 20 AVATAR_MAX_AVATARS_PER_USER = 20
AVATAR_EXPOSE_USERNAMES = True AVATAR_AUTO_GENERATE_SIZES = [51, 62, (33, 22), 80]
MEDIA_ROOT = os.path.join(SETTINGS_DIR, "../test-media")

View file

@ -1,7 +1,10 @@
import math import math
import os.path import os.path
from pathlib import Path
from shutil import rmtree
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.core import management
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.urls import reverse from django.urls import reverse
@ -55,8 +58,14 @@ def root_mean_square_difference(image1, image2):
class AvatarTests(TestCase): class AvatarTests(TestCase):
@classmethod
def setUpClass(cls):
cls.path = os.path.dirname(__file__)
cls.testdatapath = os.path.join(cls.path, "data")
cls.testmediapath = os.path.join(cls.path, "../test-media/")
return super().setUpClass()
def setUp(self): def setUp(self):
self.testdatapath = os.path.join(os.path.dirname(__file__), "data")
self.user = get_user_model().objects.create_user( self.user = get_user_model().objects.create_user(
"test", "lennon@thebeatles.com", "testpassword" "test", "lennon@thebeatles.com", "testpassword"
) )
@ -65,6 +74,16 @@ class AvatarTests(TestCase):
self.site = AdminSite() self.site = AdminSite()
Image.init() Image.init()
def tearDown(self):
if os.path.exists(self.testmediapath):
rmtree(self.testmediapath)
return super().tearDown()
def assertMediaFileExists(self, path):
full_path = os.path.join(self.testmediapath, f".{path}")
if not Path(full_path).resolve().is_file():
raise AssertionError(f"File does not exist: {full_path}")
def test_admin_get_avatar_returns_different_image_tags(self): def test_admin_get_avatar_returns_different_image_tags(self):
self.test_normal_image_upload() self.test_normal_image_upload()
self.test_normal_image_upload() self.test_normal_image_upload()
@ -119,7 +138,7 @@ class AvatarTests(TestCase):
"avatar_render_primary", "avatar_render_primary",
kwargs={ kwargs={
"user": self.user.username, "user": self.user.username,
"size": 80, "width": 80,
}, },
) )
) )
@ -274,6 +293,16 @@ class AvatarTests(TestCase):
result = avatar_tags.avatar(self.user.username) result = avatar_tags.avatar(self.user.username)
self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result)
self.assertIn('width="80" height="80" alt="User Avatar" />', result)
@override_settings(AVATAR_EXPOSE_USERNAMES=True)
def test_avatar_tag_works_with_exposed_username(self):
upload_helper(self, "test.png")
avatar = get_primary_avatar(self.user)
result = avatar_tags.avatar(self.user.username)
self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result) self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result)
self.assertIn('width="80" height="80" alt="test" />', result) self.assertIn('width="80" height="80" alt="test" />', result)
@ -284,7 +313,7 @@ class AvatarTests(TestCase):
result = avatar_tags.avatar(self.user) result = avatar_tags.avatar(self.user)
self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result) self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result)
self.assertIn('width="80" height="80" alt="test" />', result) self.assertIn('width="80" height="80" alt="User Avatar" />', result)
def test_avatar_tag_works_with_custom_size(self): def test_avatar_tag_works_with_custom_size(self):
upload_helper(self, "test.png") upload_helper(self, "test.png")
@ -293,20 +322,80 @@ class AvatarTests(TestCase):
result = avatar_tags.avatar(self.user, 100) result = avatar_tags.avatar(self.user, 100)
self.assertIn('<img src="{}"'.format(avatar.avatar_url(100)), result) self.assertIn('<img src="{}"'.format(avatar.avatar_url(100)), result)
self.assertIn('width="100" height="100" alt="test" />', result) self.assertIn('width="100" height="100" alt="User Avatar" />', result)
def test_avatar_tag_works_with_rectangle(self):
upload_helper(self, "test.png")
avatar = get_primary_avatar(self.user)
result = avatar_tags.avatar(self.user, 100, 150)
self.assertIn('<img src="{}"'.format(avatar.avatar_url(100, 150)), result)
self.assertIn('width="100" height="150" alt="User Avatar" />', result)
def test_avatar_tag_works_with_kwargs(self): def test_avatar_tag_works_with_kwargs(self):
upload_helper(self, "test.png") upload_helper(self, "test.png")
avatar = get_primary_avatar(self.user) avatar = get_primary_avatar(self.user)
result = avatar_tags.avatar(self.user, title="Avatar") result = avatar_tags.avatar(self.user, title="Avatar")
html = ( html = '<img src="{}" width="80" height="80" alt="User Avatar" title="Avatar" />'.format(
'<img src="{}" width="80" height="80" alt="test" title="Avatar" />'.format( avatar.avatar_url(80)
avatar.avatar_url(80)
)
) )
self.assertInHTML(html, result) self.assertInHTML(html, result)
def test_primary_avatar_tag_works(self):
upload_helper(self, "test.png")
result = avatar_tags.primary_avatar(self.user)
self.assertIn(f'<img src="/avatar/render_primary/{self.user.id}/80/"', result)
self.assertIn('width="80" height="80" alt="User Avatar" />', result)
response = self.client.get(f"/avatar/render_primary/{self.user.id}/80/")
self.assertEqual(response.status_code, 302)
self.assertMediaFileExists(response.url)
def test_primary_avatar_tag_works_with_custom_size(self):
upload_helper(self, "test.png")
result = avatar_tags.primary_avatar(self.user, 90)
self.assertIn(f'<img src="/avatar/render_primary/{self.user.id}/90/"', result)
self.assertIn('width="90" height="90" alt="User Avatar" />', result)
response = self.client.get(f"/avatar/render_primary/{self.user.id}/90/")
self.assertEqual(response.status_code, 302)
self.assertMediaFileExists(response.url)
def test_primary_avatar_tag_works_with_rectangle(self):
upload_helper(self, "test.png")
result = avatar_tags.primary_avatar(self.user, 60, 110)
self.assertIn(
f'<img src="/avatar/render_primary/{self.user.id}/60/110/"', result
)
self.assertIn('width="60" height="110" alt="User Avatar" />', result)
response = self.client.get(f"/avatar/render_primary/{self.user.id}/60/110/")
self.assertEqual(response.status_code, 302)
self.assertMediaFileExists(response.url)
@override_settings(AVATAR_EXPOSE_USERNAMES=True)
def test_primary_avatar_tag_works_with_exposed_user(self):
upload_helper(self, "test.png")
result = avatar_tags.primary_avatar(self.user)
self.assertIn(
f'<img src="/avatar/render_primary/{self.user.username}/80/"', result
)
self.assertIn('width="80" height="80" alt="test" />', result)
response = self.client.get(f"/avatar/render_primary/{self.user.username}/80/")
self.assertEqual(response.status_code, 302)
self.assertMediaFileExists(response.url)
def test_default_add_template(self): def test_default_add_template(self):
response = self.client.get("/avatar/add/") response = self.client.get("/avatar/add/")
self.assertContains(response, "Upload New Image") self.assertContains(response, "Upload New Image")
@ -341,6 +430,41 @@ class AvatarTests(TestCase):
self.assertNotContains(response, "like to delete.") self.assertNotContains(response, "like to delete.")
self.assertContains(response, "ALTERNATE DELETE TEMPLATE") self.assertContains(response, "ALTERNATE DELETE TEMPLATE")
def get_media_file_mtime(self, path):
full_path = os.path.join(self.testmediapath, f".{path}")
return os.path.getmtime(full_path)
def test_rebuild_avatars(self):
upload_helper(self, "test.png")
avatar_51_url = get_primary_avatar(self.user).avatar_url(51)
self.assertMediaFileExists(avatar_51_url)
avatar_51_mtime = self.get_media_file_mtime(avatar_51_url)
avatar_62_url = get_primary_avatar(self.user).avatar_url(62)
self.assertMediaFileExists(avatar_62_url)
avatar_62_mtime = self.get_media_file_mtime(avatar_62_url)
avatar_33_22_url = get_primary_avatar(self.user).avatar_url(33, 22)
self.assertMediaFileExists(avatar_33_22_url)
avatar_33_22_mtime = self.get_media_file_mtime(avatar_33_22_url)
avatar_80_url = get_primary_avatar(self.user).avatar_url(80)
self.assertMediaFileExists(avatar_80_url)
avatar_80_mtime = self.get_media_file_mtime(avatar_80_url)
# Rebuild all avatars
management.call_command("rebuild_avatars", verbosity=0)
# Make sure the media files all exist, but that their modification times differ
self.assertMediaFileExists(avatar_51_url)
self.assertNotEqual(avatar_51_mtime, self.get_media_file_mtime(avatar_51_url))
self.assertMediaFileExists(avatar_62_url)
self.assertNotEqual(avatar_62_mtime, self.get_media_file_mtime(avatar_62_url))
self.assertMediaFileExists(avatar_33_22_url)
self.assertNotEqual(
avatar_33_22_mtime, self.get_media_file_mtime(avatar_33_22_url)
)
self.assertMediaFileExists(avatar_80_url)
self.assertNotEqual(avatar_80_mtime, self.get_media_file_mtime(avatar_80_url))
# def testAvatarOrder # def testAvatarOrder
# def testReplaceAvatarWhenMaxIsOne # def testReplaceAvatarWhenMaxIsOne
# def testHashFileName # def testHashFileName