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
test_proj/media
.python-version
/test-media/

View file

@ -2,6 +2,7 @@ Changelog
=========
* 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).
* 6.0.1 (August 12, 2022)

View file

@ -30,7 +30,7 @@ class AvatarAdmin(admin.ModelAdmin):
get_avatar.allow_tags = True
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)

View file

@ -10,12 +10,12 @@ from avatar.conf import settings
from avatar.models import Avatar
def avatar_img(avatar, size):
if not avatar.thumbnail_exists(size):
avatar.create_thumbnail(size)
def avatar_img(avatar, width, height):
if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(width, height)
return mark_safe(
'<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):
self.user = kwargs.pop("user")
super(UploadAvatarForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
def clean_avatar(self):
data = self.cleaned_data["avatar"]
@ -73,22 +73,25 @@ class UploadAvatarForm(forms.Form):
class PrimaryAvatarForm(forms.Form):
def __init__(self, *args, **kwargs):
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")
super(PrimaryAvatarForm, self).__init__(*args, **kwargs)
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
super().__init__(*args, **kwargs)
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):
def __init__(self, *args, **kwargs):
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")
super(DeleteAvatarForm, self).__init__(*args, **kwargs)
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
super().__init__(*args, **kwargs)
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 avatar.conf import settings
from avatar.models import Avatar
from avatar.models import Avatar, remove_avatar_images
class Command(BaseCommand):
@ -12,10 +12,15 @@ class Command(BaseCommand):
def handle(self, *args, **options):
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:
if options["verbosity"] != 0:
self.stdout.write(
"Rebuilding Avatar id=%s at size %s." % (avatar.id, size)
)
avatar.create_thumbnail(size)
if isinstance(size, int):
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)()
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]
if settings.AVATAR_HASH_USERDIRNAMES:
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
# it won't conflict with another filename
(root, oldext) = os.path.splitext(filename)
filename = root + "." + ext
filename = root + "." + ext.lower()
else:
# File doesn't exist yet
if settings.AVATAR_HASH_FILENAMES:
@ -48,8 +50,8 @@ def avatar_path_handler(instance=None, filename=None, size=None, ext=None):
else:
filename = hashlib.md5(force_bytes(filename)).hexdigest()
filename = filename + ext
if size:
tmppath.extend(["resized", str(size)])
if width or height:
tmppath.extend(["resized", str(width), str(height)])
tmppath.append(os.path.basename(filename))
return os.path.join(*tmppath)
@ -116,8 +118,8 @@ class Avatar(models.Model):
avatars.delete()
super(Avatar, self).save(*args, **kwargs)
def thumbnail_exists(self, size):
return self.avatar.storage.exists(self.avatar_name(size))
def thumbnail_exists(self, width, height=None):
return self.avatar.storage.exists(self.avatar_name(width, height))
def transpose_image(self, image):
"""
@ -144,9 +146,11 @@ class Avatar(models.Model):
image = image.transpose(getattr(Image, method))
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_cache(self.user, size)
invalidate_cache(self.user, width, height)
try:
orig = self.avatar.storage.open(self.avatar.name, "rb")
except IOError:
@ -156,37 +160,45 @@ class Avatar(models.Model):
image = self.transpose_image(image)
quality = quality or settings.AVATAR_THUMB_QUALITY
w, h = image.size
if w != size or h != size:
if w > h:
diff = int((w - h) / 2)
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))
else:
diff = int((h - w) / 2)
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((size, size), settings.AVATAR_RESIZE_METHOD)
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 = self.avatar.storage.save(self.avatar_name(size), thumb_file)
thumb = self.avatar.storage.save(
self.avatar_name(width, height), thumb_file
)
except IOError:
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):
return self.avatar.storage.url(self.avatar_name(size))
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, 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, size=size, ext=ext)
return avatar_file_path(instance=self, width=width, height=height, ext=ext)
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)
if created:
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
path, filename = os.path.split(base_filepath)
# iterate through resized avatars directories and delete resized avatars
resized_sizes, _ = instance.avatar.storage.listdir(os.path.join(path, "resized"))
for size in resized_sizes:
if instance.thumbnail_exists(size):
instance.avatar.storage.delete(instance.avatar_name(size))
if instance.avatar.storage.exists(instance.avatar.name):
instance.avatar.storage.delete(instance.avatar.name)
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)

View file

@ -24,7 +24,7 @@ class DefaultAvatarProvider(object):
"""
@classmethod
def get_avatar_url(cls, user, size):
def get_avatar_url(cls, user, width, height):
return get_default_avatar_url()
@ -34,10 +34,10 @@ class PrimaryAvatarProvider(object):
"""
@classmethod
def get_avatar_url(cls, user, size):
avatar = get_primary_avatar(user, size)
def get_avatar_url(cls, user, width, height):
avatar = get_primary_avatar(user, width, height)
if avatar:
return avatar.avatar_url(size)
return avatar.avatar_url(width, height)
class GravatarAvatarProvider(object):
@ -46,8 +46,8 @@ class GravatarAvatarProvider(object):
"""
@classmethod
def get_avatar_url(cls, user, size):
params = {"s": str(size)}
def get_avatar_url(cls, user, width, _height):
params = {"s": str(width)}
if settings.AVATAR_GRAVATAR_DEFAULT:
params["d"] = settings.AVATAR_GRAVATAR_DEFAULT
if settings.AVATAR_GRAVATAR_FORCEDEFAULT:
@ -68,11 +68,11 @@ class FacebookAvatarProvider(object):
"""
@classmethod
def get_avatar_url(cls, user, size):
def get_avatar_url(cls, user, width, height):
fb_id = get_facebook_id(user)
if fb_id:
url = "https://graph.facebook.com/{fb_id}/picture?type=square&width={size}&height={size}"
return url.format(fb_id=fb_id, size=size)
url = "https://graph.facebook.com/{fb_id}/picture?type=square&width={width}&height={height}"
return url.format(fb_id=fb_id, width=width, height=height)
class InitialsAvatarProvider(object):
@ -82,13 +82,13 @@ class InitialsAvatarProvider(object):
"""
@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]
if not initials:
initials = user.username[:1]
initials = initials.upper()
context = {
"fontsize": (size * 1.1) / 2,
"fontsize": (width * 1.1) / 2,
"initials": initials,
"hue": user.pk % 360,
"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()
@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:
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:
return avatar_url
@cache_result()
@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()):
try:
user = get_user(user)
@ -31,7 +35,7 @@ def avatar(user, size=settings.AVATAR_DEFAULT_SIZE, **kwargs):
alt = str(user)
else:
alt = _("User Avatar")
url = avatar_url(user, size)
url = avatar_url(user, width, height)
except get_user_model().DoesNotExist:
url = get_default_avatar_url()
alt = _("Default Avatar")
@ -40,13 +44,14 @@ def avatar(user, size=settings.AVATAR_DEFAULT_SIZE, **kwargs):
alt = str(user)
else:
alt = _("User Avatar")
url = avatar_url(user, size)
url = avatar_url(user, width, height)
kwargs.update({"alt": alt})
context = {
"user": user,
"alt": alt,
"size": size,
"width": width,
"height": height,
"kwargs": kwargs,
}
template_name = "avatar/avatar_tag.html"
@ -69,55 +74,44 @@ def has_avatar(user):
@cache_result()
@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
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,
we will avoid many db calls.
"""
alt = str(user)
url = reverse("avatar_render_primary", kwargs={"user": user, "size": size})
return """<img src="%s" alt="%s" width="%s" height="%s" />""" % (
kwargs = {"width": width}
if settings.AVATAR_EXPOSE_USERNAMES:
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,
width,
height,
alt,
size,
size,
)
@cache_result()
@register.simple_tag
def render_avatar(avatar, size=settings.AVATAR_DEFAULT_SIZE):
if not avatar.thumbnail_exists(size):
avatar.create_thumbnail(size)
def render_avatar(avatar, width=settings.AVATAR_DEFAULT_SIZE, height=None):
if height is None:
height = width
if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(width, height)
return """<img src="%s" alt="%s" width="%s" height="%s" />""" % (
avatar.avatar_url(size),
avatar.avatar_url(width, height),
str(avatar),
size,
size,
width,
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"^delete/$", views.delete, name="avatar_delete"),
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,
name="avatar_render_primary",
),

View file

@ -18,18 +18,23 @@ def get_username(user):
return user.username
def get_user(username):
"""Return user from a username/ish identifier"""
return get_user_model().objects.get_by_natural_key(username)
def get_user(userdescriptor):
"""Return user from a username/ID/ish identifier"""
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.
"""
if isinstance(user_or_username, get_user_model()):
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" % (
slugify(key)[:100],
hashlib.md5(force_bytes(key)).hexdigest(),
@ -43,8 +48,8 @@ def cache_set(key, value):
def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
"""
Decorator to cache the result of functions that take a ``user`` and a
``size`` value.
Decorator to cache the result of functions that take a ``user``, a
``width`` and a ``height`` value.
"""
if not settings.AVATAR_CACHE_ENABLED:
@ -54,13 +59,13 @@ def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
return decorator
def decorator(func):
def cached_func(user, size=None, **kwargs):
def cached_func(user, width=None, height=None, **kwargs):
prefix = func.__name__
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)
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)
return result
@ -69,16 +74,20 @@ def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
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)
if size is not None:
sizes.add(size)
if width is not None:
sizes.add((width, height or width))
for prefix in cached_funcs:
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():
@ -101,7 +110,7 @@ def get_default_avatar_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()
if not isinstance(user, User):
try:
@ -117,6 +126,6 @@ def get_primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE):
except IndexError:
avatar = None
if avatar:
if not avatar.thumbnail_exists(size):
avatar.create_thumbnail(size)
if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(width, height)
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)
def render_primary(request, user=None, size=settings.AVATAR_DEFAULT_SIZE):
size = int(size)
avatar = get_primary_avatar(user, size=size)
def render_primary(request, user=None, width=settings.AVATAR_DEFAULT_SIZE, height=None):
if height is None:
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:
# FIXME: later, add an option to render the resized avatar dynamically
# instead of redirecting to an already created static file. This could
# be useful in certain situations, particulary if there is a CDN and
# we want to minimize the storage usage on our static server, letting
# the CDN store those files instead
url = avatar.avatar_url(size)
url = avatar.avatar_url(width, height)
else:
url = get_default_avatar_url()

View file

@ -69,6 +69,10 @@ that are required. A minimal integration can work like this:
{% 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::
{% 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
An iterable of integers representing the sizes of avatars to generate on
upload. This can save rendering time later on if you pre-generate the
resized versions. Defaults to ``(80,)``
An iterable of integers and/or sequences in the format ``(width, height)``
representing the sizes of avatars to generate on upload. This can save
rendering time later on if you pre-generate the resized versions. Defaults
to ``(80,)``.
.. 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
``get_avatar_url(user, size)``.
``get_avatar_url(user, width, height)``.
.. py:class:: avatar.providers.PrimaryAvatarProvider

View file

@ -57,4 +57,7 @@ STATIC_URL = "/site_media/static/"
AVATAR_ALLOWED_FILE_EXTS = (".jpg", ".png")
AVATAR_MAX_SIZE = 1024 * 1024
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 os.path
from pathlib import Path
from shutil import rmtree
from django.contrib.admin.sites import AdminSite
from django.core import management
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse
@ -55,8 +58,14 @@ def root_mean_square_difference(image1, image2):
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):
self.testdatapath = os.path.join(os.path.dirname(__file__), "data")
self.user = get_user_model().objects.create_user(
"test", "lennon@thebeatles.com", "testpassword"
)
@ -65,6 +74,16 @@ class AvatarTests(TestCase):
self.site = AdminSite()
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):
self.test_normal_image_upload()
self.test_normal_image_upload()
@ -119,7 +138,7 @@ class AvatarTests(TestCase):
"avatar_render_primary",
kwargs={
"user": self.user.username,
"size": 80,
"width": 80,
},
)
)
@ -274,6 +293,16 @@ class AvatarTests(TestCase):
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('width="80" height="80" alt="test" />', result)
@ -284,7 +313,7 @@ class AvatarTests(TestCase):
result = avatar_tags.avatar(self.user)
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):
upload_helper(self, "test.png")
@ -293,20 +322,80 @@ class AvatarTests(TestCase):
result = avatar_tags.avatar(self.user, 100)
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):
upload_helper(self, "test.png")
avatar = get_primary_avatar(self.user)
result = avatar_tags.avatar(self.user, title="Avatar")
html = (
'<img src="{}" width="80" height="80" alt="test" title="Avatar" />'.format(
avatar.avatar_url(80)
)
html = '<img src="{}" width="80" height="80" alt="User Avatar" title="Avatar" />'.format(
avatar.avatar_url(80)
)
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):
response = self.client.get("/avatar/add/")
self.assertContains(response, "Upload New Image")
@ -341,6 +430,41 @@ class AvatarTests(TestCase):
self.assertNotContains(response, "like to delete.")
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 testReplaceAvatarWhenMaxIsOne
# def testHashFileName