diff --git a/.gitignore b/.gitignore
index 0aba264..1f4e031 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ htmlcov/
*.sqlite3
test_proj/media
.python-version
+/test-media/
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0939b4a..22902f0 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -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)
diff --git a/avatar/admin.py b/avatar/admin.py
index a877086..e20abca 100644
--- a/avatar/admin.py
+++ b/avatar/admin.py
@@ -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)
diff --git a/avatar/forms.py b/avatar/forms.py
index caf2a4c..0cceb42 100644
--- a/avatar/forms.py
+++ b/avatar/forms.py
@@ -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(
'
'
- % (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,
)
diff --git a/avatar/management/commands/rebuild_avatars.py b/avatar/management/commands/rebuild_avatars.py
index 2dda07c..27251f1 100644
--- a/avatar/management/commands/rebuild_avatars.py
+++ b/avatar/management/commands/rebuild_avatars.py
@@ -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])
diff --git a/avatar/models.py b/avatar/models.py
index d3a793f..4d7ae60 100644
--- a/avatar/models.py
+++ b/avatar/models.py
@@ -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)
diff --git a/avatar/providers.py b/avatar/providers.py
index 0d5aae8..7461100 100644
--- a/avatar/providers.py
+++ b/avatar/providers.py
@@ -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%",
diff --git a/avatar/templates/avatar/avatar_tag.html b/avatar/templates/avatar/avatar_tag.html
index 623b5df..5680153 100644
--- a/avatar/templates/avatar/avatar_tag.html
+++ b/avatar/templates/avatar/avatar_tag.html
@@ -1 +1 @@
-
+
diff --git a/avatar/templatetags/avatar_tags.py b/avatar/templatetags/avatar_tags.py
index 3dddc92..c50556d 100644
--- a/avatar/templatetags/avatar_tags.py
+++ b/avatar/templatetags/avatar_tags.py
@@ -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 """
""" % (
+ 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 """
""" % (
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 """
""" % (
- 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()
diff --git a/avatar/urls.py b/avatar/urls.py
index a286158..9a043f8 100644
--- a/avatar/urls.py
+++ b/avatar/urls.py
@@ -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[\w\d\@\.\-_]+)/(?P[\d]+)/$",
+ r"^render_primary/(?P[\w\d\@\.\-_]+)/(?P[\d]+)/$",
+ views.render_primary,
+ name="avatar_render_primary",
+ ),
+ re_path(
+ r"^render_primary/(?P[\w\d\@\.\-_]+)/(?P[\d]+)/(?P[\d]+)/$",
views.render_primary,
name="avatar_render_primary",
),
diff --git a/avatar/utils.py b/avatar/utils.py
index e3c1daf..2522081 100644
--- a/avatar/utils.py
+++ b/avatar/utils.py
@@ -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
diff --git a/avatar/views.py b/avatar/views.py
index 1b1d213..4d38056 100644
--- a/avatar/views.py
+++ b/avatar/views.py
@@ -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()
diff --git a/docs/index.txt b/docs/index.txt
index 099b24e..cf3628b 100644
--- a/docs/index.txt
+++ b/docs/index.txt
@@ -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
diff --git a/tests/settings.py b/tests/settings.py
index 458c42d..f17ab14 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -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")
diff --git a/tests/tests.py b/tests/tests.py
index c1ca0ca..a99fb75 100644
--- a/tests/tests.py
+++ b/tests/tests.py
@@ -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('
', 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('
', result)
@@ -284,7 +313,7 @@ class AvatarTests(TestCase):
result = avatar_tags.avatar(self.user)
self.assertIn('
', 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('
', 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('
', 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 = (
- '
'.format(
- avatar.avatar_url(80)
- )
+ html = '
'.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'
', 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'
', 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'
', 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'
', 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