mirror of
https://github.com/jazzband/django-avatar.git
synced 2026-03-17 14:40:24 +00:00
Compare commits
No commits in common. "main" and "6.0.0" have entirely different histories.
53 changed files with 319 additions and 1397 deletions
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
49
.github/workflows/test.yml
vendored
49
.github/workflows/test.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 *
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
__version__ = "9.0.0"
|
||||
__version__ = "6.0.0"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
from appconf import AppConf
|
||||
|
||||
|
||||
class AvatarAPIConf(AppConf):
|
||||
# allow updating avatar image in put method
|
||||
AVATAR_CHANGE_IMAGE = False
|
||||
|
|
@ -1 +0,0 @@
|
|||
djangorestframework
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
|
@ -7,6 +7,7 @@ import avatar.models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import avatar.models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("avatar", "0001_initial"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import avatar.models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("avatar", "0002_add_verbose_names_to_avatar_fields"),
|
||||
]
|
||||
|
|
|
|||
180
avatar/models.py
180
avatar/models.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
191
docs/avatar.rst
191
docs/avatar.rst
|
|
@ -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``.
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Pillow>=10.0.1
|
||||
django-appconf>=1.0.5
|
||||
dnspython>=2.3.0
|
||||
32
setup.py
32
setup.py
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ INSTALLED_APPS = [
|
|||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"avatar",
|
||||
"rest_framework",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -1,3 +1,2 @@
|
|||
coverage~=7.1.0
|
||||
coverage==6.2
|
||||
django
|
||||
python-magic
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
233
tests/tests.py
233
tests/tests.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue