Compare commits

..

No commits in common. "main" and "6.0.0" have entirely different histories.
main ... 6.0.0

53 changed files with 319 additions and 1397 deletions

View file

@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v2
with:
python-version: 3.11
python-version: 3.8
- name: Install dependencies
run: |
@ -31,7 +31,7 @@ jobs:
twine check dist/*
- name: Upload packages to Jazzband
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@master
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}

View file

@ -5,34 +5,41 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
django-version: ['4.2', '5.2', '6.0.*']
exclude:
- python-version: 3.13
django-version: 4.2
- python-version: 3.14
django-version: 4.2
- python-version: 3.10
django-version: 6.0.*
- python-version: 3.11
django-version: 6.0.*
fail-fast: false
include:
- python-version: 3.6
django-version: 3.2
- python-version: 3.7
django-version: 3.2
- python-version: 3.8
django-version: 3.2
- python-version: 3.9
django-version: 3.2
- python-version: '3.10'
django-version: 3.2
- python-version: 3.8
django-version: 4.0
- python-version: 3.9
django-version: 4.0
- python-version: '3.10'
django-version: 4.0
- python-version: 3.8
django-version: 4.1
- python-version: 3.9
django-version: 4.1
- python-version: '3.10'
django-version: 4.1
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v3
- name: 'Set up Python ${{ matrix.python-version }}'
uses: actions/setup-python@v6
uses: actions/setup-python@v3
with:
python-version: '${{ matrix.python-version }}'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -e .
pip install -r tests/requirements.txt
pip install "Django==${{ matrix.django-version }}" .
pip install "Django~=${{ matrix.django-version }}.0" .
- name: Run Tests
run: |
echo "$(python --version) / Django $(django-admin --version)"
@ -42,4 +49,4 @@ jobs:
coverage report
coverage xml
- name: Upload coverage reports to Codecov with GitHub Action
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v3

4
.gitignore vendored
View file

@ -1,5 +1,4 @@
*.pyc
__pycache__
build/
src/
pip-log.txt
@ -14,6 +13,3 @@ htmlcov/
*.sqlite3
test_proj/media
.python-version
/test-media/
.envrc
.direnv/

View file

@ -1,24 +1,24 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v4.3.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/pycqa/isort
rev: "7.0.0"
rev: "5.10.1"
hooks:
- id: isort
args: ["--profile", "black"]
- repo: https://github.com/psf/black
rev: 25.12.0
rev: 22.6.0
hooks:
- id: black
args: [--target-version=py310]
- repo: https://github.com/pycqa/flake8
rev: '7.3.0'
rev: '5.0.4'
hooks:
- id: flake8
additional_dependencies:

View file

@ -1,35 +0,0 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the OS, Python version and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.12"
# You can also specify other tool versions:
# nodejs: "20"
# rust: "1.70"
# golang: "1.20"
# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/conf.py
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
# builder: "dirhtml"
# Fail on all warnings to avoid broken references
# fail_on_warning: true
# Optionally build your docs in additional formats such as PDF and ePub
# formats:
# - pdf
# - epub
# Optional but recommended, declare the Python requirements required
# to build your documentation
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
# python:
# install:
# - requirements: docs/requirements.txt

View file

@ -1,45 +1,5 @@
Changelog
=========
* 9.0.0
* Fix files not closed in `create_thumbnail`
* Add Django 5.2 and 6.0 support
* Add Python 3.13, 3.14 support
* Drop Python 3.8, 3.9 support
* 8.0.1
* Fix Django 5.1 compatibility
* 8.0.0 (October 16, 2023)
* Add Django 4.2 support
* Remove Python 3.7 support
* Use path and path converters (changes all url names from prefix `avatar_` to `avatar:`.)
* Add support for Django STORAGES (Django 4.2)
* Add optional api app (requires djangorestframework)
* Use ``Image.Resampling.LANCZOS`` instead of ``Image.LANCZOS`` that was removed in Pillow 10.0.0
* 7.1.1 (February 23, 2023)
* Switch to setuptools for building
* 7.1.0 (February 23, 2023)
* Add LibRavatar support
* Faster admin when many users are present
* Check for corrupted image during upload
* Switch Pillow Resize method from ``Image.ANTIALIAS`` to ``Image.LANCZOS``
* Removed Python 3.6 testing
* Added Python 3.11 support
* 7.0.1 (October 27, 2022)
* Remove height requirement for providers (broke 6 to 7 upgrades)
* 7.0.0 (August 16, 2022)
* 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).
* Fix invalidate_cache for on-the-fly created thumbnails.
* New setting ``AVATAR_ALLOWED_MIMETYPES``. If enabled, it checks mimetypes of uploaded files using ``python-magic``. Default is ``None``.
* Fix thumbnail transposing for Safari.
* 6.0.1 (August 12, 2022)
* Exclude tests folder from distribution.
* 6.0.0 (August 12, 2022)
* Added Django 3.2, 4.0 and 4.1 support.

View file

@ -3,6 +3,6 @@ include LICENSE.txt
include CONTRIBUTORS.txt
include avatar/media/avatar/img/default.jpg
recursive-include docs *
recursive-include tests *
recursive-include avatar/templates *.html *.txt
recursive-include avatar/locale/*/LC_MESSAGES *.mo *.po
recursive-exclude tests *

View file

@ -25,7 +25,7 @@ django-avatar
:alt: PyPI badge
.. image:: https://readthedocs.org/projects/django-avatar/badge/?version=latest
:target: https://django-avatar.readthedocs.org/en/latest/?badge=latest
:target: http://django-avatar.readthedocs.org/en/latest/?badge=latest
:alt: Documentation Status
Django-avatar is a reusable application for handling user avatars. It has the
@ -33,4 +33,4 @@ ability to default to Gravatar if no avatar is found for a certain user.
Django-avatar automatically generates thumbnails and stores them to your default
file storage backend for retrieval later.
For more information see the documentation at https://django-avatar.readthedocs.org/
For more information see the documentation at http://django-avatar.readthedocs.org/

View file

@ -1 +1 @@
__version__ = "9.0.0"
__version__ = "6.0.0"

View file

@ -10,26 +10,27 @@ from avatar.utils import get_user_model
class AvatarAdmin(admin.ModelAdmin):
list_display = ("get_avatar", "user", "primary", "date_uploaded")
list_filter = ("primary",)
autocomplete_fields = ("user",)
search_fields = (
"user__%s" % getattr(get_user_model(), "USERNAME_FIELD", "username"),
)
list_per_page = 50
def get_avatar(self, avatar_in):
context = {
"user": avatar_in.user,
"url": avatar_in.avatar.url,
"alt": str(avatar_in.user),
"size": 80,
}
context = dict(
{
"user": avatar_in.user,
"url": avatar_in.avatar.url,
"alt": str(avatar_in.user),
"size": 80,
}
)
return render_to_string("avatar/avatar_tag.html", context)
get_avatar.short_description = _("Avatar")
get_avatar.allow_tags = True
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
super(AvatarAdmin, self).save_model(request, obj, form, change)
avatar_updated.send(sender=Avatar, user=request.user, avatar=obj)

View file

View file

@ -1,22 +0,0 @@
from django.apps import AppConfig
from django.db.models import signals
from avatar.models import Avatar
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "avatar.api"
def ready(self):
from .conf import settings as api_settings
from .signals import (
create_default_thumbnails,
remove_previous_avatar_images_when_update,
)
if api_settings.API_AVATAR_CHANGE_IMAGE:
signals.pre_save.connect(
remove_previous_avatar_images_when_update, sender=Avatar
)
signals.post_save.connect(create_default_thumbnails, sender=Avatar)

View file

@ -1,6 +0,0 @@
from appconf import AppConf
class AvatarAPIConf(AppConf):
# allow updating avatar image in put method
AVATAR_CHANGE_IMAGE = False

View file

@ -1 +0,0 @@
djangorestframework

View file

@ -1,126 +0,0 @@
import os
from django.template.defaultfilters import filesizeformat
from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageOps
from rest_framework import serializers
from avatar.conf import settings
from avatar.conf import settings as api_setting
from avatar.models import Avatar
class AvatarSerializer(serializers.ModelSerializer):
avatar_url = serializers.HyperlinkedIdentityField(
view_name="avatar-detail",
)
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
class Meta:
model = Avatar
fields = ["id", "avatar_url", "avatar", "primary", "user"]
extra_kwargs = {"avatar": {"required": True}}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request = kwargs.get("context").get("request", None)
self.user = request.user
def get_fields(self, *args, **kwargs):
fields = super(AvatarSerializer, self).get_fields(*args, **kwargs)
request = self.context.get("request", None)
# remove avatar url field in detail page
if bool(self.context.get("view").kwargs):
fields.pop("avatar_url")
# remove avatar field in put method
if request and getattr(request, "method", None) == "PUT":
# avatar updates only when primary=true and API_AVATAR_CHANGE_IMAGE = True
if (
not api_setting.API_AVATAR_CHANGE_IMAGE
or self.instance
and not self.instance.primary
):
fields.pop("avatar")
else:
fields.get("avatar", None).required = False
return fields
def validate_avatar(self, value):
data = value
if settings.AVATAR_ALLOWED_MIMETYPES:
try:
import magic
except ImportError:
raise ImportError(
"python-magic library must be installed in order to use uploaded file content limitation"
)
# Construct 256 bytes needed for mime validation
magic_buffer = bytes()
for chunk in data.chunks():
magic_buffer += chunk
if len(magic_buffer) >= 256:
break
# https://github.com/ahupp/python-magic#usage
mime = magic.from_buffer(magic_buffer, mime=True)
if mime not in settings.AVATAR_ALLOWED_MIMETYPES:
raise serializers.ValidationError(
_(
"File content is invalid. Detected: %(mimetype)s Allowed content types are: %(valid_mime_list)s"
)
% {
"valid_mime_list": ", ".join(settings.AVATAR_ALLOWED_MIMETYPES),
"mimetype": mime,
}
)
if settings.AVATAR_ALLOWED_FILE_EXTS:
root, ext = os.path.splitext(data.name.lower())
if ext not in settings.AVATAR_ALLOWED_FILE_EXTS:
valid_exts = ", ".join(settings.AVATAR_ALLOWED_FILE_EXTS)
error = _(
"%(ext)s is an invalid file extension. "
"Authorized extensions are : %(valid_exts_list)s"
)
raise serializers.ValidationError(
error % {"ext": ext, "valid_exts_list": valid_exts}
)
if data.size > settings.AVATAR_MAX_SIZE:
error = _(
"Your file is too big (%(size)s), "
"the maximum allowed size is %(max_valid_size)s"
)
raise serializers.ValidationError(
error
% {
"size": filesizeformat(data.size),
"max_valid_size": filesizeformat(settings.AVATAR_MAX_SIZE),
}
)
try:
image = Image.open(data)
ImageOps.exif_transpose(image)
except TypeError:
raise serializers.ValidationError(_("Corrupted image"))
count = Avatar.objects.filter(user=self.user).count()
if 1 < settings.AVATAR_MAX_AVATARS_PER_USER <= count:
error = _(
"You already have %(nb_avatars)d avatars, "
"and the maximum allowed is %(nb_max_avatars)d."
)
raise serializers.ValidationError(
error
% {
"nb_avatars": count,
"nb_max_avatars": settings.AVATAR_MAX_AVATARS_PER_USER,
}
)
return data

View file

@ -1,27 +0,0 @@
from django.shortcuts import _get_queryset
def get_object_or_none(klass, *args, **kwargs):
"""
Use get() to return an object, or return None if the object
does not exist.
klass may be a Model, Manager, or QuerySet object. All other passed
arguments and keyword arguments are used in the get() query.
Like with QuerySet.get(), MultipleObjectsReturned is raised if more than
one object is found.
"""
queryset = _get_queryset(klass)
if not hasattr(queryset, "get"):
klass__name = (
klass.__name__ if isinstance(klass, type) else klass.__class__.__name__
)
raise ValueError(
"First argument to get_object_or_404() must be a Model, Manager, "
"or QuerySet, not '%s'." % klass__name
)
try:
return queryset.get(*args, **kwargs)
except queryset.model.DoesNotExist:
return None

View file

@ -1,48 +0,0 @@
import os
from avatar.api.shortcut import get_object_or_none
from avatar.conf import settings
from avatar.models import Avatar, invalidate_avatar_cache
def create_default_thumbnails(sender, instance, created=False, **kwargs):
invalidate_avatar_cache(sender, instance)
if not created:
for size in settings.AVATAR_AUTO_GENERATE_SIZES:
if isinstance(size, int):
if not instance.thumbnail_exists(size, size):
instance.create_thumbnail(size, size)
else:
# Size is specified with height and width.
if not instance.thumbnail_exists(size[0, size[0]]):
instance.create_thumbnail(size[0], size[1])
def remove_previous_avatar_images_when_update(
sender, instance=None, created=False, update_main_avatar=True, **kwargs
):
if not created:
old_instance = get_object_or_none(Avatar, pk=instance.pk)
if old_instance and not old_instance.avatar == instance.avatar:
base_filepath = old_instance.avatar.name
path, filename = os.path.split(base_filepath)
# iterate through resized avatars directories and delete resized avatars
resized_path = os.path.join(path, "resized")
try:
resized_widths, _ = old_instance.avatar.storage.listdir(resized_path)
for width in resized_widths:
resized_width_path = os.path.join(resized_path, width)
resized_heights, _ = old_instance.avatar.storage.listdir(
resized_width_path
)
for height in resized_heights:
if old_instance.thumbnail_exists(width, height):
old_instance.avatar.storage.delete(
old_instance.avatar_name(width, height)
)
if update_main_avatar:
if old_instance.avatar.storage.exists(old_instance.avatar.name):
old_instance.avatar.storage.delete(old_instance.avatar.name)
except FileNotFoundError:
pass

View file

@ -1,8 +0,0 @@
from rest_framework.routers import SimpleRouter
from avatar.api.views import AvatarViewSets
router = SimpleRouter()
router.register("avatar", AvatarViewSets)
urlpatterns = router.urls

View file

@ -1,52 +0,0 @@
from html.parser import HTMLParser
from avatar.conf import settings
class HTMLTagParser(HTMLParser):
"""
URL parser for getting (url ,width ,height) from avatar templatetags
"""
def __init__(self, output=None):
HTMLParser.__init__(self)
if output is None:
self.output = {}
else:
self.output = output
def handle_starttag(self, tag, attrs):
self.output.update(dict(attrs))
def assign_width_or_height(query_params):
"""
Getting width and height in url parameters and specifying them
"""
avatar_default_size = settings.AVATAR_DEFAULT_SIZE
width = query_params.get("width", avatar_default_size)
height = query_params.get("height", avatar_default_size)
if width == "":
width = avatar_default_size
if height == "":
height = avatar_default_size
if height == avatar_default_size and height != "":
height = width
elif width == avatar_default_size and width != "":
width = height
width = int(width)
height = int(height)
context = {"width": width, "height": height}
return context
def set_new_primary(query_set, instance):
queryset = query_set.exclude(id=instance.id).first()
if queryset:
queryset.primary = True
queryset.save()

View file

@ -1,134 +0,0 @@
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from avatar.api.serializers import AvatarSerializer
from avatar.api.utils import HTMLTagParser, assign_width_or_height, set_new_primary
from avatar.models import Avatar
from avatar.templatetags.avatar_tags import avatar
from avatar.utils import get_default_avatar_url, get_primary_avatar, invalidate_cache
class AvatarViewSets(viewsets.ModelViewSet):
serializer_class = AvatarSerializer
permission_classes = [permissions.IsAuthenticated]
queryset = Avatar.objects.select_related("user").order_by(
"-primary", "-date_uploaded"
)
@property
def parse_html_to_json(self):
default_avatar = avatar(self.request.user)
html_parser = HTMLTagParser()
html_parser.feed(default_avatar)
return html_parser.output
def get_queryset(self):
assert self.queryset is not None, (
"'%s' should either include a `queryset` attribute, "
"or override the `get_queryset()` method." % self.__class__.__name__
)
queryset = self.queryset
if isinstance(queryset, QuerySet):
# Ensure queryset is re-evaluated on each request.
queryset = queryset.filter(user=self.request.user)
return queryset
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
if queryset:
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
data = serializer.data
return Response(data)
return Response(
{
"message": "You haven't uploaded an avatar yet. Please upload one now.",
"default_avatar": self.parse_html_to_json,
}
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
message = _("Successfully uploaded a new avatar.")
context_data = {"message": message, "data": serializer.data}
return Response(context_data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.primary is True:
# Find the next avatar, and set it as the new primary
set_new_primary(self.get_queryset(), instance)
self.perform_destroy(instance)
message = _("Successfully deleted the requested avatars.")
return Response(message, status=status.HTTP_204_NO_CONTENT)
def update(self, request, *args, **kwargs):
partial = kwargs.pop("partial", False)
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
avatar_image = serializer.validated_data.get("avatar")
primary_avatar = serializer.validated_data.get("primary")
if not primary_avatar and avatar_image:
raise ValidationError("You cant update an avatar image that is not primary")
if instance.primary is True:
# Find the next avatar, and set it as the new primary
set_new_primary(self.get_queryset(), instance)
self.perform_update(serializer)
invalidate_cache(request.user)
message = _("Successfully updated your avatar.")
if getattr(instance, "_prefetched_objects_cache", None):
# If 'prefetch_related' has been applied to a queryset, we need to
# forcibly invalidate the prefetch cache on the instance.
instance._prefetched_objects_cache = {}
context_data = {"message": message, "data": serializer.data}
return Response(context_data)
@action(
["GET"], detail=False, url_path="render_primary", name="Render Primary Avatar"
)
def render_primary(self, request, *args, **kwargs):
"""
URL Example :
1 - render_primary/
2 - render_primary/?width=400 or render_primary/?height=400
3 - render_primary/?width=500&height=400
"""
context_data = {}
avatar_size = assign_width_or_height(request.query_params)
width = avatar_size.get("width")
height = avatar_size.get("height")
primary_avatar = get_primary_avatar(request.user, width=width, height=height)
if primary_avatar and primary_avatar.primary:
url = primary_avatar.avatar_url(width, height)
else:
url = get_default_avatar_url()
if bool(request.query_params):
context_data.update(
{"message": "Resize parameters not working for default avatar"}
)
context_data.update({"image_url": request.build_absolute_uri(url)})
return Response(context_data)

View file

@ -5,7 +5,7 @@ from PIL import Image
class AvatarConf(AppConf):
DEFAULT_SIZE = 80
RESIZE_METHOD = Image.Resampling.LANCZOS
RESIZE_METHOD = Image.ANTIALIAS
STORAGE_DIR = "avatars"
PATH_HANDLER = "avatar.models.avatar_path_handler"
GRAVATAR_BASE_URL = "https://www.gravatar.com/avatar/"
@ -22,12 +22,9 @@ class AvatarConf(AppConf):
HASH_USERDIRNAMES = False
EXPOSE_USERNAMES = False
ALLOWED_FILE_EXTS = None
ALLOWED_MIMETYPES = None
CACHE_TIMEOUT = 60 * 60
if hasattr(settings, "DEFAULT_FILE_STORAGE"):
STORAGE = settings.DEFAULT_FILE_STORAGE # deprecated settings
STORAGE_ALIAS = "default"
CLEANUP_DELETED = True
STORAGE = settings.DEFAULT_FILE_STORAGE
CLEANUP_DELETED = False
AUTO_GENERATE_SIZES = (DEFAULT_SIZE,)
FACEBOOK_GET_ID = None
CACHE_ENABLED = True
@ -37,7 +34,6 @@ class AvatarConf(AppConf):
DELETE_TEMPLATE = ""
PROVIDERS = (
"avatar.providers.PrimaryAvatarProvider",
"avatar.providers.LibRAvatarProvider",
"avatar.providers.GravatarAvatarProvider",
"avatar.providers.DefaultAvatarProvider",
)

View file

@ -5,18 +5,17 @@ from django.forms import widgets
from django.template.defaultfilters import filesizeformat
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageOps
from avatar.conf import settings
from avatar.models import Avatar
def avatar_img(avatar, width, height):
if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(width, height)
def avatar_img(avatar, size):
if not avatar.thumbnail_exists(size):
avatar.create_thumbnail(size)
return mark_safe(
'<img src="%s" alt="%s" width="%s" height="%s" />'
% (avatar.avatar_url(width, height), str(avatar), width, height)
% (avatar.avatar_url(size), str(avatar), size, size)
)
@ -25,39 +24,11 @@ class UploadAvatarForm(forms.Form):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
super(UploadAvatarForm, self).__init__(*args, **kwargs)
def clean_avatar(self):
data = self.cleaned_data["avatar"]
if settings.AVATAR_ALLOWED_MIMETYPES:
try:
import magic
except ImportError:
raise ImportError(
"python-magic library must be installed in order to use uploaded file content limitation"
)
# Construct 256 bytes needed for mime validation
magic_buffer = bytes()
for chunk in data.chunks():
magic_buffer += chunk
if len(magic_buffer) >= 256:
break
# https://github.com/ahupp/python-magic#usage
mime = magic.from_buffer(magic_buffer, mime=True)
if mime not in settings.AVATAR_ALLOWED_MIMETYPES:
raise forms.ValidationError(
_(
"File content is invalid. Detected: %(mimetype)s Allowed content types are: %(valid_mime_list)s"
)
% {
"valid_mime_list": ", ".join(settings.AVATAR_ALLOWED_MIMETYPES),
"mimetype": mime,
}
)
if settings.AVATAR_ALLOWED_FILE_EXTS:
root, ext = os.path.splitext(data.name.lower())
if ext not in settings.AVATAR_ALLOWED_FILE_EXTS:
@ -83,12 +54,6 @@ class UploadAvatarForm(forms.Form):
}
)
try:
image = Image.open(data)
ImageOps.exif_transpose(image)
except TypeError:
raise forms.ValidationError(_("Corrupted image"))
count = Avatar.objects.filter(user=self.user).count()
if 1 < settings.AVATAR_MAX_AVATARS_PER_USER <= count:
error = _(
@ -108,25 +73,22 @@ class UploadAvatarForm(forms.Form):
class PrimaryAvatarForm(forms.Form):
def __init__(self, *args, **kwargs):
kwargs.pop("user")
width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
size = kwargs.pop("size", settings.AVATAR_DEFAULT_SIZE)
avatars = kwargs.pop("avatars")
super().__init__(*args, **kwargs)
super(PrimaryAvatarForm, self).__init__(*args, **kwargs)
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
self.fields["choice"] = forms.ChoiceField(
choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
widget=widgets.RadioSelect,
label=_("Choices"), choices=choices, widget=widgets.RadioSelect
)
class DeleteAvatarForm(forms.Form):
def __init__(self, *args, **kwargs):
kwargs.pop("user")
width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
size = kwargs.pop("size", settings.AVATAR_DEFAULT_SIZE)
avatars = kwargs.pop("avatars")
super().__init__(*args, **kwargs)
super(DeleteAvatarForm, self).__init__(*args, **kwargs)
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
self.fields["choices"] = forms.MultipleChoiceField(
label=_("Choices"),
choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
widget=widgets.CheckboxSelectMultiple,
label=_("Choices"), choices=choices, widget=widgets.CheckboxSelectMultiple
)

View file

@ -1,7 +1,7 @@
from django.core.management.base import BaseCommand
from avatar.conf import settings
from avatar.models import Avatar, remove_avatar_images
from avatar.models import Avatar
class Command(BaseCommand):
@ -12,15 +12,10 @@ 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)
)
if isinstance(size, int):
avatar.create_thumbnail(size, size)
else:
# Size is specified with height and width.
avatar.create_thumbnail(size[0], size[1])
avatar.create_thumbnail(size)

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -7,6 +7,7 @@ import avatar.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

View file

@ -8,6 +8,7 @@ import avatar.models
class Migration(migrations.Migration):
dependencies = [
("avatar", "0001_initial"),
]

View file

@ -4,6 +4,7 @@ import avatar.models
class Migration(migrations.Migration):
dependencies = [
("avatar", "0002_add_verbose_names_to_avatar_fields"),
]

View file

@ -1,35 +1,26 @@
import binascii
import hashlib
import os
from contextlib import closing
from io import BytesIO
from django.core.files import File
from django.core.files.base import ContentFile
from django.core.files.storage import get_storage_class
from django.db import models
from django.db.models import signals
from django.utils.encoding import force_bytes, force_str
from django.utils.encoding import force_str
from django.utils.module_loading import import_string
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageOps
from PIL import Image
from avatar.conf import settings
from avatar.utils import get_username, invalidate_cache
from avatar.utils import force_bytes, get_username, invalidate_cache
try: # Django 4.2+
from django.core.files.storage import storages
avatar_storage = storages[settings.AVATAR_STORAGE_ALIAS]
except ImportError:
from django.core.files.storage import get_storage_class
avatar_storage = get_storage_class(settings.AVATAR_STORAGE)()
avatar_storage = get_storage_class(settings.AVATAR_STORAGE)()
def avatar_path_handler(
instance=None, filename=None, width=None, height=None, ext=None
):
def avatar_path_handler(instance=None, filename=None, size=None, ext=None):
tmppath = [settings.AVATAR_STORAGE_DIR]
if settings.AVATAR_HASH_USERDIRNAMES:
tmp = hashlib.md5(force_bytes(get_username(instance.user))).hexdigest()
@ -41,23 +32,24 @@ def avatar_path_handler(
if not filename:
# Filename already stored in database
filename = instance.avatar.name
if ext:
if ext and settings.AVATAR_HASH_FILENAMES:
# An extension was provided, probably because the thumbnail
# is in a different format than the file. Use it. Because it's
# 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.lower()
filename = root + "." + ext
else:
# File doesn't exist yet
(root, oldext) = os.path.splitext(filename)
if settings.AVATAR_HASH_FILENAMES:
(root, ext) = os.path.splitext(filename)
if settings.AVATAR_RANDOMIZE_HASHES:
root = binascii.hexlify(os.urandom(16)).decode("ascii")
filename = binascii.hexlify(os.urandom(16)).decode("ascii")
else:
root = hashlib.md5(force_bytes(root)).hexdigest()
if ext:
filename = root + "." + ext.lower()
else:
filename = root + oldext.lower()
if width or height:
tmppath.extend(["resized", str(width), str(height)])
filename = hashlib.md5(force_bytes(filename)).hexdigest()
filename = filename + ext
if size:
tmppath.extend(["resized", str(size)])
tmppath.append(os.path.basename(filename))
return os.path.join(*tmppath)
@ -76,7 +68,7 @@ def find_extension(format):
class AvatarField(models.ImageField):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(AvatarField, self).__init__(*args, **kwargs)
self.max_length = 1024
self.upload_to = avatar_file_path
@ -84,7 +76,7 @@ class AvatarField(models.ImageField):
self.blank = True
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
name, path, args, kwargs = super(models.ImageField, self).deconstruct()
return name, path, (), {}
@ -122,71 +114,79 @@ class Avatar(models.Model):
avatars.update(primary=False)
else:
avatars.delete()
super().save(*args, **kwargs)
super(Avatar, self).save(*args, **kwargs)
def thumbnail_exists(self, width, height=None):
return self.avatar.storage.exists(self.avatar_name(width, height))
def thumbnail_exists(self, size):
return self.avatar.storage.exists(self.avatar_name(size))
def transpose_image(self, image):
EXIF_ORIENTATION = 0x0112
exif_code = image.getexif().get(EXIF_ORIENTATION, 1)
if exif_code and exif_code != 1:
image = ImageOps.exif_transpose(image)
"""
Transpose based on EXIF information.
Borrowed from django-imagekit:
imagekit.processors.Transpose
"""
EXIF_ORIENTATION_STEPS = {
1: [],
2: ["FLIP_LEFT_RIGHT"],
3: ["ROTATE_180"],
4: ["FLIP_TOP_BOTTOM"],
5: ["ROTATE_270", "FLIP_LEFT_RIGHT"],
6: ["ROTATE_270"],
7: ["ROTATE_90", "FLIP_LEFT_RIGHT"],
8: ["ROTATE_90"],
}
try:
orientation = image._getexif()[0x0112]
ops = EXIF_ORIENTATION_STEPS[orientation]
except TypeError:
ops = []
for method in ops:
image = image.transpose(getattr(Image, method))
return image
def create_thumbnail(self, width, height=None, quality=None):
if height is None:
height = width
def create_thumbnail(self, size, quality=None):
# invalidate the cache of the thumbnail with the given size first
invalidate_cache(self.user, width, height)
invalidate_cache(self.user, size)
try:
orig = self.avatar.storage.open(self.avatar.name, "rb")
except IOError:
return # What should we do here? Render a "sorry, didn't work" img?
with closing(orig):
try:
image = Image.open(orig)
except IOError:
thumb_file = File(orig)
else:
image = self.transpose_image(image)
quality = quality or settings.AVATAR_THUMB_QUALITY
w, h = image.size
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))
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((width, height), settings.AVATAR_RESIZE_METHOD)
thumb = BytesIO()
image.save(thumb, settings.AVATAR_THUMB_FORMAT, quality=quality)
thumb_file = ContentFile(thumb.getvalue())
try:
image = Image.open(orig)
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)
image = image.crop((diff, 0, w - diff, h))
else:
thumb_file = File(orig)
thumb_name = self.avatar_name(width, height)
thumb = self.avatar.storage.save(thumb_name, thumb_file)
invalidate_cache(self.user, width, height)
diff = int((h - w) / 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)
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)
except IOError:
thumb_file = File(orig)
thumb = self.avatar.storage.save(self.avatar_name(size), thumb_file)
def avatar_url(self, width, height=None):
return self.avatar.storage.url(self.avatar_name(width, height))
def avatar_url(self, size):
return self.avatar.storage.url(self.avatar_name(size))
def get_absolute_url(self):
return self.avatar_url(settings.AVATAR_DEFAULT_SIZE)
def avatar_name(self, width, height=None):
if height is None:
height = width
def avatar_name(self, size):
ext = find_extension(settings.AVATAR_THUMB_FORMAT)
return avatar_file_path(instance=self, width=width, height=height, ext=ext)
return avatar_file_path(instance=self, size=size, ext=ext)
def invalidate_avatar_cache(sender, instance, **kwargs):
@ -198,28 +198,16 @@ def create_default_thumbnails(sender, instance, created=False, **kwargs):
invalidate_avatar_cache(sender, instance)
if created:
for size in settings.AVATAR_AUTO_GENERATE_SIZES:
if isinstance(size, int):
instance.create_thumbnail(size, size)
else:
# Size is specified with height and width.
instance.create_thumbnail(size[0], size[1])
instance.create_thumbnail(size)
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_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)
def remove_avatar_images(instance=None, **kwargs):
if hasattr(instance, "user"):
for size in settings.AVATAR_AUTO_GENERATE_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)
signals.post_save.connect(create_default_thumbnails, sender=Avatar)

View file

@ -1,8 +1,6 @@
import hashlib
import re
from urllib.parse import urlencode, urljoin
import dns.resolver
from django.utils.module_loading import import_string
from avatar.conf import settings
@ -26,7 +24,7 @@ class DefaultAvatarProvider(object):
"""
@classmethod
def get_avatar_url(cls, user, width, height=None):
def get_avatar_url(cls, user, size):
return get_default_avatar_url()
@ -36,12 +34,10 @@ class PrimaryAvatarProvider(object):
"""
@classmethod
def get_avatar_url(cls, user, width, height=None):
if not height:
height = width
avatar = get_primary_avatar(user, width, height)
def get_avatar_url(cls, user, size):
avatar = get_primary_avatar(user, size)
if avatar:
return avatar.avatar_url(width, height)
return avatar.avatar_url(size)
class GravatarAvatarProvider(object):
@ -50,8 +46,8 @@ class GravatarAvatarProvider(object):
"""
@classmethod
def get_avatar_url(cls, user, width, _height=None):
params = {"s": str(width)}
def get_avatar_url(cls, user, size):
params = {"s": str(size)}
if settings.AVATAR_GRAVATAR_DEFAULT:
params["d"] = settings.AVATAR_GRAVATAR_DEFAULT
if settings.AVATAR_GRAVATAR_FORCEDEFAULT:
@ -66,53 +62,17 @@ class GravatarAvatarProvider(object):
return urljoin(settings.AVATAR_GRAVATAR_BASE_URL, path)
class LibRAvatarProvider:
"""
Returns the url of an avatar by the LibRavatar service.
"""
@classmethod
def get_avatar_url(cls, user, width, _height=None):
email = getattr(user, settings.AVATAR_GRAVATAR_FIELD).encode("utf-8")
try:
_, domain = email.split(b"@")
answers = dns.resolver.query("_avatars._tcp." + domain, "SRV")
hostname = re.sub(r"\.$", "", str(answers[0].target))
# query returns "example.com." and while http requests are fine with this,
# https most certainly do not consider "example.com." and "example.com" to be the same.
port = str(answers[0].port)
if port == "443":
baseurl = "https://" + hostname + "/avatar/"
else:
baseurl = "http://" + hostname + ":" + port + "/avatar/"
except Exception:
baseurl = "https://seccdn.libravatar.org/avatar/"
params = {"s": str(width)}
if settings.AVATAR_GRAVATAR_DEFAULT:
params["d"] = settings.AVATAR_GRAVATAR_DEFAULT
if settings.AVATAR_GRAVATAR_FORCEDEFAULT:
params["f"] = "y"
path = "%s/?%s" % (
hashlib.md5(force_bytes(email.strip().lower())).hexdigest(),
urlencode(params),
)
return urljoin(baseurl, path)
class FacebookAvatarProvider(object):
"""
Returns the url of a Facebook profile image.
"""
@classmethod
def get_avatar_url(cls, user, width, height=None):
if not height:
height = width
def get_avatar_url(cls, user, size):
fb_id = get_facebook_id(user)
if fb_id:
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)
url = "https://graph.facebook.com/{fb_id}/picture?type=square&width={size}&height={size}"
return url.format(fb_id=fb_id, size=size)
class InitialsAvatarProvider(object):
@ -122,13 +82,13 @@ class InitialsAvatarProvider(object):
"""
@classmethod
def get_avatar_url(cls, user, width, _height=None):
def get_avatar_url(cls, user, size):
initials = user.first_name[:1] + user.last_name[:1]
if not initials:
initials = user.username[:1]
initials = initials.upper()
context = {
"fontsize": (width * 1.1) / 2,
"fontsize": (size * 1.1) / 2,
"initials": initials,
"hue": user.pk % 360,
"saturation": "65%",

View file

@ -7,7 +7,7 @@
{% if not avatars %}
<p>{% trans "You haven't uploaded an avatar yet. Please upload one now." %}</p>
{% endif %}
<form enctype="multipart/form-data" method="POST" action="{% url 'avatar:add' %}">
<form enctype="multipart/form-data" method="POST" action="{% url 'avatar_add' %}">
{{ upload_avatar_form.as_p }}
<p>{% csrf_token %}<input type="submit" value="{% trans "Upload New Image" %}" /></p>
</form>

View file

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

View file

@ -7,14 +7,14 @@
{% if not avatars %}
<p>{% trans "You haven't uploaded an avatar yet. Please upload one now." %}</p>
{% else %}
<form method="POST" action="{% url 'avatar:change' %}">
<form method="POST" action="{% url 'avatar_change' %}">
<ul>
{{ primary_avatar_form.as_ul }}
</ul>
<p>{% csrf_token %}<input type="submit" value="{% trans "Choose new Default" %}" /></p>
</form>
{% endif %}
<form enctype="multipart/form-data" method="POST" action="{% url 'avatar:add' %}">
<form enctype="multipart/form-data" method="POST" action="{% url 'avatar_add' %}">
{{ upload_avatar_form.as_p }}
<p>{% csrf_token %}<input type="submit" value="{% trans "Upload New Image" %}" /></p>
</form>

View file

@ -3,11 +3,11 @@
{% block content %}
{% if not avatars %}
{% url 'avatar:change' as avatar_change_url %}
{% url 'avatar_change' as avatar_change_url %}
<p>{% blocktrans %}You have no avatars to delete. Please <a href="{{ avatar_change_url }}">upload one</a> now.{% endblocktrans %}</p>
{% else %}
<p>{% trans "Please select the avatars that you would like to delete." %}</p>
<form method="POST" action="{% url 'avatar:delete' %}">
<form method="POST" action="{% url 'avatar_delete' %}">
<ul>
{{ delete_avatar_form.as_ul }}
</ul>

View file

@ -13,22 +13,17 @@ register = template.Library()
@cache_result()
@register.simple_tag
def avatar_url(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
if height is None:
height = width
def avatar_url(user, size=settings.AVATAR_DEFAULT_SIZE):
for provider_path in settings.AVATAR_PROVIDERS:
provider = import_string(provider_path)
avatar_url = provider.get_avatar_url(user, width, height)
avatar_url = provider.get_avatar_url(user, size)
if avatar_url:
return avatar_url
return get_default_avatar_url()
@cache_result()
@register.simple_tag
def avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None, **kwargs):
if height is None:
height = width
def avatar(user, size=settings.AVATAR_DEFAULT_SIZE, **kwargs):
if not isinstance(user, get_user_model()):
try:
user = get_user(user)
@ -36,7 +31,7 @@ def avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None, **kwargs):
alt = str(user)
else:
alt = _("User Avatar")
url = avatar_url(user, width, height)
url = avatar_url(user, size)
except get_user_model().DoesNotExist:
url = get_default_avatar_url()
alt = _("Default Avatar")
@ -45,14 +40,13 @@ def avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None, **kwargs):
alt = str(user)
else:
alt = _("User Avatar")
url = avatar_url(user, width, height)
url = avatar_url(user, size)
kwargs.update({"alt": alt})
context = {
"user": user,
"alt": alt,
"width": width,
"height": height,
"size": size,
"kwargs": kwargs,
}
template_name = "avatar/avatar_tag.html"
@ -75,44 +69,55 @@ def has_avatar(user):
@cache_result()
@register.simple_tag
def primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
def primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE):
"""
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.
"""
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" />""" % (
alt = str(user)
url = reverse("avatar_render_primary", kwargs={"user": user, "size": size})
return """<img src="%s" alt="%s" width="%s" height="%s" />""" % (
url,
width,
height,
alt,
size,
size,
)
@cache_result()
@register.simple_tag
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)
def render_avatar(avatar, size=settings.AVATAR_DEFAULT_SIZE):
if not avatar.thumbnail_exists(size):
avatar.create_thumbnail(size)
return """<img src="%s" alt="%s" width="%s" height="%s" />""" % (
avatar.avatar_url(width, height),
avatar.avatar_url(size),
str(avatar),
width,
height,
size,
size,
)
@register.tag
def primary_avatar_object(parser, token):
split = token.split_contents()
if len(split) == 4:
return UsersAvatarObjectNode(split[1], split[3])
raise template.TemplateSyntaxError("%r tag takes three arguments." % split[0])
class UsersAvatarObjectNode(template.Node):
def __init__(self, user, key):
self.user = template.Variable(user)
self.key = key
def render(self, context):
user = self.user.resolve(context)
key = self.key
avatar = Avatar.objects.filter(user=user, primary=True)
if avatar:
context[key] = avatar[0]
else:
context[key] = None
return str()

View file

@ -1,24 +1,14 @@
from django.urls import path
from django.urls import re_path
from avatar import views
# For reversing namespaced urls
# https://docs.djangoproject.com/en/4.1/topics/http/urls/#reversing-namespaced-urls
app_name = "avatar"
urlpatterns = [
path("add/", views.add, name="add"),
path("change/", views.change, name="change"),
path("delete/", views.delete, name="delete"),
# https://docs.djangoproject.com/en/4.1/topics/http/urls/#path-converters
path(
"render_primary/<slug:user>/<int:width>/",
re_path(r"^add/$", views.add, name="avatar_add"),
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]+)/$",
views.render_primary,
name="render_primary",
),
path(
"render_primary/<slug:user>/<int:width>/<int:height>/",
views.render_primary,
name="render_primary",
name="avatar_render_primary",
),
]

View file

@ -18,31 +18,18 @@ def get_username(user):
return user.username
def get_user(userdescriptor):
"""Return user from a username/ID/ish identifier"""
User = get_user_model()
if isinstance(userdescriptor, int):
user = User.objects.filter(id=userdescriptor).first()
if user:
return user
elif userdescriptor.isdigit():
user = User.objects.filter(id=int(userdescriptor)).first()
if user:
return user
return User.objects.get_by_natural_key(userdescriptor)
def get_user(username):
"""Return user from a username/ish identifier"""
return get_user_model().objects.get_by_natural_key(username)
def get_cache_key(user_or_username, prefix, width=None, height=None):
def get_cache_key(user_or_username, size, 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 = f"{prefix}_{user_or_username}"
if width:
key += f"_{width}"
if height or width:
key += f"x{height or width}"
key = "%s_%s_%s" % (prefix, user_or_username, size)
return "%s_%s" % (
slugify(key)[:100],
hashlib.md5(force_bytes(key)).hexdigest(),
@ -56,8 +43,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``, a
``width`` and a ``height`` value.
Decorator to cache the result of functions that take a ``user`` and a
``size`` value.
"""
if not settings.AVATAR_CACHE_ENABLED:
@ -67,19 +54,14 @@ def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
return decorator
def decorator(func):
def cached_func(user, width=None, height=None, **kwargs):
def cached_func(user, size=None, **kwargs):
prefix = func.__name__
cached_funcs.add(prefix)
key = get_cache_key(user, prefix, width or default_size, height)
key = get_cache_key(user, size or default_size, prefix=prefix)
result = cache.get(key)
if result is None:
result = func(user, width or default_size, height, **kwargs)
result = func(user, size or default_size, **kwargs)
cache_set(key, result)
# add image size to set of cached sizes so we can invalidate them later
sizes_key = get_cache_key(user, "cached_sizes")
sizes = cache.get(sizes_key, set())
sizes.add((width or default_size, height or width or default_size))
cache_set(sizes_key, sizes)
return result
return cached_func
@ -87,22 +69,16 @@ def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
return decorator
def invalidate_cache(user, width=None, height=None):
def invalidate_cache(user, size=None):
"""
Function to be called when saving or changing a user's avatars.
Function to be called when saving or changing an user's avatars.
"""
sizes_key = get_cache_key(user, "cached_sizes")
sizes = cache.get(sizes_key, set())
if width is not None:
sizes.add((width, height or width))
sizes = set(settings.AVATAR_AUTO_GENERATE_SIZES)
if size is not None:
sizes.add(size)
for prefix in cached_funcs:
for size in sizes:
if isinstance(size, int):
cache.delete(get_cache_key(user, prefix, size))
else:
# Size is specified with height and width.
cache.delete(get_cache_key(user, prefix, size[0], size[1]))
cache.set(sizes_key, set())
cache.delete(get_cache_key(user, size, prefix))
def get_default_avatar_url():
@ -125,7 +101,7 @@ def get_default_avatar_url():
return "%s%s" % (base_url, settings.AVATAR_DEFAULT_URL)
def get_primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
def get_primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE):
User = get_user_model()
if not isinstance(user, User):
try:
@ -141,6 +117,6 @@ def get_primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
except IndexError:
avatar = None
if avatar:
if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(width, height)
if not avatar.thumbnail_exists(size):
avatar.create_thumbnail(size)
return avatar

View file

@ -173,27 +173,16 @@ def delete(request, extra_context=None, next_override=None, *args, **kwargs):
return render(request, template_name, context)
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)
def render_primary(request, user=None, size=settings.AVATAR_DEFAULT_SIZE):
size = int(size)
avatar = get_primary_avatar(user, size=size)
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(width, height)
url = avatar.avatar_url(size)
else:
url = get_default_avatar_url()

View file

@ -9,7 +9,7 @@ BUILDDIR = _build
# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif
# Internal variables.

View file

@ -1,191 +0,0 @@
API Descriptions
================
Avatar List
^^^^^^^^^^^
send a request for listing user avatars as shown below.
``GET`` ``/api/avatar/``
default response of avatar list : ::
{
"message": "You haven't uploaded an avatar yet. Please upload one now.",
"default_avatar": {
"src": "https://seccdn.libravatar.org/avatar/4a9328d595472d0728195a7c8191a50b",
"width": "80",
"height": "80",
"alt": "User Avatar"
}
}
if you have an avatar object : ::
[
{
"id": "image_id",
"avatar_url": "https://example.com/api/avatar/1/",
"avatar": "https://example.com/media/avatars/1/first_avatar.png",
"primary": true
},
]
-----------------------------------------------
Create Avatar
^^^^^^^^^^^^^
send a request for creating user avatar as shown below .
``POST`` ``/api/avatar/``
Request : ::
{
"avatar": "image file",
"primary": true
}
``Note`` : avatar field is required.
Response : ::
{
"message": "Successfully uploaded a new avatar.",
"data": {
"id": "image_id",
"avatar_url": "https://example.com/api/avatar/1/",
"avatar": "https://example.com/media/avatars/1/example.png",
"primary": true
}
}
-----------------------------------------------
Avatar Detail
^^^^^^^^^^^^^
send a request for retrieving user avatar.
``GET`` ``/api/avatar/image_id/``
Response : ::
{
"id": "image_id",
"avatar": "https://example.com/media/avatars/1/example.png",
"primary": true
}
-----------------------------------------------
Update Avatar
^^^^^^^^^^^^^
send a request for updating user avatar.
``PUT`` ``/api/avatar/image_id/``
Request : ::
{
"avatar":"image file"
"primary": true
}
``Note`` : for update avatar image set ``API_AVATAR_CHANGE_IMAGE = True`` in your settings file and set ``primary = True``.
Response : ::
{
"message": "Successfully updated your avatar.",
"data": {
"id": "image_id",
"avatar": "https://example.com/media/avatars/1/custom_admin_en.png",
"primary": true
}
}
-----------------------------------------------
Delete Avatar
^^^^^^^^^^^^^
send a request for deleting user avatar.
``DELETE`` ``/api/avatar/image_id/``
Response : ::
"Successfully deleted the requested avatars."
-----------------------------------------------
Render Primary Avatar
^^^^^^^^^^^^^^^^^^^^^
send a request for retrieving resized primary avatar .
default sizes ``80``:
``GET`` ``/api/avatar/render_primary/``
Response : ::
{
"image_url": "https://example.com/media/avatars/1/resized/80/80/example.png"
}
custom ``width`` and ``height`` :
``GET`` ``/api/avatar/render_primary/?width=width_size&height=height_size``
Response : ::
{
"image_url": "http://127.0.0.1:8000/media/avatars/1/resized/width_size/height_size/python.png"
}
If the entered parameter is one of ``width`` or ``height``, it will be considered for both .
``GET`` ``/api/avatar/render_primary/?width=size`` :
Response : ::
{
"image_url": "http://127.0.0.1:8000/media/avatars/1/resized/size/size/python.png"
}
``Note`` : Resize parameters not working for default avatar.
API Setting
===========
.. py:data:: API_AVATAR_CHANGE_IMAGE
It Allows the user to Change the avatar image in ``PUT`` method. Default is ``False``.

View file

@ -32,7 +32,7 @@ extensions = []
templates_path = ["_templates"]
# The suffix of source filenames.
source_suffix = ".rst"
source_suffix = ".txt"
# The encoding of source files.
# source_encoding = 'utf-8-sig'

View file

@ -1,4 +1,3 @@
django-avatar
=============
@ -8,7 +7,7 @@ or Facebook) if no avatar is found for a certain user. Django-avatar
automatically generates thumbnails and stores them to your default file
storage backend for retrieval later.
.. _Gravatar: https://gravatar.com
.. _Gravatar: http://gravatar.com
Installation
------------
@ -55,7 +54,7 @@ that are required. A minimal integration can work like this:
4. Somewhere in your template navigation scheme, link to the change avatar
page::
<a href="{% url 'avatar:change' %}">Change your avatar</a>
<a href="{% url 'avatar_change' %}">Change your avatar</a>
5. Wherever you want to display an avatar for a user, first load the avatar
template tags::
@ -70,10 +69,6 @@ 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" %}
@ -111,10 +106,9 @@ appear on the site. Listed below are those settings:
.. py:data:: AVATAR_AUTO_GENERATE_SIZES
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,)``.
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,)``
.. py:data:: AVATAR_CACHE_ENABLED
@ -181,13 +175,12 @@ appear on the site. Listed below are those settings:
(
'avatar.providers.PrimaryAvatarProvider',
'avatar.providers.LibRAvatarProvider',
'avatar.providers.GravatarAvatarProvider',
'avatar.providers.DefaultAvatarProvider',
)
If you want to implement your own provider, it must provide a class method
``get_avatar_url(user, width, height)``.
``get_avatar_url(user, size)``.
.. py:class:: avatar.providers.PrimaryAvatarProvider
@ -212,13 +205,14 @@ appear on the site. Listed below are those settings:
.. py:data:: AVATAR_RESIZE_METHOD
The method to use when resizing images, based on the options available in
Pillow. Defaults to ``Image.Resampling.LANCZOS``.
Pillow. Defaults to ``Image.ANTIALIAS``.
.. py:data:: AVATAR_STORAGE_DIR
The directory under ``MEDIA_ROOT`` to store the images. If using a
non-filesystem storage device, this will simply be appended to the beginning
of the file name. Defaults to ``avatars``.
Pillow. Defaults to ``Image.ANTIALIAS``.
.. py:data:: AVATAR_THUMB_FORMAT
@ -239,7 +233,7 @@ appear on the site. Listed below are those settings:
.. py:data:: AVATAR_CLEANUP_DELETED
``True`` if the avatar image files should be deleted when an avatar is
deleted from the database. Defaults to ``True``.
deleted from the database. Defaults to ``False``.
.. py:data:: AVATAR_ADD_TEMPLATE
@ -255,20 +249,6 @@ appear on the site. Listed below are those settings:
Path to the Django template to use for confirming a delete of a user's
avatar. Defaults to ``avatar/avatar/confirm_delete.html``.
.. py:data:: AVATAR_ALLOWED_MIMETYPES
Limit allowed avatar image uploads by their actual content payload and what image codecs we wish to support.
This limits website user content site attack vectors against image codec buffer overflow and similar bugs.
`You must have python-imaging library installed <https://github.com/ahupp/python-magic>`_.
Suggested safe setting: ``("image/png", "image/gif", "image/jpeg")``.
When enabled you'll get the following error on the form upload *File content is invalid. Detected: image/tiff Allowed content types are: image/png, image/gif, image/jpg*.
.. py:data:: AVATAR_STORAGE_ALIAS
Default: 'default'
Alias of the storage backend (from STORAGES settings) to use for storing avatars.
Management Commands
-------------------
@ -278,37 +258,4 @@ the avatars for the pixel sizes specified in the
:py:data:`AVATAR_AUTO_GENERATE_SIZES` setting.
.. _pip: https://www.pip-installer.org/
-----------------------------------------------
API
---
To use API there are relatively few things that are required.
after `Installation <#installation>`_ .
1. in your ``INSTALLED_APPS`` of your settings file : ::
INSTALLED_APPS = (
# ...
'avatar',
'rest_framework'
)
2. Add the avatar api urls to the end of your root url config : ::
urlpatterns = [
# ...
path('api/', include('avatar.api.urls')),
]
-----------------------------------------------
.. toctree::
:maxdepth: 1
avatar
.. _pip: http://www.pip-installer.org/

View file

@ -56,7 +56,7 @@ if errorlevel 9009 (
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://sphinx-doc.org/
echo.http://sphinx-doc.org/
exit /b 1
)

View file

@ -1,40 +0,0 @@
[build-system]
requires = ["setuptools>=65.6.3", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "django-avatar"
description = "A Django app for handling user avatars"
authors = [{email = "floguy@gmail.com", name = "Eric Florenzano"}]
maintainers = [{email = "johannes@fiduswriter.org", name = "Johannes Wilm"}]
license = {text = "BSD-4-Clause"}
readme = "README.rst"
keywords=["avatar", "django"]
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Intended Audience :: Developers",
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.2",
"Framework :: Django :: 6.0",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dynamic = ["version", "dependencies"]
[project.urls]
homepage = "https://github.com/jazzband/django-avatar"
repository = "https://github.com/jazzband/django-avatar"
documentation = "https://django-avatar.readthedocs.io"
[tool.setuptools.dynamic]
version = {attr = "avatar.__version__"}
dependencies = {file = "requirements.txt"}

View file

@ -1,3 +0,0 @@
Pillow>=10.0.1
django-appconf>=1.0.5
dnspython>=2.3.0

View file

@ -20,6 +20,34 @@ def find_version(*file_paths):
setup(
name="django-avatar",
version=find_version("avatar", "__init__.py"),
description="A Django app for handling user avatars",
long_description=read("README.rst"),
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Framework :: Django",
"Intended Audience :: Developers",
"Framework :: Django",
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.0",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
],
keywords="avatar, django",
author="Eric Florenzano",
author_email="floguy@gmail.com",
maintainer="Johannes Wilm",
maintainer_email="johannes@fiduswriter.org",
url="http://github.com/jazzband/django-avatar/",
license="BSD",
packages=find_packages(exclude=["tests"]),
package_data={
"avatar": [
@ -29,5 +57,9 @@ setup(
"media/avatar/img/default.jpg",
],
},
install_requires=[
"Pillow>=8.4.0",
"django-appconf>=1.0.5",
],
zip_safe=False,
)

View file

@ -38,7 +38,6 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"avatar",
"rest_framework",
]
MIDDLEWARE = [

View file

@ -1,18 +1,16 @@
from django.conf import settings
from django.conf.urls import include
from django.conf.urls import include, url
from django.contrib import admin
from django.urls import re_path
from django.views.static import serve
urlpatterns = [
re_path(r"^admin/", admin.site.urls),
re_path(r"^avatar/", include("avatar.urls")),
re_path(r"^api/", include("avatar.api.urls")),
url(r"^admin/", admin.site.urls),
url(r"^avatar/", include("avatar.urls")),
]
if settings.DEBUG:
# static files (images, css, javascript, etc.)
urlpatterns += [
re_path(r"^media/(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT})
url(r"^media/(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT})
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

View file

@ -1,3 +1,2 @@
coverage~=7.1.0
coverage==6.2
django
python-magic

View file

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

View file

@ -1,13 +1,7 @@
import math
import os.path
import sys
from pathlib import Path
from shutil import rmtree
from unittest import skipIf
from django.contrib.admin.sites import AdminSite
from django.core import management
from django.core.cache import cache
from django.test import TestCase
from django.test.utils import override_settings
from django.urls import reverse
@ -18,12 +12,7 @@ from avatar.conf import settings
from avatar.models import Avatar
from avatar.signals import avatar_deleted
from avatar.templatetags import avatar_tags
from avatar.utils import (
get_cache_key,
get_primary_avatar,
get_user_model,
invalidate_cache,
)
from avatar.utils import get_primary_avatar, get_user_model
class AssertSignal:
@ -45,7 +34,7 @@ class AssertSignal:
def upload_helper(o, filename):
f = open(os.path.join(o.testdatapath, filename), "rb")
response = o.client.post(
reverse("avatar:add"),
reverse("avatar_add"),
{
"avatar": f,
},
@ -66,14 +55,8 @@ 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"
)
@ -82,16 +65,6 @@ 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,43 +92,15 @@ class AvatarTests(TestCase):
self.assertEqual(avatar.user, self.user)
self.assertTrue(avatar.primary)
# We allow the .tiff file extension but not the mime type
@skipIf(sys.platform == "win32", "Skipping test on Windows platform")
@override_settings(AVATAR_ALLOWED_FILE_EXTS=(".png", ".gif", ".jpg", ".tiff"))
@override_settings(
AVATAR_ALLOWED_MIMETYPES=("image/png", "image/gif", "image/jpeg")
)
def test_unsupported_image_format_upload(self):
"""Check with python-magic that we detect corrupted / unapprovd image files correctly"""
response = upload_helper(self, "test.tiff")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
# We allow the .tiff file extension and the mime type
@skipIf(sys.platform == "win32", "Skipping test on Windows platform")
@override_settings(AVATAR_ALLOWED_FILE_EXTS=(".png", ".gif", ".jpg", ".tiff"))
@override_settings(
AVATAR_ALLOWED_MIMETYPES=("image/png", "image/gif", "image/jpeg", "image/tiff")
)
def test_supported_image_format_upload(self):
"""Check with python-magic that we detect corrupted / unapprovd image files correctly"""
response = upload_helper(self, "test.tiff")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.redirect_chain), 1) # Redirect only if it worked
self.assertEqual(response.context["upload_avatar_form"].errors, {})
@skipIf(sys.platform == "win32", "Skipping test on Windows platform")
@override_settings(AVATAR_ALLOWED_FILE_EXTS=(".jpg", ".png"))
def test_image_without_wrong_extension(self):
# use with AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png')
response = upload_helper(self, "imagefilewithoutext")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
@skipIf(sys.platform == "win32", "Skipping test on Windows platform")
@override_settings(AVATAR_ALLOWED_FILE_EXTS=(".jpg", ".png"))
def test_image_with_wrong_extension(self):
# use with AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png')
response = upload_helper(self, "imagefilewithwrongext.ogg")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
@ -171,10 +116,10 @@ class AvatarTests(TestCase):
def test_default_url(self):
response = self.client.get(
reverse(
"avatar:render_primary",
"avatar_render_primary",
kwargs={
"user": self.user.username,
"width": 80,
"size": 80,
},
)
)
@ -202,7 +147,7 @@ class AvatarTests(TestCase):
receiver = AssertSignal()
avatar_deleted.connect(receiver)
response = self.client.post(
reverse("avatar:delete"),
reverse("avatar_delete"),
{
"choices": [avatar[0].id],
},
@ -222,7 +167,7 @@ class AvatarTests(TestCase):
primary = get_primary_avatar(self.user)
oid = primary.id
self.client.post(
reverse("avatar:delete"),
reverse("avatar_delete"),
{
"choices": [oid],
},
@ -235,7 +180,7 @@ class AvatarTests(TestCase):
def test_change_avatar_get(self):
self.test_normal_image_upload()
response = self.client.get(reverse("avatar:change"))
response = self.client.get(reverse("avatar_change"))
self.assertEqual(response.status_code, 200)
self.assertIsNotNone(response.context["avatar"])
@ -245,7 +190,7 @@ class AvatarTests(TestCase):
old_primary = Avatar.objects.get(user=self.user, primary=True)
choice = Avatar.objects.filter(user=self.user, primary=False)[0]
response = self.client.post(
reverse("avatar:change"),
reverse("avatar_change"),
{
"choice": choice.pk,
},
@ -293,12 +238,6 @@ class AvatarTests(TestCase):
)
self.assertEqual(image.mode, "RGB")
def test_automatic_thumbnail_creation_image_type_conversion(self):
upload_helper(self, "django_pony_cmyk.jpg")
self.assertMediaFileExists(
f"/avatars/{self.user.id}/resized/80/80/django_pony_cmyk.png"
)
def test_thumbnail_transpose_based_on_exif(self):
upload_helper(self, "image_no_exif.jpg")
avatar = get_primary_avatar(self.user)
@ -318,12 +257,6 @@ class AvatarTests(TestCase):
self.assertLess(root_mean_square_difference(image_with_exif, image_no_exif), 1)
def test_automatic_thumbnail_creation_nondefault_filename(self):
upload_helper(self, "django #3.png")
self.assertMediaFileExists(
f"/avatars/{self.user.id}/resized/80/80/django_3.png"
)
def test_has_avatar_False_if_no_avatar(self):
self.assertFalse(avatar_tags.has_avatar(self.user))
@ -341,16 +274,6 @@ 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)
@ -361,7 +284,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="User Avatar" />', result)
self.assertIn('width="80" height="80" alt="test" />', result)
def test_avatar_tag_works_with_custom_size(self):
upload_helper(self, "test.png")
@ -370,80 +293,20 @@ 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="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)
self.assertIn('width="100" height="100" alt="test" />', 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="User Avatar" title="Avatar" />'.format(
avatar.avatar_url(80)
html = (
'<img src="{}" width="80" height="80" alt="test" 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")
@ -478,58 +341,10 @@ 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 test_invalidate_cache(self):
upload_helper(self, "test.png")
sizes_key = get_cache_key(self.user, "cached_sizes")
sizes = cache.get(sizes_key, set())
# Only default 80x80 thumbnail is cached
self.assertEqual(len(sizes), 1)
# Invalidate cache
invalidate_cache(self.user)
sizes = cache.get(sizes_key, set())
# No thumbnail is cached.
self.assertEqual(len(sizes), 0)
# Create a custom 25x25 thumbnail and check that it is cached
avatar_tags.avatar(self.user, 25)
sizes = cache.get(sizes_key, set())
self.assertEqual(len(sizes), 1)
# Invalidate cache again.
invalidate_cache(self.user)
sizes = cache.get(sizes_key, set())
# It should now be empty again
self.assertEqual(len(sizes), 0)
# def testAvatarOrder
# def testReplaceAvatarWhenMaxIsOne
# def testHashFileName
# def testHashUserName
# def testChangePrimaryAvatar
# def testDeleteThumbnailAndRecreation
# def testAutomaticThumbnailCreation