mirror of
https://github.com/jazzband/django-avatar.git
synced 2026-03-16 22:20:30 +00:00
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:
parent
8017d6fc4c
commit
99a979b057
15 changed files with 317 additions and 135 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,3 +13,4 @@ htmlcov/
|
|||
*.sqlite3
|
||||
test_proj/media
|
||||
.python-version
|
||||
/test-media/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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 %}/>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
140
tests/tests.py
140
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('<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
|
||||
|
|
|
|||
Loading…
Reference in a new issue