diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4f2bb4a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + tags: + - '*' + +jobs: + build: + if: github.repository == 'jazzband/django-avatar' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -U setuptools twine wheel + - name: Build package + run: | + python setup.py --version + python setup.py sdist --format=gztar bdist_wheel + 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 + with: + user: jazzband + password: ${{ secrets.JAZZBAND_RELEASE_KEY }} + repository_url: https://jazzband.co/projects/django-avatar/upload diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..34ba072 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,52 @@ +name: Test +on: [push, pull_request] +jobs: + Build: + runs-on: ubuntu-latest + strategy: + matrix: + 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@v3 + - name: 'Set up Python ${{ matrix.python-version }}' + uses: actions/setup-python@v3 + with: + python-version: '${{ matrix.python-version }}' + cache: 'pip' + - name: Install dependencies + run: | + pip install -e . + pip install -r tests/requirements.txt + pip install "Django~=${{ matrix.django-version }}.0" . + - name: Run Tests + run: | + echo "$(python --version) / Django $(django-admin --version)" + export DJANGO_SETTINGS_MODULE=tests.settings + export PYTHONPATH=. + coverage run --source=avatar `which django-admin` test tests + coverage report + coverage xml + - name: Upload coverage reports to Codecov with GitHub Action + uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore index f9e4d1d..1f4e031 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ docs/_build htmlcov/ *.sqlite3 test_proj/media +.python-version +/test-media/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..92c3839 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/pycqa/isort + rev: "5.10.1" + hooks: + - id: isort + args: ["--profile", "black"] + + - repo: https://github.com/psf/black + rev: 22.6.0 + hooks: + - id: black + args: [--target-version=py310] + + - repo: https://github.com/pycqa/flake8 + rev: '5.0.4' + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + - flake8-tidy-imports + - flake8-print + args: [--max-line-length=120] diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index be0dc2e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -language: python -python: - - 2.7 - - 3.4 - - 3.5 - - 3.6 -before_install: - - pip install coveralls -install: - - pip install -e . - - pip install -r tests/requirements.txt - - pip install Django==${DJANGO} -script: make test -env: - - DJANGO=1.9.13 - - DJANGO=1.10.7 - - DJANGO=1.11.8 - - DJANGO=2.0 -matrix: - exclude: - - python: 3.6 - env: DJANGO=1.9.13 - - python: 3.6 - env: DJANGO=1.10.7 - - python: 2.7 - env: DJANGO=2.0 -after_success: - - coveralls diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0845138..22902f0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,32 @@ Changelog ========= +* Unreleased + * Allowed for rectangular avatars. Custom avatar tag templates now require the specification of both a ``width`` and ``height`` attribute instead of ``size``. + * Made ``True`` the default value of ``AVATAR_CLEANUP_DELETED``. (Set to ``False`` to obtain previous behavior). + +* 6.0.1 (August 12, 2022) + * Exclude tests folder from distribution. + +* 6.0.0 (August 12, 2022) + * Added Django 3.2, 4.0 and 4.1 support. + * Removed Django 1.9, 1.10, 1.11, 2.0, 2.1, 2.2 and 3.0 support. + * Added Python 3.9 and 3.10 support. + * Removed Python 2.7, 3.4 and 3.5 support. + * Made ``"PNG"`` the default value for ``AVATAR_THUMB_FORMAT`` (Set to ``"JPEG"`` to obtain previous behavior). + * Made ``False`` the default value for ``AVATAR_EXPOSE_USERNAMES`` (Set to ``True`` to obtain previous behavior). + * Don't leak usernames through image alt-tags when ``AVATAR_EXPOSE_USERNAMES`` is `False`. + * New setting ``AVATAR_THUMB_MODES``. Default is ``['RGB', 'RGBA']``. + * Use original image as thumbnail if thumbnail creation failed but image saving succeeds. + * Add farsi translation. + * Introduce black and flake8 linting + +* 5.0.0 (January 4, 2019) + * Added Django 2.1, 2.2, and 3.0 support. + * Added Python 3.7 and 3.8 support. + * Removed Python 1.9 and 1.10 support. + * Fixed bug where avatars couldn't be deleted if file was already deleted. + * 4.1.0 (December 20, 2017) * Added Django 2.0 support. * Added ``avatar_deleted`` signal. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ad78220 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) + +This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index af1dd9e..0909b8c 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -1,4 +1,3 @@ This application was originally written by Eric Florenzano. -It is now maintained by Grant McConnaughey and a league of awesome contributors. -See the full list here: https://github.com/grantmcconnaughey/django-avatar/graphs/contributors +See the full list here: https://github.com/jazzband/django-avatar/graphs/contributors diff --git a/LICENSE.txt b/LICENSE.txt index 56f2f5e..72714db 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -26,4 +26,3 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - diff --git a/MANIFEST.in b/MANIFEST.in index 5028a83..0e65e01 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -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 * diff --git a/Makefile b/Makefile deleted file mode 100644 index 68d7ba7..0000000 --- a/Makefile +++ /dev/null @@ -1,18 +0,0 @@ -export DJANGO_SETTINGS_MODULE=tests.settings -export PYTHONPATH=. - -.PHONY: test - -test: - flake8 avatar --ignore=E124,E501,E127,E128,E722 - coverage run --source=avatar `which django-admin.py` test tests - coverage report - -publish: clean - python setup.py sdist - twine upload dist/* - -clean: - rm -vrf ./build ./dist ./*.egg-info - find . -name '*.pyc' -delete - find . -name '*.tgz' -delete diff --git a/README.rst b/README.rst index 2026333..b643200 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,24 @@ django-avatar ============= +.. image:: https://jazzband.co/static/img/badge.png + :target: https://jazzband.co/ + :alt: Jazzband + +.. image:: https://img.shields.io/pypi/pyversions/django-avatar.svg + :target: https://pypi.org/project/django-avatar/ + :alt: Supported Python versions + +.. image:: https://img.shields.io/pypi/djversions/django-avatar.svg + :target: https://pypi.org/project/django-avatar/ + :alt: Supported Django versions + +.. image:: https://github.com/jazzband/django-avatar/actions/workflows/test.yml/badge.svg + :target: https://github.com/jazzband/django-avatar/actions/workflows/test.yml + +.. image:: https://codecov.io/gh/jazzband/django-avatar/branch/main/graph/badge.svg?token=BO1e4kkgtq + :target: https://codecov.io/gh/jazzband/django-avatar + .. image:: https://badge.fury.io/py/django-avatar.svg :target: https://badge.fury.io/py/django-avatar :alt: PyPI badge @@ -10,18 +28,6 @@ django-avatar :target: http://django-avatar.readthedocs.org/en/latest/?badge=latest :alt: Documentation Status -.. image:: https://travis-ci.org/grantmcconnaughey/django-avatar.svg?branch=master - :target: https://travis-ci.org/grantmcconnaughey/django-avatar - :alt: Travis CI Build Status - -.. image:: https://coveralls.io/repos/grantmcconnaughey/django-avatar/badge.svg?branch=master&service=github - :target: https://coveralls.io/github/grantmcconnaughey/django-avatar?branch=master - :alt: Coverage - -.. image:: https://lintly.com/gh/grantmcconnaughey/django-avatar/badge.svg - :target: https://lintly.com/gh/grantmcconnaughey/django-avatar/ - :alt: Lintly - Django-avatar is a reusable application for handling user avatars. It has the 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 diff --git a/avatar/__init__.py b/avatar/__init__.py index fa721b4..79a961b 100644 --- a/avatar/__init__.py +++ b/avatar/__init__.py @@ -1 +1 @@ -__version__ = '4.1.0' +__version__ = "6.0.1" diff --git a/avatar/admin.py b/avatar/admin.py index 98d833e..e20abca 100644 --- a/avatar/admin.py +++ b/avatar/admin.py @@ -1,7 +1,6 @@ from django.contrib import admin -from django.utils.translation import ugettext_lazy as _ -from django.utils import six from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ from avatar.models import Avatar from avatar.signals import avatar_updated @@ -9,25 +8,29 @@ from avatar.utils import get_user_model class AvatarAdmin(admin.ModelAdmin): - list_display = ('get_avatar', 'user', 'primary', "date_uploaded") - list_filter = ('primary',) - search_fields = ('user__%s' % getattr(get_user_model(), 'USERNAME_FIELD', 'username'),) + list_display = ("get_avatar", "user", "primary", "date_uploaded") + list_filter = ("primary",) + search_fields = ( + "user__%s" % getattr(get_user_model(), "USERNAME_FIELD", "username"), + ) list_per_page = 50 def get_avatar(self, avatar_in): - context = dict({ - 'user': avatar_in.user, - 'url': avatar_in.avatar.url, - 'alt': six.text_type(avatar_in.user), - 'size': 80, - }) - return render_to_string('avatar/avatar_tag.html', context) + 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.short_description = _("Avatar") get_avatar.allow_tags = True def save_model(self, request, obj, form, change): - super(AvatarAdmin, self).save_model(request, obj, form, change) + super().save_model(request, obj, form, change) avatar_updated.send(sender=Avatar, user=request.user, avatar=obj) diff --git a/avatar/apps.py b/avatar/apps.py new file mode 100644 index 0000000..0b91ed4 --- /dev/null +++ b/avatar/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class Config(AppConfig): + name = "avatar" + default_auto_field = "django.db.models.AutoField" diff --git a/avatar/conf.py b/avatar/conf.py index bfe51ef..4a84407 100644 --- a/avatar/conf.py +++ b/avatar/conf.py @@ -1,43 +1,44 @@ +from appconf import AppConf from django.conf import settings from PIL import Image -from appconf import AppConf - class AvatarConf(AppConf): DEFAULT_SIZE = 80 RESIZE_METHOD = Image.ANTIALIAS - STORAGE_DIR = 'avatars' - PATH_HANDLER = 'avatar.models.avatar_path_handler' - GRAVATAR_BASE_URL = 'https://www.gravatar.com/avatar/' - GRAVATAR_FIELD = 'email' + STORAGE_DIR = "avatars" + PATH_HANDLER = "avatar.models.avatar_path_handler" + GRAVATAR_BASE_URL = "https://www.gravatar.com/avatar/" + GRAVATAR_FIELD = "email" GRAVATAR_DEFAULT = None AVATAR_GRAVATAR_FORCEDEFAULT = False - DEFAULT_URL = 'avatar/img/default.jpg' + DEFAULT_URL = "avatar/img/default.jpg" MAX_AVATARS_PER_USER = 42 MAX_SIZE = 1024 * 1024 - THUMB_FORMAT = 'JPEG' + THUMB_FORMAT = "PNG" THUMB_QUALITY = 85 + THUMB_MODES = ("RGB", "RGBA") HASH_FILENAMES = False HASH_USERDIRNAMES = False - EXPOSE_USERNAMES = True + EXPOSE_USERNAMES = False ALLOWED_FILE_EXTS = None CACHE_TIMEOUT = 60 * 60 STORAGE = settings.DEFAULT_FILE_STORAGE - CLEANUP_DELETED = False + CLEANUP_DELETED = True AUTO_GENERATE_SIZES = (DEFAULT_SIZE,) FACEBOOK_GET_ID = None CACHE_ENABLED = True RANDOMIZE_HASHES = False - ADD_TEMPLATE = '' - CHANGE_TEMPLATE = '' - DELETE_TEMPLATE = '' + ADD_TEMPLATE = "" + CHANGE_TEMPLATE = "" + DELETE_TEMPLATE = "" PROVIDERS = ( - 'avatar.providers.PrimaryAvatarProvider', - 'avatar.providers.GravatarAvatarProvider', - 'avatar.providers.DefaultAvatarProvider', + "avatar.providers.PrimaryAvatarProvider", + "avatar.providers.GravatarAvatarProvider", + "avatar.providers.DefaultAvatarProvider", ) def configure_auto_generate_avatar_sizes(self, value): - return value or getattr(settings, 'AVATAR_AUTO_GENERATE_SIZES', - (self.DEFAULT_SIZE,)) + return value or getattr( + settings, "AVATAR_AUTO_GENERATE_SIZES", (self.DEFAULT_SIZE,) + ) diff --git a/avatar/forms.py b/avatar/forms.py index 04d07c4..0cceb42 100644 --- a/avatar/forms.py +++ b/avatar/forms.py @@ -2,84 +2,96 @@ import os from django import forms from django.forms import widgets -from django.utils import six -from django.utils.safestring import mark_safe -from django.utils.translation import ugettext_lazy as _ from django.template.defaultfilters import filesizeformat +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ from avatar.conf import settings from avatar.models import Avatar -def avatar_img(avatar, size): - if not avatar.thumbnail_exists(size): - avatar.create_thumbnail(size) - return mark_safe('%s' % - (avatar.avatar_url(size), six.text_type(avatar), - size, size)) +def avatar_img(avatar, width, height): + if not avatar.thumbnail_exists(width, height): + avatar.create_thumbnail(width, height) + return mark_safe( + '%s' + % (avatar.avatar_url(width, height), str(avatar), width, height) + ) class UploadAvatarForm(forms.Form): - avatar = forms.ImageField(label=_("avatar")) def __init__(self, *args, **kwargs): - self.user = kwargs.pop('user') - super(UploadAvatarForm, self).__init__(*args, **kwargs) + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) def clean_avatar(self): - data = self.cleaned_data['avatar'] + data = self.cleaned_data["avatar"] 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 forms.ValidationError(error % - {'ext': ext, - 'valid_exts_list': valid_exts}) + error = _( + "%(ext)s is an invalid file extension. " + "Authorized extensions are : %(valid_exts_list)s" + ) + raise forms.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 forms.ValidationError(error % { - 'size': filesizeformat(data.size), - 'max_valid_size': filesizeformat(settings.AVATAR_MAX_SIZE) - }) + error = _( + "Your file is too big (%(size)s), " + "the maximum allowed size is %(max_valid_size)s" + ) + raise forms.ValidationError( + error + % { + "size": filesizeformat(data.size), + "max_valid_size": filesizeformat(settings.AVATAR_MAX_SIZE), + } + ) 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 forms.ValidationError(error % { - 'nb_avatars': count, - 'nb_max_avatars': settings.AVATAR_MAX_AVATARS_PER_USER, - }) + error = _( + "You already have %(nb_avatars)d avatars, " + "and the maximum allowed is %(nb_max_avatars)d." + ) + raise forms.ValidationError( + error + % { + "nb_avatars": count, + "nb_max_avatars": settings.AVATAR_MAX_AVATARS_PER_USER, + } + ) return class PrimaryAvatarForm(forms.Form): - def __init__(self, *args, **kwargs): - kwargs.pop('user') - size = kwargs.pop('size', settings.AVATAR_DEFAULT_SIZE) - avatars = kwargs.pop('avatars') - super(PrimaryAvatarForm, self).__init__(*args, **kwargs) - choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars] - self.fields['choice'] = forms.ChoiceField(label=_("Choices"), - choices=choices, - widget=widgets.RadioSelect) + kwargs.pop("user") + width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE) + height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE) + avatars = kwargs.pop("avatars") + super().__init__(*args, **kwargs) + self.fields["choice"] = forms.ChoiceField( + choices=[(c.id, avatar_img(c, width, height)) for c in avatars], + widget=widgets.RadioSelect, + ) class DeleteAvatarForm(forms.Form): - def __init__(self, *args, **kwargs): - kwargs.pop('user') - size = kwargs.pop('size', settings.AVATAR_DEFAULT_SIZE) - avatars = kwargs.pop('avatars') - 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=choices, - widget=widgets.CheckboxSelectMultiple) + kwargs.pop("user") + width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE) + height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE) + avatars = kwargs.pop("avatars") + super().__init__(*args, **kwargs) + self.fields["choices"] = forms.MultipleChoiceField( + label=_("Choices"), + choices=[(c.id, avatar_img(c, width, height)) for c in avatars], + widget=widgets.CheckboxSelectMultiple, + ) diff --git a/avatar/locale/fa/LC_MESSAGES/django.mo b/avatar/locale/fa/LC_MESSAGES/django.mo new file mode 100644 index 0000000..42dcb67 Binary files /dev/null and b/avatar/locale/fa/LC_MESSAGES/django.mo differ diff --git a/avatar/locale/fa/LC_MESSAGES/django.po b/avatar/locale/fa/LC_MESSAGES/django.po new file mode 100644 index 0000000..182c2d8 --- /dev/null +++ b/avatar/locale/fa/LC_MESSAGES/django.po @@ -0,0 +1,160 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Mahdi Firouzjaah, 2020. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2020-12-16 20:12:00.982404\n" +"PO-Revision-Date: 2020-12-16 20:12:00.982404\n" +"Last-Translator: Mahdi Firouzjaah\n" +"Language-Team: LANGUAGE \n" +"Language: fa\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: admin.py:30 +msgid "Avatar" +msgstr "آواتار" + +#: forms.py:28 models.py:105 models.py:114 +msgid "avatar" +msgstr "آواتار" + +#: forms.py:41 +#, python-format +msgid "" +"%(ext)s is an invalid file extension. Authorized extensions are : " +"%(valid_exts_list)s" +msgstr "" +"%(ext)s یک فایل با پسوند نامناسب است. پسوندهای مناسب این‌ها هستند:" +"%(valid_exts_list)s" + +#: forms.py:48 +#, python-format +msgid "" +"Your file is too big (%(size)s), the maximum allowed size is " +"%(max_valid_size)s" +msgstr "" +"فایلی که فرستادید بیش از حد مجاز بزرگ است(%(size)s). حداکثر مجاز این است:" +"%(max_valid_size)s" + +#: forms.py:56 +#, python-format +msgid "" +"You already have %(nb_avatars)d avatars, and the maximum allowed is " +"%(nb_max_avatars)d." +msgstr "" +"شما هم‌اکنون %(nb_avatars)d تعداد آواتار دارید و حداکثر مجاز" +"%(nb_max_avatars)d تا است." + + +#: forms.py:73 forms.py:86 +msgid "Choices" +msgstr "انتخاب‌ها" + +#: models.py:98 +msgid "user" +msgstr "کاربر" + +#: models.py:101 +msgid "primary" +msgstr "اصلی" + +#: models.py:108 +msgid "uploaded at" +msgstr "بارگزاری شده در" + +#: models.py:115 +msgid "avatars" +msgstr "آواتارها" + +#: templates/avatar/add.html:5 templates/avatar/change.html:5 +msgid "Your current avatar: " +msgstr "آواتار فعلی شما: " + +#: templates/avatar/add.html:8 templates/avatar/change.html:8 +msgid "You haven't uploaded an avatar yet. Please upload one now." +msgstr "شما تاکنون آواتاری بارگزاری نکرده‌اید، لطفا یکی بارگزاری کنید." + +#: templates/avatar/add.html:12 templates/avatar/change.html:19 +msgid "Upload New Image" +msgstr "بارگزاری عکس جدید" + +#: templates/avatar/change.html:14 +msgid "Choose new Default" +msgstr "انتخاب یکی به عنوان پیش‌فرض" + +#: templates/avatar/confirm_delete.html:5 +msgid "Please select the avatars that you would like to delete." +msgstr "لطفا آواتاری که مایلید حذف شود، انتخاب کنید." + +#: templates/avatar/confirm_delete.html:8 +#, python-format +msgid "" +"You have no avatars to delete. Please upload one now." +msgstr "" +"شما آواتاری برای حذف کردن ندارید؛" +"لطفا یکی بارگزاری کنید." + +#: templates/avatar/confirm_delete.html:14 +msgid "Delete These" +msgstr "این(ها) را حذف کن" + +#: templates/notification/avatar_friend_updated/full.txt:1 +#, python-format +msgid "" +"%(avatar_creator)s has updated their avatar %(avatar)s.\n" +"\n" +"http://%(current_site)s%(avatar_url)s\n" +msgstr "" +"%(avatar_creator)s آواتار خود را بروزرسانی کردند %(avatar)s.\n\n" +"http://%(current_site)s%(avatar_url)s\n" + +#: templates/notification/avatar_friend_updated/notice.html:2 +#, python-format +msgid "" +"%(avatar_creator)s has updated their avatar %(avatar)s." +msgstr "" +"%(avatar_creator)s آواتار خود را بروزرسانی کردند." +"%(avatar)s." + + +#: templates/notification/avatar_updated/full.txt:1 +#, python-format +msgid "" +"Your avatar has been updated. %(avatar)s\n" +"\n" +"http://%(current_site)s%(avatar_url)s\n" +msgstr "" +"آواتار شما بروزرسانی شد. %(avatar)s\n\n" +"http://%(current_site)s%(avatar_url)s\n" + +#: templates/notification/avatar_updated/notice.html:2 +#, python-format +msgid "You have updated your avatar %(avatar)s." +msgstr "شما آواتار خود را بروزرسانی کردید. %(avatar)s." + +#: templatetags/avatar_tags.py:49 +msgid "Default Avatar" +msgstr "آواتار پیش‌فرض" + +#: views.py:76 +msgid "Successfully uploaded a new avatar." +msgstr "آواتار جدید با موفقیت بارگزاری شد." + +#: views.py:114 +msgid "Successfully updated your avatar." +msgstr "آواتار شما با موفقیت بروزرسانی شد." + +#: views.py:157 +msgid "Successfully deleted the requested avatars." +msgstr "آواتارهای مدنظر با موفقیت حذف شدند." diff --git a/avatar/locale/fr/LC_MESSAGES/django.mo b/avatar/locale/fr/LC_MESSAGES/django.mo index 2abb979..0ac6f61 100644 Binary files a/avatar/locale/fr/LC_MESSAGES/django.mo and b/avatar/locale/fr/LC_MESSAGES/django.mo differ diff --git a/avatar/locale/pt_BR/LC_MESSAGES/django.mo b/avatar/locale/pt_BR/LC_MESSAGES/django.mo index 7a17848..f103c9c 100644 Binary files a/avatar/locale/pt_BR/LC_MESSAGES/django.mo and b/avatar/locale/pt_BR/LC_MESSAGES/django.mo differ diff --git a/avatar/locale/ru/LC_MESSAGES/django.mo b/avatar/locale/ru/LC_MESSAGES/django.mo index 77b8e21..3c314ec 100644 Binary files a/avatar/locale/ru/LC_MESSAGES/django.mo and b/avatar/locale/ru/LC_MESSAGES/django.mo differ diff --git a/avatar/management/commands/rebuild_avatars.py b/avatar/management/commands/rebuild_avatars.py index 2061946..27251f1 100644 --- a/avatar/management/commands/rebuild_avatars.py +++ b/avatar/management/commands/rebuild_avatars.py @@ -1,17 +1,26 @@ from django.core.management.base import BaseCommand from avatar.conf import settings -from avatar.models import Avatar +from avatar.models import Avatar, remove_avatar_images class Command(BaseCommand): - help = ("Regenerates avatar thumbnails for the sizes specified in " - "settings.AVATAR_AUTO_GENERATE_SIZES.") + help = ( + "Regenerates avatar thumbnails for the sizes specified in " + "settings.AVATAR_AUTO_GENERATE_SIZES." + ) 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: - print("Rebuilding Avatar id=%s at size %s." % (avatar.id, size)) - - avatar.create_thumbnail(size) + 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]) diff --git a/avatar/migrations/0001_initial.py b/avatar/migrations/0001_initial.py index c2c7a3b..3ef74bc 100644 --- a/avatar/migrations/0001_initial.py +++ b/avatar/migrations/0001_initial.py @@ -1,11 +1,9 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import models, migrations -import django.utils.timezone -import avatar.models import django.core.files.storage +import django.utils.timezone from django.conf import settings +from django.db import migrations, models + +import avatar.models class Migration(migrations.Migration): @@ -16,13 +14,37 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Avatar', + name="Avatar", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('primary', models.BooleanField(default=False)), - ('avatar', models.ImageField(storage=django.core.files.storage.FileSystemStorage(), max_length=1024, upload_to=avatar.models.avatar_file_path, blank=True)), - ('date_uploaded', models.DateTimeField(default=django.utils.timezone.now)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("primary", models.BooleanField(default=False)), + ( + "avatar", + models.ImageField( + storage=django.core.files.storage.FileSystemStorage(), + max_length=1024, + upload_to=avatar.models.avatar_file_path, + blank=True, + ), + ), + ( + "date_uploaded", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "user", + models.ForeignKey( + to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE + ), + ), ], ), ] diff --git a/avatar/migrations/0002_add_verbose_names_to_avatar_fields.py b/avatar/migrations/0002_add_verbose_names_to_avatar_fields.py index ca12b15..52700e1 100644 --- a/avatar/migrations/0002_add_verbose_names_to_avatar_fields.py +++ b/avatar/migrations/0002_add_verbose_names_to_avatar_fields.py @@ -1,44 +1,53 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.1 on 2016-09-16 08:50 -from __future__ import unicode_literals - -import avatar.models -from django.conf import settings import django.core.files.storage -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + +import avatar.models class Migration(migrations.Migration): dependencies = [ - ('avatar', '0001_initial'), + ("avatar", "0001_initial"), ] operations = [ migrations.AlterModelOptions( - name='avatar', - options={'verbose_name': 'avatar', 'verbose_name_plural': 'avatars'}, + name="avatar", + options={"verbose_name": "avatar", "verbose_name_plural": "avatars"}, ), migrations.AlterField( - model_name='avatar', - name='avatar', - field=models.ImageField(blank=True, max_length=1024, storage=django.core.files.storage.FileSystemStorage(), upload_to=avatar.models.avatar_path_handler, verbose_name='avatar'), + model_name="avatar", + name="avatar", + field=models.ImageField( + blank=True, + max_length=1024, + storage=django.core.files.storage.FileSystemStorage(), + upload_to=avatar.models.avatar_path_handler, + verbose_name="avatar", + ), ), migrations.AlterField( - model_name='avatar', - name='date_uploaded', - field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='uploaded at'), + model_name="avatar", + name="date_uploaded", + field=models.DateTimeField( + default=django.utils.timezone.now, verbose_name="uploaded at" + ), ), migrations.AlterField( - model_name='avatar', - name='primary', - field=models.BooleanField(default=False, verbose_name='primary'), + model_name="avatar", + name="primary", + field=models.BooleanField(default=False, verbose_name="primary"), ), migrations.AlterField( - model_name='avatar', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user'), + model_name="avatar", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), ), ] diff --git a/avatar/migrations/0003_auto_20170827_1345.py b/avatar/migrations/0003_auto_20170827_1345.py index c85e774..8e39423 100644 --- a/avatar/migrations/0003_auto_20170827_1345.py +++ b/avatar/migrations/0003_auto_20170827_1345.py @@ -1,21 +1,18 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.4 on 2017-08-27 13:45 -from __future__ import unicode_literals +from django.db import migrations import avatar.models -from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('avatar', '0002_add_verbose_names_to_avatar_fields'), + ("avatar", "0002_add_verbose_names_to_avatar_fields"), ] operations = [ migrations.AlterField( - model_name='avatar', - name='avatar', + model_name="avatar", + name="avatar", field=avatar.models.AvatarField(), ), ] diff --git a/avatar/models.py b/avatar/models.py index 5b836fc..8a625f1 100644 --- a/avatar/models.py +++ b/avatar/models.py @@ -1,32 +1,28 @@ import binascii -import datetime -import os import hashlib -from PIL import Image +import os +from io import BytesIO -from django.db import models from django.core.files import File from django.core.files.base import ContentFile from django.core.files.storage import get_storage_class -from django.utils.module_loading import import_string -from django.utils.translation import ugettext_lazy as _ -from django.utils.encoding import force_text -from django.utils import six +from django.db import models from django.db.models import signals +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 from avatar.conf import settings -from avatar.utils import get_username, force_bytes, invalidate_cache - -try: - from django.utils.timezone import now -except ImportError: - now = datetime.datetime.now - +from avatar.utils import force_bytes, get_username, invalidate_cache avatar_storage = get_storage_class(settings.AVATAR_STORAGE)() -def avatar_path_handler(instance=None, filename=None, size=None, ext=None): +def avatar_path_handler( + instance=None, filename=None, width=None, height=None, ext=None +): tmppath = [settings.AVATAR_STORAGE_DIR] if settings.AVATAR_HASH_USERDIRNAMES: tmp = hashlib.md5(force_bytes(get_username(instance.user))).hexdigest() @@ -34,7 +30,7 @@ def avatar_path_handler(instance=None, filename=None, size=None, ext=None): if settings.AVATAR_EXPOSE_USERNAMES: tmppath.append(get_username(instance.user)) else: - tmppath.append(force_text(instance.user.pk)) + tmppath.append(force_str(instance.user.pk)) if not filename: # Filename already stored in database filename = instance.avatar.name @@ -44,18 +40,18 @@ def avatar_path_handler(instance=None, filename=None, size=None, ext=None): # only enabled if AVATAR_HASH_FILENAMES is true, we can trust # it won't conflict with another filename (root, oldext) = os.path.splitext(filename) - filename = root + "." + ext + filename = root + "." + ext.lower() else: # File doesn't exist yet if settings.AVATAR_HASH_FILENAMES: (root, ext) = os.path.splitext(filename) if settings.AVATAR_RANDOMIZE_HASHES: - filename = binascii.hexlify(os.urandom(16)).decode('ascii') + filename = binascii.hexlify(os.urandom(16)).decode("ascii") else: filename = hashlib.md5(force_bytes(filename)).hexdigest() filename = filename + ext - if size: - tmppath.extend(['resized', str(size)]) + if width or height: + tmppath.extend(["resized", str(width), str(height)]) tmppath.append(os.path.basename(filename)) return os.path.join(*tmppath) @@ -66,14 +62,13 @@ avatar_file_path = import_string(settings.AVATAR_PATH_HANDLER) def find_extension(format): format = format.lower() - if format == 'jpeg': - format = 'jpg' + if format == "jpeg": + format = "jpg" return format class AvatarField(models.ImageField): - def __init__(self, *args, **kwargs): super(AvatarField, self).__init__(*args, **kwargs) @@ -89,28 +84,27 @@ class AvatarField(models.ImageField): class Avatar(models.Model): user = models.ForeignKey( - getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), - verbose_name=_("user"), on_delete=models.CASCADE, + getattr(settings, "AUTH_USER_MODEL", "auth.User"), + verbose_name=_("user"), + on_delete=models.CASCADE, ) primary = models.BooleanField( verbose_name=_("primary"), default=False, ) - avatar = AvatarField( - verbose_name=_("avatar") - ) + avatar = AvatarField(verbose_name=_("avatar")) date_uploaded = models.DateTimeField( verbose_name=_("uploaded at"), default=now, ) class Meta: - app_label = 'avatar' - verbose_name = _('avatar') - verbose_name_plural = _('avatars') + app_label = "avatar" + verbose_name = _("avatar") + verbose_name_plural = _("avatars") - def __unicode__(self): - return _(six.u('Avatar for %s')) % self.user + def __str__(self): + return _("Avatar for %s") % self.user def save(self, *args, **kwargs): avatars = Avatar.objects.filter(user=self.user) @@ -124,93 +118,121 @@ class Avatar(models.Model): avatars.delete() super(Avatar, self).save(*args, **kwargs) - def thumbnail_exists(self, size): - return self.avatar.storage.exists(self.avatar_name(size)) + def thumbnail_exists(self, width, height=None): + return self.avatar.storage.exists(self.avatar_name(width, height)) def transpose_image(self, image): """ - Transpose based on EXIF information. - Borrowed from django-imagekit: - imagekit.processors.Transpose + 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'], + 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: + except TypeError: ops = [] for method in ops: image = image.transpose(getattr(Image, method)) return image - def create_thumbnail(self, size, quality=None): + def create_thumbnail(self, width, height=None, quality=None): + if height is None: + height = width # invalidate the cache of the thumbnail with the given size first - invalidate_cache(self.user, size) + invalidate_cache(self.user, width, height) + try: + orig = self.avatar.storage.open(self.avatar.name, "rb") + except IOError: + return # What should we do here? Render a "sorry, didn't work" img? try: - orig = self.avatar.storage.open(self.avatar.name, 'rb') 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) + if w != width or h != height: + ratioReal = 1.0 * w / h + ratioWant = 1.0 * width / height + if ratioReal > ratioWant: + diff = int((w - (h * ratioWant)) / 2) image = image.crop((diff, 0, w - diff, h)) - else: - diff = int((h - w) / 2) + elif ratioReal < ratioWant: + diff = int((h - (w / ratioWant)) / 2) image = image.crop((0, diff, w, h - diff)) - if image.mode not in ("RGB", "RGBA"): + if settings.AVATAR_THUMB_FORMAT == "JPEG" and image.mode == "RGBA": image = image.convert("RGB") - image = image.resize((size, size), settings.AVATAR_RESIZE_METHOD) - thumb = six.BytesIO() + 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()) else: thumb_file = File(orig) - thumb = self.avatar.storage.save(self.avatar_name(size), thumb_file) + thumb_name = self.avatar_name(width, height) + if self.avatar.storage.exists(thumb_name): + self.avatar.storage.delete(thumb_name) + thumb = self.avatar.storage.save(thumb_name, thumb_file) except IOError: - return # What should we do here? Render a "sorry, didn't work" img? + thumb_file = File(orig) + thumb = self.avatar.storage.save( + self.avatar_name(width, height), thumb_file + ) - def avatar_url(self, size): - return self.avatar.storage.url(self.avatar_name(size)) + def avatar_url(self, width, height=None): + return self.avatar.storage.url(self.avatar_name(width, height)) def get_absolute_url(self): return self.avatar_url(settings.AVATAR_DEFAULT_SIZE) - def avatar_name(self, size): + def avatar_name(self, width, height=None): + if height is None: + height = width ext = find_extension(settings.AVATAR_THUMB_FORMAT) - return avatar_file_path( - instance=self, - size=size, - ext=ext - ) + return avatar_file_path(instance=self, width=width, height=height, ext=ext) def invalidate_avatar_cache(sender, instance, **kwargs): - invalidate_cache(instance.user) + if hasattr(instance, "user"): + invalidate_cache(instance.user) def create_default_thumbnails(sender, instance, created=False, **kwargs): invalidate_avatar_cache(sender, instance) if created: for size in settings.AVATAR_AUTO_GENERATE_SIZES: - instance.create_thumbnail(size) + if isinstance(size, int): + instance.create_thumbnail(size, size) + else: + # Size is specified with height and width. + instance.create_thumbnail(size[0], size[1]) -def remove_avatar_images(instance=None, **kwargs): - for size in settings.AVATAR_AUTO_GENERATE_SIZES: - if instance.thumbnail_exists(size): - instance.avatar.storage.delete(instance.avatar_name(size)) - instance.avatar.storage.delete(instance.avatar.name) +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) signals.post_save.connect(create_default_thumbnails, sender=Avatar) diff --git a/avatar/providers.py b/avatar/providers.py index 7b4c5bb..7461100 100644 --- a/avatar/providers.py +++ b/avatar/providers.py @@ -1,27 +1,17 @@ import hashlib - -try: - from urllib.parse import urljoin, urlencode -except ImportError: - from urlparse import urljoin - from urllib import urlencode - - -from avatar.conf import settings -from avatar.utils import ( - force_bytes, - get_default_avatar_url, - get_primary_avatar, -) +from urllib.parse import urlencode, urljoin from django.utils.module_loading import import_string +from avatar.conf import settings +from avatar.utils import force_bytes, get_default_avatar_url, get_primary_avatar + # If the FacebookAvatarProvider is used, a mechanism needs to be defined on # how to obtain the user's Facebook UID. This is done via # ``AVATAR_FACEBOOK_GET_ID``. get_facebook_id = None -if 'avatar.providers.FacebookAvatarProvider' in settings.AVATAR_PROVIDERS: +if "avatar.providers.FacebookAvatarProvider" in settings.AVATAR_PROVIDERS: if callable(settings.AVATAR_FACEBOOK_GET_ID): get_facebook_id = settings.AVATAR_FACEBOOK_GET_ID else: @@ -34,7 +24,7 @@ class DefaultAvatarProvider(object): """ @classmethod - def get_avatar_url(self, user, size): + def get_avatar_url(cls, user, width, height): return get_default_avatar_url() @@ -44,10 +34,10 @@ class PrimaryAvatarProvider(object): """ @classmethod - def get_avatar_url(self, user, size): - avatar = get_primary_avatar(user, size) + def get_avatar_url(cls, user, width, height): + avatar = get_primary_avatar(user, width, height) if avatar: - return avatar.avatar_url(size) + return avatar.avatar_url(width, height) class GravatarAvatarProvider(object): @@ -56,14 +46,18 @@ class GravatarAvatarProvider(object): """ @classmethod - def get_avatar_url(self, user, size): - params = {'s': str(size)} + def get_avatar_url(cls, user, width, _height): + params = {"s": str(width)} if settings.AVATAR_GRAVATAR_DEFAULT: - params['d'] = settings.AVATAR_GRAVATAR_DEFAULT + params["d"] = settings.AVATAR_GRAVATAR_DEFAULT if settings.AVATAR_GRAVATAR_FORCEDEFAULT: - params['f'] = 'y' - path = "%s/?%s" % (hashlib.md5(force_bytes(getattr(user, - settings.AVATAR_GRAVATAR_FIELD))).hexdigest(), urlencode(params)) + params["f"] = "y" + path = "%s/?%s" % ( + hashlib.md5( + force_bytes(getattr(user, settings.AVATAR_GRAVATAR_FIELD)) + ).hexdigest(), + urlencode(params), + ) return urljoin(settings.AVATAR_GRAVATAR_BASE_URL, path) @@ -74,11 +68,30 @@ class FacebookAvatarProvider(object): """ @classmethod - def get_avatar_url(self, user, size): + def get_avatar_url(cls, user, width, height): fb_id = get_facebook_id(user) if fb_id: - url = 'https://graph.facebook.com/{fb_id}/picture?type=square&width={size}&height={size}' - return url.format( - fb_id=fb_id, - size=size - ) + url = "https://graph.facebook.com/{fb_id}/picture?type=square&width={width}&height={height}" + return url.format(fb_id=fb_id, width=width, height=height) + + +class InitialsAvatarProvider(object): + """ + Returns a tuple with template_name and context for rendering the given user's avatar as their + initials in white against a background with random hue based on their primary key. + """ + + @classmethod + def get_avatar_url(cls, user, width, _height): + initials = user.first_name[:1] + user.last_name[:1] + if not initials: + initials = user.username[:1] + initials = initials.upper() + context = { + "fontsize": (width * 1.1) / 2, + "initials": initials, + "hue": user.pk % 360, + "saturation": "65%", + "lightness": "60%", + } + return ("avatar/initials.html", context) diff --git a/avatar/signals.py b/avatar/signals.py index 080910e..0491d4b 100644 --- a/avatar/signals.py +++ b/avatar/signals.py @@ -1,5 +1,4 @@ import django.dispatch - -avatar_updated = django.dispatch.Signal(providing_args=["user", "avatar"]) -avatar_deleted = django.dispatch.Signal(providing_args=["user", "avatar"]) +avatar_updated = django.dispatch.Signal() +avatar_deleted = django.dispatch.Signal() diff --git a/avatar/templates/avatar/avatar_tag.html b/avatar/templates/avatar/avatar_tag.html index e21ca17..5680153 100644 --- a/avatar/templates/avatar/avatar_tag.html +++ b/avatar/templates/avatar/avatar_tag.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/avatar/templates/avatar/base.html b/avatar/templates/avatar/base.html index 79ea0d4..a407d07 100644 --- a/avatar/templates/avatar/base.html +++ b/avatar/templates/avatar/base.html @@ -5,4 +5,4 @@ {% block content %}{% endblock %} - \ No newline at end of file + diff --git a/avatar/templates/avatar/confirm_delete.html b/avatar/templates/avatar/confirm_delete.html index f81c815..aad11a9 100644 --- a/avatar/templates/avatar/confirm_delete.html +++ b/avatar/templates/avatar/confirm_delete.html @@ -2,11 +2,11 @@ {% load i18n %} {% block content %} -

{% trans "Please select the avatars that you would like to delete." %}

{% if not avatars %} {% url 'avatar_change' as avatar_change_url %}

{% blocktrans %}You have no avatars to delete. Please upload one now.{% endblocktrans %}

{% else %} +

{% trans "Please select the avatars that you would like to delete." %}

    {{ delete_avatar_form.as_ul }} diff --git a/avatar/templates/avatar/initials.html b/avatar/templates/avatar/initials.html new file mode 100644 index 0000000..12f4930 --- /dev/null +++ b/avatar/templates/avatar/initials.html @@ -0,0 +1,11 @@ + + {{ initials }} diff --git a/avatar/templates/notification/avatar_updated/notice.html b/avatar/templates/notification/avatar_updated/notice.html index 3c2abbf..0a8a548 100644 --- a/avatar/templates/notification/avatar_updated/notice.html +++ b/avatar/templates/notification/avatar_updated/notice.html @@ -1,2 +1,2 @@ {% load i18n %} -{% blocktrans with user as avatar_creator and avatar.get_absolute_url as avatar_url %}You have updated your avatar {{ avatar }}.{% endblocktrans %} \ No newline at end of file +{% blocktrans with user as avatar_creator and avatar.get_absolute_url as avatar_url %}You have updated your avatar {{ avatar }}.{% endblocktrans %} diff --git a/avatar/templatetags/avatar_tags.py b/avatar/templatetags/avatar_tags.py index 7626e0c..c50556d 100644 --- a/avatar/templatetags/avatar_tags.py +++ b/avatar/templatetags/avatar_tags.py @@ -1,60 +1,68 @@ from django import template -try: - from django.urls import reverse -except ImportError: - # For Django < 1.10 - from django.core.urlresolvers import reverse from django.template.loader import render_to_string -from django.utils import six -from django.utils.translation import ugettext as _ +from django.urls import reverse from django.utils.module_loading import import_string +from django.utils.translation import gettext as _ from avatar.conf import settings from avatar.models import Avatar -from avatar.utils import ( - cache_result, - get_default_avatar_url, - get_user_model, - get_user, -) - +from avatar.utils import cache_result, get_default_avatar_url, get_user, get_user_model register = template.Library() @cache_result() @register.simple_tag -def avatar_url(user, size=settings.AVATAR_DEFAULT_SIZE): +def avatar_url(user, width=settings.AVATAR_DEFAULT_SIZE, height=None): + if height is None: + height = width for provider_path in settings.AVATAR_PROVIDERS: provider = import_string(provider_path) - avatar_url = provider.get_avatar_url(user, size) + avatar_url = provider.get_avatar_url(user, width, height) if avatar_url: return avatar_url @cache_result() @register.simple_tag -def avatar(user, size=settings.AVATAR_DEFAULT_SIZE, **kwargs): +def avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None, **kwargs): + if height is None: + height = width if not isinstance(user, get_user_model()): try: user = get_user(user) - alt = six.text_type(user) - url = avatar_url(user, size) + if settings.AVATAR_EXPOSE_USERNAMES: + alt = str(user) + else: + alt = _("User Avatar") + url = avatar_url(user, width, height) except get_user_model().DoesNotExist: url = get_default_avatar_url() alt = _("Default Avatar") else: - alt = six.text_type(user) - url = avatar_url(user, size) - kwargs.update({'alt': alt}) + if settings.AVATAR_EXPOSE_USERNAMES: + alt = str(user) + else: + alt = _("User Avatar") + url = avatar_url(user, width, height) + kwargs.update({"alt": alt}) context = { - 'user': user, - 'url': url, - 'size': size, - 'kwargs': kwargs, + "user": user, + "alt": alt, + "width": width, + "height": height, + "kwargs": kwargs, } - return render_to_string('avatar/avatar_tag.html', context) + template_name = "avatar/avatar_tag.html" + ext_context = None + try: + template_name, ext_context = url + except ValueError: + context["url"] = url + if ext_context: + context = dict(context, **ext_context) + return render_to_string(template_name, context) @register.filter @@ -66,48 +74,44 @@ def has_avatar(user): @cache_result() @register.simple_tag -def primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE): +def primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None): """ This tag tries to get the default avatar for a user without doing any db requests. It achieve this by linking to a special view that will do all the work for us. If that special view is then cached by a CDN for instance, we will avoid many db calls. """ - alt = six.text_type(user) - url = reverse('avatar_render_primary', kwargs={'user': user, 'size': size}) - return ("""%s""" % - (url, alt, size, size)) + kwargs = {"width": width} + if settings.AVATAR_EXPOSE_USERNAMES: + alt = str(user) + kwargs["user"] = user + else: + alt = _("User Avatar") + kwargs["user"] = user.id + if height is None: + height = width + else: + kwargs["height"] = height + + url = reverse("avatar_render_primary", kwargs=kwargs) + return """%s""" % ( + url, + width, + height, + alt, + ) @cache_result() @register.simple_tag -def render_avatar(avatar, size=settings.AVATAR_DEFAULT_SIZE): - if not avatar.thumbnail_exists(size): - avatar.create_thumbnail(size) +def render_avatar(avatar, width=settings.AVATAR_DEFAULT_SIZE, height=None): + if height is None: + height = width + if not avatar.thumbnail_exists(width, height): + avatar.create_thumbnail(width, height) return """%s""" % ( - avatar.avatar_url(size), six.text_type(avatar), 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 six.text_type() + avatar.avatar_url(width, height), + str(avatar), + width, + height, + ) diff --git a/avatar/urls.py b/avatar/urls.py index b031d60..9a043f8 100644 --- a/avatar/urls.py +++ b/avatar/urls.py @@ -1,12 +1,19 @@ -from django.conf.urls import url +from django.urls import re_path from avatar import views urlpatterns = [ - url(r'^add/$', views.add, name='avatar_add'), - url(r'^change/$', views.change, name='avatar_change'), - url(r'^delete/$', views.delete, name='avatar_delete'), - url(r'^render_primary/(?P[\w\d\@\.\-_]+)/(?P[\d]+)/$', + 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[\w\d\@\.\-_]+)/(?P[\d]+)/$", views.render_primary, - name='avatar_render_primary'), + name="avatar_render_primary", + ), + re_path( + r"^render_primary/(?P[\w\d\@\.\-_]+)/(?P[\d]+)/(?P[\d]+)/$", + views.render_primary, + name="avatar_render_primary", + ), ] diff --git a/avatar/utils.py b/avatar/utils.py index 9978cfc..652dca1 100644 --- a/avatar/utils.py +++ b/avatar/utils.py @@ -1,44 +1,44 @@ import hashlib -from django.core.cache import cache -from django.utils import six -from django.template.defaultfilters import slugify - -try: - from django.utils.encoding import force_bytes -except ImportError: - force_bytes = str - from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.template.defaultfilters import slugify +from django.utils.encoding import force_bytes from avatar.conf import settings - cached_funcs = set() def get_username(user): - """ Return username of a User instance """ - if hasattr(user, 'get_username'): + """Return username of a User instance""" + if hasattr(user, "get_username"): return user.get_username() else: return user.username -def get_user(username): - """ Return user from a username/ish identifier """ - return get_user_model().objects.get_by_natural_key(username) +def get_user(userdescriptor): + """Return user from a username/ID/ish identifier""" + User = get_user_model() + if userdescriptor.isdigit(): + user = User.objects.filter(id=int(userdescriptor)).first() + if user: + return user + return User.objects.get_by_natural_key(userdescriptor) -def get_cache_key(user_or_username, size, prefix): +def get_cache_key(user_or_username, width, height, prefix): """ Returns a cache key consisten of a username and image size. """ if isinstance(user_or_username, get_user_model()): user_or_username = get_username(user_or_username) - key = six.u('%s_%s_%s') % (prefix, user_or_username, size) - return six.u('%s_%s') % (slugify(key)[:100], - hashlib.md5(force_bytes(key)).hexdigest()) + key = "%s_%s_%s_%s" % (prefix, user_or_username, width, height or width) + return "%s_%s" % ( + slugify(key)[:100], + hashlib.md5(force_bytes(key)).hexdigest(), + ) def cache_set(key, value): @@ -48,67 +48,75 @@ def cache_set(key, value): def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE): """ - Decorator to cache the result of functions that take a ``user`` and a - ``size`` value. + Decorator to cache the result of functions that take a ``user``, a + ``width`` and a ``height`` value. """ if not settings.AVATAR_CACHE_ENABLED: + def decorator(func): return func + return decorator def decorator(func): - def cached_func(user, size=None, **kwargs): + def cached_func(user, width=None, height=None, **kwargs): prefix = func.__name__ cached_funcs.add(prefix) - key = get_cache_key(user, size or default_size, prefix=prefix) + key = get_cache_key(user, width or default_size, height, prefix=prefix) result = cache.get(key) if result is None: - result = func(user, size or default_size, **kwargs) + result = func(user, width or default_size, height, **kwargs) cache_set(key, result) # add image size to set of cached sizes so we can invalidate them later sizes_key = get_cache_key(user, '', prefix='cached_sizes') sizes = cache.get(sizes_key, set()) - sizes.add(size) + sizes.add((width or default_size, height or width)) cache_set(sizes_key, sizes) return result + return cached_func + return decorator -def invalidate_cache(user, size=None): +def invalidate_cache(user, width=None, height=None): """ - Function to be called when saving or changing an user's avatars. + Function to be called when saving or changing a user's avatars. """ sizes_key = get_cache_key(user, '', prefix='cached_sizes') sizes = cache.get(sizes_key, set()) - if size is not None: - sizes.add(size) + if width is not None: + sizes.add((width, height or width)) for prefix in cached_funcs: for size in sizes: - cache.delete(get_cache_key(user, size, prefix)) + if isinstance(size, int): + cache.delete(get_cache_key(user, size, size, prefix)) + else: + # Size is specified with height and width. + cache.delete(get_cache_key(user, size[0], size[1], prefix)) def get_default_avatar_url(): - base_url = getattr(settings, 'STATIC_URL', None) + base_url = getattr(settings, "STATIC_URL", None) if not base_url: - base_url = getattr(settings, 'MEDIA_URL', '') + base_url = getattr(settings, "MEDIA_URL", "") # Don't use base_url if the default url starts with http:// of https:// - if settings.AVATAR_DEFAULT_URL.startswith(('http://', 'https://')): + if settings.AVATAR_DEFAULT_URL.startswith(("http://", "https://")): return settings.AVATAR_DEFAULT_URL # We'll be nice and make sure there are no duplicated forward slashes - ends = base_url.endswith('/') + ends = base_url.endswith("/") - begins = settings.AVATAR_DEFAULT_URL.startswith('/') + begins = settings.AVATAR_DEFAULT_URL.startswith("/") if ends and begins: base_url = base_url[:-1] elif not ends and not begins: - return '%s/%s' % (base_url, settings.AVATAR_DEFAULT_URL) + return "%s/%s" % (base_url, settings.AVATAR_DEFAULT_URL) - return '%s%s' % (base_url, settings.AVATAR_DEFAULT_URL) + return "%s%s" % (base_url, settings.AVATAR_DEFAULT_URL) -def get_primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE): +def get_primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None): User = get_user_model() if not isinstance(user, User): try: @@ -124,6 +132,6 @@ def get_primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE): except IndexError: avatar = None if avatar: - if not avatar.thumbnail_exists(size): - avatar.create_thumbnail(size) + if not avatar.thumbnail_exists(width, height): + avatar.create_thumbnail(width, height) return avatar diff --git a/avatar/views.py b/avatar/views.py index 4796a42..4d38056 100644 --- a/avatar/views.py +++ b/avatar/views.py @@ -1,16 +1,13 @@ -from django.shortcuts import render, redirect -from django.utils import six -from django.utils.translation import ugettext as _ - from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect, render +from django.utils.translation import gettext as _ from avatar.conf import settings -from avatar.forms import PrimaryAvatarForm, DeleteAvatarForm, UploadAvatarForm +from avatar.forms import DeleteAvatarForm, PrimaryAvatarForm, UploadAvatarForm from avatar.models import Avatar -from avatar.signals import avatar_updated, avatar_deleted -from avatar.utils import (get_primary_avatar, get_default_avatar_url, - invalidate_cache) +from avatar.signals import avatar_deleted, avatar_updated +from avatar.utils import get_default_avatar_url, get_primary_avatar, invalidate_cache def _get_next(request): @@ -28,8 +25,9 @@ def _get_next(request): 3. If Django can determine the previous page from the HTTP headers, the view will redirect to that previous page. """ - next = request.POST.get('next', request.GET.get('next', - request.META.get('HTTP_REFERER', None))) + next = request.POST.get( + "next", request.GET.get("next", request.META.get("HTTP_REFERER", None)) + ) if not next: next = request.path return next @@ -40,7 +38,7 @@ def _get_avatars(user): avatars = user.avatar_set.all() # Current avatar - primary_avatar = avatars.order_by('-primary')[:1] + primary_avatar = avatars.order_by("-primary")[:1] if primary_avatar: avatar = primary_avatar[0] else: @@ -51,59 +49,70 @@ def _get_avatars(user): else: # Slice the default set now that we used # the queryset for the primary avatar - avatars = avatars[:settings.AVATAR_MAX_AVATARS_PER_USER] + avatars = avatars[: settings.AVATAR_MAX_AVATARS_PER_USER] return (avatar, avatars) @login_required -def add(request, extra_context=None, next_override=None, - upload_form=UploadAvatarForm, *args, **kwargs): +def add( + request, + extra_context=None, + next_override=None, + upload_form=UploadAvatarForm, + *args, + **kwargs, +): if extra_context is None: extra_context = {} avatar, avatars = _get_avatars(request.user) - upload_avatar_form = upload_form(request.POST or None, - request.FILES or None, - user=request.user) - if request.method == "POST" and 'avatar' in request.FILES: + upload_avatar_form = upload_form( + request.POST or None, request.FILES or None, user=request.user + ) + if request.method == "POST" and "avatar" in request.FILES: if upload_avatar_form.is_valid(): avatar = Avatar(user=request.user, primary=True) - image_file = request.FILES['avatar'] + image_file = request.FILES["avatar"] avatar.avatar.save(image_file.name, image_file) avatar.save() messages.success(request, _("Successfully uploaded a new avatar.")) avatar_updated.send(sender=Avatar, user=request.user, avatar=avatar) return redirect(next_override or _get_next(request)) context = { - 'avatar': avatar, - 'avatars': avatars, - 'upload_avatar_form': upload_avatar_form, - 'next': next_override or _get_next(request), + "avatar": avatar, + "avatars": avatars, + "upload_avatar_form": upload_avatar_form, + "next": next_override or _get_next(request), } context.update(extra_context) - template_name = settings.AVATAR_ADD_TEMPLATE or 'avatar/add.html' + template_name = settings.AVATAR_ADD_TEMPLATE or "avatar/add.html" return render(request, template_name, context) @login_required -def change(request, extra_context=None, next_override=None, - upload_form=UploadAvatarForm, primary_form=PrimaryAvatarForm, - *args, **kwargs): +def change( + request, + extra_context=None, + next_override=None, + upload_form=UploadAvatarForm, + primary_form=PrimaryAvatarForm, + *args, + **kwargs, +): if extra_context is None: extra_context = {} avatar, avatars = _get_avatars(request.user) if avatar: - kwargs = {'initial': {'choice': avatar.id}} + kwargs = {"initial": {"choice": avatar.id}} else: kwargs = {} upload_avatar_form = upload_form(user=request.user, **kwargs) - primary_avatar_form = primary_form(request.POST or None, - user=request.user, - avatars=avatars, **kwargs) + primary_avatar_form = primary_form( + request.POST or None, user=request.user, avatars=avatars, **kwargs + ) if request.method == "POST": updated = False - if 'choice' in request.POST and primary_avatar_form.is_valid(): - avatar = Avatar.objects.get( - id=primary_avatar_form.cleaned_data['choice']) + if "choice" in request.POST and primary_avatar_form.is_valid(): + avatar = Avatar.objects.get(id=primary_avatar_form.cleaned_data["choice"]) avatar.primary = True avatar.save() updated = True @@ -114,14 +123,14 @@ def change(request, extra_context=None, next_override=None, return redirect(next_override or _get_next(request)) context = { - 'avatar': avatar, - 'avatars': avatars, - 'upload_avatar_form': upload_avatar_form, - 'primary_avatar_form': primary_avatar_form, - 'next': next_override or _get_next(request) + "avatar": avatar, + "avatars": avatars, + "upload_avatar_form": upload_avatar_form, + "primary_avatar_form": primary_avatar_form, + "next": next_override or _get_next(request), } context.update(extra_context) - template_name = settings.AVATAR_CHANGE_TEMPLATE or 'avatar/change.html' + template_name = settings.AVATAR_CHANGE_TEMPLATE or "avatar/change.html" return render(request, template_name, context) @@ -130,51 +139,61 @@ def delete(request, extra_context=None, next_override=None, *args, **kwargs): if extra_context is None: extra_context = {} avatar, avatars = _get_avatars(request.user) - delete_avatar_form = DeleteAvatarForm(request.POST or None, - user=request.user, - avatars=avatars) - if request.method == 'POST': + delete_avatar_form = DeleteAvatarForm( + request.POST or None, user=request.user, avatars=avatars + ) + if request.method == "POST": if delete_avatar_form.is_valid(): - ids = delete_avatar_form.cleaned_data['choices'] + ids = delete_avatar_form.cleaned_data["choices"] for a in avatars: - if six.text_type(a.id) in ids: - avatar_deleted.send(sender=Avatar, user=request.user, - avatar=a) - if six.text_type(avatar.id) in ids and avatars.count() > len(ids): + if str(a.id) in ids: + avatar_deleted.send(sender=Avatar, user=request.user, avatar=a) + if str(avatar.id) in ids and avatars.count() > len(ids): # Find the next best avatar, and set it as the new primary for a in avatars: - if six.text_type(a.id) not in ids: + if str(a.id) not in ids: a.primary = True a.save() - avatar_updated.send(sender=Avatar, user=request.user, - avatar=avatar) + avatar_updated.send( + sender=Avatar, user=request.user, avatar=avatar + ) break Avatar.objects.filter(id__in=ids).delete() - messages.success(request, - _("Successfully deleted the requested avatars.")) + messages.success(request, _("Successfully deleted the requested avatars.")) return redirect(next_override or _get_next(request)) context = { - 'avatar': avatar, - 'avatars': avatars, - 'delete_avatar_form': delete_avatar_form, - 'next': next_override or _get_next(request), + "avatar": avatar, + "avatars": avatars, + "delete_avatar_form": delete_avatar_form, + "next": next_override or _get_next(request), } context.update(extra_context) - template_name = settings.AVATAR_DELETE_TEMPLATE or 'avatar/confirm_delete.html' + template_name = settings.AVATAR_DELETE_TEMPLATE or "avatar/confirm_delete.html" return render(request, template_name, context) -def render_primary(request, user=None, size=settings.AVATAR_DEFAULT_SIZE): - size = int(size) - avatar = get_primary_avatar(user, size=size) +def render_primary(request, user=None, width=settings.AVATAR_DEFAULT_SIZE, height=None): + if height is None: + height = width + width = int(width) + height = int(height) + avatar = get_primary_avatar(user, width=width, height=height) + if width == 0 and height == 0: + avatar = get_primary_avatar( + user, + width=settings.AVATAR_DEFAULT_SIZE, + height=settings.AVATAR_DEFAULT_SIZE, + ) + else: + avatar = get_primary_avatar(user, width=width, height=height) if avatar: # FIXME: later, add an option to render the resized avatar dynamically # instead of redirecting to an already created static file. This could # be useful in certain situations, particulary if there is a CDN and # we want to minimize the storage usage on our static server, letting # the CDN store those files instead - url = avatar.avatar_url(size) + url = avatar.avatar_url(width, height) else: url = get_default_avatar_url() diff --git a/docs/conf.py b/docs/conf.py index 9caf686..b0d6823 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,204 +11,208 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import os +import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath(".")) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.txt' +source_suffix = ".txt" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'django-avatar' -copyright = u'2013, django-avatar developers' +project = "django-avatar" +copyright = "2013, django-avatar developers" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '2.0' +version = "2.0" # The full version, including alpha/beta/rc tags. -release = '2.0' +release = "2.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'django-avatardoc' +htmlhelp_basename = "django-avatardoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'django-avatar.tex', u'django-avatar Documentation', - u'django-avatar developers', 'manual'), + ( + "index", + "django-avatar.tex", + "django-avatar Documentation", + "django-avatar developers", + "manual", + ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- @@ -216,12 +220,17 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'django-avatar', u'django-avatar Documentation', - [u'django-avatar developers'], 1) + ( + "index", + "django-avatar", + "django-avatar Documentation", + ["django-avatar developers"], + 1, + ) ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -230,19 +239,25 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'django-avatar', u'django-avatar Documentation', - u'django-avatar developers', 'django-avatar', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "django-avatar", + "django-avatar Documentation", + "django-avatar developers", + "django-avatar", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/docs/index.txt b/docs/index.txt index 6a17dc5..cf3628b 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -48,7 +48,7 @@ that are required. A minimal integration can work like this: urlpatterns = [ # ... - url('avatar/', include('avatar.urls')), + path('avatar/', include('avatar.urls')), ] 4. Somewhere in your template navigation scheme, link to the change avatar @@ -69,6 +69,10 @@ that are required. A minimal integration can work like this: {% avatar user 65 %} + Or specify a width and height (in pixels) explicitly:: + + {% avatar user 65 50 %} + Example for customize the attribute of the HTML ``img`` tag:: {% avatar user 65 class="img-circle img-responsive" id="user_avatar" %} @@ -106,9 +110,10 @@ appear on the site. Listed below are those settings: .. py:data:: AVATAR_AUTO_GENERATE_SIZES - An iterable of integers representing the sizes of avatars to generate on - upload. This can save rendering time later on if you pre-generate the - resized versions. Defaults to ``(80,)`` + An iterable of integers and/or sequences in the format ``(width, height)`` + representing the sizes of avatars to generate on upload. This can save + rendering time later on if you pre-generate the resized versions. Defaults + to ``(80,)``. .. py:data:: AVATAR_CACHE_ENABLED @@ -124,7 +129,7 @@ appear on the site. Listed below are those settings: Puts the User's username field in the URL path when ``True``. Set to ``False`` to use the User's primary key instead, preventing their email - from being searchable on the web. Defaults to ``True``. + from being searchable on the web. Defaults to ``False``. .. py:data:: AVATAR_FACEBOOK_GET_ID @@ -156,7 +161,7 @@ appear on the site. Listed below are those settings: .. py:data:: AVATAR_MAX_SIZE File size limit for avatar upload. Default is ``1024 * 1024`` (1 MB). - gravatar in ``user.gravatar``. Defaults to ``email``. + gravatar in ``user.gravatar``. .. py:data:: AVATAR_MAX_AVATARS_PER_USER @@ -180,7 +185,7 @@ appear on the site. Listed below are those settings: ) If you want to implement your own provider, it must provide a class method - ``get_avatar_url(user, size)``. + ``get_avatar_url(user, width, height)``. .. py:class:: avatar.providers.PrimaryAvatarProvider @@ -205,19 +210,35 @@ 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 - PIL. Defaults to ``Image.ANTIALIAS``. + 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``. - PIL. Defaults to ``Image.ANTIALIAS``. + Pillow. Defaults to ``Image.ANTIALIAS``. + +.. py:data:: AVATAR_THUMB_FORMAT + + The file format of thumbnails, based on the options available in + Pillow. Defaults to `PNG`. + +.. py:data:: AVATAR_THUMB_QUALITY + + The quality of thumbnails, between 0 (worst) to 95 (best) or the string + "keep" (only JPEG) as provided by Pillow. Defaults to `85`. + +.. py:data:: AVATAR_THUMB_MODES + + A sequence of acceptable modes for thumbnails as provided by Pillow. If the mode + of the image is not in the list, the thumbnail will be converted to the + first mode in the list. Defaults to `('RGB', 'RGBA')`. .. py:data:: AVATAR_CLEANUP_DELETED ``True`` if the avatar image files should be deleted when an avatar is - deleted from the database. Defaults to ``False``. + deleted from the database. Defaults to ``True``. .. py:data:: AVATAR_ADD_TEMPLATE diff --git a/setup.py b/setup.py index 4dffac8..205aefd 100644 --- a/setup.py +++ b/setup.py @@ -1,66 +1,65 @@ import codecs import re from os import path -from setuptools import setup, find_packages + +from setuptools import find_packages, setup def read(*parts): filename = path.join(path.dirname(__file__), *parts) - with codecs.open(filename, encoding='utf-8') as fp: + with codecs.open(filename, encoding="utf-8") as fp: return fp.read() def find_version(*file_paths): version_file = read(*file_paths) - version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", - version_file, re.M) + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) if version_match: return version_match.group(1) raise RuntimeError("Unable to find version string.") + setup( - name='django-avatar', + name="django-avatar", version=find_version("avatar", "__init__.py"), description="A Django app for handling user avatars", - long_description=read('README.rst'), + long_description=read("README.rst"), classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'Framework :: Django', - 'Framework :: Django :: 1.9', - 'Framework :: Django :: 1.10', - 'Framework :: Django :: 1.11', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', + "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='Grant McConnaughey', - maintainer_email='grantmcconnaughey@gmail.com', - url='http://github.com/grantmcconnaughey/django-avatar/', - license='BSD', - packages=find_packages(exclude=['tests']), + 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': [ - 'templates/notification/*/*.*', - 'templates/avatar/*.html', - 'locale/*/LC_MESSAGES/*', - 'media/avatar/img/default.jpg', + "avatar": [ + "templates/notification/*/*.*", + "templates/avatar/*.html", + "locale/*/LC_MESSAGES/*", + "media/avatar/img/default.jpg", ], }, install_requires=[ - 'Pillow>=2.0', - 'django-appconf>=0.6', + "Pillow>=8.4.0", + "django-appconf>=1.0.5", ], zip_safe=False, ) diff --git a/test_proj/manage.py b/test_proj/manage.py index d61e194..1844990 100755 --- a/test_proj/manage.py +++ b/test_proj/manage.py @@ -1,26 +1,26 @@ #!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" import os import sys -if __name__ == "__main__": + +def main(): + """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_proj.settings") # Add the django-avatar directory to the Python path. That way the # avatar module can be imported. - sys.path.append('..') + sys.path.append("..") try: from django.core.management import execute_from_command_line - except ImportError: - # The above import may fail for some other reason. Ensure that the - # issue is really that Django is missing to avoid masking other - # exceptions on Python 2. - try: - import django - except ImportError: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) - raise + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/test_proj/test_proj/settings.py b/test_proj/test_proj/settings.py index 81638d8..cd24a2b 100644 --- a/test_proj/test_proj/settings.py +++ b/test_proj/test_proj/settings.py @@ -20,7 +20,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = '0o$jym8^hgw%vwx9hy%@ncr!29n7gik30(ln$pd$!3*4zu+9dv' +SECRET_KEY = "0o$jym8^hgw%vwx9hy%@ncr!29n7gik30(ln$pd$!3*4zu+9dv" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -31,54 +31,53 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - 'avatar', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "avatar", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'test_proj.urls' +ROOT_URLCONF = "test_proj.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'test_proj.wsgi.application' +WSGI_APPLICATION = "test_proj.wsgi.application" # Database # https://docs.djangoproject.com/en/1.10/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), } } @@ -86,9 +85,9 @@ DATABASES = { # Internationalization # https://docs.djangoproject.com/en/1.10/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -100,7 +99,7 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.10/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') -MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, "media") +MEDIA_URL = "/media/" diff --git a/test_proj/test_proj/urls.py b/test_proj/test_proj/urls.py index 1144ca1..e874078 100644 --- a/test_proj/test_proj/urls.py +++ b/test_proj/test_proj/urls.py @@ -1,17 +1,16 @@ from django.conf import settings -from django.conf.urls import url, include +from django.conf.urls import include, url from django.contrib import admin from django.views.static import serve urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^avatar/', include('avatar.urls')), + url(r"^admin/", admin.site.urls), + url(r"^avatar/", include("avatar.urls")), ] if settings.DEBUG: # static files (images, css, javascript, etc.) urlpatterns += [ - url(r'^media/(?P.*)$', serve, { - 'document_root': settings.MEDIA_ROOT}) + url(r"^media/(?P.*)$", serve, {"document_root": settings.MEDIA_ROOT}) ] diff --git a/tests/requirements.txt b/tests/requirements.txt index ef0ba9e..cee13f4 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1,2 @@ -flake8 -coverage==4.2 \ No newline at end of file +coverage==6.2 +django diff --git a/tests/settings.py b/tests/settings.py index 51316ce..f17ab14 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -2,33 +2,25 @@ import os SETTINGS_DIR = os.path.dirname(__file__) -DATABASE_ENGINE = 'sqlite3' +DATABASE_ENGINE = "sqlite3" DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", } } INSTALLED_APPS = [ - 'django.contrib.sessions', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sites', - 'avatar', + "django.contrib.admin", + "django.contrib.messages", + "django.contrib.sessions", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sites", + "avatar", ] -# Django 1.9 -MIDDLEWARE_CLASSES = ( - "django.middleware.common.CommonMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", -) - -# Django 1.10+ MIDDLEWARE = ( "django.middleware.common.CommonMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -39,24 +31,33 @@ MIDDLEWARE = ( TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'APP_DIRS': True, - 'DIRS': [ - os.path.join(SETTINGS_DIR, 'templates') - ] + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "DIRS": [os.path.join(SETTINGS_DIR, "templates")], + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.request", + ] + }, } ] -ROOT_URLCONF = 'tests.urls' +ROOT_URLCONF = "tests.urls" SITE_ID = 1 -SECRET_KEY = 'something-something' +SECRET_KEY = "something-something" -ROOT_URLCONF = 'tests.urls' +ROOT_URLCONF = "tests.urls" -STATIC_URL = '/site_media/static/' +STATIC_URL = "/site_media/static/" -AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png') +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") diff --git a/tests/tests.py b/tests/tests.py index 4466247..a99fb75 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,22 +1,21 @@ -import os.path - import math +import os.path +from pathlib import Path +from shutil import rmtree + from django.contrib.admin.sites import AdminSite +from django.core import management from django.test import TestCase -try: - from django.urls import reverse -except ImportError: - # For Django < 1.10 - from django.core.urlresolvers import reverse from django.test.utils import override_settings +from django.urls import reverse +from PIL import Image, ImageChops from avatar.admin import AvatarAdmin from avatar.conf import settings -from avatar.utils import get_primary_avatar, get_user_model from avatar.models import Avatar -from avatar.templatetags import avatar_tags from avatar.signals import avatar_deleted -from PIL import Image, ImageChops +from avatar.templatetags import avatar_tags +from avatar.utils import get_primary_avatar, get_user_model class AssertSignal: @@ -37,32 +36,54 @@ class AssertSignal: def upload_helper(o, filename): f = open(os.path.join(o.testdatapath, filename), "rb") - response = o.client.post(reverse('avatar_add'), { - 'avatar': f, - }, follow=True) + response = o.client.post( + reverse("avatar_add"), + { + "avatar": f, + }, + follow=True, + ) f.close() return response def root_mean_square_difference(image1, image2): "Calculate the root-mean-square difference between two images" - diff = ImageChops.difference(image1, image2).convert('L') + diff = ImageChops.difference(image1, image2).convert("L") h = diff.histogram() - sq = (value * (idx ** 2) for idx, value in enumerate(h)) + sq = (value * (idx**2) for idx, value in enumerate(h)) sum_of_squares = sum(sq) rms = math.sqrt(sum_of_squares / float(image1.size[0] * image1.size[1])) return rms 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') + self.user = get_user_model().objects.create_user( + "test", "lennon@thebeatles.com", "testpassword" + ) self.user.save() - self.client.login(username='test', password='testpassword') + self.client.login(username="test", password="testpassword") 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() @@ -78,13 +99,13 @@ class AvatarTests(TestCase): def test_non_image_upload(self): response = upload_helper(self, "nonimagefile") self.assertEqual(response.status_code, 200) - self.assertNotEqual(response.context['upload_avatar_form'].errors, {}) + self.assertNotEqual(response.context["upload_avatar_form"].errors, {}) def test_normal_image_upload(self): response = upload_helper(self, "test.png") self.assertEqual(response.status_code, 200) self.assertEqual(len(response.redirect_chain), 1) - self.assertEqual(response.context['upload_avatar_form'].errors, {}) + self.assertEqual(response.context["upload_avatar_form"].errors, {}) avatar = get_primary_avatar(self.user) self.assertIsNotNone(avatar) self.assertEqual(avatar.user, self.user) @@ -95,29 +116,34 @@ class AvatarTests(TestCase): 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, {}) + self.assertNotEqual(response.context["upload_avatar_form"].errors, {}) 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 - self.assertNotEqual(response.context['upload_avatar_form'].errors, {}) + self.assertNotEqual(response.context["upload_avatar_form"].errors, {}) def test_image_too_big(self): # use with AVATAR_MAX_SIZE = 1024 * 1024 response = upload_helper(self, "testbig.png") 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, {}) + self.assertNotEqual(response.context["upload_avatar_form"].errors, {}) def test_default_url(self): - response = self.client.get(reverse('avatar_render_primary', kwargs={ - 'user': self.user.username, - 'size': 80, - })) - loc = response['Location'] - base_url = getattr(settings, 'STATIC_URL', None) + response = self.client.get( + reverse( + "avatar_render_primary", + kwargs={ + "user": self.user.username, + "width": 80, + }, + ) + ) + loc = response["Location"] + base_url = getattr(settings, "STATIC_URL", None) if not base_url: base_url = settings.MEDIA_URL self.assertTrue(base_url in loc) @@ -128,7 +154,7 @@ class AvatarTests(TestCase): self.assertEqual(a, None) def test_there_can_be_only_one_primary_avatar(self): - for i in range(1, 10): + for _ in range(1, 10): self.test_normal_image_upload() count = Avatar.objects.filter(user=self.user, primary=True).count() self.assertEqual(count, 1) @@ -139,9 +165,13 @@ class AvatarTests(TestCase): self.assertEqual(len(avatar), 1) receiver = AssertSignal() avatar_deleted.connect(receiver) - response = self.client.post(reverse('avatar_delete'), { - 'choices': [avatar[0].id], - }, follow=True) + response = self.client.post( + reverse("avatar_delete"), + { + "choices": [avatar[0].id], + }, + follow=True, + ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.redirect_chain), 1) count = Avatar.objects.filter(user=self.user).count() @@ -155,9 +185,12 @@ class AvatarTests(TestCase): self.test_there_can_be_only_one_primary_avatar() primary = get_primary_avatar(self.user) oid = primary.id - self.client.post(reverse('avatar_delete'), { - 'choices': [oid], - }) + self.client.post( + reverse("avatar_delete"), + { + "choices": [oid], + }, + ) primaries = Avatar.objects.filter(user=self.user, primary=True) self.assertEqual(len(primaries), 1) self.assertNotEqual(oid, primaries[0].id) @@ -166,57 +199,80 @@ 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']) + self.assertIsNotNone(response.context["avatar"]) def test_change_avatar_post_updates_primary_avatar(self): self.test_there_can_be_only_one_primary_avatar() 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'), { - 'choice': choice.pk, - }) + response = self.client.post( + reverse("avatar_change"), + { + "choice": choice.pk, + }, + ) self.assertEqual(response.status_code, 302) new_primary = Avatar.objects.get(user=self.user, primary=True) self.assertEqual(new_primary.pk, choice.pk) # Avatar with old primary pk exists but it is not primary anymore - self.assertTrue(Avatar.objects.filter(user=self.user, pk=old_primary.pk, primary=False).exists()) + self.assertTrue( + Avatar.objects.filter( + user=self.user, pk=old_primary.pk, primary=False + ).exists() + ) def test_too_many_avatars(self): - for i in range(0, settings.AVATAR_MAX_AVATARS_PER_USER): + for _ in range(0, settings.AVATAR_MAX_AVATARS_PER_USER): self.test_normal_image_upload() count_before = Avatar.objects.filter(user=self.user).count() response = upload_helper(self, "test.png") count_after = Avatar.objects.filter(user=self.user).count() 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, {}) + self.assertNotEqual(response.context["upload_avatar_form"].errors, {}) self.assertEqual(count_before, count_after) - @override_settings(AVATAR_THUMB_FORMAT='png') def test_automatic_thumbnail_creation_RGBA(self): upload_helper(self, "django.png") avatar = get_primary_avatar(self.user) - image = Image.open(avatar.avatar.storage.open(avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), 'rb')) - self.assertEqual(image.mode, 'RGBA') + image = Image.open( + avatar.avatar.storage.open( + avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb" + ) + ) + self.assertEqual(image.mode, "RGBA") + @override_settings(AVATAR_THUMB_FORMAT="JPEG") def test_automatic_thumbnail_creation_CMYK(self): upload_helper(self, "django_pony_cmyk.jpg") avatar = get_primary_avatar(self.user) - image = Image.open(avatar.avatar.storage.open(avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), 'rb')) - self.assertEqual(image.mode, 'RGB') + image = Image.open( + avatar.avatar.storage.open( + avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb" + ) + ) + self.assertEqual(image.mode, "RGB") def test_thumbnail_transpose_based_on_exif(self): upload_helper(self, "image_no_exif.jpg") avatar = get_primary_avatar(self.user) - image_no_exif = Image.open(avatar.avatar.storage.open(avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), 'rb')) + image_no_exif = Image.open( + avatar.avatar.storage.open( + avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb" + ) + ) upload_helper(self, "image_exif_orientation.jpg") avatar = get_primary_avatar(self.user) - image_with_exif = Image.open(avatar.avatar.storage.open(avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), 'rb')) + image_with_exif = Image.open( + avatar.avatar.storage.open( + avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb" + ) + ) self.assertLess(root_mean_square_difference(image_with_exif, image_no_exif), 1) @@ -237,6 +293,16 @@ class AvatarTests(TestCase): result = avatar_tags.avatar(self.user.username) + self.assertIn('User Avatar', result) + + @override_settings(AVATAR_EXPOSE_USERNAMES=True) + def test_avatar_tag_works_with_exposed_username(self): + upload_helper(self, "test.png") + avatar = get_primary_avatar(self.user) + + result = avatar_tags.avatar(self.user.username) + self.assertIn('test', result) @@ -247,7 +313,7 @@ class AvatarTests(TestCase): result = avatar_tags.avatar(self.user) self.assertIn('test', result) + self.assertIn('width="80" height="80" alt="User Avatar" />', result) def test_avatar_tag_works_with_custom_size(self): upload_helper(self, "test.png") @@ -256,48 +322,148 @@ class AvatarTests(TestCase): result = avatar_tags.avatar(self.user, 100) self.assertIn('test', result) + self.assertIn('width="100" height="100" alt="User Avatar" />', result) + + def test_avatar_tag_works_with_rectangle(self): + upload_helper(self, "test.png") + avatar = get_primary_avatar(self.user) + + result = avatar_tags.avatar(self.user, 100, 150) + + self.assertIn('User Avatar', result) def test_avatar_tag_works_with_kwargs(self): upload_helper(self, "test.png") avatar = get_primary_avatar(self.user) result = avatar_tags.avatar(self.user, title="Avatar") - html = 'test'.format(avatar.avatar_url(80)) + html = 'User Avatar'.format( + avatar.avatar_url(80) + ) self.assertInHTML(html, result) - def test_default_add_template(self): - response = self.client.get('/avatar/add/') - self.assertContains(response, 'Upload New Image') - self.assertNotContains(response, 'ALTERNATE ADD TEMPLATE') + def test_primary_avatar_tag_works(self): + upload_helper(self, "test.png") - @override_settings(AVATAR_ADD_TEMPLATE='alt/add.html') + result = avatar_tags.primary_avatar(self.user) + + self.assertIn(f'User Avatar', result) + + response = self.client.get(f"/avatar/render_primary/{self.user.id}/80/") + self.assertEqual(response.status_code, 302) + self.assertMediaFileExists(response.url) + + def test_primary_avatar_tag_works_with_custom_size(self): + upload_helper(self, "test.png") + + result = avatar_tags.primary_avatar(self.user, 90) + + self.assertIn(f'User Avatar', result) + + response = self.client.get(f"/avatar/render_primary/{self.user.id}/90/") + self.assertEqual(response.status_code, 302) + self.assertMediaFileExists(response.url) + + def test_primary_avatar_tag_works_with_rectangle(self): + upload_helper(self, "test.png") + + result = avatar_tags.primary_avatar(self.user, 60, 110) + + self.assertIn( + f'User Avatar', result) + + response = self.client.get(f"/avatar/render_primary/{self.user.id}/60/110/") + self.assertEqual(response.status_code, 302) + self.assertMediaFileExists(response.url) + + @override_settings(AVATAR_EXPOSE_USERNAMES=True) + def test_primary_avatar_tag_works_with_exposed_user(self): + upload_helper(self, "test.png") + + result = avatar_tags.primary_avatar(self.user) + + self.assertIn( + f'test', result) + + response = self.client.get(f"/avatar/render_primary/{self.user.username}/80/") + self.assertEqual(response.status_code, 302) + self.assertMediaFileExists(response.url) + + def test_default_add_template(self): + response = self.client.get("/avatar/add/") + self.assertContains(response, "Upload New Image") + self.assertNotContains(response, "ALTERNATE ADD TEMPLATE") + + @override_settings(AVATAR_ADD_TEMPLATE="alt/add.html") def test_custom_add_template(self): - response = self.client.get('/avatar/add/') - self.assertNotContains(response, 'Upload New Image') - self.assertContains(response, 'ALTERNATE ADD TEMPLATE') + response = self.client.get("/avatar/add/") + self.assertNotContains(response, "Upload New Image") + self.assertContains(response, "ALTERNATE ADD TEMPLATE") def test_default_change_template(self): - response = self.client.get('/avatar/change/') - self.assertContains(response, 'Upload New Image') - self.assertNotContains(response, 'ALTERNATE CHANGE TEMPLATE') + response = self.client.get("/avatar/change/") + self.assertContains(response, "Upload New Image") + self.assertNotContains(response, "ALTERNATE CHANGE TEMPLATE") - @override_settings(AVATAR_CHANGE_TEMPLATE='alt/change.html') + @override_settings(AVATAR_CHANGE_TEMPLATE="alt/change.html") def test_custom_change_template(self): - response = self.client.get('/avatar/change/') - self.assertNotContains(response, 'Upload New Image') - self.assertContains(response, 'ALTERNATE CHANGE TEMPLATE') + response = self.client.get("/avatar/change/") + self.assertNotContains(response, "Upload New Image") + self.assertContains(response, "ALTERNATE CHANGE TEMPLATE") def test_default_delete_template(self): - response = self.client.get('/avatar/delete/') - self.assertContains(response, 'like to delete.') - self.assertNotContains(response, 'ALTERNATE DELETE TEMPLATE') + upload_helper(self, "test.png") + response = self.client.get("/avatar/delete/") + self.assertContains(response, "like to delete.") + self.assertNotContains(response, "ALTERNATE DELETE TEMPLATE") - @override_settings(AVATAR_DELETE_TEMPLATE='alt/delete.html') + @override_settings(AVATAR_DELETE_TEMPLATE="alt/delete.html") def test_custom_delete_template(self): - response = self.client.get('/avatar/delete/') - self.assertNotContains(response, 'like to delete.') - self.assertContains(response, 'ALTERNATE DELETE TEMPLATE') + response = self.client.get("/avatar/delete/") + self.assertNotContains(response, "like to delete.") + self.assertContains(response, "ALTERNATE DELETE TEMPLATE") + + def get_media_file_mtime(self, path): + full_path = os.path.join(self.testmediapath, f".{path}") + return os.path.getmtime(full_path) + + def test_rebuild_avatars(self): + upload_helper(self, "test.png") + avatar_51_url = get_primary_avatar(self.user).avatar_url(51) + self.assertMediaFileExists(avatar_51_url) + avatar_51_mtime = self.get_media_file_mtime(avatar_51_url) + + avatar_62_url = get_primary_avatar(self.user).avatar_url(62) + self.assertMediaFileExists(avatar_62_url) + avatar_62_mtime = self.get_media_file_mtime(avatar_62_url) + + avatar_33_22_url = get_primary_avatar(self.user).avatar_url(33, 22) + self.assertMediaFileExists(avatar_33_22_url) + avatar_33_22_mtime = self.get_media_file_mtime(avatar_33_22_url) + + avatar_80_url = get_primary_avatar(self.user).avatar_url(80) + self.assertMediaFileExists(avatar_80_url) + avatar_80_mtime = self.get_media_file_mtime(avatar_80_url) + # Rebuild all avatars + management.call_command("rebuild_avatars", verbosity=0) + # Make sure the media files all exist, but that their modification times differ + self.assertMediaFileExists(avatar_51_url) + self.assertNotEqual(avatar_51_mtime, self.get_media_file_mtime(avatar_51_url)) + self.assertMediaFileExists(avatar_62_url) + self.assertNotEqual(avatar_62_mtime, self.get_media_file_mtime(avatar_62_url)) + self.assertMediaFileExists(avatar_33_22_url) + self.assertNotEqual( + avatar_33_22_mtime, self.get_media_file_mtime(avatar_33_22_url) + ) + self.assertMediaFileExists(avatar_80_url) + self.assertNotEqual(avatar_80_mtime, self.get_media_file_mtime(avatar_80_url)) # def testAvatarOrder # def testReplaceAvatarWhenMaxIsOne diff --git a/tests/urls.py b/tests/urls.py index ae590d0..0bc6b7e 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,6 +1,5 @@ -from django.conf.urls import include, url - +from django.urls import include, re_path urlpatterns = [ - url(r'^avatar/', include('avatar.urls')), + re_path(r"^avatar/", include("avatar.urls")), ]