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( '%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, ) 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 """%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 """%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 """%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() 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('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('test', result) @@ -284,7 +313,7 @@ class AvatarTests(TestCase): result = avatar_tags.avatar(self.user) self.assertIn('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('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('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 = ( - 'test'.format( - avatar.avatar_url(80) - ) + html = 'User 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'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'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'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'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