├── tests
├── __init__.py
├── templates
│ └── alt
│ │ ├── add.html
│ │ ├── change.html
│ │ └── delete.html
├── requirements.txt
├── data
│ ├── test.png
│ ├── test.tiff
│ ├── django.png
│ ├── testbig.png
│ ├── django #3.png
│ ├── nonimagefile
│ ├── image_no_exif.jpg
│ ├── imagefilewithoutext
│ ├── django_pony_cmyk.jpg
│ ├── image_exif_orientation.jpg
│ └── imagefilewithwrongext.ogg
├── urls.py
├── settings.py
└── tests.py
├── avatar
├── api
│ ├── __init__.py
│ ├── migrations
│ │ └── __init__.py
│ ├── requirements.txt
│ ├── conf.py
│ ├── urls.py
│ ├── apps.py
│ ├── shortcut.py
│ ├── utils.py
│ ├── signals.py
│ ├── serializers.py
│ └── views.py
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── rebuild_avatars.py
├── migrations
│ ├── __init__.py
│ ├── 0003_auto_20170827_1345.py
│ ├── 0001_initial.py
│ └── 0002_add_verbose_names_to_avatar_fields.py
├── templatetags
│ ├── __init__.py
│ └── avatar_tags.py
├── __init__.py
├── static
│ └── avatar
│ │ └── img
│ │ └── default.jpg
├── locale
│ ├── de
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── es
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── fa
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── fr
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── it
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── ja
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── nl
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── pl
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── ru
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ ├── pt_BR
│ │ └── LC_MESSAGES
│ │ │ ├── django.mo
│ │ │ └── django.po
│ └── zh_CN
│ │ └── LC_MESSAGES
│ │ ├── django.mo
│ │ └── django.po
├── signals.py
├── apps.py
├── templates
│ ├── avatar
│ │ ├── avatar_tag.html
│ │ ├── base.html
│ │ ├── initials.html
│ │ ├── add.html
│ │ ├── confirm_delete.html
│ │ └── change.html
│ └── notification
│ │ ├── avatar_updated
│ │ ├── full.txt
│ │ └── notice.html
│ │ └── avatar_friend_updated
│ │ ├── full.txt
│ │ └── notice.html
├── urls.py
├── admin.py
├── conf.py
├── providers.py
├── forms.py
├── utils.py
├── views.py
└── models.py
├── test_proj
├── test_proj
│ ├── __init__.py
│ ├── wsgi.py
│ ├── urls.py
│ └── settings.py
└── manage.py
├── requirements.txt
├── CONTRIBUTORS.txt
├── .gitignore
├── MANIFEST.in
├── CONTRIBUTING.md
├── .coveragerc
├── .pre-commit-config.yaml
├── setup.py
├── .github
└── workflows
│ ├── release.yml
│ └── test.yml
├── .readthedocs.yaml
├── README.rst
├── LICENSE.txt
├── pyproject.toml
├── docs
├── avatar.rst
├── Makefile
├── make.bat
├── conf.py
└── index.rst
└── CHANGELOG.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/avatar/api/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/avatar/api/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/avatar/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/avatar/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/avatar/templatetags/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test_proj/test_proj/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/avatar/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/avatar/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "8.0.1"
2 |
--------------------------------------------------------------------------------
/avatar/api/requirements.txt:
--------------------------------------------------------------------------------
1 | djangorestframework
2 |
--------------------------------------------------------------------------------
/tests/templates/alt/add.html:
--------------------------------------------------------------------------------
1 | ALTERNATE ADD TEMPLATE
2 |
--------------------------------------------------------------------------------
/tests/templates/alt/change.html:
--------------------------------------------------------------------------------
1 | ALTERNATE CHANGE TEMPLATE
2 |
--------------------------------------------------------------------------------
/tests/templates/alt/delete.html:
--------------------------------------------------------------------------------
1 | ALTERNATE DELETE TEMPLATE
2 |
--------------------------------------------------------------------------------
/tests/requirements.txt:
--------------------------------------------------------------------------------
1 | coverage~=7.1.0
2 | django
3 | python-magic
4 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Pillow>=10.0.1
2 | django-appconf>=1.0.5
3 | dnspython>=2.3.0
4 |
--------------------------------------------------------------------------------
/tests/data/test.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/test.png
--------------------------------------------------------------------------------
/tests/data/test.tiff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/test.tiff
--------------------------------------------------------------------------------
/tests/data/django.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/django.png
--------------------------------------------------------------------------------
/tests/data/testbig.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/testbig.png
--------------------------------------------------------------------------------
/tests/data/django #3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/django #3.png
--------------------------------------------------------------------------------
/tests/data/nonimagefile:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/nonimagefile
--------------------------------------------------------------------------------
/tests/data/image_no_exif.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/image_no_exif.jpg
--------------------------------------------------------------------------------
/tests/data/imagefilewithoutext:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/imagefilewithoutext
--------------------------------------------------------------------------------
/tests/data/django_pony_cmyk.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/django_pony_cmyk.jpg
--------------------------------------------------------------------------------
/avatar/static/avatar/img/default.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/static/avatar/img/default.jpg
--------------------------------------------------------------------------------
/tests/data/image_exif_orientation.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/image_exif_orientation.jpg
--------------------------------------------------------------------------------
/tests/data/imagefilewithwrongext.ogg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/tests/data/imagefilewithwrongext.ogg
--------------------------------------------------------------------------------
/avatar/locale/de/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/de/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/es/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/es/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/fa/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/fa/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/fr/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/fr/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/it/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/it/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/ja/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/ja/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/nl/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/nl/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/pl/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/pl/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/ru/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/ru/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/pt_BR/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/pt_BR/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/locale/zh_CN/LC_MESSAGES/django.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jazzband/django-avatar/main/avatar/locale/zh_CN/LC_MESSAGES/django.mo
--------------------------------------------------------------------------------
/avatar/signals.py:
--------------------------------------------------------------------------------
1 | import django.dispatch
2 |
3 | avatar_updated = django.dispatch.Signal()
4 | avatar_deleted = django.dispatch.Signal()
5 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import include, re_path
2 |
3 | urlpatterns = [
4 | re_path(r"^avatar/", include("avatar.urls")),
5 | ]
6 |
--------------------------------------------------------------------------------
/avatar/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class Config(AppConfig):
5 | name = "avatar"
6 | default_auto_field = "django.db.models.AutoField"
7 |
--------------------------------------------------------------------------------
/CONTRIBUTORS.txt:
--------------------------------------------------------------------------------
1 | This application was originally written by Eric Florenzano.
2 |
3 | See the full list here: https://github.com/jazzband/django-avatar/graphs/contributors
4 |
--------------------------------------------------------------------------------
/avatar/templates/avatar/avatar_tag.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/avatar/api/conf.py:
--------------------------------------------------------------------------------
1 | from appconf import AppConf
2 |
3 |
4 | class AvatarAPIConf(AppConf):
5 | # allow updating avatar image in put method
6 | AVATAR_CHANGE_IMAGE = False
7 |
--------------------------------------------------------------------------------
/avatar/api/urls.py:
--------------------------------------------------------------------------------
1 | from rest_framework.routers import SimpleRouter
2 |
3 | from avatar.api.views import AvatarViewSets
4 |
5 | router = SimpleRouter()
6 | router.register("avatar", AvatarViewSets)
7 |
8 | urlpatterns = router.urls
9 |
--------------------------------------------------------------------------------
/avatar/templates/avatar/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% block title %}django-avatar{% endblock %}
4 |
5 |
6 | {% block content %}{% endblock %}
7 |
8 |
9 |
--------------------------------------------------------------------------------
/avatar/templates/notification/avatar_updated/full.txt:
--------------------------------------------------------------------------------
1 | {% load i18n %}{% blocktrans with avatar.get_absolute_url as avatar_url %}Your avatar has been updated. {{ avatar }}
2 |
3 | http://{{ current_site }}{{ avatar_url }}
4 | {% endblocktrans %}
5 |
--------------------------------------------------------------------------------
/avatar/templates/notification/avatar_updated/notice.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 | {% blocktrans with user as avatar_creator and avatar.get_absolute_url as avatar_url %}You have updated your avatar {{ avatar }}.{% endblocktrans %}
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | __pycache__
3 | build/
4 | src/
5 | pip-log.txt
6 | *DS_Store
7 | *~
8 | dist/
9 | *.egg-info/
10 | avatars
11 | .coverage
12 | docs/_build
13 | htmlcov/
14 | *.sqlite3
15 | test_proj/media
16 | .python-version
17 | /test-media/
18 | .envrc
19 | .direnv/
20 |
--------------------------------------------------------------------------------
/avatar/templates/notification/avatar_friend_updated/full.txt:
--------------------------------------------------------------------------------
1 | {% load i18n %}{% blocktrans with user as avatar_creator and avatar.get_absolute_url as avatar_url %}{{ avatar_creator }} has updated their avatar {{ avatar }}.
2 |
3 | http://{{ current_site }}{{ avatar_url }}
4 | {% endblocktrans %}
5 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst
2 | include LICENSE.txt
3 | include CONTRIBUTORS.txt
4 | include avatar/media/avatar/img/default.jpg
5 | recursive-include docs *
6 | recursive-include avatar/templates *.html *.txt
7 | recursive-include avatar/locale/*/LC_MESSAGES *.mo *.po
8 | recursive-exclude tests *
9 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | [](https://jazzband.co/)
2 |
3 | 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).
4 |
--------------------------------------------------------------------------------
/avatar/templates/notification/avatar_friend_updated/notice.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}{% url 'profile_detail' username=user.username as user_url %}{# TODO: support custom user models via get_username; actually, is this template even used anymore? #}
2 | {% blocktrans with user as avatar_creator and avatar.get_absolute_url as avatar_url %}{{ avatar_creator }} has updated their avatar {{ avatar }}.{% endblocktrans %}
3 |
--------------------------------------------------------------------------------
/avatar/migrations/0003_auto_20170827_1345.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations
2 |
3 | import avatar.models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("avatar", "0002_add_verbose_names_to_avatar_fields"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name="avatar",
14 | name="avatar",
15 | field=avatar.models.AvatarField(),
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/test_proj/test_proj/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for test_proj project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_proj.settings")
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/avatar/templates/avatar/initials.html:
--------------------------------------------------------------------------------
1 |
11 | {{ initials }}
12 |
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | [report]
2 | exclude_lines =
3 | pragma: no cover
4 |
5 | # Don't complain about missing debug-only code:
6 | def __repr__
7 | if self\.debug
8 |
9 | # Don't complain if tests don't hit defensive assertion code:
10 | raise AssertionError
11 | raise NotImplementedError
12 | except ImportError
13 |
14 | # Don't complain if non-runnable code isn't run:
15 | if 0:
16 | if __name__ == .__main__.:
17 |
18 | omit =
19 | avatar/migrations/*
20 |
21 | show_missing = True
22 | precision = 2
23 |
24 | [html]
25 | directory = htmlcov/
26 |
--------------------------------------------------------------------------------
/avatar/templates/avatar/add.html:
--------------------------------------------------------------------------------
1 | {% extends "avatar/base.html" %}
2 | {% load i18n avatar_tags %}
3 |
4 | {% block content %}
5 | {% trans "Your current avatar: " %}
6 | {% avatar user %}
7 | {% if not avatars %}
8 | {% trans "You haven't uploaded an avatar yet. Please upload one now." %}
9 | {% endif %}
10 |
14 | {% endblock %}
15 |
--------------------------------------------------------------------------------
/test_proj/test_proj/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.conf.urls import include
3 | from django.contrib import admin
4 | from django.urls import re_path
5 | from django.views.static import serve
6 |
7 | urlpatterns = [
8 | re_path(r"^admin/", admin.site.urls),
9 | re_path(r"^avatar/", include("avatar.urls")),
10 | re_path(r"^api/", include("avatar.api.urls")),
11 | ]
12 |
13 |
14 | if settings.DEBUG:
15 | # static files (images, css, javascript, etc.)
16 | urlpatterns += [
17 | re_path(r"^media/(?P.*)$", serve, {"document_root": settings.MEDIA_ROOT})
18 | ]
19 |
--------------------------------------------------------------------------------
/avatar/api/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 | from django.db.models import signals
3 |
4 | from avatar.models import Avatar
5 |
6 |
7 | class ApiConfig(AppConfig):
8 | default_auto_field = "django.db.models.BigAutoField"
9 | name = "avatar.api"
10 |
11 | def ready(self):
12 | from .conf import settings as api_settings
13 | from .signals import (
14 | create_default_thumbnails,
15 | remove_previous_avatar_images_when_update,
16 | )
17 |
18 | if api_settings.API_AVATAR_CHANGE_IMAGE:
19 | signals.pre_save.connect(
20 | remove_previous_avatar_images_when_update, sender=Avatar
21 | )
22 | signals.post_save.connect(create_default_thumbnails, sender=Avatar)
23 |
--------------------------------------------------------------------------------
/avatar/templates/avatar/confirm_delete.html:
--------------------------------------------------------------------------------
1 | {% extends "avatar/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 | {% if not avatars %}
6 | {% url 'avatar_change' as avatar_change_url %}
7 | {% blocktrans %}You have no avatars to delete. Please upload one now.{% endblocktrans %}
8 | {% else %}
9 | {% trans "Please select the avatars that you would like to delete." %}
10 |
16 | {% endif %}
17 | {% endblock %}
18 |
--------------------------------------------------------------------------------
/avatar/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | from avatar import views
4 |
5 | # For reversing namespaced urls
6 | # https://docs.djangoproject.com/en/4.1/topics/http/urls/#reversing-namespaced-urls
7 | app_name = "avatar"
8 |
9 | urlpatterns = [
10 | path("add/", views.add, name="add"),
11 | path("change/", views.change, name="change"),
12 | path("delete/", views.delete, name="delete"),
13 | # https://docs.djangoproject.com/en/4.1/topics/http/urls/#path-converters
14 | path(
15 | "render_primary///",
16 | views.render_primary,
17 | name="render_primary",
18 | ),
19 | path(
20 | "render_primary////",
21 | views.render_primary,
22 | name="render_primary",
23 | ),
24 | ]
25 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v5.0.0
4 | hooks:
5 | - id: end-of-file-fixer
6 | - id: trailing-whitespace
7 |
8 | - repo: https://github.com/pycqa/isort
9 | rev: "6.0.0"
10 | hooks:
11 | - id: isort
12 | args: ["--profile", "black"]
13 |
14 | - repo: https://github.com/psf/black
15 | rev: 25.1.0
16 | hooks:
17 | - id: black
18 | args: [--target-version=py310]
19 |
20 | - repo: https://github.com/pycqa/flake8
21 | rev: '7.1.2'
22 | hooks:
23 | - id: flake8
24 | additional_dependencies:
25 | - flake8-bugbear
26 | - flake8-comprehensions
27 | - flake8-tidy-imports
28 | - flake8-print
29 | args: [--max-line-length=120]
30 |
--------------------------------------------------------------------------------
/avatar/templates/avatar/change.html:
--------------------------------------------------------------------------------
1 | {% extends "avatar/base.html" %}
2 | {% load i18n avatar_tags %}
3 |
4 | {% block content %}
5 | {% trans "Your current avatar: " %}
6 | {% avatar user %}
7 | {% if not avatars %}
8 | {% trans "You haven't uploaded an avatar yet. Please upload one now." %}
9 | {% else %}
10 |
16 | {% endif %}
17 |
21 | {% endblock %}
22 |
--------------------------------------------------------------------------------
/test_proj/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_proj.settings")
10 |
11 | # Add the django-avatar directory to the Python path. That way the
12 | # avatar module can be imported.
13 | sys.path.append("..")
14 | try:
15 | from django.core.management import execute_from_command_line
16 | except ImportError as exc:
17 | raise ImportError(
18 | "Couldn't import Django. Are you sure it's installed and "
19 | "available on your PYTHONPATH environment variable? Did you "
20 | "forget to activate a virtual environment?"
21 | ) from exc
22 | execute_from_command_line(sys.argv)
23 |
24 |
25 | if __name__ == "__main__":
26 | main()
27 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import codecs
2 | import re
3 | from os import path
4 |
5 | from setuptools import find_packages, setup
6 |
7 |
8 | def read(*parts):
9 | filename = path.join(path.dirname(__file__), *parts)
10 | with codecs.open(filename, encoding="utf-8") as fp:
11 | return fp.read()
12 |
13 |
14 | def find_version(*file_paths):
15 | version_file = read(*file_paths)
16 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
17 | if version_match:
18 | return version_match.group(1)
19 | raise RuntimeError("Unable to find version string.")
20 |
21 |
22 | setup(
23 | packages=find_packages(exclude=["tests"]),
24 | package_data={
25 | "avatar": [
26 | "templates/notification/*/*.*",
27 | "templates/avatar/*.html",
28 | "locale/*/LC_MESSAGES/*",
29 | "media/avatar/img/default.jpg",
30 | ],
31 | },
32 | zip_safe=False,
33 | )
34 |
--------------------------------------------------------------------------------
/avatar/api/shortcut.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import _get_queryset
2 |
3 |
4 | def get_object_or_none(klass, *args, **kwargs):
5 | """
6 | Use get() to return an object, or return None if the object
7 | does not exist.
8 |
9 | klass may be a Model, Manager, or QuerySet object. All other passed
10 | arguments and keyword arguments are used in the get() query.
11 |
12 | Like with QuerySet.get(), MultipleObjectsReturned is raised if more than
13 | one object is found.
14 | """
15 | queryset = _get_queryset(klass)
16 | if not hasattr(queryset, "get"):
17 | klass__name = (
18 | klass.__name__ if isinstance(klass, type) else klass.__class__.__name__
19 | )
20 | raise ValueError(
21 | "First argument to get_object_or_404() must be a Model, Manager, "
22 | "or QuerySet, not '%s'." % klass__name
23 | )
24 | try:
25 | return queryset.get(*args, **kwargs)
26 | except queryset.model.DoesNotExist:
27 | return None
28 |
--------------------------------------------------------------------------------
/avatar/management/commands/rebuild_avatars.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from avatar.conf import settings
4 | from avatar.models import Avatar, remove_avatar_images
5 |
6 |
7 | class Command(BaseCommand):
8 | help = (
9 | "Regenerates avatar thumbnails for the sizes specified in "
10 | "settings.AVATAR_AUTO_GENERATE_SIZES."
11 | )
12 |
13 | def handle(self, *args, **options):
14 | for avatar in Avatar.objects.all():
15 | if settings.AVATAR_CLEANUP_DELETED:
16 | remove_avatar_images(avatar, delete_main_avatar=False)
17 | for size in settings.AVATAR_AUTO_GENERATE_SIZES:
18 | if options["verbosity"] != 0:
19 | self.stdout.write(
20 | "Rebuilding Avatar id=%s at size %s." % (avatar.id, size)
21 | )
22 | if isinstance(size, int):
23 | avatar.create_thumbnail(size, size)
24 | else:
25 | # Size is specified with height and width.
26 | avatar.create_thumbnail(size[0], size[1])
27 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | jobs:
9 | build:
10 | if: github.repository == 'jazzband/django-avatar'
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v2
15 | with:
16 | fetch-depth: 0
17 |
18 | - name: Set up Python
19 | uses: actions/setup-python@v2
20 | with:
21 | python-version: 3.11
22 |
23 | - name: Install dependencies
24 | run: |
25 | python -m pip install -U pip
26 | python -m pip install -U setuptools twine wheel
27 | - name: Build package
28 | run: |
29 | python setup.py --version
30 | python setup.py sdist --format=gztar bdist_wheel
31 | twine check dist/*
32 | - name: Upload packages to Jazzband
33 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
34 | uses: pypa/gh-action-pypi-publish@release/v1
35 | with:
36 | user: jazzband
37 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
38 | repository_url: https://jazzband.co/projects/django-avatar/upload
39 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # Read the Docs configuration file for Sphinx projects
2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3 |
4 | # Required
5 | version: 2
6 |
7 | # Set the OS, Python version and other tools you might need
8 | build:
9 | os: ubuntu-22.04
10 | tools:
11 | python: "3.12"
12 | # You can also specify other tool versions:
13 | # nodejs: "20"
14 | # rust: "1.70"
15 | # golang: "1.20"
16 |
17 | # Build documentation in the "docs/" directory with Sphinx
18 | sphinx:
19 | configuration: docs/conf.py
20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
21 | # builder: "dirhtml"
22 | # Fail on all warnings to avoid broken references
23 | # fail_on_warning: true
24 |
25 | # Optionally build your docs in additional formats such as PDF and ePub
26 | # formats:
27 | # - pdf
28 | # - epub
29 |
30 | # Optional but recommended, declare the Python requirements required
31 | # to build your documentation
32 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
33 | # python:
34 | # install:
35 | # - requirements: docs/requirements.txt
36 |
--------------------------------------------------------------------------------
/avatar/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.template.loader import render_to_string
3 | from django.utils.translation import gettext_lazy as _
4 |
5 | from avatar.models import Avatar
6 | from avatar.signals import avatar_updated
7 | from avatar.utils import get_user_model
8 |
9 |
10 | class AvatarAdmin(admin.ModelAdmin):
11 | list_display = ("get_avatar", "user", "primary", "date_uploaded")
12 | list_filter = ("primary",)
13 | autocomplete_fields = ("user",)
14 | search_fields = (
15 | "user__%s" % getattr(get_user_model(), "USERNAME_FIELD", "username"),
16 | )
17 | list_per_page = 50
18 |
19 | def get_avatar(self, avatar_in):
20 | context = {
21 | "user": avatar_in.user,
22 | "url": avatar_in.avatar.url,
23 | "alt": str(avatar_in.user),
24 | "size": 80,
25 | }
26 | return render_to_string("avatar/avatar_tag.html", context)
27 |
28 | get_avatar.short_description = _("Avatar")
29 | get_avatar.allow_tags = True
30 |
31 | def save_model(self, request, obj, form, change):
32 | super().save_model(request, obj, form, change)
33 | avatar_updated.send(sender=Avatar, user=request.user, avatar=obj)
34 |
35 |
36 | admin.site.register(Avatar, AvatarAdmin)
37 |
--------------------------------------------------------------------------------
/avatar/api/utils.py:
--------------------------------------------------------------------------------
1 | from html.parser import HTMLParser
2 |
3 | from avatar.conf import settings
4 |
5 |
6 | class HTMLTagParser(HTMLParser):
7 | """
8 | URL parser for getting (url ,width ,height) from avatar templatetags
9 | """
10 |
11 | def __init__(self, output=None):
12 | HTMLParser.__init__(self)
13 | if output is None:
14 | self.output = {}
15 | else:
16 | self.output = output
17 |
18 | def handle_starttag(self, tag, attrs):
19 | self.output.update(dict(attrs))
20 |
21 |
22 | def assign_width_or_height(query_params):
23 | """
24 | Getting width and height in url parameters and specifying them
25 | """
26 | avatar_default_size = settings.AVATAR_DEFAULT_SIZE
27 |
28 | width = query_params.get("width", avatar_default_size)
29 | height = query_params.get("height", avatar_default_size)
30 |
31 | if width == "":
32 | width = avatar_default_size
33 | if height == "":
34 | height = avatar_default_size
35 |
36 | if height == avatar_default_size and height != "":
37 | height = width
38 | elif width == avatar_default_size and width != "":
39 | width = height
40 |
41 | width = int(width)
42 | height = int(height)
43 |
44 | context = {"width": width, "height": height}
45 | return context
46 |
47 |
48 | def set_new_primary(query_set, instance):
49 | queryset = query_set.exclude(id=instance.id).first()
50 | if queryset:
51 | queryset.primary = True
52 | queryset.save()
53 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | =============
2 | django-avatar
3 | =============
4 |
5 | .. image:: https://jazzband.co/static/img/badge.png
6 | :target: https://jazzband.co/
7 | :alt: Jazzband
8 |
9 | .. image:: https://img.shields.io/pypi/pyversions/django-avatar.svg
10 | :target: https://pypi.org/project/django-avatar/
11 | :alt: Supported Python versions
12 |
13 | .. image:: https://img.shields.io/pypi/djversions/django-avatar.svg
14 | :target: https://pypi.org/project/django-avatar/
15 | :alt: Supported Django versions
16 |
17 | .. image:: https://github.com/jazzband/django-avatar/actions/workflows/test.yml/badge.svg
18 | :target: https://github.com/jazzband/django-avatar/actions/workflows/test.yml
19 |
20 | .. image:: https://codecov.io/gh/jazzband/django-avatar/branch/main/graph/badge.svg?token=BO1e4kkgtq
21 | :target: https://codecov.io/gh/jazzband/django-avatar
22 |
23 | .. image:: https://badge.fury.io/py/django-avatar.svg
24 | :target: https://badge.fury.io/py/django-avatar
25 | :alt: PyPI badge
26 |
27 | .. image:: https://readthedocs.org/projects/django-avatar/badge/?version=latest
28 | :target: https://django-avatar.readthedocs.org/en/latest/?badge=latest
29 | :alt: Documentation Status
30 |
31 | Django-avatar is a reusable application for handling user avatars. It has the
32 | ability to default to Gravatar if no avatar is found for a certain user.
33 | Django-avatar automatically generates thumbnails and stores them to your default
34 | file storage backend for retrieval later.
35 |
36 | For more information see the documentation at https://django-avatar.readthedocs.org/
37 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright (c) 2008, Eric Florenzano
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are
6 | met:
7 |
8 | * Redistributions of source code must retain the above copyright
9 | notice, this list of conditions and the following disclaimer.
10 | * Redistributions in binary form must reproduce the above
11 | copyright notice, this list of conditions and the following
12 | disclaimer in the documentation and/or other materials provided
13 | with the distribution.
14 | * Neither the name of the author nor the names of other
15 | contributors may be used to endorse or promote products derived
16 | from this software without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["setuptools>=65.6.3", "wheel"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "django-avatar"
7 | description = "A Django app for handling user avatars"
8 | authors = [{email = "floguy@gmail.com", name = "Eric Florenzano"}]
9 | maintainers = [{email = "johannes@fiduswriter.org", name = "Johannes Wilm"}]
10 | license = {text = "BSD-4-Clause"}
11 | readme = "README.rst"
12 | keywords=["avatar", "django"]
13 | classifiers=[
14 | "Development Status :: 5 - Production/Stable",
15 | "Environment :: Web Environment",
16 | "Framework :: Django",
17 | "Intended Audience :: Developers",
18 | "Framework :: Django",
19 | "Framework :: Django :: 3.2",
20 | "Framework :: Django :: 4.1",
21 | "Framework :: Django :: 4.2",
22 | "License :: OSI Approved :: BSD License",
23 | "Operating System :: OS Independent",
24 | "Programming Language :: Python",
25 | "Programming Language :: Python :: 3.7",
26 | "Programming Language :: Python :: 3.8",
27 | "Programming Language :: Python :: 3.9",
28 | "Programming Language :: Python :: 3.10",
29 | "Programming Language :: Python :: 3.11",
30 | ]
31 | dynamic = ["version", "dependencies"]
32 |
33 | [project.urls]
34 | homepage = "https://github.com/jazzband/django-avatar"
35 | repository = "https://github.com/jazzband/django-avatar"
36 | documentation = "https://django-avatar.readthedocs.io"
37 |
38 | [tool.setuptools.dynamic]
39 | version = {attr = "avatar.__version__"}
40 | dependencies = {file = "requirements.txt"}
41 |
--------------------------------------------------------------------------------
/avatar/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | import django.core.files.storage
2 | import django.utils.timezone
3 | from django.conf import settings
4 | from django.db import migrations, models
5 |
6 | import avatar.models
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name="Avatar",
17 | fields=[
18 | (
19 | "id",
20 | models.AutoField(
21 | verbose_name="ID",
22 | serialize=False,
23 | auto_created=True,
24 | primary_key=True,
25 | ),
26 | ),
27 | ("primary", models.BooleanField(default=False)),
28 | (
29 | "avatar",
30 | models.ImageField(
31 | storage=django.core.files.storage.FileSystemStorage(),
32 | max_length=1024,
33 | upload_to=avatar.models.avatar_file_path,
34 | blank=True,
35 | ),
36 | ),
37 | (
38 | "date_uploaded",
39 | models.DateTimeField(default=django.utils.timezone.now),
40 | ),
41 | (
42 | "user",
43 | models.ForeignKey(
44 | to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE
45 | ),
46 | ),
47 | ],
48 | ),
49 | ]
50 |
--------------------------------------------------------------------------------
/avatar/conf.py:
--------------------------------------------------------------------------------
1 | from appconf import AppConf
2 | from django.conf import settings
3 | from PIL import Image
4 |
5 |
6 | class AvatarConf(AppConf):
7 | DEFAULT_SIZE = 80
8 | RESIZE_METHOD = Image.Resampling.LANCZOS
9 | STORAGE_DIR = "avatars"
10 | PATH_HANDLER = "avatar.models.avatar_path_handler"
11 | GRAVATAR_BASE_URL = "https://www.gravatar.com/avatar/"
12 | GRAVATAR_FIELD = "email"
13 | GRAVATAR_DEFAULT = None
14 | AVATAR_GRAVATAR_FORCEDEFAULT = False
15 | DEFAULT_URL = "avatar/img/default.jpg"
16 | MAX_AVATARS_PER_USER = 42
17 | MAX_SIZE = 1024 * 1024
18 | THUMB_FORMAT = "PNG"
19 | THUMB_QUALITY = 85
20 | THUMB_MODES = ("RGB", "RGBA")
21 | HASH_FILENAMES = False
22 | HASH_USERDIRNAMES = False
23 | EXPOSE_USERNAMES = False
24 | ALLOWED_FILE_EXTS = None
25 | ALLOWED_MIMETYPES = None
26 | CACHE_TIMEOUT = 60 * 60
27 | if hasattr(settings, "DEFAULT_FILE_STORAGE"):
28 | STORAGE = settings.DEFAULT_FILE_STORAGE # deprecated settings
29 | STORAGE_ALIAS = "default"
30 | CLEANUP_DELETED = True
31 | AUTO_GENERATE_SIZES = (DEFAULT_SIZE,)
32 | FACEBOOK_GET_ID = None
33 | CACHE_ENABLED = True
34 | RANDOMIZE_HASHES = False
35 | ADD_TEMPLATE = ""
36 | CHANGE_TEMPLATE = ""
37 | DELETE_TEMPLATE = ""
38 | PROVIDERS = (
39 | "avatar.providers.PrimaryAvatarProvider",
40 | "avatar.providers.LibRAvatarProvider",
41 | "avatar.providers.GravatarAvatarProvider",
42 | "avatar.providers.DefaultAvatarProvider",
43 | )
44 |
45 | def configure_auto_generate_avatar_sizes(self, value):
46 | return value or getattr(
47 | settings, "AVATAR_AUTO_GENERATE_SIZES", (self.DEFAULT_SIZE,)
48 | )
49 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on: [push, pull_request]
3 | jobs:
4 | Build:
5 | runs-on: ubuntu-latest
6 | strategy:
7 | matrix:
8 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
9 | django-version: ['3.2', '4.1', '4.2', '5.0', '5.1.*']
10 | exclude:
11 | - python-version: 3.11
12 | django-version: 3.2
13 |
14 | - python-version: 3.12
15 | django-version: 3.2
16 |
17 | - python-version: 3.8
18 | django-version: 5.0
19 |
20 | - python-version: 3.9
21 | django-version: 5.0
22 |
23 | - python-version: 3.8
24 | django-version: 5.1.*
25 |
26 | - python-version: 3.9
27 | django-version: 5.1.*
28 | fail-fast: false
29 |
30 | steps:
31 | - uses: actions/checkout@v3
32 | - name: 'Set up Python ${{ matrix.python-version }}'
33 | uses: actions/setup-python@v3
34 | with:
35 | python-version: '${{ matrix.python-version }}'
36 | cache: 'pip'
37 | - name: Install dependencies
38 | run: |
39 | pip install -r requirements.txt
40 | pip install -r tests/requirements.txt
41 | pip install "Django==${{ matrix.django-version }}" .
42 | - name: Run Tests
43 | run: |
44 | echo "$(python --version) / Django $(django-admin --version)"
45 | export DJANGO_SETTINGS_MODULE=tests.settings
46 | export PYTHONPATH=.
47 | coverage run --source=avatar `which django-admin` test tests
48 | coverage report
49 | coverage xml
50 | - name: Upload coverage reports to Codecov with GitHub Action
51 | uses: codecov/codecov-action@v3
52 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | SETTINGS_DIR = os.path.dirname(__file__)
4 |
5 | DATABASE_ENGINE = "sqlite3"
6 |
7 | DATABASES = {
8 | "default": {
9 | "ENGINE": "django.db.backends.sqlite3",
10 | "NAME": ":memory:",
11 | }
12 | }
13 |
14 | INSTALLED_APPS = [
15 | "django.contrib.admin",
16 | "django.contrib.messages",
17 | "django.contrib.sessions",
18 | "django.contrib.auth",
19 | "django.contrib.contenttypes",
20 | "django.contrib.sites",
21 | "avatar",
22 | ]
23 |
24 | MIDDLEWARE = (
25 | "django.middleware.common.CommonMiddleware",
26 | "django.contrib.sessions.middleware.SessionMiddleware",
27 | "django.middleware.csrf.CsrfViewMiddleware",
28 | "django.contrib.auth.middleware.AuthenticationMiddleware",
29 | "django.contrib.messages.middleware.MessageMiddleware",
30 | )
31 |
32 | TEMPLATES = [
33 | {
34 | "BACKEND": "django.template.backends.django.DjangoTemplates",
35 | "APP_DIRS": True,
36 | "DIRS": [os.path.join(SETTINGS_DIR, "templates")],
37 | "OPTIONS": {
38 | "context_processors": [
39 | "django.contrib.auth.context_processors.auth",
40 | "django.contrib.messages.context_processors.messages",
41 | "django.template.context_processors.request",
42 | ]
43 | },
44 | }
45 | ]
46 |
47 | ROOT_URLCONF = "tests.urls"
48 |
49 | SITE_ID = 1
50 |
51 | SECRET_KEY = "something-something"
52 |
53 | ROOT_URLCONF = "tests.urls"
54 |
55 | STATIC_URL = "/site_media/static/"
56 |
57 | AVATAR_ALLOWED_FILE_EXTS = (".jpg", ".png")
58 | AVATAR_MAX_SIZE = 1024 * 1024
59 | AVATAR_MAX_AVATARS_PER_USER = 20
60 | AVATAR_AUTO_GENERATE_SIZES = [51, 62, (33, 22), 80]
61 |
62 |
63 | MEDIA_ROOT = os.path.join(SETTINGS_DIR, "../test-media")
64 |
--------------------------------------------------------------------------------
/avatar/migrations/0002_add_verbose_names_to_avatar_fields.py:
--------------------------------------------------------------------------------
1 | import django.core.files.storage
2 | import django.db.models.deletion
3 | import django.utils.timezone
4 | from django.conf import settings
5 | from django.db import migrations, models
6 |
7 | import avatar.models
8 |
9 |
10 | class Migration(migrations.Migration):
11 | dependencies = [
12 | ("avatar", "0001_initial"),
13 | ]
14 |
15 | operations = [
16 | migrations.AlterModelOptions(
17 | name="avatar",
18 | options={"verbose_name": "avatar", "verbose_name_plural": "avatars"},
19 | ),
20 | migrations.AlterField(
21 | model_name="avatar",
22 | name="avatar",
23 | field=models.ImageField(
24 | blank=True,
25 | max_length=1024,
26 | storage=django.core.files.storage.FileSystemStorage(),
27 | upload_to=avatar.models.avatar_path_handler,
28 | verbose_name="avatar",
29 | ),
30 | ),
31 | migrations.AlterField(
32 | model_name="avatar",
33 | name="date_uploaded",
34 | field=models.DateTimeField(
35 | default=django.utils.timezone.now, verbose_name="uploaded at"
36 | ),
37 | ),
38 | migrations.AlterField(
39 | model_name="avatar",
40 | name="primary",
41 | field=models.BooleanField(default=False, verbose_name="primary"),
42 | ),
43 | migrations.AlterField(
44 | model_name="avatar",
45 | name="user",
46 | field=models.ForeignKey(
47 | on_delete=django.db.models.deletion.CASCADE,
48 | to=settings.AUTH_USER_MODEL,
49 | verbose_name="user",
50 | ),
51 | ),
52 | ]
53 |
--------------------------------------------------------------------------------
/avatar/api/signals.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from avatar.api.shortcut import get_object_or_none
4 | from avatar.conf import settings
5 | from avatar.models import Avatar, invalidate_avatar_cache
6 |
7 |
8 | def create_default_thumbnails(sender, instance, created=False, **kwargs):
9 | invalidate_avatar_cache(sender, instance)
10 |
11 | if not created:
12 | for size in settings.AVATAR_AUTO_GENERATE_SIZES:
13 | if isinstance(size, int):
14 | if not instance.thumbnail_exists(size, size):
15 | instance.create_thumbnail(size, size)
16 | else:
17 | # Size is specified with height and width.
18 | if not instance.thumbnail_exists(size[0, size[0]]):
19 | instance.create_thumbnail(size[0], size[1])
20 |
21 |
22 | def remove_previous_avatar_images_when_update(
23 | sender, instance=None, created=False, update_main_avatar=True, **kwargs
24 | ):
25 | if not created:
26 | old_instance = get_object_or_none(Avatar, pk=instance.pk)
27 | if old_instance and not old_instance.avatar == instance.avatar:
28 | base_filepath = old_instance.avatar.name
29 | path, filename = os.path.split(base_filepath)
30 | # iterate through resized avatars directories and delete resized avatars
31 | resized_path = os.path.join(path, "resized")
32 | try:
33 | resized_widths, _ = old_instance.avatar.storage.listdir(resized_path)
34 | for width in resized_widths:
35 | resized_width_path = os.path.join(resized_path, width)
36 | resized_heights, _ = old_instance.avatar.storage.listdir(
37 | resized_width_path
38 | )
39 | for height in resized_heights:
40 | if old_instance.thumbnail_exists(width, height):
41 | old_instance.avatar.storage.delete(
42 | old_instance.avatar_name(width, height)
43 | )
44 | if update_main_avatar:
45 | if old_instance.avatar.storage.exists(old_instance.avatar.name):
46 | old_instance.avatar.storage.delete(old_instance.avatar.name)
47 | except FileNotFoundError:
48 | pass
49 |
--------------------------------------------------------------------------------
/test_proj/test_proj/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for test_proj project.
3 |
4 | Generated by 'django-admin startproject' using Django 1.10.1.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/1.10/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/1.10/ref/settings/
11 | """
12 |
13 | import os
14 |
15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17 |
18 |
19 | # Quick-start development settings - unsuitable for production
20 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
21 |
22 | # SECURITY WARNING: keep the secret key used in production secret!
23 | SECRET_KEY = "0o$jym8^hgw%vwx9hy%@ncr!29n7gik30(ln$pd$!3*4zu+9dv"
24 |
25 | # SECURITY WARNING: don't run with debug turned on in production!
26 | DEBUG = True
27 |
28 | ALLOWED_HOSTS = []
29 |
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = [
34 | "django.contrib.admin",
35 | "django.contrib.auth",
36 | "django.contrib.contenttypes",
37 | "django.contrib.sessions",
38 | "django.contrib.messages",
39 | "django.contrib.staticfiles",
40 | "avatar",
41 | "rest_framework",
42 | ]
43 |
44 | MIDDLEWARE = [
45 | "django.middleware.security.SecurityMiddleware",
46 | "django.contrib.sessions.middleware.SessionMiddleware",
47 | "django.middleware.common.CommonMiddleware",
48 | "django.middleware.csrf.CsrfViewMiddleware",
49 | "django.contrib.auth.middleware.AuthenticationMiddleware",
50 | "django.contrib.messages.middleware.MessageMiddleware",
51 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
52 | ]
53 |
54 | ROOT_URLCONF = "test_proj.urls"
55 |
56 | TEMPLATES = [
57 | {
58 | "BACKEND": "django.template.backends.django.DjangoTemplates",
59 | "DIRS": [],
60 | "APP_DIRS": True,
61 | "OPTIONS": {
62 | "context_processors": [
63 | "django.template.context_processors.debug",
64 | "django.template.context_processors.request",
65 | "django.contrib.auth.context_processors.auth",
66 | "django.contrib.messages.context_processors.messages",
67 | ],
68 | },
69 | },
70 | ]
71 |
72 | WSGI_APPLICATION = "test_proj.wsgi.application"
73 |
74 |
75 | # Database
76 | # https://docs.djangoproject.com/en/1.10/ref/settings/#databases
77 |
78 | DATABASES = {
79 | "default": {
80 | "ENGINE": "django.db.backends.sqlite3",
81 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
82 | }
83 | }
84 |
85 |
86 | # Internationalization
87 | # https://docs.djangoproject.com/en/1.10/topics/i18n/
88 |
89 | LANGUAGE_CODE = "en-us"
90 |
91 | TIME_ZONE = "UTC"
92 |
93 | USE_I18N = True
94 |
95 | USE_L10N = True
96 |
97 | USE_TZ = True
98 |
99 |
100 | # Static files (CSS, JavaScript, Images)
101 | # https://docs.djangoproject.com/en/1.10/howto/static-files/
102 |
103 | STATIC_URL = "/static/"
104 |
105 | MEDIA_ROOT = os.path.join(BASE_DIR, "media")
106 | MEDIA_URL = "/media/"
107 |
--------------------------------------------------------------------------------
/avatar/templatetags/avatar_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from django.template.loader import render_to_string
3 | from django.urls import reverse
4 | from django.utils.module_loading import import_string
5 | from django.utils.translation import gettext as _
6 |
7 | from avatar.conf import settings
8 | from avatar.models import Avatar
9 | from avatar.utils import cache_result, get_default_avatar_url, get_user, get_user_model
10 |
11 | register = template.Library()
12 |
13 |
14 | @cache_result()
15 | @register.simple_tag
16 | def avatar_url(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
17 | if height is None:
18 | height = width
19 | for provider_path in settings.AVATAR_PROVIDERS:
20 | provider = import_string(provider_path)
21 | avatar_url = provider.get_avatar_url(user, width, height)
22 | if avatar_url:
23 | return avatar_url
24 | return get_default_avatar_url()
25 |
26 |
27 | @cache_result()
28 | @register.simple_tag
29 | def avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None, **kwargs):
30 | if height is None:
31 | height = width
32 | if not isinstance(user, get_user_model()):
33 | try:
34 | user = get_user(user)
35 | if settings.AVATAR_EXPOSE_USERNAMES:
36 | alt = str(user)
37 | else:
38 | alt = _("User Avatar")
39 | url = avatar_url(user, width, height)
40 | except get_user_model().DoesNotExist:
41 | url = get_default_avatar_url()
42 | alt = _("Default Avatar")
43 | else:
44 | if settings.AVATAR_EXPOSE_USERNAMES:
45 | alt = str(user)
46 | else:
47 | alt = _("User Avatar")
48 | url = avatar_url(user, width, height)
49 | kwargs.update({"alt": alt})
50 |
51 | context = {
52 | "user": user,
53 | "alt": alt,
54 | "width": width,
55 | "height": height,
56 | "kwargs": kwargs,
57 | }
58 | template_name = "avatar/avatar_tag.html"
59 | ext_context = None
60 | try:
61 | template_name, ext_context = url
62 | except ValueError:
63 | context["url"] = url
64 | if ext_context:
65 | context = dict(context, **ext_context)
66 | return render_to_string(template_name, context)
67 |
68 |
69 | @register.filter
70 | def has_avatar(user):
71 | if not isinstance(user, get_user_model()):
72 | return False
73 | return Avatar.objects.filter(user=user, primary=True).exists()
74 |
75 |
76 | @cache_result()
77 | @register.simple_tag
78 | def primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
79 | """
80 | This tag tries to get the default avatar for a user without doing any db
81 | requests. It achieve this by linking to a special view that will do all the
82 | work for us. If that special view is then cached by a CDN for instance,
83 | we will avoid many db calls.
84 | """
85 | kwargs = {"width": width}
86 | if settings.AVATAR_EXPOSE_USERNAMES:
87 | alt = str(user)
88 | kwargs["user"] = user
89 | else:
90 | alt = _("User Avatar")
91 | kwargs["user"] = user.id
92 | if height is None:
93 | height = width
94 | else:
95 | kwargs["height"] = height
96 |
97 | url = reverse("avatar:render_primary", kwargs=kwargs)
98 | return """
""" % (
99 | url,
100 | width,
101 | height,
102 | alt,
103 | )
104 |
105 |
106 | @cache_result()
107 | @register.simple_tag
108 | def render_avatar(avatar, width=settings.AVATAR_DEFAULT_SIZE, height=None):
109 | if height is None:
110 | height = width
111 | if not avatar.thumbnail_exists(width, height):
112 | avatar.create_thumbnail(width, height)
113 | return """
""" % (
114 | avatar.avatar_url(width, height),
115 | str(avatar),
116 | width,
117 | height,
118 | )
119 |
--------------------------------------------------------------------------------
/avatar/locale/zh_CN/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: django-avatar\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2016-09-14 16:37+0200\n"
11 | "PO-Revision-Date: 2014-03-26 17:08+0800\n"
12 | "Last-Translator: Bruce Yang \n"
13 | "Language-Team: Bruce Yang \n"
14 | "Language: \n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Plural-Forms: nplurals=1; plural=0;\n"
19 | "X-Generator: Poedit 1.5.7\n"
20 |
21 | #: admin.py:26
22 | msgid "Avatar"
23 | msgstr "头像"
24 |
25 | #: forms.py:24 models.py:84 models.py:97
26 | msgid "avatar"
27 | msgstr "头像"
28 |
29 | #: forms.py:37
30 | #, python-format
31 | msgid ""
32 | "%(ext)s is an invalid file extension. Authorized extensions are : "
33 | "%(valid_exts_list)s"
34 | msgstr "%(ext)s 是不正确的文件扩展名。 正确的扩展名为 : %(valid_exts_list)s"
35 |
36 | #: forms.py:44
37 | #, python-format
38 | msgid ""
39 | "Your file is too big (%(size)s), the maximum allowed size is "
40 | "%(max_valid_size)s"
41 | msgstr "上传文件太大 (%(size)s), 允许的最大文件为 %(max_valid_size)s"
42 |
43 | #: forms.py:54
44 | #, python-format
45 | msgid ""
46 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
47 | "%(nb_max_avatars)d."
48 | msgstr "您目前有 %(nb_avatars)d 个头像, 最多可以有 %(nb_max_avatars)d 个。"
49 |
50 | #: forms.py:71 forms.py:84
51 | msgid "Choices"
52 | msgstr "选项"
53 |
54 | #: models.py:77
55 | msgid "user"
56 | msgstr ""
57 |
58 | #: models.py:80
59 | msgid "primary"
60 | msgstr ""
61 |
62 | #: models.py:91
63 | msgid "uploaded at"
64 | msgstr ""
65 |
66 | #: models.py:98
67 | #, fuzzy
68 | #| msgid "avatar"
69 | msgid "avatars"
70 | msgstr "头像"
71 |
72 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
73 | msgid "Your current avatar: "
74 | msgstr "您当前的头像:"
75 |
76 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
77 | msgid "You haven't uploaded an avatar yet. Please upload one now."
78 | msgstr "您还没有上传任何头像,请现在上传一个吧。"
79 |
80 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
81 | msgid "Upload New Image"
82 | msgstr "上传新照片"
83 |
84 | #: templates/avatar/change.html:14
85 | msgid "Choose new Default"
86 | msgstr "选择默认"
87 |
88 | #: templates/avatar/confirm_delete.html:5
89 | msgid "Please select the avatars that you would like to delete."
90 | msgstr "选择要删除的头像。"
91 |
92 | #: templates/avatar/confirm_delete.html:8
93 | #, python-format
94 | msgid ""
95 | "You have no avatars to delete. Please upload one now."
97 | msgstr ""
98 | "没有头像可以删除. 请 上传一个新头像。"
99 |
100 | #: templates/avatar/confirm_delete.html:14
101 | msgid "Delete These"
102 | msgstr "删除"
103 |
104 | #: templates/notification/avatar_friend_updated/full.txt:1
105 | #, python-format
106 | msgid ""
107 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
108 | "\n"
109 | "http://%(current_site)s%(avatar_url)s\n"
110 | msgstr ""
111 | "%(avatar_creator)s 更新了头像 %(avatar)s.\n"
112 | "\n"
113 | "http://%(current_site)s%(avatar_url)s\n"
114 |
115 | #: templates/notification/avatar_friend_updated/notice.html:2
116 | #, python-format
117 | msgid ""
118 | "%(avatar_creator)s has updated their avatar %(avatar)s."
120 | msgstr ""
121 | "%(avatar_creator)s 更新了头像 %(avatar)s."
123 |
124 | #: templates/notification/avatar_updated/full.txt:1
125 | #, python-format
126 | msgid ""
127 | "Your avatar has been updated. %(avatar)s\n"
128 | "\n"
129 | "http://%(current_site)s%(avatar_url)s\n"
130 | msgstr ""
131 | "您的头像已经更新。 %(avatar)s\n"
132 | "\n"
133 | "http://%(current_site)s%(avatar_url)s\n"
134 |
135 | #: templates/notification/avatar_updated/notice.html:2
136 | #, python-format
137 | msgid "You have updated your avatar %(avatar)s."
138 | msgstr "您已经更新了头像 %(avatar)s."
139 |
140 | #: templatetags/avatar_tags.py:69
141 | msgid "Default Avatar"
142 | msgstr "默认头像"
143 |
144 | #: views.py:73
145 | msgid "Successfully uploaded a new avatar."
146 | msgstr "成功上传头像。"
147 |
148 | #: views.py:111
149 | msgid "Successfully updated your avatar."
150 | msgstr "更新头像成功。"
151 |
152 | #: views.py:150
153 | msgid "Successfully deleted the requested avatars."
154 | msgstr "成功删除头像。"
155 |
--------------------------------------------------------------------------------
/avatar/providers.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import re
3 | from urllib.parse import urlencode, urljoin
4 |
5 | import dns.resolver
6 | from django.utils.module_loading import import_string
7 |
8 | from avatar.conf import settings
9 | from avatar.utils import force_bytes, get_default_avatar_url, get_primary_avatar
10 |
11 | # If the FacebookAvatarProvider is used, a mechanism needs to be defined on
12 | # how to obtain the user's Facebook UID. This is done via
13 | # ``AVATAR_FACEBOOK_GET_ID``.
14 | get_facebook_id = None
15 |
16 | if "avatar.providers.FacebookAvatarProvider" in settings.AVATAR_PROVIDERS:
17 | if callable(settings.AVATAR_FACEBOOK_GET_ID):
18 | get_facebook_id = settings.AVATAR_FACEBOOK_GET_ID
19 | else:
20 | get_facebook_id = import_string(settings.AVATAR_FACEBOOK_GET_ID)
21 |
22 |
23 | class DefaultAvatarProvider(object):
24 | """
25 | Returns the default url defined by ``settings.DEFAULT_AVATAR_URL``.
26 | """
27 |
28 | @classmethod
29 | def get_avatar_url(cls, user, width, height=None):
30 | return get_default_avatar_url()
31 |
32 |
33 | class PrimaryAvatarProvider(object):
34 | """
35 | Returns the primary Avatar from the users avatar set.
36 | """
37 |
38 | @classmethod
39 | def get_avatar_url(cls, user, width, height=None):
40 | if not height:
41 | height = width
42 | avatar = get_primary_avatar(user, width, height)
43 | if avatar:
44 | return avatar.avatar_url(width, height)
45 |
46 |
47 | class GravatarAvatarProvider(object):
48 | """
49 | Returns the url for an avatar by the Gravatar service.
50 | """
51 |
52 | @classmethod
53 | def get_avatar_url(cls, user, width, _height=None):
54 | params = {"s": str(width)}
55 | if settings.AVATAR_GRAVATAR_DEFAULT:
56 | params["d"] = settings.AVATAR_GRAVATAR_DEFAULT
57 | if settings.AVATAR_GRAVATAR_FORCEDEFAULT:
58 | params["f"] = "y"
59 | path = "%s/?%s" % (
60 | hashlib.md5(
61 | force_bytes(getattr(user, settings.AVATAR_GRAVATAR_FIELD))
62 | ).hexdigest(),
63 | urlencode(params),
64 | )
65 |
66 | return urljoin(settings.AVATAR_GRAVATAR_BASE_URL, path)
67 |
68 |
69 | class LibRAvatarProvider:
70 | """
71 | Returns the url of an avatar by the Ravatar service.
72 | """
73 |
74 | @classmethod
75 | def get_avatar_url(cls, user, width, _height=None):
76 | email = getattr(user, settings.AVATAR_GRAVATAR_FIELD).encode("utf-8")
77 | try:
78 | _, domain = email.split(b"@")
79 | answers = dns.resolver.query("_avatars._tcp." + domain, "SRV")
80 | hostname = re.sub(r"\.$", "", str(answers[0].target))
81 | # query returns "example.com." and while http requests are fine with this,
82 | # https most certainly do not consider "example.com." and "example.com" to be the same.
83 | port = str(answers[0].port)
84 | if port == "443":
85 | baseurl = "https://" + hostname + "/avatar/"
86 | else:
87 | baseurl = "http://" + hostname + ":" + port + "/avatar/"
88 | except Exception:
89 | baseurl = "https://seccdn.libravatar.org/avatar/"
90 | hash = hashlib.md5(email.strip().lower()).hexdigest()
91 | return baseurl + hash
92 |
93 |
94 | class FacebookAvatarProvider(object):
95 | """
96 | Returns the url of a Facebook profile image.
97 | """
98 |
99 | @classmethod
100 | def get_avatar_url(cls, user, width, height=None):
101 | if not height:
102 | height = width
103 | fb_id = get_facebook_id(user)
104 | if fb_id:
105 | url = "https://graph.facebook.com/{fb_id}/picture?type=square&width={width}&height={height}"
106 | return url.format(fb_id=fb_id, width=width, height=height)
107 |
108 |
109 | class InitialsAvatarProvider(object):
110 | """
111 | Returns a tuple with template_name and context for rendering the given user's avatar as their
112 | initials in white against a background with random hue based on their primary key.
113 | """
114 |
115 | @classmethod
116 | def get_avatar_url(cls, user, width, _height=None):
117 | initials = user.first_name[:1] + user.last_name[:1]
118 | if not initials:
119 | initials = user.username[:1]
120 | initials = initials.upper()
121 | context = {
122 | "fontsize": (width * 1.1) / 2,
123 | "initials": initials,
124 | "hue": user.pk % 360,
125 | "saturation": "65%",
126 | "lightness": "60%",
127 | }
128 | return ("avatar/initials.html", context)
129 |
--------------------------------------------------------------------------------
/docs/avatar.rst:
--------------------------------------------------------------------------------
1 |
2 | API Descriptions
3 | ================
4 |
5 | Avatar List
6 | ^^^^^^^^^^^
7 |
8 |
9 | send a request for listing user avatars as shown below.
10 |
11 | ``GET`` ``/api/avatar/``
12 |
13 |
14 |
15 | default response of avatar list : ::
16 |
17 | {
18 | "message": "You haven't uploaded an avatar yet. Please upload one now.",
19 | "default_avatar": {
20 | "src": "https://seccdn.libravatar.org/avatar/4a9328d595472d0728195a7c8191a50b",
21 | "width": "80",
22 | "height": "80",
23 | "alt": "User Avatar"
24 | }
25 | }
26 |
27 |
28 | if you have an avatar object : ::
29 |
30 | [
31 | {
32 | "id": "image_id",
33 | "avatar_url": "https://example.com/api/avatar/1/",
34 | "avatar": "https://example.com/media/avatars/1/first_avatar.png",
35 | "primary": true
36 | },
37 | ]
38 |
39 |
40 |
41 | -----------------------------------------------
42 |
43 | Create Avatar
44 | ^^^^^^^^^^^^^
45 |
46 |
47 | send a request for creating user avatar as shown below .
48 |
49 | ``POST`` ``/api/avatar/``
50 |
51 |
52 | Request : ::
53 |
54 | {
55 | "avatar": "image file",
56 | "primary": true
57 | }
58 |
59 | ``Note`` : avatar field is required.
60 |
61 | Response : ::
62 |
63 | {
64 | "message": "Successfully uploaded a new avatar.",
65 | "data": {
66 | "id": "image_id",
67 | "avatar_url": "https://example.com/api/avatar/1/",
68 | "avatar": "https://example.com/media/avatars/1/example.png",
69 | "primary": true
70 | }
71 | }
72 |
73 |
74 |
75 | -----------------------------------------------
76 |
77 | Avatar Detail
78 | ^^^^^^^^^^^^^
79 |
80 |
81 | send a request for retrieving user avatar.
82 |
83 | ``GET`` ``/api/avatar/image_id/``
84 |
85 |
86 | Response : ::
87 |
88 | {
89 | "id": "image_id",
90 | "avatar": "https://example.com/media/avatars/1/example.png",
91 | "primary": true
92 | }
93 |
94 |
95 |
96 | -----------------------------------------------
97 |
98 | Update Avatar
99 | ^^^^^^^^^^^^^
100 |
101 |
102 | send a request for updating user avatar.
103 |
104 | ``PUT`` ``/api/avatar/image_id/``
105 |
106 |
107 | Request : ::
108 |
109 | {
110 | "avatar":"image file"
111 | "primary": true
112 | }
113 |
114 | ``Note`` : for update avatar image set ``API_AVATAR_CHANGE_IMAGE = True`` in your settings file and set ``primary = True``.
115 |
116 | Response : ::
117 |
118 | {
119 | "message": "Successfully updated your avatar.",
120 | "data": {
121 | "id": "image_id",
122 | "avatar": "https://example.com/media/avatars/1/custom_admin_en.png",
123 | "primary": true
124 | }
125 | }
126 |
127 | -----------------------------------------------
128 |
129 | Delete Avatar
130 | ^^^^^^^^^^^^^
131 |
132 |
133 | send a request for deleting user avatar.
134 |
135 | ``DELETE`` ``/api/avatar/image_id/``
136 |
137 |
138 | Response : ::
139 |
140 | "Successfully deleted the requested avatars."
141 |
142 |
143 |
144 |
145 | -----------------------------------------------
146 |
147 | Render Primary Avatar
148 | ^^^^^^^^^^^^^^^^^^^^^
149 |
150 | send a request for retrieving resized primary avatar .
151 |
152 |
153 | default sizes ``80``:
154 |
155 | ``GET`` ``/api/avatar/render_primary/``
156 |
157 | Response : ::
158 |
159 | {
160 | "image_url": "https://example.com/media/avatars/1/resized/80/80/example.png"
161 | }
162 |
163 | custom ``width`` and ``height`` :
164 |
165 | ``GET`` ``/api/avatar/render_primary/?width=width_size&height=height_size``
166 |
167 | Response : ::
168 |
169 | {
170 | "image_url": "http://127.0.0.1:8000/media/avatars/1/resized/width_size/height_size/python.png"
171 | }
172 |
173 |
174 | If the entered parameter is one of ``width`` or ``height``, it will be considered for both .
175 |
176 | ``GET`` ``/api/avatar/render_primary/?width=size`` :
177 |
178 | Response : ::
179 |
180 | {
181 | "image_url": "http://127.0.0.1:8000/media/avatars/1/resized/size/size/python.png"
182 | }
183 |
184 | ``Note`` : Resize parameters not working for default avatar.
185 |
186 | API Setting
187 | ===========
188 |
189 | .. py:data:: API_AVATAR_CHANGE_IMAGE
190 |
191 | It Allows the user to Change the avatar image in ``PUT`` method. Default is ``False``.
192 |
--------------------------------------------------------------------------------
/avatar/locale/ja/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2016-09-14 16:37+0200\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=1; plural=0;\n"
20 |
21 | #: admin.py:26
22 | msgid "Avatar"
23 | msgstr "プロフィール画像"
24 |
25 | #: forms.py:24 models.py:84 models.py:97
26 | msgid "avatar"
27 | msgstr "プロフィール画像"
28 |
29 | #: forms.py:37
30 | #, python-format
31 | msgid ""
32 | "%(ext)s is an invalid file extension. Authorized extensions are : "
33 | "%(valid_exts_list)s"
34 | msgstr ""
35 | "%(ext)s は利用できない拡張子です。 使用可能な拡張子 : %(valid_exts_list)s"
36 |
37 | #: forms.py:44
38 | #, python-format
39 | msgid ""
40 | "Your file is too big (%(size)s), the maximum allowed size is "
41 | "%(max_valid_size)s"
42 | msgstr ""
43 | "ファイルが大きすぎます(%(size)s)。アップロード可能な最大サイズは "
44 | "%(max_valid_size)s です。"
45 |
46 | #: forms.py:54
47 | #, python-format
48 | msgid ""
49 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
50 | "%(nb_max_avatars)d."
51 | msgstr ""
52 | "登録可能なプロフィール画像は %(nb_max_avatars)d 個までです。すでに "
53 | "%(nb_avatars)d 個登録されています。"
54 |
55 | #: forms.py:71 forms.py:84
56 | msgid "Choices"
57 | msgstr "選択"
58 |
59 | #: models.py:77
60 | msgid "user"
61 | msgstr ""
62 |
63 | #: models.py:80
64 | msgid "primary"
65 | msgstr ""
66 |
67 | #: models.py:91
68 | msgid "uploaded at"
69 | msgstr ""
70 |
71 | #: models.py:98
72 | #, fuzzy
73 | #| msgid "avatar"
74 | msgid "avatars"
75 | msgstr "プロフィール画像"
76 |
77 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
78 | msgid "Your current avatar: "
79 | msgstr "現在のプロフィール画像:"
80 |
81 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
82 | msgid "You haven't uploaded an avatar yet. Please upload one now."
83 | msgstr "登録されているプロフィール画像はありません。アップロードしてください。"
84 |
85 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
86 | msgid "Upload New Image"
87 | msgstr "新しい画像のアップロード"
88 |
89 | #: templates/avatar/change.html:14
90 | msgid "Choose new Default"
91 | msgstr "デフォルトの画像を選択"
92 |
93 | #: templates/avatar/confirm_delete.html:5
94 | msgid "Please select the avatars that you would like to delete."
95 | msgstr "削除したいプロフィール画像を選択してください。"
96 |
97 | #: templates/avatar/confirm_delete.html:8
98 | #, python-format
99 | msgid ""
100 | "You have no avatars to delete. Please upload one now."
102 | msgstr ""
103 | "削除できるプロフィール画像はありません。新"
104 | "規画像のアップロード."
105 |
106 | #: templates/avatar/confirm_delete.html:14
107 | msgid "Delete These"
108 | msgstr "削除"
109 |
110 | #: templates/notification/avatar_friend_updated/full.txt:1
111 | #, python-format
112 | msgid ""
113 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
114 | "\n"
115 | "http://%(current_site)s%(avatar_url)s\n"
116 | msgstr ""
117 | "%(avatar_creator)s さんがプロフィール画像 %(avatar)s をアップロードしまし"
118 | "た。\n"
119 | "\n"
120 | "http://%(current_site)s%(avatar_url)s\n"
121 |
122 | #: templates/notification/avatar_friend_updated/notice.html:2
123 | #, python-format
124 | msgid ""
125 | "%(avatar_creator)s has updated their avatar %(avatar)s."
127 | msgstr ""
128 | "%(avatar_creator)s さんがプロフィール画像 %(avatar)s をアップロードしました。"
130 |
131 | #: templates/notification/avatar_updated/full.txt:1
132 | #, python-format
133 | msgid ""
134 | "Your avatar has been updated. %(avatar)s\n"
135 | "\n"
136 | "http://%(current_site)s%(avatar_url)s\n"
137 | msgstr ""
138 | "プロフィール画像を更新しました。 %(avatar)s\n"
139 | "\n"
140 | "http://%(current_site)s%(avatar_url)s\n"
141 |
142 | #: templates/notification/avatar_updated/notice.html:2
143 | #, python-format
144 | msgid "You have updated your avatar %(avatar)s."
145 | msgstr ""
146 | "プロフィール画像を更新しました。 %(avatar)s."
147 |
148 | #: templatetags/avatar_tags.py:69
149 | msgid "Default Avatar"
150 | msgstr "デフォルトのプロフィール画像"
151 |
152 | #: views.py:73
153 | msgid "Successfully uploaded a new avatar."
154 | msgstr "新しいプロフィール画像をアップロードしました。"
155 |
156 | #: views.py:111
157 | msgid "Successfully updated your avatar."
158 | msgstr "プロフィール画像を更新しました。"
159 |
160 | #: views.py:150
161 | msgid "Successfully deleted the requested avatars."
162 | msgstr "指定されたプロフィール画像を削除しました。"
163 |
--------------------------------------------------------------------------------
/avatar/locale/nl/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: PACKAGE VERSION\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2016-09-14 16:37+0200\n"
11 | "PO-Revision-Date: 2013-11-11 12:49+0100\n"
12 | "Last-Translator: Ivor \n"
13 | "Language-Team: LANGUAGE \n"
14 | "Language: \n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19 | "X-Generator: Poedit 1.5.5\n"
20 |
21 | #: admin.py:26
22 | msgid "Avatar"
23 | msgstr "Profielfoto"
24 |
25 | #: forms.py:24 models.py:84 models.py:97
26 | msgid "avatar"
27 | msgstr "profielfoto"
28 |
29 | #: forms.py:37
30 | #, python-format
31 | msgid ""
32 | "%(ext)s is an invalid file extension. Authorized extensions are : "
33 | "%(valid_exts_list)s"
34 | msgstr ""
35 | "%(ext)s is een ongeldig bestandsformaat. Toegestane formaten zijn : "
36 | "%(valid_exts_list)s"
37 |
38 | #: forms.py:44
39 | #, python-format
40 | msgid ""
41 | "Your file is too big (%(size)s), the maximum allowed size is "
42 | "%(max_valid_size)s"
43 | msgstr ""
44 | "Het bestand is te groot (%(size)s), de maximale groote is %(max_valid_size)s"
45 |
46 | #: forms.py:54
47 | #, python-format
48 | msgid ""
49 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
50 | "%(nb_max_avatars)d."
51 | msgstr ""
52 | "Er zijn al %(nb_avatars)d profielfoto's, het maximale aantal is "
53 | "%(nb_max_avatars)d."
54 |
55 | #: forms.py:71 forms.py:84
56 | msgid "Choices"
57 | msgstr "Keuzes"
58 |
59 | #: models.py:77
60 | msgid "user"
61 | msgstr ""
62 |
63 | #: models.py:80
64 | msgid "primary"
65 | msgstr ""
66 |
67 | #: models.py:91
68 | msgid "uploaded at"
69 | msgstr ""
70 |
71 | #: models.py:98
72 | #, fuzzy
73 | #| msgid "avatar"
74 | msgid "avatars"
75 | msgstr "profielfoto"
76 |
77 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
78 | msgid "Your current avatar: "
79 | msgstr "De huidige profielfoto:"
80 |
81 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
82 | msgid "You haven't uploaded an avatar yet. Please upload one now."
83 | msgstr "Er is nog geen profielfoto. Upload een nieuwe."
84 |
85 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
86 | msgid "Upload New Image"
87 | msgstr "Upload nieuw plaatje"
88 |
89 | #: templates/avatar/change.html:14
90 | msgid "Choose new Default"
91 | msgstr "Kies nieuwe standaard"
92 |
93 | #: templates/avatar/confirm_delete.html:5
94 | msgid "Please select the avatars that you would like to delete."
95 | msgstr "Selecteer de te verwijderen de profielfoto's."
96 |
97 | #: templates/avatar/confirm_delete.html:8
98 | #, python-format
99 | msgid ""
100 | "You have no avatars to delete. Please upload one now."
102 | msgstr ""
103 | "Er zijn geen profielfoto's om te verwijderen. Upload een nieuwe."
105 |
106 | #: templates/avatar/confirm_delete.html:14
107 | msgid "Delete These"
108 | msgstr "Verwijder deze"
109 |
110 | #: templates/notification/avatar_friend_updated/full.txt:1
111 | #, python-format
112 | msgid ""
113 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
114 | "\n"
115 | "http://%(current_site)s%(avatar_url)s\n"
116 | msgstr ""
117 | "%(avatar_creator)s heeft zijn profielfoto vernieuwd %(avatar)s.\n"
118 | "\n"
119 | "http://%(current_site)s%(avatar_url)s\n"
120 |
121 | #: templates/notification/avatar_friend_updated/notice.html:2
122 | #, python-format
123 | msgid ""
124 | "%(avatar_creator)s has updated their avatar %(avatar)s."
126 | msgstr ""
127 | "%(avatar_creator)s heeft zijn profielfoto "
128 | "vernieuwd %(avatar)s."
129 |
130 | #: templates/notification/avatar_updated/full.txt:1
131 | #, python-format
132 | msgid ""
133 | "Your avatar has been updated. %(avatar)s\n"
134 | "\n"
135 | "http://%(current_site)s%(avatar_url)s\n"
136 | msgstr ""
137 | "Je profielfoto is vernieuwd. %(avatar)s\n"
138 | "\n"
139 | "http://%(current_site)s%(avatar_url)s\n"
140 |
141 | #: templates/notification/avatar_updated/notice.html:2
142 | #, python-format
143 | msgid "You have updated your avatar %(avatar)s."
144 | msgstr "De profielfoto is vernieuwd %(avatar)s."
145 |
146 | #: templatetags/avatar_tags.py:69
147 | msgid "Default Avatar"
148 | msgstr "Standaard profielfoto"
149 |
150 | #: views.py:73
151 | msgid "Successfully uploaded a new avatar."
152 | msgstr "De profielfoto is ververst."
153 |
154 | #: views.py:111
155 | msgid "Successfully updated your avatar."
156 | msgstr "Profielfoto vernieuwd."
157 |
158 | #: views.py:150
159 | msgid "Successfully deleted the requested avatars."
160 | msgstr "Profielfoto verwijderd."
161 |
--------------------------------------------------------------------------------
/avatar/locale/fa/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # Mahdi Firouzjaah, 2020.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2020-12-16 20:12:00.982404\n"
12 | "PO-Revision-Date: 2020-12-16 20:12:00.982404\n"
13 | "Last-Translator: Mahdi Firouzjaah\n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: fa\n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n"
20 |
21 | #: admin.py:30
22 | msgid "Avatar"
23 | msgstr "آواتار"
24 |
25 | #: forms.py:28 models.py:105 models.py:114
26 | msgid "avatar"
27 | msgstr "آواتار"
28 |
29 | #: forms.py:41
30 | #, python-format
31 | msgid ""
32 | "%(ext)s is an invalid file extension. Authorized extensions are : "
33 | "%(valid_exts_list)s"
34 | msgstr ""
35 | "%(ext)s یک فایل با پسوند نامناسب است. پسوندهای مناسب اینها هستند:"
36 | "%(valid_exts_list)s"
37 |
38 | #: forms.py:48
39 | #, python-format
40 | msgid ""
41 | "Your file is too big (%(size)s), the maximum allowed size is "
42 | "%(max_valid_size)s"
43 | msgstr ""
44 | "فایلی که فرستادید بیش از حد مجاز بزرگ است(%(size)s). حداکثر مجاز این است:"
45 | "%(max_valid_size)s"
46 |
47 | #: forms.py:56
48 | #, python-format
49 | msgid ""
50 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
51 | "%(nb_max_avatars)d."
52 | msgstr ""
53 | "شما هماکنون %(nb_avatars)d تعداد آواتار دارید و حداکثر مجاز"
54 | "%(nb_max_avatars)d تا است."
55 |
56 |
57 | #: forms.py:73 forms.py:86
58 | msgid "Choices"
59 | msgstr "انتخابها"
60 |
61 | #: models.py:98
62 | msgid "user"
63 | msgstr "کاربر"
64 |
65 | #: models.py:101
66 | msgid "primary"
67 | msgstr "اصلی"
68 |
69 | #: models.py:108
70 | msgid "uploaded at"
71 | msgstr "بارگزاری شده در"
72 |
73 | #: models.py:115
74 | msgid "avatars"
75 | msgstr "آواتارها"
76 |
77 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
78 | msgid "Your current avatar: "
79 | msgstr "آواتار فعلی شما: "
80 |
81 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
82 | msgid "You haven't uploaded an avatar yet. Please upload one now."
83 | msgstr "شما تاکنون آواتاری بارگزاری نکردهاید، لطفا یکی بارگزاری کنید."
84 |
85 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
86 | msgid "Upload New Image"
87 | msgstr "بارگزاری عکس جدید"
88 |
89 | #: templates/avatar/change.html:14
90 | msgid "Choose new Default"
91 | msgstr "انتخاب یکی به عنوان پیشفرض"
92 |
93 | #: templates/avatar/confirm_delete.html:5
94 | msgid "Please select the avatars that you would like to delete."
95 | msgstr "لطفا آواتاری که مایلید حذف شود، انتخاب کنید."
96 |
97 | #: templates/avatar/confirm_delete.html:8
98 | #, python-format
99 | msgid ""
100 | "You have no avatars to delete. Please upload one now."
102 | msgstr ""
103 | "شما آواتاری برای حذف کردن ندارید؛"
104 | "لطفا یکی بارگزاری کنید."
106 |
107 | #: templates/avatar/confirm_delete.html:14
108 | msgid "Delete These"
109 | msgstr "این(ها) را حذف کن"
110 |
111 | #: templates/notification/avatar_friend_updated/full.txt:1
112 | #, python-format
113 | msgid ""
114 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
115 | "\n"
116 | "http://%(current_site)s%(avatar_url)s\n"
117 | msgstr ""
118 | "%(avatar_creator)s آواتار خود را بروزرسانی کردند %(avatar)s.\n\n"
119 | "http://%(current_site)s%(avatar_url)s\n"
120 |
121 | #: templates/notification/avatar_friend_updated/notice.html:2
122 | #, python-format
123 | msgid ""
124 | "%(avatar_creator)s has updated their avatar %(avatar)s."
126 | msgstr ""
127 | "%(avatar_creator)s آواتار خود را بروزرسانی کردند."
128 | "%(avatar)s."
129 |
130 |
131 | #: templates/notification/avatar_updated/full.txt:1
132 | #, python-format
133 | msgid ""
134 | "Your avatar has been updated. %(avatar)s\n"
135 | "\n"
136 | "http://%(current_site)s%(avatar_url)s\n"
137 | msgstr ""
138 | "آواتار شما بروزرسانی شد. %(avatar)s\n\n"
139 | "http://%(current_site)s%(avatar_url)s\n"
140 |
141 | #: templates/notification/avatar_updated/notice.html:2
142 | #, python-format
143 | msgid "You have updated your avatar %(avatar)s."
144 | msgstr "شما آواتار خود را بروزرسانی کردید. %(avatar)s."
145 |
146 | #: templatetags/avatar_tags.py:49
147 | msgid "Default Avatar"
148 | msgstr "آواتار پیشفرض"
149 |
150 | #: views.py:76
151 | msgid "Successfully uploaded a new avatar."
152 | msgstr "آواتار جدید با موفقیت بارگزاری شد."
153 |
154 | #: views.py:114
155 | msgid "Successfully updated your avatar."
156 | msgstr "آواتار شما با موفقیت بروزرسانی شد."
157 |
158 | #: views.py:157
159 | msgid "Successfully deleted the requested avatars."
160 | msgstr "آواتارهای مدنظر با موفقیت حذف شدند."
161 |
--------------------------------------------------------------------------------
/avatar/api/serializers.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.template.defaultfilters import filesizeformat
4 | from django.utils.translation import gettext_lazy as _
5 | from PIL import Image, ImageOps
6 | from rest_framework import serializers
7 |
8 | from avatar.conf import settings
9 | from avatar.conf import settings as api_setting
10 | from avatar.models import Avatar
11 |
12 |
13 | class AvatarSerializer(serializers.ModelSerializer):
14 | avatar_url = serializers.HyperlinkedIdentityField(
15 | view_name="avatar-detail",
16 | )
17 | user = serializers.HiddenField(default=serializers.CurrentUserDefault())
18 |
19 | class Meta:
20 | model = Avatar
21 | fields = ["id", "avatar_url", "avatar", "primary", "user"]
22 | extra_kwargs = {"avatar": {"required": True}}
23 |
24 | def __init__(self, *args, **kwargs):
25 | super().__init__(*args, **kwargs)
26 | request = kwargs.get("context").get("request", None)
27 |
28 | self.user = request.user
29 |
30 | def get_fields(self, *args, **kwargs):
31 | fields = super(AvatarSerializer, self).get_fields(*args, **kwargs)
32 | request = self.context.get("request", None)
33 |
34 | # remove avatar url field in detail page
35 | if bool(self.context.get("view").kwargs):
36 | fields.pop("avatar_url")
37 |
38 | # remove avatar field in put method
39 | if request and getattr(request, "method", None) == "PUT":
40 | # avatar updates only when primary=true and API_AVATAR_CHANGE_IMAGE = True
41 | if (
42 | not api_setting.API_AVATAR_CHANGE_IMAGE
43 | or self.instance
44 | and not self.instance.primary
45 | ):
46 | fields.pop("avatar")
47 | else:
48 | fields.get("avatar", None).required = False
49 | return fields
50 |
51 | def validate_avatar(self, value):
52 | data = value
53 |
54 | if settings.AVATAR_ALLOWED_MIMETYPES:
55 | try:
56 | import magic
57 | except ImportError:
58 | raise ImportError(
59 | "python-magic library must be installed in order to use uploaded file content limitation"
60 | )
61 |
62 | # Construct 256 bytes needed for mime validation
63 | magic_buffer = bytes()
64 | for chunk in data.chunks():
65 | magic_buffer += chunk
66 | if len(magic_buffer) >= 256:
67 | break
68 |
69 | # https://github.com/ahupp/python-magic#usage
70 | mime = magic.from_buffer(magic_buffer, mime=True)
71 | if mime not in settings.AVATAR_ALLOWED_MIMETYPES:
72 | raise serializers.ValidationError(
73 | _(
74 | "File content is invalid. Detected: %(mimetype)s Allowed content types are: %(valid_mime_list)s"
75 | )
76 | % {
77 | "valid_mime_list": ", ".join(settings.AVATAR_ALLOWED_MIMETYPES),
78 | "mimetype": mime,
79 | }
80 | )
81 |
82 | if settings.AVATAR_ALLOWED_FILE_EXTS:
83 | root, ext = os.path.splitext(data.name.lower())
84 | if ext not in settings.AVATAR_ALLOWED_FILE_EXTS:
85 | valid_exts = ", ".join(settings.AVATAR_ALLOWED_FILE_EXTS)
86 | error = _(
87 | "%(ext)s is an invalid file extension. "
88 | "Authorized extensions are : %(valid_exts_list)s"
89 | )
90 | raise serializers.ValidationError(
91 | error % {"ext": ext, "valid_exts_list": valid_exts}
92 | )
93 |
94 | if data.size > settings.AVATAR_MAX_SIZE:
95 | error = _(
96 | "Your file is too big (%(size)s), "
97 | "the maximum allowed size is %(max_valid_size)s"
98 | )
99 | raise serializers.ValidationError(
100 | error
101 | % {
102 | "size": filesizeformat(data.size),
103 | "max_valid_size": filesizeformat(settings.AVATAR_MAX_SIZE),
104 | }
105 | )
106 |
107 | try:
108 | image = Image.open(data)
109 | ImageOps.exif_transpose(image)
110 | except TypeError:
111 | raise serializers.ValidationError(_("Corrupted image"))
112 |
113 | count = Avatar.objects.filter(user=self.user).count()
114 | if 1 < settings.AVATAR_MAX_AVATARS_PER_USER <= count:
115 | error = _(
116 | "You already have %(nb_avatars)d avatars, "
117 | "and the maximum allowed is %(nb_max_avatars)d."
118 | )
119 | raise serializers.ValidationError(
120 | error
121 | % {
122 | "nb_avatars": count,
123 | "nb_max_avatars": settings.AVATAR_MAX_AVATARS_PER_USER,
124 | }
125 | )
126 | return data
127 |
--------------------------------------------------------------------------------
/avatar/locale/es/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: 2.0a10\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2016-09-14 16:37+0200\n"
11 | "PO-Revision-Date: 2013-08-27 00:21-0600\n"
12 | "Last-Translator: David Loaiza M. \n"
13 | "Language-Team: es \n"
14 | "Language: es\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19 | "X-Generator: Poedit 1.5.7\n"
20 |
21 | #: admin.py:26
22 | msgid "Avatar"
23 | msgstr "Avatar"
24 |
25 | #: forms.py:24 models.py:84 models.py:97
26 | msgid "avatar"
27 | msgstr "avatar"
28 |
29 | #: forms.py:37
30 | #, python-format
31 | msgid ""
32 | "%(ext)s is an invalid file extension. Authorized extensions are : "
33 | "%(valid_exts_list)s"
34 | msgstr ""
35 | "%(ext)s es una extensión de archivo inválida. Las extensiones de archivo "
36 | "autorizadas son: %(valid_exts_list)s"
37 |
38 | #: forms.py:44
39 | #, python-format
40 | msgid ""
41 | "Your file is too big (%(size)s), the maximum allowed size is "
42 | "%(max_valid_size)s"
43 | msgstr ""
44 | "Su archivo es muy grande (%(size)s), el tamaño máximo permitido es "
45 | "%(max_valid_size)s"
46 |
47 | #: forms.py:54
48 | #, python-format
49 | msgid ""
50 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
51 | "%(nb_max_avatars)d."
52 | msgstr ""
53 | "Usted ya tiene %(nb_avatars)d avatares, y el máximo permitido es "
54 | "%(nb_max_avatars)d."
55 |
56 | #: forms.py:71 forms.py:84
57 | msgid "Choices"
58 | msgstr "Opciones"
59 |
60 | #: models.py:77
61 | msgid "user"
62 | msgstr ""
63 |
64 | #: models.py:80
65 | msgid "primary"
66 | msgstr ""
67 |
68 | #: models.py:91
69 | msgid "uploaded at"
70 | msgstr ""
71 |
72 | #: models.py:98
73 | #, fuzzy
74 | #| msgid "avatar"
75 | msgid "avatars"
76 | msgstr "avatar"
77 |
78 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
79 | msgid "Your current avatar: "
80 | msgstr "Su avatar actual:"
81 |
82 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
83 | msgid "You haven't uploaded an avatar yet. Please upload one now."
84 | msgstr "No ha subido un avatar aún. Por favor, suba uno ahora."
85 |
86 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
87 | msgid "Upload New Image"
88 | msgstr "Subir Nueva Imagen"
89 |
90 | #: templates/avatar/change.html:14
91 | msgid "Choose new Default"
92 | msgstr "Elige nuevo predeterminado"
93 |
94 | #: templates/avatar/confirm_delete.html:5
95 | msgid "Please select the avatars that you would like to delete."
96 | msgstr "Por favor seleccione los avatares que le gustaría eliminar."
97 |
98 | #: templates/avatar/confirm_delete.html:8
99 | #, python-format
100 | msgid ""
101 | "You have no avatars to delete. Please upload one now."
103 | msgstr ""
104 | "No tiene avatares para borrar. Por favor suba uno ahora."
106 |
107 | #: templates/avatar/confirm_delete.html:14
108 | msgid "Delete These"
109 | msgstr "Eliminar Estos"
110 |
111 | #: templates/notification/avatar_friend_updated/full.txt:1
112 | #, python-format
113 | msgid ""
114 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
115 | "\n"
116 | "http://%(current_site)s%(avatar_url)s\n"
117 | msgstr ""
118 | "%(avatar_creator)s ha actualizado su avatar %(avatar)s.\n"
119 | "\n"
120 | "http://%(current_site)s%(avatar_url)s\n"
121 |
122 | #: templates/notification/avatar_friend_updated/notice.html:2
123 | #, python-format
124 | msgid ""
125 | "%(avatar_creator)s has updated their avatar %(avatar)s."
127 | msgstr ""
128 | "%(avatar_creator)s ha actualizado su avatar %(avatar)s."
130 |
131 | #: templates/notification/avatar_updated/full.txt:1
132 | #, python-format
133 | msgid ""
134 | "Your avatar has been updated. %(avatar)s\n"
135 | "\n"
136 | "http://%(current_site)s%(avatar_url)s\n"
137 | msgstr ""
138 | "Su avatar ha sido actualizado. %(avatar)s\n"
139 | "\n"
140 | "http://%(current_site)s%(avatar_url)s\n"
141 |
142 | #: templates/notification/avatar_updated/notice.html:2
143 | #, python-format
144 | msgid "You have updated your avatar %(avatar)s."
145 | msgstr "Ha actualizado su avatar %(avatar)s."
146 |
147 | #: templatetags/avatar_tags.py:69
148 | msgid "Default Avatar"
149 | msgstr "Avatar Predeterminado"
150 |
151 | #: views.py:73
152 | msgid "Successfully uploaded a new avatar."
153 | msgstr "Se ha subido correctamente un nuevo avatar"
154 |
155 | #: views.py:111
156 | msgid "Successfully updated your avatar."
157 | msgstr "Se ha actualizado correctamente su avatar."
158 |
159 | #: views.py:150
160 | msgid "Successfully deleted the requested avatars."
161 | msgstr "Se han eliminado correctamente los avatares solicitados."
162 |
--------------------------------------------------------------------------------
/avatar/locale/it/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: 3.1.0\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2017-02-13 16:00+0200\n"
11 | "PO-Revision-Date: 2013-08-27 00:21-0600\n"
12 | "Last-Translator: Bruno Santeramo \n"
13 | "Language-Team: it \n"
14 | "Language: it\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19 | "X-Generator: po2mo.net\n"
20 |
21 | #: admin.py:26
22 | msgid "Avatar"
23 | msgstr "Avatar"
24 |
25 | #: forms.py:24 models.py:84 models.py:97
26 | msgid "avatar"
27 | msgstr "avatar"
28 |
29 | #: forms.py:37
30 | #, python-format
31 | msgid ""
32 | "%(ext)s is an invalid file extension. Authorized extensions are : "
33 | "%(valid_exts_list)s"
34 | msgstr ""
35 | "%(ext)s non è una estensione valida. Le estensioni accettate sono : "
36 | "%(valid_exts_list)s"
37 |
38 | #: forms.py:44
39 | #, python-format
40 | msgid ""
41 | "Your file is too big (%(size)s), the maximum allowed size is "
42 | "%(max_valid_size)s"
43 | msgstr ""
44 | "Il file è troppo grande (%(size)s), la massima dimensione consentita "
45 | "è %(max_valid_size)s"
46 |
47 | #: forms.py:54
48 | #, python-format
49 | msgid ""
50 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
51 | "%(nb_max_avatars)d."
52 | msgstr ""
53 | "Hai già %(nb_avatars)d avatar, e il massimo numero consentito è "
54 | "%(nb_max_avatars)d."
55 |
56 | #: forms.py:71 forms.py:84
57 | msgid "Choices"
58 | msgstr "Opzioni"
59 |
60 | #: models.py:77
61 | msgid "user"
62 | msgstr "utente"
63 |
64 | #: models.py:80
65 | msgid "primary"
66 | msgstr "principale"
67 |
68 | #: models.py:91
69 | msgid "uploaded at"
70 | msgstr "caricato su"
71 |
72 | #: models.py:98
73 | #, fuzzy
74 | #| msgid "avatar"
75 | msgid "avatars"
76 | msgstr "avatar"
77 |
78 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
79 | msgid "Your current avatar: "
80 | msgstr "Il tuo attuale avatar è:"
81 |
82 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
83 | msgid "You haven't uploaded an avatar yet. Please upload one now."
84 | msgstr "Non hai ancora caricato un avatar. Per favore, carica uno adesso."
85 |
86 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
87 | msgid "Upload New Image"
88 | msgstr "Carica una Nuova Immagine"
89 |
90 | #: templates/avatar/change.html:14
91 | msgid "Choose new Default"
92 | msgstr "Scegli un nuovo predefinito"
93 |
94 | #: templates/avatar/confirm_delete.html:5
95 | msgid "Please select the avatars that you would like to delete."
96 | msgstr "Per favore, seleziona gli avatar che vuoi eliminare."
97 |
98 | #: templates/avatar/confirm_delete.html:8
99 | #, python-format
100 | msgid ""
101 | "You have no avatars to delete. Please upload one now."
103 | msgstr ""
104 | "Non hai avatar da eliminare. Per favore carica uno adesso."
106 |
107 | #: templates/avatar/confirm_delete.html:14
108 | msgid "Delete These"
109 | msgstr "Elimina Questi"
110 |
111 | #: templates/notification/avatar_friend_updated/full.txt:1
112 | #, python-format
113 | msgid ""
114 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
115 | "\n"
116 | "http://%(current_site)s%(avatar_url)s\n"
117 | msgstr ""
118 | "%(avatar_creator)s ha aggiornato i suoi avatar %(avatar)s.\n"
119 | "\n"
120 | "http://%(current_site)s%(avatar_url)s\n"
121 |
122 | #: templates/notification/avatar_friend_updated/notice.html:2
123 | #, python-format
124 | msgid ""
125 | "%(avatar_creator)s has updated their avatar %(avatar)s."
127 | msgstr ""
128 | "%(avatar_creator)s ha aggiornato i suoi avatar %(avatar)s."
130 |
131 | #: templates/notification/avatar_updated/full.txt:1
132 | #, python-format
133 | msgid ""
134 | "Your avatar has been updated. %(avatar)s\n"
135 | "\n"
136 | "http://%(current_site)s%(avatar_url)s\n"
137 | msgstr ""
138 | "Il tuo avatar è stato aggiornato. %(avatar)s\n"
139 | "\n"
140 | "http://%(current_site)s%(avatar_url)s\n"
141 |
142 | #: templates/notification/avatar_updated/notice.html:2
143 | #, python-format
144 | msgid "You have updated your avatar %(avatar)s."
145 | msgstr "Hai aggiornato il tuo avatar %(avatar)s."
146 |
147 | #: templatetags/avatar_tags.py:69
148 | msgid "Default Avatar"
149 | msgstr "Avatar Predefinito"
150 |
151 | #: views.py:73
152 | msgid "Successfully uploaded a new avatar."
153 | msgstr "Nuovo avatar caricato con successo"
154 |
155 | #: views.py:111
156 | msgid "Successfully updated your avatar."
157 | msgstr "Il tuo avatar è stato aggiornato con successo."
158 |
159 | #: views.py:150
160 | msgid "Successfully deleted the requested avatars."
161 | msgstr "Gli avatar selezionati sono stati eliminati con successo."
162 |
--------------------------------------------------------------------------------
/avatar/locale/ru/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: PACKAGE VERSION\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2016-09-14 16:37+0200\n"
11 | "PO-Revision-Date: 2012-03-17 00:31+0400\n"
12 | "Last-Translator: frost-nzcr4 \n"
13 | "Language-Team: LANGUAGE \n"
14 | "Language: \n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "X-Poedit-Language: Russian\n"
19 | "X-Poedit-Country: RUSSIAN FEDERATION\n"
20 | "Plural-Forms: nplurals=2; plural=(n != 1)\n"
21 | "X-Poedit-SourceCharset: utf-8\n"
22 |
23 | #: admin.py:26
24 | #, fuzzy
25 | #| msgid "Avatar for %s"
26 | msgid "Avatar"
27 | msgstr "Аватар для %s"
28 |
29 | #: forms.py:24 models.py:84 models.py:97
30 | #, fuzzy
31 | #| msgid "Default Avatar"
32 | msgid "avatar"
33 | msgstr "Аватар по умолчанию"
34 |
35 | #: forms.py:37
36 | #, python-format
37 | msgid ""
38 | "%(ext)s is an invalid file extension. Authorized extensions are : "
39 | "%(valid_exts_list)s"
40 | msgstr ""
41 | "%(ext)s запрещённое расширение. Разрешённые расширения: %(valid_exts_list)s"
42 |
43 | #: forms.py:44
44 | #, python-format
45 | msgid ""
46 | "Your file is too big (%(size)s), the maximum allowed size is "
47 | "%(max_valid_size)s"
48 | msgstr ""
49 | "Файл слишком большой (%(size)s), максимальный допустимый размер "
50 | "%(max_valid_size)s"
51 |
52 | #: forms.py:54
53 | #, python-format
54 | msgid ""
55 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
56 | "%(nb_max_avatars)d."
57 | msgstr ""
58 | "У вас уже %(nb_avatars)d аватаров, максимально допустимо %(nb_max_avatars)d."
59 |
60 | #: forms.py:71 forms.py:84
61 | msgid "Choices"
62 | msgstr ""
63 |
64 | #: models.py:77
65 | msgid "user"
66 | msgstr ""
67 |
68 | #: models.py:80
69 | msgid "primary"
70 | msgstr ""
71 |
72 | #: models.py:91
73 | msgid "uploaded at"
74 | msgstr ""
75 |
76 | #: models.py:98
77 | #, fuzzy
78 | #| msgid "Avatar for %s"
79 | msgid "avatars"
80 | msgstr "Аватар для %s"
81 |
82 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
83 | msgid "Your current avatar: "
84 | msgstr "Ваш аватар:"
85 |
86 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
87 | msgid "You haven't uploaded an avatar yet. Please upload one now."
88 | msgstr "Вы ещё не загружали аватар. Пожалуйста, загрузите его."
89 |
90 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
91 | msgid "Upload New Image"
92 | msgstr "Загрузить новое изображение"
93 |
94 | #: templates/avatar/change.html:14
95 | msgid "Choose new Default"
96 | msgstr "Выберите используемый по умолчанию"
97 |
98 | #: templates/avatar/confirm_delete.html:5
99 | msgid "Please select the avatars that you would like to delete."
100 | msgstr "Выберите аватары, которые собираетесь удалить"
101 |
102 | #: templates/avatar/confirm_delete.html:8
103 | #, python-format
104 | msgid ""
105 | "You have no avatars to delete. Please upload one now."
107 | msgstr ""
108 | "У вас нет аватаров. Загрузите его."
109 |
110 | #: templates/avatar/confirm_delete.html:14
111 | msgid "Delete These"
112 | msgstr "Удалить эти"
113 |
114 | #: templates/notification/avatar_friend_updated/full.txt:1
115 | #, fuzzy, python-format
116 | #| msgid ""
117 | #| "%(avatar_creator)s has updated their avatar "
118 | #| "%(avatar)s."
119 | msgid ""
120 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
121 | "\n"
122 | "http://%(current_site)s%(avatar_url)s\n"
123 | msgstr ""
124 | "%(avatar_creator)s обновил свои аватары %(avatar)s.\n"
125 | "\n"
126 | "http://%(current_site)s%(avatar_url)s\n"
127 |
128 | #: templates/notification/avatar_friend_updated/notice.html:2
129 | #, python-format
130 | msgid ""
131 | "%(avatar_creator)s has updated their avatar %(avatar)s."
133 | msgstr ""
134 | "%(avatar_creator)s обновил свои аватары %(avatar)s."
136 |
137 | #: templates/notification/avatar_updated/full.txt:1
138 | #, python-format
139 | msgid ""
140 | "Your avatar has been updated. %(avatar)s\n"
141 | "\n"
142 | "http://%(current_site)s%(avatar_url)s\n"
143 | msgstr ""
144 |
145 | #: templates/notification/avatar_updated/notice.html:2
146 | #, python-format
147 | msgid "You have updated your avatar %(avatar)s."
148 | msgstr "Вы обновили аватар %(avatar)s."
149 |
150 | #: templatetags/avatar_tags.py:69
151 | msgid "Default Avatar"
152 | msgstr "Аватар по умолчанию"
153 |
154 | #: views.py:73
155 | msgid "Successfully uploaded a new avatar."
156 | msgstr "Новый аватар загружен."
157 |
158 | #: views.py:111
159 | msgid "Successfully updated your avatar."
160 | msgstr "Аватар обновлён."
161 |
162 | #: views.py:150
163 | msgid "Successfully deleted the requested avatars."
164 | msgstr "Выбранные аватары удалены."
165 |
--------------------------------------------------------------------------------
/avatar/locale/pl/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: django-avatar 0.0.2\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2016-09-14 16:37+0200\n"
11 | "PO-Revision-Date: 2015-07-19 15:46+0100\n"
12 | "Last-Translator: Adam Dobrawy \n"
13 | "Language-Team: Adam Dobrawy \n"
14 | "Language: pl\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
19 | "|| n%100>=20) ? 1 : 2);\n"
20 | "X-Generator: Poedit 1.5.4\n"
21 |
22 | #: admin.py:26
23 | msgid "Avatar"
24 | msgstr "Avatar"
25 |
26 | #: forms.py:24 models.py:84 models.py:97
27 | msgid "avatar"
28 | msgstr "avatar"
29 |
30 | #: forms.py:37
31 | #, python-format
32 | msgid ""
33 | "%(ext)s is an invalid file extension. Authorized extensions are : "
34 | "%(valid_exts_list)s"
35 | msgstr ""
36 | "%(ext)s jest nieprawidłowym rozszerzeniem. Dozwolone rozszerzenia to: "
37 | "%(valid_exts_list)s"
38 |
39 | #: forms.py:44
40 | #, python-format
41 | msgid ""
42 | "Your file is too big (%(size)s), the maximum allowed size is "
43 | "%(max_valid_size)s"
44 | msgstr ""
45 | "Twój plik jest zbyt duży (%(size)s), maksymalny dopuszcalny rozmiar wynosi "
46 | "%(max_valid_size)s"
47 |
48 | #: forms.py:54
49 | #, python-format
50 | msgid ""
51 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
52 | "%(nb_max_avatars)d."
53 | msgstr ""
54 | "Aktualnie masz %(nb_avatars)d avatarów, podczas gdy maksymalna dopuszczalna "
55 | "liczba wynosi %(nb_max_avatars)d."
56 |
57 | #: forms.py:71 forms.py:84
58 | msgid "Choices"
59 | msgstr "Opcje wyboru"
60 |
61 | #: models.py:77
62 | msgid "user"
63 | msgstr ""
64 |
65 | #: models.py:80
66 | msgid "primary"
67 | msgstr ""
68 |
69 | #: models.py:91
70 | msgid "uploaded at"
71 | msgstr ""
72 |
73 | #: models.py:98
74 | #, fuzzy
75 | #| msgid "avatar"
76 | msgid "avatars"
77 | msgstr "avatar"
78 |
79 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
80 | msgid "Your current avatar: "
81 | msgstr "Twój aktualny avatar"
82 |
83 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
84 | msgid "You haven't uploaded an avatar yet. Please upload one now."
85 | msgstr "Nie masz aktualnie żadnych avatarów. Prosimy wyślij teraz. "
86 |
87 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
88 | msgid "Upload New Image"
89 | msgstr "Wyślij nowy obraz"
90 |
91 | #: templates/avatar/change.html:14
92 | msgid "Choose new Default"
93 | msgstr "Wybierz nowy domyślny"
94 |
95 | #: templates/avatar/confirm_delete.html:5
96 | msgid "Please select the avatars that you would like to delete."
97 | msgstr "Wybierz avatar, który chcesz usunąć."
98 |
99 | #: templates/avatar/confirm_delete.html:8
100 | #, python-format
101 | msgid ""
102 | "You have no avatars to delete. Please upload one now."
104 | msgstr ""
105 | "Nie masz avatarów do usunięcia. Prosimy dodaj nowy."
107 |
108 | #: templates/avatar/confirm_delete.html:14
109 | msgid "Delete These"
110 | msgstr "Usuń wybrane"
111 |
112 | #: templates/notification/avatar_friend_updated/full.txt:1
113 | #, python-format
114 | msgid ""
115 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
116 | "\n"
117 | "http://%(current_site)s%(avatar_url)s\n"
118 | msgstr ""
119 | "%(avatar_creator)s zaktualizował / zaktualizowała avatar %(avatar)s.\n"
120 | "\n"
121 | "http://%(current_site)s%(avatar_url)s\n"
122 |
123 | #: templates/notification/avatar_friend_updated/notice.html:2
124 | #, python-format
125 | msgid ""
126 | "%(avatar_creator)s has updated their avatar %(avatar)s."
128 | msgstr ""
129 | "%(avatar_creator)s zaktualizował / "
130 | "zaktualizowała %(avatar)s."
131 |
132 | #: templates/notification/avatar_updated/full.txt:1
133 | #, python-format
134 | msgid ""
135 | "Your avatar has been updated. %(avatar)s\n"
136 | "\n"
137 | "http://%(current_site)s%(avatar_url)s\n"
138 | msgstr ""
139 | "Twój avatar został zaktualizowany %(avatar)s\n"
140 | "\n"
141 | "http://%(current_site)s%(avatar_url)s\n"
142 |
143 | #: templates/notification/avatar_updated/notice.html:2
144 | #, python-format
145 | msgid "You have updated your avatar %(avatar)s."
146 | msgstr ""
147 | "Zaktualizowałeś / zaktualizowałaś swój avatar "
148 | "%(avatar)s."
149 |
150 | #: templatetags/avatar_tags.py:69
151 | msgid "Default Avatar"
152 | msgstr "Domyślny avatar"
153 |
154 | #: views.py:73
155 | msgid "Successfully uploaded a new avatar."
156 | msgstr "Pomyślnie wysłano nowy avatar."
157 |
158 | #: views.py:111
159 | msgid "Successfully updated your avatar."
160 | msgstr "Pomyślnie zaktualizowano Twój avatar."
161 |
162 | #: views.py:150
163 | msgid "Successfully deleted the requested avatars."
164 | msgstr "Pomyślnie usunięto wskazany avatar."
165 |
--------------------------------------------------------------------------------
/avatar/forms.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django import forms
4 | from django.forms import widgets
5 | from django.template.defaultfilters import filesizeformat
6 | from django.utils.safestring import mark_safe
7 | from django.utils.translation import gettext_lazy as _
8 | from PIL import Image, ImageOps
9 |
10 | from avatar.conf import settings
11 | from avatar.models import Avatar
12 |
13 |
14 | def avatar_img(avatar, width, height):
15 | if not avatar.thumbnail_exists(width, height):
16 | avatar.create_thumbnail(width, height)
17 | return mark_safe(
18 | '
'
19 | % (avatar.avatar_url(width, height), str(avatar), width, height)
20 | )
21 |
22 |
23 | class UploadAvatarForm(forms.Form):
24 | avatar = forms.ImageField(label=_("avatar"))
25 |
26 | def __init__(self, *args, **kwargs):
27 | self.user = kwargs.pop("user")
28 | super().__init__(*args, **kwargs)
29 |
30 | def clean_avatar(self):
31 | data = self.cleaned_data["avatar"]
32 |
33 | if settings.AVATAR_ALLOWED_MIMETYPES:
34 | try:
35 | import magic
36 | except ImportError:
37 | raise ImportError(
38 | "python-magic library must be installed in order to use uploaded file content limitation"
39 | )
40 |
41 | # Construct 256 bytes needed for mime validation
42 | magic_buffer = bytes()
43 | for chunk in data.chunks():
44 | magic_buffer += chunk
45 | if len(magic_buffer) >= 256:
46 | break
47 |
48 | # https://github.com/ahupp/python-magic#usage
49 | mime = magic.from_buffer(magic_buffer, mime=True)
50 | if mime not in settings.AVATAR_ALLOWED_MIMETYPES:
51 | raise forms.ValidationError(
52 | _(
53 | "File content is invalid. Detected: %(mimetype)s Allowed content types are: %(valid_mime_list)s"
54 | )
55 | % {
56 | "valid_mime_list": ", ".join(settings.AVATAR_ALLOWED_MIMETYPES),
57 | "mimetype": mime,
58 | }
59 | )
60 |
61 | if settings.AVATAR_ALLOWED_FILE_EXTS:
62 | root, ext = os.path.splitext(data.name.lower())
63 | if ext not in settings.AVATAR_ALLOWED_FILE_EXTS:
64 | valid_exts = ", ".join(settings.AVATAR_ALLOWED_FILE_EXTS)
65 | error = _(
66 | "%(ext)s is an invalid file extension. "
67 | "Authorized extensions are : %(valid_exts_list)s"
68 | )
69 | raise forms.ValidationError(
70 | error % {"ext": ext, "valid_exts_list": valid_exts}
71 | )
72 |
73 | if data.size > settings.AVATAR_MAX_SIZE:
74 | error = _(
75 | "Your file is too big (%(size)s), "
76 | "the maximum allowed size is %(max_valid_size)s"
77 | )
78 | raise forms.ValidationError(
79 | error
80 | % {
81 | "size": filesizeformat(data.size),
82 | "max_valid_size": filesizeformat(settings.AVATAR_MAX_SIZE),
83 | }
84 | )
85 |
86 | try:
87 | image = Image.open(data)
88 | ImageOps.exif_transpose(image)
89 | except TypeError:
90 | raise forms.ValidationError(_("Corrupted image"))
91 |
92 | count = Avatar.objects.filter(user=self.user).count()
93 | if 1 < settings.AVATAR_MAX_AVATARS_PER_USER <= count:
94 | error = _(
95 | "You already have %(nb_avatars)d avatars, "
96 | "and the maximum allowed is %(nb_max_avatars)d."
97 | )
98 | raise forms.ValidationError(
99 | error
100 | % {
101 | "nb_avatars": count,
102 | "nb_max_avatars": settings.AVATAR_MAX_AVATARS_PER_USER,
103 | }
104 | )
105 | return
106 |
107 |
108 | class PrimaryAvatarForm(forms.Form):
109 | def __init__(self, *args, **kwargs):
110 | kwargs.pop("user")
111 | width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
112 | height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
113 | avatars = kwargs.pop("avatars")
114 | super().__init__(*args, **kwargs)
115 | self.fields["choice"] = forms.ChoiceField(
116 | choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
117 | widget=widgets.RadioSelect,
118 | )
119 |
120 |
121 | class DeleteAvatarForm(forms.Form):
122 | def __init__(self, *args, **kwargs):
123 | kwargs.pop("user")
124 | width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
125 | height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
126 | avatars = kwargs.pop("avatars")
127 | super().__init__(*args, **kwargs)
128 | self.fields["choices"] = forms.MultipleChoiceField(
129 | label=_("Choices"),
130 | choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
131 | widget=widgets.CheckboxSelectMultiple,
132 | )
133 |
--------------------------------------------------------------------------------
/avatar/utils.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 |
3 | from django.contrib.auth import get_user_model
4 | from django.core.cache import cache
5 | from django.template.defaultfilters import slugify
6 | from django.utils.encoding import force_bytes
7 |
8 | from avatar.conf import settings
9 |
10 | cached_funcs = set()
11 |
12 |
13 | def get_username(user):
14 | """Return username of a User instance"""
15 | if hasattr(user, "get_username"):
16 | return user.get_username()
17 | else:
18 | return user.username
19 |
20 |
21 | def get_user(userdescriptor):
22 | """Return user from a username/ID/ish identifier"""
23 | User = get_user_model()
24 | if isinstance(userdescriptor, int):
25 | user = User.objects.filter(id=userdescriptor).first()
26 | if user:
27 | return user
28 | elif userdescriptor.isdigit():
29 | user = User.objects.filter(id=int(userdescriptor)).first()
30 | if user:
31 | return user
32 | return User.objects.get_by_natural_key(userdescriptor)
33 |
34 |
35 | def get_cache_key(user_or_username, prefix, width=None, height=None):
36 | """
37 | Returns a cache key consisten of a username and image size.
38 | """
39 | if isinstance(user_or_username, get_user_model()):
40 | user_or_username = get_username(user_or_username)
41 | key = f"{prefix}_{user_or_username}"
42 | if width:
43 | key += f"_{width}"
44 | if height or width:
45 | key += f"x{height or width}"
46 | return "%s_%s" % (
47 | slugify(key)[:100],
48 | hashlib.md5(force_bytes(key)).hexdigest(),
49 | )
50 |
51 |
52 | def cache_set(key, value):
53 | cache.set(key, value, settings.AVATAR_CACHE_TIMEOUT)
54 | return value
55 |
56 |
57 | def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
58 | """
59 | Decorator to cache the result of functions that take a ``user``, a
60 | ``width`` and a ``height`` value.
61 | """
62 | if not settings.AVATAR_CACHE_ENABLED:
63 |
64 | def decorator(func):
65 | return func
66 |
67 | return decorator
68 |
69 | def decorator(func):
70 | def cached_func(user, width=None, height=None, **kwargs):
71 | prefix = func.__name__
72 | cached_funcs.add(prefix)
73 | key = get_cache_key(user, prefix, width or default_size, height)
74 | result = cache.get(key)
75 | if result is None:
76 | result = func(user, width or default_size, height, **kwargs)
77 | cache_set(key, result)
78 | # add image size to set of cached sizes so we can invalidate them later
79 | sizes_key = get_cache_key(user, "cached_sizes")
80 | sizes = cache.get(sizes_key, set())
81 | sizes.add((width or default_size, height or width or default_size))
82 | cache_set(sizes_key, sizes)
83 | return result
84 |
85 | return cached_func
86 |
87 | return decorator
88 |
89 |
90 | def invalidate_cache(user, width=None, height=None):
91 | """
92 | Function to be called when saving or changing a user's avatars.
93 | """
94 | sizes_key = get_cache_key(user, "cached_sizes")
95 | sizes = cache.get(sizes_key, set())
96 | if width is not None:
97 | sizes.add((width, height or width))
98 | for prefix in cached_funcs:
99 | for size in sizes:
100 | if isinstance(size, int):
101 | cache.delete(get_cache_key(user, prefix, size))
102 | else:
103 | # Size is specified with height and width.
104 | cache.delete(get_cache_key(user, prefix, size[0], size[1]))
105 | cache.set(sizes_key, set())
106 |
107 |
108 | def get_default_avatar_url():
109 | base_url = getattr(settings, "STATIC_URL", None)
110 | if not base_url:
111 | base_url = getattr(settings, "MEDIA_URL", "")
112 |
113 | # Don't use base_url if the default url starts with http:// of https://
114 | if settings.AVATAR_DEFAULT_URL.startswith(("http://", "https://")):
115 | return settings.AVATAR_DEFAULT_URL
116 | # We'll be nice and make sure there are no duplicated forward slashes
117 | ends = base_url.endswith("/")
118 |
119 | begins = settings.AVATAR_DEFAULT_URL.startswith("/")
120 | if ends and begins:
121 | base_url = base_url[:-1]
122 | elif not ends and not begins:
123 | return "%s/%s" % (base_url, settings.AVATAR_DEFAULT_URL)
124 |
125 | return "%s%s" % (base_url, settings.AVATAR_DEFAULT_URL)
126 |
127 |
128 | def get_primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
129 | User = get_user_model()
130 | if not isinstance(user, User):
131 | try:
132 | user = get_user(user)
133 | except User.DoesNotExist:
134 | return None
135 | try:
136 | # Order by -primary first; this means if a primary=True avatar exists
137 | # it will be first, and then ordered by date uploaded, otherwise a
138 | # primary=False avatar will be first. Exactly the fallback behavior we
139 | # want.
140 | avatar = user.avatar_set.order_by("-primary", "-date_uploaded")[0]
141 | except IndexError:
142 | avatar = None
143 | if avatar:
144 | if not avatar.thumbnail_exists(width, height):
145 | avatar.create_thumbnail(width, height)
146 | return avatar
147 |
--------------------------------------------------------------------------------
/avatar/locale/de/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: \n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2016-09-14 16:37+0200\n"
11 | "PO-Revision-Date: 2016-09-14 14:34+0200\n"
12 | "Last-Translator: \n"
13 | "Language-Team: \n"
14 | "Language: de\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19 | "X-Generator: Poedit 1.8.9\n"
20 |
21 | #: admin.py:26
22 | msgid "Avatar"
23 | msgstr "Avatar"
24 |
25 | #: forms.py:24 models.py:84 models.py:97
26 | msgid "avatar"
27 | msgstr "avatar"
28 |
29 | #: forms.py:37
30 | #, python-format
31 | msgid ""
32 | "%(ext)s is an invalid file extension. Authorized extensions are : "
33 | "%(valid_exts_list)s"
34 | msgstr ""
35 | "%(ext)s ist ein ungültiges Dateiformat. Erlaubte Formate sind: "
36 | "%(valid_exts_list)s"
37 |
38 | #: forms.py:44
39 | #, python-format
40 | msgid ""
41 | "Your file is too big (%(size)s), the maximum allowed size is "
42 | "%(max_valid_size)s"
43 | msgstr ""
44 | "Die Datei ist zu groß (%(size)s), die Maximalgröße ist %(max_valid_size)s"
45 |
46 | #: forms.py:54
47 | #, python-format
48 | msgid ""
49 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
50 | "%(nb_max_avatars)d."
51 | msgstr ""
52 | "Sie haben bereits %(nb_avatars)d Avatarbilder hochgeladen. Die maximale "
53 | "Anzahl ist %(nb_max_avatars)d."
54 |
55 | #: forms.py:71 forms.py:84
56 | msgid "Choices"
57 | msgstr "Auswahl"
58 |
59 | #: models.py:77
60 | msgid "user"
61 | msgstr "Benutzer"
62 |
63 | #: models.py:80
64 | msgid "primary"
65 | msgstr "primär"
66 |
67 | #: models.py:91
68 | msgid "uploaded at"
69 | msgstr "hochgeladen am"
70 |
71 | #: models.py:98
72 | msgid "avatars"
73 | msgstr "Avatare"
74 |
75 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
76 | msgid "Your current avatar: "
77 | msgstr "Ihr aktueller Avatar: "
78 |
79 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
80 | msgid "You haven't uploaded an avatar yet. Please upload one now."
81 | msgstr ""
82 | "Sie haben noch keinen Avatar hochgeladen. Bitte laden Sie nun einen hoch."
83 |
84 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
85 | msgid "Upload New Image"
86 | msgstr "Neues Bild hochladen"
87 |
88 | #: templates/avatar/change.html:14
89 | msgid "Choose new Default"
90 | msgstr "Standard auswählen"
91 |
92 | #: templates/avatar/confirm_delete.html:5
93 | msgid "Please select the avatars that you would like to delete."
94 | msgstr "Bitte wählen Sie den Avatar aus, den Sie löschen möchten."
95 |
96 | #: templates/avatar/confirm_delete.html:8
97 | #, python-format
98 | msgid ""
99 | "You have no avatars to delete. Please upload one now."
101 | msgstr ""
102 | "Sie haben keine Avatare zum Löschen. Bitte laden Sie einen hoch."
104 |
105 | #: templates/avatar/confirm_delete.html:14
106 | msgid "Delete These"
107 | msgstr "Auswahl löschen"
108 |
109 | #: templates/notification/avatar_friend_updated/full.txt:1
110 | #, python-format
111 | msgid ""
112 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
113 | "\n"
114 | "http://%(current_site)s%(avatar_url)s\n"
115 | msgstr ""
116 | "%(avatar_creator)s hat seinen/ihren Avatar %(avatar)s aktualisiert.\n"
117 | "\n"
118 | "http://%(current_site)s%(avatar_url)s\n"
119 |
120 | #: templates/notification/avatar_friend_updated/notice.html:2
121 | #, python-format
122 | msgid ""
123 | "%(avatar_creator)s has updated their avatar %(avatar)s."
125 | msgstr ""
126 | "%(avatar_creator)s hat ihren/seinen Avatar "
127 | "aktualisiert %(avatar)s."
128 |
129 | #: templates/notification/avatar_updated/full.txt:1
130 | #, python-format
131 | msgid ""
132 | "Your avatar has been updated. %(avatar)s\n"
133 | "\n"
134 | "http://%(current_site)s%(avatar_url)s\n"
135 | msgstr ""
136 | "Ihr Avatar wurde aktualisiert. %(avatar)s\n"
137 | "\n"
138 | "http://%(current_site)s%(avatar_url)s\n"
139 |
140 | #: templates/notification/avatar_updated/notice.html:2
141 | #, python-format
142 | msgid "You have updated your avatar %(avatar)s."
143 | msgstr ""
144 | "Sie haben Ihren Avatar aktualisiert %(avatar)s"
145 | "a>."
146 |
147 | #: templatetags/avatar_tags.py:69
148 | msgid "Default Avatar"
149 | msgstr "Standard-Avatar"
150 |
151 | #: views.py:73
152 | msgid "Successfully uploaded a new avatar."
153 | msgstr "Ein neuer Avatar wurde erfolgreich hochgeladen."
154 |
155 | #: views.py:111
156 | msgid "Successfully updated your avatar."
157 | msgstr "Ihr Avatar wurde erfolgreich aktualisiert."
158 |
159 | #: views.py:150
160 | msgid "Successfully deleted the requested avatars."
161 | msgstr "Ihr Avatar wurde erfolgreich gelöscht."
162 |
163 | #~ msgid "Avatar Updated"
164 | #~ msgstr "Avatar aktualisiert"
165 |
166 | #~ msgid "your avatar has been updated"
167 | #~ msgstr "Ihr Avatar wurde aktualisiert"
168 |
169 | #~ msgid "Friend Updated Avatar"
170 | #~ msgstr "Freund aktualisierte Avatar"
171 |
172 | #~ msgid "a friend has updated their avatar"
173 | #~ msgstr "Avatar eines Freundes wurde aktualisiert"
174 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | Changelog
2 | =========
3 | * 8.0.1
4 | * Fix Django 5.1 compatibility
5 |
6 | * 8.0.0 (October 16, 2023)
7 | * Add Django 4.2 support
8 | * Remove Python 3.7 support
9 | * Use path and path converters (changes all url names from prefix `avatar_` to `avatar:`.)
10 | * Add support for Django STORAGES (Django 4.2)
11 | * Add optional api app (requires djangorestframework)
12 | * Use ``Image.Resampling.LANCZOS`` instead of ``Image.LANCZOS`` that was removed in Pillow 10.0.0
13 |
14 | * 7.1.1 (February 23, 2023)
15 | * Switch to setuptools for building
16 |
17 | * 7.1.0 (February 23, 2023)
18 | * Add LibRavatar support
19 | * Faster admin when many users are present
20 | * Check for corrupted image during upload
21 | * Switch Pillow Resize method from ``Image.ANTIALIAS`` to ``Image.LANCZOS``
22 | * Removed Python 3.6 testing
23 | * Added Python 3.11 support
24 |
25 | * 7.0.1 (October 27, 2022)
26 | * Remove height requirement for providers (broke 6 to 7 upgrades)
27 |
28 | * 7.0.0 (August 16, 2022)
29 | * Allowed for rectangular avatars. Custom avatar tag templates now require the specification of both a ``width`` and ``height`` attribute instead of ``size``.
30 | * Made ``True`` the default value of ``AVATAR_CLEANUP_DELETED``. (Set to ``False`` to obtain previous behavior).
31 | * Fix invalidate_cache for on-the-fly created thumbnails.
32 | * New setting ``AVATAR_ALLOWED_MIMETYPES``. If enabled, it checks mimetypes of uploaded files using ``python-magic``. Default is ``None``.
33 | * Fix thumbnail transposing for Safari.
34 |
35 | * 6.0.1 (August 12, 2022)
36 | * Exclude tests folder from distribution.
37 |
38 | * 6.0.0 (August 12, 2022)
39 | * Added Django 3.2, 4.0 and 4.1 support.
40 | * Removed Django 1.9, 1.10, 1.11, 2.0, 2.1, 2.2 and 3.0 support.
41 | * Added Python 3.9 and 3.10 support.
42 | * Removed Python 2.7, 3.4 and 3.5 support.
43 | * Made ``"PNG"`` the default value for ``AVATAR_THUMB_FORMAT`` (Set to ``"JPEG"`` to obtain previous behavior).
44 | * Made ``False`` the default value for ``AVATAR_EXPOSE_USERNAMES`` (Set to ``True`` to obtain previous behavior).
45 | * Don't leak usernames through image alt-tags when ``AVATAR_EXPOSE_USERNAMES`` is `False`.
46 | * New setting ``AVATAR_THUMB_MODES``. Default is ``['RGB', 'RGBA']``.
47 | * Use original image as thumbnail if thumbnail creation failed but image saving succeeds.
48 | * Add farsi translation.
49 | * Introduce black and flake8 linting
50 |
51 | * 5.0.0 (January 4, 2019)
52 | * Added Django 2.1, 2.2, and 3.0 support.
53 | * Added Python 3.7 and 3.8 support.
54 | * Removed Python 1.9 and 1.10 support.
55 | * Fixed bug where avatars couldn't be deleted if file was already deleted.
56 |
57 | * 4.1.0 (December 20, 2017)
58 | * Added Django 2.0 support.
59 | * Added ``avatar_deleted`` signal.
60 | * Ensure thumbnails are the correct orientation.
61 |
62 | * 4.0.0 (May 27, 2017)
63 | * **Backwards incompatible:** Added ``AVATAR_PROVIDERS`` setting. Avatar providers are classes that return an avatar URL for a given user.
64 | * Added ``verbose_name`` to ``Avatar`` model fields.
65 | * Added the ability to override the ``alt`` attribute using the ``avatar`` template tag.
66 | * Added Italian translations.
67 | * Improved German translations.
68 | * Fixed bug where ``rebuild_avatars`` would fail on Django 1.10+.
69 | * Added Django 1.11 support.
70 | * Added Python 3.6 support.
71 | * Removed Django 1.7 and 1.8 support.
72 | * Removed Python 3.3 support.
73 |
74 | * 3.1.0 (September 10, 2016)
75 | * Added the ability to override templates using ``AVATAR_ADD_TEMPLATE``, ``AVATAR_CHANGE_TEMPLATE``, and ``AVATAR_DELETE_TEMPLATE``.
76 | * Added the ability to pass additional HTML attributes using the ``{% avatar %}`` template tag.
77 | * Fixed unused verbosity setting in ``rebuild_avatars.py``.
78 | * Added Django 1.10 support
79 | * Removed Python 3.2 support
80 |
81 | * 3.0.0 (February 26, 2016):
82 | * Added the ability to hide usernames/emails from avatar URLs.
83 | * Added the ability to use a Facebook Graph avatar as a backup.
84 | * Added a way to customize where avatars are stored.
85 | * Added a setting to disable the avatar cache.
86 | * Updated thumbnail creation to preserve RGBA.
87 | * Fixed issue where ``render_primary`` would not work if username/email was greater than 30 characters.
88 | * Fixed issue where cache was not invalidated after updating avatar
89 | * **Backwards Incompatible:** Renamed the ``avatar.util`` module to ``avatar.utils``.
90 |
91 | * 2.2.1 (January 11, 2016)
92 | * Added AVATAR_GRAVATAR_FIELD setting to define the user field to get the gravatar email.
93 | * Improved Django 1.9/1.10 compatibility
94 | * Improved Brazilian translations
95 |
96 | * 2.2.0 (December 2, 2015)
97 | * Added Python 3.5 support
98 | * Added Django 1.9 support
99 | * Removed Python 2.6 support
100 | * Removed Django 1.4, 1.5, and 1.6 support
101 |
102 | * 2.1.1 (August 10, 2015)
103 | * Added Polish locale
104 | * Fixed RemovedInDjango19Warning warnings
105 |
106 | * 2.1 (May 2, 2015)
107 | * Django 1.7 and 1.8 support
108 | * Add South and Django migrations
109 | * Changed Gravatar link to use HTTPS by default
110 | * Fixed a bug where the admin avatar list page would only show a user's primary avatar
111 | * Updated render_primary view to accept usernames with @ signs in them
112 | * Updated translations (added Dutch, Japanese, and Simple Chinese)
113 |
--------------------------------------------------------------------------------
/avatar/locale/fr/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: PACKAGE VERSION\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2016-09-14 16:37+0200\n"
11 | "PO-Revision-Date: 2010-03-26 18:35+0100\n"
12 | "Last-Translator: Mathieu Pillard \n"
13 | "Language-Team: LANGUAGE \n"
14 | "Language: \n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 |
19 | #: admin.py:26
20 | #, fuzzy
21 | #| msgid "Avatar for %s"
22 | msgid "Avatar"
23 | msgstr "Avatar pour %s"
24 |
25 | #: forms.py:24 models.py:84 models.py:97
26 | #, fuzzy
27 | #| msgid "Default Avatar"
28 | msgid "avatar"
29 | msgstr "Avatar par défaut"
30 |
31 | #: forms.py:37
32 | #, python-format
33 | msgid ""
34 | "%(ext)s is an invalid file extension. Authorized extensions are : "
35 | "%(valid_exts_list)s"
36 | msgstr ""
37 | "%(ext)s n'est pas une extension de fichier valide. Les extensions autorisées "
38 | "sont: %(valid_exts_list)s"
39 |
40 | #: forms.py:44
41 | #, python-format
42 | msgid ""
43 | "Your file is too big (%(size)s), the maximum allowed size is "
44 | "%(max_valid_size)s"
45 | msgstr ""
46 | "Le fichier est trop gros (%(size)s), la taille maximum autorisée est "
47 | "%(max_valid_size)s"
48 |
49 | #: forms.py:54
50 | #, python-format
51 | msgid ""
52 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
53 | "%(nb_max_avatars)d."
54 | msgstr ""
55 | "Vous avez déjà %(nb_avatars)d avatars, et le maximum autorisé est "
56 | "%(nb_max_avatars)d."
57 |
58 | #: forms.py:71 forms.py:84
59 | msgid "Choices"
60 | msgstr "Choix"
61 |
62 | #: models.py:77
63 | msgid "user"
64 | msgstr ""
65 |
66 | #: models.py:80
67 | msgid "primary"
68 | msgstr ""
69 |
70 | #: models.py:91
71 | msgid "uploaded at"
72 | msgstr ""
73 |
74 | #: models.py:98
75 | #, fuzzy
76 | #| msgid "Avatar for %s"
77 | msgid "avatars"
78 | msgstr "Avatar pour %s"
79 |
80 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
81 | msgid "Your current avatar: "
82 | msgstr "Votre avatar actuel:"
83 |
84 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
85 | msgid "You haven't uploaded an avatar yet. Please upload one now."
86 | msgstr "Vous n'avez pas encore ajouté d'avatar. Veuillez le faire maintenant."
87 |
88 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
89 | msgid "Upload New Image"
90 | msgstr "Ajouter une nouvelle image"
91 |
92 | #: templates/avatar/change.html:14
93 | msgid "Choose new Default"
94 | msgstr "Choisir le nouvel avatar par défaut"
95 |
96 | #: templates/avatar/confirm_delete.html:5
97 | msgid "Please select the avatars that you would like to delete."
98 | msgstr "Veuillez sélectionner les avatars que vous souhaitez effacer"
99 |
100 | #: templates/avatar/confirm_delete.html:8
101 | #, python-format
102 | msgid ""
103 | "You have no avatars to delete. Please upload one now."
105 | msgstr ""
106 | "Vous n'avez aucun avatar à effacer. Veuillez en ajouter un maintenant."
108 |
109 | #: templates/avatar/confirm_delete.html:14
110 | msgid "Delete These"
111 | msgstr "Effacer"
112 |
113 | #: templates/notification/avatar_friend_updated/full.txt:1
114 | #, fuzzy, python-format
115 | msgid ""
116 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
117 | "\n"
118 | "http://%(current_site)s%(avatar_url)s\n"
119 | msgstr ""
120 | "%(avatar_creator)s a mis à jour son avatar %(avatar)s\n"
121 | "\n"
122 | "http://%(current_site)s%(avatar_url)s\n"
123 |
124 | #: templates/notification/avatar_friend_updated/notice.html:2
125 | #, python-format
126 | msgid ""
127 | "%(avatar_creator)s has updated their avatar %(avatar)s."
129 | msgstr ""
130 | "%(avatar_creator)s a mis à jour son avatar %(avatar)s."
132 |
133 | #: templates/notification/avatar_updated/full.txt:1
134 | #, python-format
135 | msgid ""
136 | "Your avatar has been updated. %(avatar)s\n"
137 | "\n"
138 | "http://%(current_site)s%(avatar_url)s\n"
139 | msgstr ""
140 | "Votre avatar a été mis à jour. %(avatar)s\n"
141 | "\n"
142 | "http://%(current_site)s%(avatar_url)s\n"
143 |
144 | #: templates/notification/avatar_updated/notice.html:2
145 | #, python-format
146 | msgid "You have updated your avatar %(avatar)s."
147 | msgstr "Vous avez mis à jour votre %(avatar)s."
148 |
149 | #: templatetags/avatar_tags.py:69
150 | msgid "Default Avatar"
151 | msgstr "Avatar par défaut"
152 |
153 | #: views.py:73
154 | msgid "Successfully uploaded a new avatar."
155 | msgstr "Votre nouveau avatar a été uploadé avec succès."
156 |
157 | #: views.py:111
158 | msgid "Successfully updated your avatar."
159 | msgstr "Votre avatar a été mis à jour avec succès."
160 |
161 | #: views.py:150
162 | msgid "Successfully deleted the requested avatars."
163 | msgstr "Les avatars sélectionnés ont été effacés avec succès."
164 |
165 | #~ msgid "Avatar Updated"
166 | #~ msgstr "Avatar mis à jour"
167 |
168 | #~ msgid "your avatar has been updated"
169 | #~ msgstr "votre avatar a été mis à jour"
170 |
171 | #~ msgid "Friend Updated Avatar"
172 | #~ msgstr "Avatar mis à jour par un ami"
173 |
174 | #~ msgid "a friend has updated their avatar"
175 | #~ msgstr "un ami a mis à jour son avatar"
176 |
--------------------------------------------------------------------------------
/avatar/locale/pt_BR/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # SOME DESCRIPTIVE TITLE.
2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
3 | # This file is distributed under the same license as the PACKAGE package.
4 | # FIRST AUTHOR , YEAR.
5 | #
6 | #, fuzzy
7 | msgid ""
8 | msgstr ""
9 | "Project-Id-Version: PACKAGE VERSION\n"
10 | "Report-Msgid-Bugs-To: \n"
11 | "POT-Creation-Date: 2016-09-14 16:37+0200\n"
12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13 | "Last-Translator: FULL NAME \n"
14 | "Language-Team: LANGUAGE \n"
15 | "Language: \n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=UTF-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 |
20 | #: admin.py:26
21 | #, fuzzy
22 | #| msgid "Avatar for %s"
23 | msgid "Avatar"
24 | msgstr "Avatar para %s"
25 |
26 | #: forms.py:24 models.py:84 models.py:97
27 | #, fuzzy
28 | #| msgid "Default Avatar"
29 | msgid "avatar"
30 | msgstr "Foto de Perfil Padrão"
31 |
32 | #: forms.py:37
33 | #, python-format
34 | msgid ""
35 | "%(ext)s is an invalid file extension. Authorized extensions are : "
36 | "%(valid_exts_list)s"
37 | msgstr ""
38 | "%(ext)s é uma extensão informada inválida. Os Formatos permitidos são : "
39 | "%(valid_exts_list)s"
40 |
41 | #: forms.py:44
42 | #, python-format
43 | msgid ""
44 | "Your file is too big (%(size)s), the maximum allowed size is "
45 | "%(max_valid_size)s"
46 | msgstr ""
47 | "Arquivo muito grande (%(size)s), o máximo permitido é %(max_valid_size)s"
48 |
49 | #: forms.py:54
50 | #, python-format
51 | msgid ""
52 | "You already have %(nb_avatars)d avatars, and the maximum allowed is "
53 | "%(nb_max_avatars)d."
54 | msgstr ""
55 | "Você já possui %(nb_avatars)d fotos. O máximo permitido é %(nb_max_avatars)d."
56 |
57 | #: forms.py:71 forms.py:84
58 | msgid "Choices"
59 | msgstr "Opções"
60 |
61 | #: models.py:77
62 | msgid "user"
63 | msgstr ""
64 |
65 | #: models.py:80
66 | msgid "primary"
67 | msgstr ""
68 |
69 | #: models.py:91
70 | msgid "uploaded at"
71 | msgstr ""
72 |
73 | #: models.py:98
74 | #, fuzzy
75 | #| msgid "Avatar for %s"
76 | msgid "avatars"
77 | msgstr "Avatar para %s"
78 |
79 | #: templates/avatar/add.html:5 templates/avatar/change.html:5
80 | msgid "Your current avatar: "
81 | msgstr "Sua foto atual:"
82 |
83 | #: templates/avatar/add.html:8 templates/avatar/change.html:8
84 | msgid "You haven't uploaded an avatar yet. Please upload one now."
85 | msgstr "Você ainda não possui uma foto de perfil"
86 |
87 | #: templates/avatar/add.html:12 templates/avatar/change.html:19
88 | msgid "Upload New Image"
89 | msgstr "Enviar foto"
90 |
91 | #: templates/avatar/change.html:14
92 | msgid "Choose new Default"
93 | msgstr "Escolher padrão"
94 |
95 | #: templates/avatar/confirm_delete.html:5
96 | msgid "Please select the avatars that you would like to delete."
97 | msgstr "Por favor, selecione as fotos que você deseja excluir"
98 |
99 | #: templates/avatar/confirm_delete.html:8
100 | #, python-format
101 | msgid ""
102 | "You have no avatars to delete. Please upload one now."
104 | msgstr ""
105 | "Você não possui uma foto. Deseja enviar "
106 | "uma agora?"
107 |
108 | #: templates/avatar/confirm_delete.html:14
109 | msgid "Delete These"
110 | msgstr "Excluir estes"
111 |
112 | #: templates/notification/avatar_friend_updated/full.txt:1
113 | #, fuzzy, python-format
114 | msgid ""
115 | "%(avatar_creator)s has updated their avatar %(avatar)s.\n"
116 | "\n"
117 | "http://%(current_site)s%(avatar_url)s\n"
118 | msgstr ""
119 | "%(avatar_creator)s atualizou a foto do perfil %(avatar)s.\n"
120 | "\n"
121 | "%(avatar_creator)s atualizou a foto de perfil "
122 | "%(avatar)s."
123 |
124 | #: templates/notification/avatar_friend_updated/notice.html:2
125 | #, fuzzy, python-format
126 | msgid ""
127 | "%(avatar_creator)s has updated their avatar %(avatar)s."
129 | msgstr ""
130 | "%(avatar_creator)s atualizou a foto de perfil "
131 | "%(avatar)s."
132 |
133 | #: templates/notification/avatar_updated/full.txt:1
134 | #, python-format
135 | msgid ""
136 | "Your avatar has been updated. %(avatar)s\n"
137 | "\n"
138 | "http://%(current_site)s%(avatar_url)s\n"
139 | msgstr ""
140 | "Sua foto de perfil foi atualizada. %(avatar)s\n"
141 | "\n"
142 | "http://%(current_site)s%(avatar_url)s\n"
143 |
144 | #: templates/notification/avatar_updated/notice.html:2
145 | #, fuzzy, python-format
146 | msgid "You have updated your avatar %(avatar)s."
147 | msgstr ""
148 | "%(avatar_creator)s atualizou a foto de perfil "
149 | "%(avatar)s."
150 |
151 | #: templatetags/avatar_tags.py:69
152 | msgid "Default Avatar"
153 | msgstr "Foto de Perfil Padrão"
154 |
155 | #: views.py:73
156 | msgid "Successfully uploaded a new avatar."
157 | msgstr "Nova foto de perfil enviada com sucesso."
158 |
159 | #: views.py:111
160 | msgid "Successfully updated your avatar."
161 | msgstr "Sua foto foi atualizada com sucesso."
162 |
163 | #: views.py:150
164 | msgid "Successfully deleted the requested avatars."
165 | msgstr "As fotos de perfil selecionadas foram excluídas com sucesso."
166 |
167 | #~ msgid "Avatar Updated"
168 | #~ msgstr "Foto de Perfil Atualizada"
169 |
170 | #~ msgid "avatar have been updated"
171 | #~ msgstr "sua foto de perfil foi atualizada"
172 |
173 | #~ msgid "Friend Updated Avatar"
174 | #~ msgstr "Amigo Atualizou Foto de Perfil"
175 |
176 | #~ msgid "a friend has updated his avatar"
177 | #~ msgstr "um amigo atualizou a foto de perfil"
178 |
179 | #~ msgid ""
180 | #~ "A new tribe %(avatar)s has been created."
181 | #~ msgstr ""
182 | #~ "Uma nova foto de perfil %(avatar)s foi "
183 | #~ "criada."
184 |
--------------------------------------------------------------------------------
/avatar/api/views.py:
--------------------------------------------------------------------------------
1 | from django.db.models import QuerySet
2 | from django.utils.translation import gettext_lazy as _
3 | from rest_framework import permissions, status, viewsets
4 | from rest_framework.decorators import action
5 | from rest_framework.exceptions import ValidationError
6 | from rest_framework.response import Response
7 |
8 | from avatar.api.serializers import AvatarSerializer
9 | from avatar.api.utils import HTMLTagParser, assign_width_or_height, set_new_primary
10 | from avatar.models import Avatar
11 | from avatar.templatetags.avatar_tags import avatar
12 | from avatar.utils import get_default_avatar_url, get_primary_avatar, invalidate_cache
13 |
14 |
15 | class AvatarViewSets(viewsets.ModelViewSet):
16 | serializer_class = AvatarSerializer
17 | permission_classes = [permissions.IsAuthenticated]
18 | queryset = Avatar.objects.select_related("user").order_by(
19 | "-primary", "-date_uploaded"
20 | )
21 |
22 | @property
23 | def parse_html_to_json(self):
24 | default_avatar = avatar(self.request.user)
25 | html_parser = HTMLTagParser()
26 | html_parser.feed(default_avatar)
27 | return html_parser.output
28 |
29 | def get_queryset(self):
30 | assert self.queryset is not None, (
31 | "'%s' should either include a `queryset` attribute, "
32 | "or override the `get_queryset()` method." % self.__class__.__name__
33 | )
34 |
35 | queryset = self.queryset
36 | if isinstance(queryset, QuerySet):
37 | # Ensure queryset is re-evaluated on each request.
38 | queryset = queryset.filter(user=self.request.user)
39 | return queryset
40 |
41 | def list(self, request, *args, **kwargs):
42 | queryset = self.filter_queryset(self.get_queryset())
43 | if queryset:
44 | page = self.paginate_queryset(queryset)
45 | if page is not None:
46 | serializer = self.get_serializer(page, many=True)
47 | return self.get_paginated_response(serializer.data)
48 |
49 | serializer = self.get_serializer(queryset, many=True)
50 | data = serializer.data
51 | return Response(data)
52 |
53 | return Response(
54 | {
55 | "message": "You haven't uploaded an avatar yet. Please upload one now.",
56 | "default_avatar": self.parse_html_to_json,
57 | }
58 | )
59 |
60 | def create(self, request, *args, **kwargs):
61 | serializer = self.get_serializer(data=request.data)
62 | serializer.is_valid(raise_exception=True)
63 | self.perform_create(serializer)
64 | headers = self.get_success_headers(serializer.data)
65 | message = _("Successfully uploaded a new avatar.")
66 |
67 | context_data = {"message": message, "data": serializer.data}
68 | return Response(context_data, status=status.HTTP_201_CREATED, headers=headers)
69 |
70 | def destroy(self, request, *args, **kwargs):
71 | instance = self.get_object()
72 | if instance.primary is True:
73 | # Find the next avatar, and set it as the new primary
74 | set_new_primary(self.get_queryset(), instance)
75 | self.perform_destroy(instance)
76 | message = _("Successfully deleted the requested avatars.")
77 | return Response(message, status=status.HTTP_204_NO_CONTENT)
78 |
79 | def update(self, request, *args, **kwargs):
80 | partial = kwargs.pop("partial", False)
81 | instance = self.get_object()
82 | serializer = self.get_serializer(instance, data=request.data, partial=partial)
83 | serializer.is_valid(raise_exception=True)
84 | avatar_image = serializer.validated_data.get("avatar")
85 | primary_avatar = serializer.validated_data.get("primary")
86 | if not primary_avatar and avatar_image:
87 | raise ValidationError("You cant update an avatar image that is not primary")
88 |
89 | if instance.primary is True:
90 | # Find the next avatar, and set it as the new primary
91 | set_new_primary(self.get_queryset(), instance)
92 |
93 | self.perform_update(serializer)
94 | invalidate_cache(request.user)
95 | message = _("Successfully updated your avatar.")
96 | if getattr(instance, "_prefetched_objects_cache", None):
97 | # If 'prefetch_related' has been applied to a queryset, we need to
98 | # forcibly invalidate the prefetch cache on the instance.
99 | instance._prefetched_objects_cache = {}
100 | context_data = {"message": message, "data": serializer.data}
101 | return Response(context_data)
102 |
103 | @action(
104 | ["GET"], detail=False, url_path="render_primary", name="Render Primary Avatar"
105 | )
106 | def render_primary(self, request, *args, **kwargs):
107 | """
108 |
109 | URL Example :
110 |
111 | 1 - render_primary/
112 | 2 - render_primary/?width=400 or render_primary/?height=400
113 | 3 - render_primary/?width=500&height=400
114 | """
115 | context_data = {}
116 | avatar_size = assign_width_or_height(request.query_params)
117 |
118 | width = avatar_size.get("width")
119 | height = avatar_size.get("height")
120 |
121 | primary_avatar = get_primary_avatar(request.user, width=width, height=height)
122 |
123 | if primary_avatar and primary_avatar.primary:
124 | url = primary_avatar.avatar_url(width, height)
125 |
126 | else:
127 | url = get_default_avatar_url()
128 | if bool(request.query_params):
129 | context_data.update(
130 | {"message": "Resize parameters not working for default avatar"}
131 | )
132 |
133 | context_data.update({"image_url": request.build_absolute_uri(url)})
134 | return Response(context_data)
135 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # User-friendly check for sphinx-build
11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/)
13 | endif
14 |
15 | # Internal variables.
16 | PAPEROPT_a4 = -D latex_paper_size=a4
17 | PAPEROPT_letter = -D latex_paper_size=letter
18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
19 | # the i18n builder cannot share the environment and doctrees with the others
20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
21 |
22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
23 |
24 | help:
25 | @echo "Please use \`make ' where is one of"
26 | @echo " html to make standalone HTML files"
27 | @echo " dirhtml to make HTML files named index.html in directories"
28 | @echo " singlehtml to make a single large HTML file"
29 | @echo " pickle to make pickle files"
30 | @echo " json to make JSON files"
31 | @echo " htmlhelp to make HTML files and a HTML help project"
32 | @echo " qthelp to make HTML files and a qthelp project"
33 | @echo " devhelp to make HTML files and a Devhelp project"
34 | @echo " epub to make an epub"
35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
36 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
38 | @echo " text to make text files"
39 | @echo " man to make manual pages"
40 | @echo " texinfo to make Texinfo files"
41 | @echo " info to make Texinfo files and run them through makeinfo"
42 | @echo " gettext to make PO message catalogs"
43 | @echo " changes to make an overview of all changed/added/deprecated items"
44 | @echo " xml to make Docutils-native XML files"
45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes"
46 | @echo " linkcheck to check all external links for integrity"
47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
48 |
49 | clean:
50 | rm -rf $(BUILDDIR)/*
51 |
52 | html:
53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
54 | @echo
55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
56 |
57 | dirhtml:
58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
59 | @echo
60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
61 |
62 | singlehtml:
63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
64 | @echo
65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
66 |
67 | pickle:
68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
69 | @echo
70 | @echo "Build finished; now you can process the pickle files."
71 |
72 | json:
73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
74 | @echo
75 | @echo "Build finished; now you can process the JSON files."
76 |
77 | htmlhelp:
78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
79 | @echo
80 | @echo "Build finished; now you can run HTML Help Workshop with the" \
81 | ".hhp project file in $(BUILDDIR)/htmlhelp."
82 |
83 | qthelp:
84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
85 | @echo
86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-avatar.qhcp"
89 | @echo "To view the help file:"
90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-avatar.qhc"
91 |
92 | devhelp:
93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
94 | @echo
95 | @echo "Build finished."
96 | @echo "To view the help file:"
97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-avatar"
98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-avatar"
99 | @echo "# devhelp"
100 |
101 | epub:
102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
103 | @echo
104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
105 |
106 | latex:
107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
108 | @echo
109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
111 | "(use \`make latexpdf' here to do that automatically)."
112 |
113 | latexpdf:
114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
115 | @echo "Running LaTeX files through pdflatex..."
116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
118 |
119 | latexpdfja:
120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
121 | @echo "Running LaTeX files through platex and dvipdfmx..."
122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
124 |
125 | text:
126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
127 | @echo
128 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
129 |
130 | man:
131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
132 | @echo
133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
134 |
135 | texinfo:
136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
137 | @echo
138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
139 | @echo "Run \`make' in that directory to run these through makeinfo" \
140 | "(use \`make info' here to do that automatically)."
141 |
142 | info:
143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
144 | @echo "Running Texinfo files through makeinfo..."
145 | make -C $(BUILDDIR)/texinfo info
146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
147 |
148 | gettext:
149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
150 | @echo
151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
152 |
153 | changes:
154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
155 | @echo
156 | @echo "The overview file is in $(BUILDDIR)/changes."
157 |
158 | linkcheck:
159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
160 | @echo
161 | @echo "Link check complete; look for any errors in the above output " \
162 | "or in $(BUILDDIR)/linkcheck/output.txt."
163 |
164 | doctest:
165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
166 | @echo "Testing of doctests in the sources finished, look at the " \
167 | "results in $(BUILDDIR)/doctest/output.txt."
168 |
169 | xml:
170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
171 | @echo
172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
173 |
174 | pseudoxml:
175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
176 | @echo
177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
178 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | REM Command file for Sphinx documentation
4 |
5 | if "%SPHINXBUILD%" == "" (
6 | set SPHINXBUILD=sphinx-build
7 | )
8 | set BUILDDIR=_build
9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
10 | set I18NSPHINXOPTS=%SPHINXOPTS% .
11 | if NOT "%PAPER%" == "" (
12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
14 | )
15 |
16 | if "%1" == "" goto help
17 |
18 | if "%1" == "help" (
19 | :help
20 | echo.Please use `make ^` where ^ is one of
21 | echo. html to make standalone HTML files
22 | echo. dirhtml to make HTML files named index.html in directories
23 | echo. singlehtml to make a single large HTML file
24 | echo. pickle to make pickle files
25 | echo. json to make JSON files
26 | echo. htmlhelp to make HTML files and a HTML help project
27 | echo. qthelp to make HTML files and a qthelp project
28 | echo. devhelp to make HTML files and a Devhelp project
29 | echo. epub to make an epub
30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
31 | echo. text to make text files
32 | echo. man to make manual pages
33 | echo. texinfo to make Texinfo files
34 | echo. gettext to make PO message catalogs
35 | echo. changes to make an overview over all changed/added/deprecated items
36 | echo. xml to make Docutils-native XML files
37 | echo. pseudoxml to make pseudoxml-XML files for display purposes
38 | echo. linkcheck to check all external links for integrity
39 | echo. doctest to run all doctests embedded in the documentation if enabled
40 | goto end
41 | )
42 |
43 | if "%1" == "clean" (
44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
45 | del /q /s %BUILDDIR%\*
46 | goto end
47 | )
48 |
49 |
50 | %SPHINXBUILD% 2> nul
51 | if errorlevel 9009 (
52 | echo.
53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
54 | echo.installed, then set the SPHINXBUILD environment variable to point
55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
56 | echo.may add the Sphinx directory to PATH.
57 | echo.
58 | echo.If you don't have Sphinx installed, grab it from
59 | echo.https://sphinx-doc.org/
60 | exit /b 1
61 | )
62 |
63 | if "%1" == "html" (
64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
65 | if errorlevel 1 exit /b 1
66 | echo.
67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html.
68 | goto end
69 | )
70 |
71 | if "%1" == "dirhtml" (
72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
73 | if errorlevel 1 exit /b 1
74 | echo.
75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
76 | goto end
77 | )
78 |
79 | if "%1" == "singlehtml" (
80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
81 | if errorlevel 1 exit /b 1
82 | echo.
83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
84 | goto end
85 | )
86 |
87 | if "%1" == "pickle" (
88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
89 | if errorlevel 1 exit /b 1
90 | echo.
91 | echo.Build finished; now you can process the pickle files.
92 | goto end
93 | )
94 |
95 | if "%1" == "json" (
96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
97 | if errorlevel 1 exit /b 1
98 | echo.
99 | echo.Build finished; now you can process the JSON files.
100 | goto end
101 | )
102 |
103 | if "%1" == "htmlhelp" (
104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
105 | if errorlevel 1 exit /b 1
106 | echo.
107 | echo.Build finished; now you can run HTML Help Workshop with the ^
108 | .hhp project file in %BUILDDIR%/htmlhelp.
109 | goto end
110 | )
111 |
112 | if "%1" == "qthelp" (
113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
114 | if errorlevel 1 exit /b 1
115 | echo.
116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^
117 | .qhcp project file in %BUILDDIR%/qthelp, like this:
118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-avatar.qhcp
119 | echo.To view the help file:
120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-avatar.ghc
121 | goto end
122 | )
123 |
124 | if "%1" == "devhelp" (
125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
126 | if errorlevel 1 exit /b 1
127 | echo.
128 | echo.Build finished.
129 | goto end
130 | )
131 |
132 | if "%1" == "epub" (
133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
134 | if errorlevel 1 exit /b 1
135 | echo.
136 | echo.Build finished. The epub file is in %BUILDDIR%/epub.
137 | goto end
138 | )
139 |
140 | if "%1" == "latex" (
141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
142 | if errorlevel 1 exit /b 1
143 | echo.
144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
145 | goto end
146 | )
147 |
148 | if "%1" == "latexpdf" (
149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
150 | cd %BUILDDIR%/latex
151 | make all-pdf
152 | cd %BUILDDIR%/..
153 | echo.
154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex.
155 | goto end
156 | )
157 |
158 | if "%1" == "latexpdfja" (
159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
160 | cd %BUILDDIR%/latex
161 | make all-pdf-ja
162 | cd %BUILDDIR%/..
163 | echo.
164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex.
165 | goto end
166 | )
167 |
168 | if "%1" == "text" (
169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
170 | if errorlevel 1 exit /b 1
171 | echo.
172 | echo.Build finished. The text files are in %BUILDDIR%/text.
173 | goto end
174 | )
175 |
176 | if "%1" == "man" (
177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
178 | if errorlevel 1 exit /b 1
179 | echo.
180 | echo.Build finished. The manual pages are in %BUILDDIR%/man.
181 | goto end
182 | )
183 |
184 | if "%1" == "texinfo" (
185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
186 | if errorlevel 1 exit /b 1
187 | echo.
188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
189 | goto end
190 | )
191 |
192 | if "%1" == "gettext" (
193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
194 | if errorlevel 1 exit /b 1
195 | echo.
196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
197 | goto end
198 | )
199 |
200 | if "%1" == "changes" (
201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
202 | if errorlevel 1 exit /b 1
203 | echo.
204 | echo.The overview file is in %BUILDDIR%/changes.
205 | goto end
206 | )
207 |
208 | if "%1" == "linkcheck" (
209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
210 | if errorlevel 1 exit /b 1
211 | echo.
212 | echo.Link check complete; look for any errors in the above output ^
213 | or in %BUILDDIR%/linkcheck/output.txt.
214 | goto end
215 | )
216 |
217 | if "%1" == "doctest" (
218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
219 | if errorlevel 1 exit /b 1
220 | echo.
221 | echo.Testing of doctests in the sources finished, look at the ^
222 | results in %BUILDDIR%/doctest/output.txt.
223 | goto end
224 | )
225 |
226 | if "%1" == "xml" (
227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
228 | if errorlevel 1 exit /b 1
229 | echo.
230 | echo.Build finished. The XML files are in %BUILDDIR%/xml.
231 | goto end
232 | )
233 |
234 | if "%1" == "pseudoxml" (
235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
236 | if errorlevel 1 exit /b 1
237 | echo.
238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
239 | goto end
240 | )
241 |
242 | :end
243 |
--------------------------------------------------------------------------------
/avatar/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.contrib.auth.decorators import login_required
3 | from django.shortcuts import redirect, render
4 | from django.utils.translation import gettext as _
5 |
6 | from avatar.conf import settings
7 | from avatar.forms import DeleteAvatarForm, PrimaryAvatarForm, UploadAvatarForm
8 | from avatar.models import Avatar
9 | from avatar.signals import avatar_deleted, avatar_updated
10 | from avatar.utils import get_default_avatar_url, get_primary_avatar, invalidate_cache
11 |
12 |
13 | def _get_next(request):
14 | """
15 | The part that's the least straightforward about views in this module is
16 | how they determine their redirects after they have finished computation.
17 |
18 | In short, they will try and determine the next place to go in the
19 | following order:
20 |
21 | 1. If there is a variable named ``next`` in the *POST* parameters, the
22 | view will redirect to that variable's value.
23 | 2. If there is a variable named ``next`` in the *GET* parameters,
24 | the view will redirect to that variable's value.
25 | 3. If Django can determine the previous page from the HTTP headers,
26 | the view will redirect to that previous page.
27 | """
28 | next = request.POST.get(
29 | "next", request.GET.get("next", request.META.get("HTTP_REFERER", None))
30 | )
31 | if not next:
32 | next = request.path
33 | return next
34 |
35 |
36 | def _get_avatars(user):
37 | # Default set. Needs to be sliced, but that's it. Keep the natural order.
38 | avatars = user.avatar_set.all()
39 |
40 | # Current avatar
41 | primary_avatar = avatars.order_by("-primary")[:1]
42 | if primary_avatar:
43 | avatar = primary_avatar[0]
44 | else:
45 | avatar = None
46 |
47 | if settings.AVATAR_MAX_AVATARS_PER_USER == 1:
48 | avatars = primary_avatar
49 | else:
50 | # Slice the default set now that we used
51 | # the queryset for the primary avatar
52 | avatars = avatars[: settings.AVATAR_MAX_AVATARS_PER_USER]
53 | return (avatar, avatars)
54 |
55 |
56 | @login_required
57 | def add(
58 | request,
59 | extra_context=None,
60 | next_override=None,
61 | upload_form=UploadAvatarForm,
62 | *args,
63 | **kwargs,
64 | ):
65 | if extra_context is None:
66 | extra_context = {}
67 | avatar, avatars = _get_avatars(request.user)
68 | upload_avatar_form = upload_form(
69 | request.POST or None, request.FILES or None, user=request.user
70 | )
71 | if request.method == "POST" and "avatar" in request.FILES:
72 | if upload_avatar_form.is_valid():
73 | avatar = Avatar(user=request.user, primary=True)
74 | image_file = request.FILES["avatar"]
75 | avatar.avatar.save(image_file.name, image_file)
76 | avatar.save()
77 | messages.success(request, _("Successfully uploaded a new avatar."))
78 | avatar_updated.send(sender=Avatar, user=request.user, avatar=avatar)
79 | return redirect(next_override or _get_next(request))
80 | context = {
81 | "avatar": avatar,
82 | "avatars": avatars,
83 | "upload_avatar_form": upload_avatar_form,
84 | "next": next_override or _get_next(request),
85 | }
86 | context.update(extra_context)
87 | template_name = settings.AVATAR_ADD_TEMPLATE or "avatar/add.html"
88 | return render(request, template_name, context)
89 |
90 |
91 | @login_required
92 | def change(
93 | request,
94 | extra_context=None,
95 | next_override=None,
96 | upload_form=UploadAvatarForm,
97 | primary_form=PrimaryAvatarForm,
98 | *args,
99 | **kwargs,
100 | ):
101 | if extra_context is None:
102 | extra_context = {}
103 | avatar, avatars = _get_avatars(request.user)
104 | if avatar:
105 | kwargs = {"initial": {"choice": avatar.id}}
106 | else:
107 | kwargs = {}
108 | upload_avatar_form = upload_form(user=request.user, **kwargs)
109 | primary_avatar_form = primary_form(
110 | request.POST or None, user=request.user, avatars=avatars, **kwargs
111 | )
112 | if request.method == "POST":
113 | updated = False
114 | if "choice" in request.POST and primary_avatar_form.is_valid():
115 | avatar = Avatar.objects.get(id=primary_avatar_form.cleaned_data["choice"])
116 | avatar.primary = True
117 | avatar.save()
118 | updated = True
119 | invalidate_cache(request.user)
120 | messages.success(request, _("Successfully updated your avatar."))
121 | if updated:
122 | avatar_updated.send(sender=Avatar, user=request.user, avatar=avatar)
123 | return redirect(next_override or _get_next(request))
124 |
125 | context = {
126 | "avatar": avatar,
127 | "avatars": avatars,
128 | "upload_avatar_form": upload_avatar_form,
129 | "primary_avatar_form": primary_avatar_form,
130 | "next": next_override or _get_next(request),
131 | }
132 | context.update(extra_context)
133 | template_name = settings.AVATAR_CHANGE_TEMPLATE or "avatar/change.html"
134 | return render(request, template_name, context)
135 |
136 |
137 | @login_required
138 | def delete(request, extra_context=None, next_override=None, *args, **kwargs):
139 | if extra_context is None:
140 | extra_context = {}
141 | avatar, avatars = _get_avatars(request.user)
142 | delete_avatar_form = DeleteAvatarForm(
143 | request.POST or None, user=request.user, avatars=avatars
144 | )
145 | if request.method == "POST":
146 | if delete_avatar_form.is_valid():
147 | ids = delete_avatar_form.cleaned_data["choices"]
148 | for a in avatars:
149 | if str(a.id) in ids:
150 | avatar_deleted.send(sender=Avatar, user=request.user, avatar=a)
151 | if str(avatar.id) in ids and avatars.count() > len(ids):
152 | # Find the next best avatar, and set it as the new primary
153 | for a in avatars:
154 | if str(a.id) not in ids:
155 | a.primary = True
156 | a.save()
157 | avatar_updated.send(
158 | sender=Avatar, user=request.user, avatar=avatar
159 | )
160 | break
161 | Avatar.objects.filter(id__in=ids).delete()
162 | messages.success(request, _("Successfully deleted the requested avatars."))
163 | return redirect(next_override or _get_next(request))
164 |
165 | context = {
166 | "avatar": avatar,
167 | "avatars": avatars,
168 | "delete_avatar_form": delete_avatar_form,
169 | "next": next_override or _get_next(request),
170 | }
171 | context.update(extra_context)
172 | template_name = settings.AVATAR_DELETE_TEMPLATE or "avatar/confirm_delete.html"
173 | return render(request, template_name, context)
174 |
175 |
176 | def render_primary(request, user=None, width=settings.AVATAR_DEFAULT_SIZE, height=None):
177 | if height is None:
178 | height = width
179 | width = int(width)
180 | height = int(height)
181 | avatar = get_primary_avatar(user, width=width, height=height)
182 | if width == 0 and height == 0:
183 | avatar = get_primary_avatar(
184 | user,
185 | width=settings.AVATAR_DEFAULT_SIZE,
186 | height=settings.AVATAR_DEFAULT_SIZE,
187 | )
188 | else:
189 | avatar = get_primary_avatar(user, width=width, height=height)
190 | if avatar:
191 | # FIXME: later, add an option to render the resized avatar dynamically
192 | # instead of redirecting to an already created static file. This could
193 | # be useful in certain situations, particulary if there is a CDN and
194 | # we want to minimize the storage usage on our static server, letting
195 | # the CDN store those files instead
196 | url = avatar.avatar_url(width, height)
197 | else:
198 | url = get_default_avatar_url()
199 |
200 | return redirect(url)
201 |
--------------------------------------------------------------------------------
/avatar/models.py:
--------------------------------------------------------------------------------
1 | import binascii
2 | import hashlib
3 | import os
4 | from io import BytesIO
5 |
6 | from django.core.files import File
7 | from django.core.files.base import ContentFile
8 | from django.db import models
9 | from django.db.models import signals
10 | from django.utils.encoding import force_bytes, force_str
11 | from django.utils.module_loading import import_string
12 | from django.utils.timezone import now
13 | from django.utils.translation import gettext_lazy as _
14 | from PIL import Image, ImageOps
15 |
16 | from avatar.conf import settings
17 | from avatar.utils import get_username, invalidate_cache
18 |
19 | try: # Django 4.2+
20 | from django.core.files.storage import storages
21 |
22 | avatar_storage = storages[settings.AVATAR_STORAGE_ALIAS]
23 | except ImportError:
24 | from django.core.files.storage import get_storage_class
25 |
26 | avatar_storage = get_storage_class(settings.AVATAR_STORAGE)()
27 |
28 |
29 | def avatar_path_handler(
30 | instance=None, filename=None, width=None, height=None, ext=None
31 | ):
32 | tmppath = [settings.AVATAR_STORAGE_DIR]
33 | if settings.AVATAR_HASH_USERDIRNAMES:
34 | tmp = hashlib.md5(force_bytes(get_username(instance.user))).hexdigest()
35 | tmppath.extend(tmp[0:2])
36 | if settings.AVATAR_EXPOSE_USERNAMES:
37 | tmppath.append(get_username(instance.user))
38 | else:
39 | tmppath.append(force_str(instance.user.pk))
40 | if not filename:
41 | # Filename already stored in database
42 | filename = instance.avatar.name
43 | if ext:
44 | (root, oldext) = os.path.splitext(filename)
45 | filename = root + "." + ext.lower()
46 | else:
47 | # File doesn't exist yet
48 | (root, oldext) = os.path.splitext(filename)
49 | if settings.AVATAR_HASH_FILENAMES:
50 | if settings.AVATAR_RANDOMIZE_HASHES:
51 | root = binascii.hexlify(os.urandom(16)).decode("ascii")
52 | else:
53 | root = hashlib.md5(force_bytes(root)).hexdigest()
54 | if ext:
55 | filename = root + "." + ext.lower()
56 | else:
57 | filename = root + oldext.lower()
58 | if width or height:
59 | tmppath.extend(["resized", str(width), str(height)])
60 | tmppath.append(os.path.basename(filename))
61 | return os.path.join(*tmppath)
62 |
63 |
64 | avatar_file_path = import_string(settings.AVATAR_PATH_HANDLER)
65 |
66 |
67 | def find_extension(format):
68 | format = format.lower()
69 |
70 | if format == "jpeg":
71 | format = "jpg"
72 |
73 | return format
74 |
75 |
76 | class AvatarField(models.ImageField):
77 | def __init__(self, *args, **kwargs):
78 | super().__init__(*args, **kwargs)
79 |
80 | self.max_length = 1024
81 | self.upload_to = avatar_file_path
82 | self.storage = avatar_storage
83 | self.blank = True
84 |
85 | def deconstruct(self):
86 | name, path, args, kwargs = super().deconstruct()
87 | return name, path, (), {}
88 |
89 |
90 | class Avatar(models.Model):
91 | user = models.ForeignKey(
92 | getattr(settings, "AUTH_USER_MODEL", "auth.User"),
93 | verbose_name=_("user"),
94 | on_delete=models.CASCADE,
95 | )
96 | primary = models.BooleanField(
97 | verbose_name=_("primary"),
98 | default=False,
99 | )
100 | avatar = AvatarField(verbose_name=_("avatar"))
101 | date_uploaded = models.DateTimeField(
102 | verbose_name=_("uploaded at"),
103 | default=now,
104 | )
105 |
106 | class Meta:
107 | app_label = "avatar"
108 | verbose_name = _("avatar")
109 | verbose_name_plural = _("avatars")
110 |
111 | def __str__(self):
112 | return _("Avatar for %s") % self.user
113 |
114 | def save(self, *args, **kwargs):
115 | avatars = Avatar.objects.filter(user=self.user)
116 | if self.pk:
117 | avatars = avatars.exclude(pk=self.pk)
118 | if settings.AVATAR_MAX_AVATARS_PER_USER > 1:
119 | if self.primary:
120 | avatars = avatars.filter(primary=True)
121 | avatars.update(primary=False)
122 | else:
123 | avatars.delete()
124 | super().save(*args, **kwargs)
125 |
126 | def thumbnail_exists(self, width, height=None):
127 | return self.avatar.storage.exists(self.avatar_name(width, height))
128 |
129 | def transpose_image(self, image):
130 | EXIF_ORIENTATION = 0x0112
131 | exif_code = image.getexif().get(EXIF_ORIENTATION, 1)
132 | if exif_code and exif_code != 1:
133 | image = ImageOps.exif_transpose(image)
134 | return image
135 |
136 | def create_thumbnail(self, width, height=None, quality=None):
137 | if height is None:
138 | height = width
139 | # invalidate the cache of the thumbnail with the given size first
140 | invalidate_cache(self.user, width, height)
141 | try:
142 | orig = self.avatar.storage.open(self.avatar.name, "rb")
143 | except IOError:
144 | return # What should we do here? Render a "sorry, didn't work" img?
145 | try:
146 | image = Image.open(orig)
147 | image = self.transpose_image(image)
148 | quality = quality or settings.AVATAR_THUMB_QUALITY
149 | w, h = image.size
150 | if w != width or h != height:
151 | ratioReal = 1.0 * w / h
152 | ratioWant = 1.0 * width / height
153 | if ratioReal > ratioWant:
154 | diff = int((w - (h * ratioWant)) / 2)
155 | image = image.crop((diff, 0, w - diff, h))
156 | elif ratioReal < ratioWant:
157 | diff = int((h - (w / ratioWant)) / 2)
158 | image = image.crop((0, diff, w, h - diff))
159 | if settings.AVATAR_THUMB_FORMAT == "JPEG" and image.mode == "RGBA":
160 | image = image.convert("RGB")
161 | elif image.mode not in (settings.AVATAR_THUMB_MODES):
162 | image = image.convert(settings.AVATAR_THUMB_MODES[0])
163 | image = image.resize((width, height), settings.AVATAR_RESIZE_METHOD)
164 | thumb = BytesIO()
165 | image.save(thumb, settings.AVATAR_THUMB_FORMAT, quality=quality)
166 | thumb_file = ContentFile(thumb.getvalue())
167 | else:
168 | thumb_file = File(orig)
169 | thumb_name = self.avatar_name(width, height)
170 | thumb = self.avatar.storage.save(thumb_name, thumb_file)
171 | except IOError:
172 | thumb_file = File(orig)
173 | thumb = self.avatar.storage.save(
174 | self.avatar_name(width, height), thumb_file
175 | )
176 | invalidate_cache(self.user, width, height)
177 |
178 | def avatar_url(self, width, height=None):
179 | return self.avatar.storage.url(self.avatar_name(width, height))
180 |
181 | def get_absolute_url(self):
182 | return self.avatar_url(settings.AVATAR_DEFAULT_SIZE)
183 |
184 | def avatar_name(self, width, height=None):
185 | if height is None:
186 | height = width
187 | ext = find_extension(settings.AVATAR_THUMB_FORMAT)
188 | return avatar_file_path(instance=self, width=width, height=height, ext=ext)
189 |
190 |
191 | def invalidate_avatar_cache(sender, instance, **kwargs):
192 | if hasattr(instance, "user"):
193 | invalidate_cache(instance.user)
194 |
195 |
196 | def create_default_thumbnails(sender, instance, created=False, **kwargs):
197 | invalidate_avatar_cache(sender, instance)
198 | if created:
199 | for size in settings.AVATAR_AUTO_GENERATE_SIZES:
200 | if isinstance(size, int):
201 | instance.create_thumbnail(size, size)
202 | else:
203 | # Size is specified with height and width.
204 | instance.create_thumbnail(size[0], size[1])
205 |
206 |
207 | def remove_avatar_images(instance=None, delete_main_avatar=True, **kwargs):
208 | base_filepath = instance.avatar.name
209 | path, filename = os.path.split(base_filepath)
210 | # iterate through resized avatars directories and delete resized avatars
211 | resized_path = os.path.join(path, "resized")
212 | resized_widths, _ = instance.avatar.storage.listdir(resized_path)
213 | for width in resized_widths:
214 | resized_width_path = os.path.join(resized_path, width)
215 | resized_heights, _ = instance.avatar.storage.listdir(resized_width_path)
216 | for height in resized_heights:
217 | if instance.thumbnail_exists(width, height):
218 | instance.avatar.storage.delete(instance.avatar_name(width, height))
219 | if delete_main_avatar:
220 | if instance.avatar.storage.exists(instance.avatar.name):
221 | instance.avatar.storage.delete(instance.avatar.name)
222 |
223 |
224 | signals.post_save.connect(create_default_thumbnails, sender=Avatar)
225 | signals.post_delete.connect(invalidate_avatar_cache, sender=Avatar)
226 |
227 | if settings.AVATAR_CLEANUP_DELETED:
228 | signals.post_delete.connect(remove_avatar_images, sender=Avatar)
229 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # django-avatar documentation build configuration file, created by
4 | # sphinx-quickstart on Fri Sep 13 17:26:12 2013.
5 | #
6 | # This file is execfile()d with the current directory set to its containing dir.
7 | #
8 | # Note that not all possible configuration values are present in this
9 | # autogenerated file.
10 | #
11 | # All configuration values have a default; values that are commented out
12 | # serve to show the default.
13 |
14 | import os
15 | import sys
16 |
17 | # If extensions (or modules to document with autodoc) are in another directory,
18 | # add these directories to sys.path here. If the directory is relative to the
19 | # documentation root, use os.path.abspath to make it absolute, like shown here.
20 | sys.path.insert(0, os.path.abspath("."))
21 |
22 | # -- General configuration -----------------------------------------------------
23 |
24 | # If your documentation needs a minimal Sphinx version, state it here.
25 | # needs_sphinx = '1.0'
26 |
27 | # Add any Sphinx extension module names here, as strings. They can be extensions
28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
29 | extensions = []
30 |
31 | # Add any paths that contain templates here, relative to this directory.
32 | templates_path = ["_templates"]
33 |
34 | # The suffix of source filenames.
35 | source_suffix = ".rst"
36 |
37 | # The encoding of source files.
38 | # source_encoding = 'utf-8-sig'
39 |
40 | # The master toctree document.
41 | master_doc = "index"
42 |
43 | # General information about the project.
44 | project = "django-avatar"
45 | copyright = "2013, django-avatar developers"
46 |
47 | # The version info for the project you're documenting, acts as replacement for
48 | # |version| and |release|, also used in various other places throughout the
49 | # built documents.
50 | #
51 | # The short X.Y version.
52 | version = "2.0"
53 | # The full version, including alpha/beta/rc tags.
54 | release = "2.0"
55 |
56 | # The language for content autogenerated by Sphinx. Refer to documentation
57 | # for a list of supported languages.
58 | # language = None
59 |
60 | # There are two options for replacing |today|: either, you set today to some
61 | # non-false value, then it is used:
62 | # today = ''
63 | # Else, today_fmt is used as the format for a strftime call.
64 | # today_fmt = '%B %d, %Y'
65 |
66 | # List of patterns, relative to source directory, that match files and
67 | # directories to ignore when looking for source files.
68 | exclude_patterns = ["_build"]
69 |
70 | # The reST default role (used for this markup: `text`) to use for all documents.
71 | # default_role = None
72 |
73 | # If true, '()' will be appended to :func: etc. cross-reference text.
74 | # add_function_parentheses = True
75 |
76 | # If true, the current module name will be prepended to all description
77 | # unit titles (such as .. function::).
78 | # add_module_names = True
79 |
80 | # If true, sectionauthor and moduleauthor directives will be shown in the
81 | # output. They are ignored by default.
82 | # show_authors = False
83 |
84 | # The name of the Pygments (syntax highlighting) style to use.
85 | pygments_style = "sphinx"
86 |
87 | # A list of ignored prefixes for module index sorting.
88 | # modindex_common_prefix = []
89 |
90 | # If true, keep warnings as "system message" paragraphs in the built documents.
91 | # keep_warnings = False
92 |
93 |
94 | # -- Options for HTML output ---------------------------------------------------
95 |
96 | # The theme to use for HTML and HTML Help pages. See the documentation for
97 | # a list of builtin themes.
98 | html_theme = "default"
99 |
100 | # Theme options are theme-specific and customize the look and feel of a theme
101 | # further. For a list of options available for each theme, see the
102 | # documentation.
103 | # html_theme_options = {}
104 |
105 | # Add any paths that contain custom themes here, relative to this directory.
106 | # html_theme_path = []
107 |
108 | # The name for this set of Sphinx documents. If None, it defaults to
109 | # " v documentation".
110 | # html_title = None
111 |
112 | # A shorter title for the navigation bar. Default is the same as html_title.
113 | # html_short_title = None
114 |
115 | # The name of an image file (relative to this directory) to place at the top
116 | # of the sidebar.
117 | # html_logo = None
118 |
119 | # The name of an image file (within the static path) to use as favicon of the
120 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
121 | # pixels large.
122 | # html_favicon = None
123 |
124 | # Add any paths that contain custom static files (such as style sheets) here,
125 | # relative to this directory. They are copied after the builtin static files,
126 | # so a file named "default.css" will overwrite the builtin "default.css".
127 | html_static_path = ["_static"]
128 |
129 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
130 | # using the given strftime format.
131 | # html_last_updated_fmt = '%b %d, %Y'
132 |
133 | # If true, SmartyPants will be used to convert quotes and dashes to
134 | # typographically correct entities.
135 | # html_use_smartypants = True
136 |
137 | # Custom sidebar templates, maps document names to template names.
138 | # html_sidebars = {}
139 |
140 | # Additional templates that should be rendered to pages, maps page names to
141 | # template names.
142 | # html_additional_pages = {}
143 |
144 | # If false, no module index is generated.
145 | # html_domain_indices = True
146 |
147 | # If false, no index is generated.
148 | # html_use_index = True
149 |
150 | # If true, the index is split into individual pages for each letter.
151 | # html_split_index = False
152 |
153 | # If true, links to the reST sources are added to the pages.
154 | # html_show_sourcelink = True
155 |
156 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
157 | # html_show_sphinx = True
158 |
159 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
160 | # html_show_copyright = True
161 |
162 | # If true, an OpenSearch description file will be output, and all pages will
163 | # contain a tag referring to it. The value of this option must be the
164 | # base URL from which the finished HTML is served.
165 | # html_use_opensearch = ''
166 |
167 | # This is the file name suffix for HTML files (e.g. ".xhtml").
168 | # html_file_suffix = None
169 |
170 | # Output file base name for HTML help builder.
171 | htmlhelp_basename = "django-avatardoc"
172 |
173 |
174 | # -- Options for LaTeX output --------------------------------------------------
175 |
176 | latex_elements = {
177 | # The paper size ('letterpaper' or 'a4paper').
178 | # 'papersize': 'letterpaper',
179 | # The font size ('10pt', '11pt' or '12pt').
180 | # 'pointsize': '10pt',
181 | # Additional stuff for the LaTeX preamble.
182 | # 'preamble': '',
183 | }
184 |
185 | # Grouping the document tree into LaTeX files. List of tuples
186 | # (source start file, target name, title, author, documentclass [howto/manual]).
187 | latex_documents = [
188 | (
189 | "index",
190 | "django-avatar.tex",
191 | "django-avatar Documentation",
192 | "django-avatar developers",
193 | "manual",
194 | ),
195 | ]
196 |
197 | # The name of an image file (relative to this directory) to place at the top of
198 | # the title page.
199 | # latex_logo = None
200 |
201 | # For "manual" documents, if this is true, then toplevel headings are parts,
202 | # not chapters.
203 | # latex_use_parts = False
204 |
205 | # If true, show page references after internal links.
206 | # latex_show_pagerefs = False
207 |
208 | # If true, show URL addresses after external links.
209 | # latex_show_urls = False
210 |
211 | # Documents to append as an appendix to all manuals.
212 | # latex_appendices = []
213 |
214 | # If false, no module index is generated.
215 | # latex_domain_indices = True
216 |
217 |
218 | # -- Options for manual page output --------------------------------------------
219 |
220 | # One entry per manual page. List of tuples
221 | # (source start file, name, description, authors, manual section).
222 | man_pages = [
223 | (
224 | "index",
225 | "django-avatar",
226 | "django-avatar Documentation",
227 | ["django-avatar developers"],
228 | 1,
229 | )
230 | ]
231 |
232 | # If true, show URL addresses after external links.
233 | # man_show_urls = False
234 |
235 |
236 | # -- Options for Texinfo output ------------------------------------------------
237 |
238 | # Grouping the document tree into Texinfo files. List of tuples
239 | # (source start file, target name, title, author,
240 | # dir menu entry, description, category)
241 | texinfo_documents = [
242 | (
243 | "index",
244 | "django-avatar",
245 | "django-avatar Documentation",
246 | "django-avatar developers",
247 | "django-avatar",
248 | "One line description of project.",
249 | "Miscellaneous",
250 | ),
251 | ]
252 |
253 | # Documents to append as an appendix to all manuals.
254 | # texinfo_appendices = []
255 |
256 | # If false, no module index is generated.
257 | # texinfo_domain_indices = True
258 |
259 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
260 | # texinfo_show_urls = 'footnote'
261 |
262 | # If true, do not generate a @detailmenu in the "Top" node's menu.
263 | # texinfo_no_detailmenu = False
264 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 |
2 | django-avatar
3 | =============
4 |
5 | Django-avatar is a reusable application for handling user avatars. It has the
6 | ability to default to avatars provided by third party services (like Gravatar_
7 | or Facebook) if no avatar is found for a certain user. Django-avatar
8 | automatically generates thumbnails and stores them to your default file
9 | storage backend for retrieval later.
10 |
11 | .. _Gravatar: https://gravatar.com
12 |
13 | Installation
14 | ------------
15 |
16 | If you have pip_ installed, you can simply run the following command
17 | to install django-avatar::
18 |
19 | pip install django-avatar
20 |
21 | Included with this application is a file named ``setup.py``. It's possible to
22 | use this file to install this application to your system, by invoking the
23 | following command::
24 |
25 | python setup.py install
26 |
27 | Once that's done, you should be able to begin using django-avatar at will.
28 |
29 | Usage
30 | -----
31 |
32 | To integrate ``django-avatar`` with your site, there are relatively few things
33 | that are required. A minimal integration can work like this:
34 |
35 | 1. List this application in the ``INSTALLED_APPS`` portion of your settings
36 | file. Your settings file will look something like::
37 |
38 | INSTALLED_APPS = (
39 | # ...
40 | 'avatar',
41 | )
42 |
43 | 2. Migrate your database::
44 |
45 | python manage.py migrate
46 |
47 | 3. Add the avatar urls to the end of your root urlconf. Your urlconf
48 | will look something like::
49 |
50 | urlpatterns = [
51 | # ...
52 | path('avatar/', include('avatar.urls')),
53 | ]
54 |
55 | 4. Somewhere in your template navigation scheme, link to the change avatar
56 | page::
57 |
58 | Change your avatar
59 |
60 | 5. Wherever you want to display an avatar for a user, first load the avatar
61 | template tags::
62 |
63 | {% load avatar_tags %}
64 |
65 | Then, use the ``avatar`` tag to display an avatar of a default size::
66 |
67 | {% avatar user %}
68 |
69 | Or specify a size (in pixels) explicitly::
70 |
71 | {% avatar user 65 %}
72 |
73 | Or specify a width and height (in pixels) explicitly::
74 |
75 | {% avatar user 65 50 %}
76 |
77 | Example for customize the attribute of the HTML ``img`` tag::
78 |
79 | {% avatar user 65 class="img-circle img-responsive" id="user_avatar" %}
80 |
81 | Template tags and filter
82 | ------------------------
83 |
84 | To begin using these template tags, you must first load the tags into the
85 | template rendering system::
86 |
87 | {% load avatar_tags %}
88 |
89 | ``{% avatar_url user [size in pixels] %}``
90 | Renders the URL of the avatar for the given user. User can be either a
91 | ``django.contrib.auth.models.User`` object instance or a username.
92 |
93 | ``{% avatar user [size in pixels] **kwargs %}``
94 | Renders an HTML ``img`` tag for the given user for the specified size. User
95 | can be either a ``django.contrib.auth.models.User`` object instance or a
96 | username. The (key, value) pairs in kwargs will be added to ``img`` tag
97 | as its attributes.
98 |
99 | ``{% render_avatar avatar [size in pixels] %}``
100 | Given an actual ``avatar.models.Avatar`` object instance, renders an HTML
101 | ``img`` tag to represent that avatar at the requested size.
102 |
103 | ``{{ request.user|has_avatar }}``
104 | Given a user object returns a boolean if the user has an avatar.
105 |
106 | Global Settings
107 | ---------------
108 |
109 | There are a number of settings available to easily customize the avatars that
110 | appear on the site. Listed below are those settings:
111 |
112 | .. py:data:: AVATAR_AUTO_GENERATE_SIZES
113 |
114 | An iterable of integers and/or sequences in the format ``(width, height)``
115 | representing the sizes of avatars to generate on upload. This can save
116 | rendering time later on if you pre-generate the resized versions. Defaults
117 | to ``(80,)``.
118 |
119 | .. py:data:: AVATAR_CACHE_ENABLED
120 |
121 | Set to ``False`` if you completely disable avatar caching. Defaults to ``True``.
122 |
123 | .. py:data:: AVATAR_DEFAULT_URL
124 |
125 | The default URL to default to if the
126 | :py:class:`~avatar.providers.GravatarAvatarProvider` is not used and there
127 | is no ``Avatar`` instance found in the system for the given user.
128 |
129 | .. py:data:: AVATAR_EXPOSE_USERNAMES
130 |
131 | Puts the User's username field in the URL path when ``True``. Set to
132 | ``False`` to use the User's primary key instead, preventing their email
133 | from being searchable on the web. Defaults to ``False``.
134 |
135 | .. py:data:: AVATAR_FACEBOOK_GET_ID
136 |
137 | A callable or string path to a callable that will return the user's
138 | Facebook ID. The callable should take a ``User`` object and return a
139 | string. If you want to use this then make sure you included
140 | :py:class:`~avatar.providers.FacebookAvatarProvider` in :py:data:`AVATAR_PROVIDERS`.
141 |
142 | .. py:data:: AVATAR_GRAVATAR_DEFAULT
143 |
144 | A string determining the default Gravatar. Can be a URL to a custom image
145 | or a style of Gravatar. Ex. `retro`. All Available options listed in the
146 | `Gravatar documentation `_. Defaults to ``None``.
148 |
149 | .. py:data:: AVATAR_GRAVATAR_FORCEDEFAULT
150 |
151 | A bool indicating whether or not to always use the default Gravitar. More
152 | details can be found in the `Gravatar documentation
153 | `_. Defaults
154 | to ``False``.
155 |
156 | .. py:data:: AVATAR_GRAVATAR_FIELD
157 |
158 | The name of the user's field containing the gravatar email. For example,
159 | if you set this to ``gravatar`` then django-avatar will get the user's
160 | gravatar in ``user.gravatar``. Defaults to ``email``.
161 |
162 | .. py:data:: AVATAR_MAX_SIZE
163 |
164 | File size limit for avatar upload. Default is ``1024 * 1024`` (1 MB).
165 | gravatar in ``user.gravatar``.
166 |
167 | .. py:data:: AVATAR_MAX_AVATARS_PER_USER
168 |
169 | The maximum number of avatars each user can have. Default is ``42``.
170 |
171 | .. py:data:: AVATAR_PATH_HANDLER
172 |
173 | Path to a method for avatar file path handling. Default is
174 | ``avatar.models.avatar_path_handler``.
175 |
176 | .. py:data:: AVATAR_PROVIDERS
177 |
178 | Tuple of classes that are tried in the given order for returning avatar
179 | URLs.
180 | Defaults to::
181 |
182 | (
183 | 'avatar.providers.PrimaryAvatarProvider',
184 | 'avatar.providers.LibRAvatarProvider',
185 | 'avatar.providers.GravatarAvatarProvider',
186 | 'avatar.providers.DefaultAvatarProvider',
187 | )
188 |
189 | If you want to implement your own provider, it must provide a class method
190 | ``get_avatar_url(user, width, height)``.
191 |
192 | .. py:class:: avatar.providers.PrimaryAvatarProvider
193 |
194 | Returns the primary avatar stored for the given user.
195 |
196 | .. py:class:: avatar.providers.GravatarAvatarProvider
197 |
198 | Adds support for the Gravatar service and will always return an avatar
199 | URL. If the user has no avatar registered with Gravatar a default will
200 | be used (see :py:data:`AVATAR_GRAVATAR_DEFAULT`).
201 |
202 | .. py:class:: avatar.providers.FacebookAvatarProvider
203 |
204 | Add this provider to :py:data:`AVATAR_PROVIDERS` in order to add
205 | support for profile images from Facebook. Note that you also need to
206 | set the :py:data:`AVATAR_FACEBOOK_GET_ID` setting.
207 |
208 | .. py:class:: avatar.providers.DefaultAvatarProvider
209 |
210 | Provides a fallback avatar defined in :py:data:`AVATAR_DEFAULT_URL`.
211 |
212 | .. py:data:: AVATAR_RESIZE_METHOD
213 |
214 | The method to use when resizing images, based on the options available in
215 | Pillow. Defaults to ``Image.Resampling.LANCZOS``.
216 |
217 | .. py:data:: AVATAR_STORAGE_DIR
218 |
219 | The directory under ``MEDIA_ROOT`` to store the images. If using a
220 | non-filesystem storage device, this will simply be appended to the beginning
221 | of the file name. Defaults to ``avatars``.
222 |
223 | .. py:data:: AVATAR_THUMB_FORMAT
224 |
225 | The file format of thumbnails, based on the options available in
226 | Pillow. Defaults to `PNG`.
227 |
228 | .. py:data:: AVATAR_THUMB_QUALITY
229 |
230 | The quality of thumbnails, between 0 (worst) to 95 (best) or the string
231 | "keep" (only JPEG) as provided by Pillow. Defaults to `85`.
232 |
233 | .. py:data:: AVATAR_THUMB_MODES
234 |
235 | A sequence of acceptable modes for thumbnails as provided by Pillow. If the mode
236 | of the image is not in the list, the thumbnail will be converted to the
237 | first mode in the list. Defaults to `('RGB', 'RGBA')`.
238 |
239 | .. py:data:: AVATAR_CLEANUP_DELETED
240 |
241 | ``True`` if the avatar image files should be deleted when an avatar is
242 | deleted from the database. Defaults to ``True``.
243 |
244 | .. py:data:: AVATAR_ADD_TEMPLATE
245 |
246 | Path to the Django template to use for adding a new avatar. Defaults to
247 | ``avatar/add.html``.
248 |
249 | .. py:data:: AVATAR_CHANGE_TEMPLATE
250 |
251 | Path to the Django template to use for changing a user's avatar. Defaults to ``avatar/change.html``.
252 |
253 | .. py:data:: AVATAR_DELETE_TEMPLATE
254 |
255 | Path to the Django template to use for confirming a delete of a user's
256 | avatar. Defaults to ``avatar/avatar/confirm_delete.html``.
257 |
258 | .. py:data:: AVATAR_ALLOWED_MIMETYPES
259 |
260 | Limit allowed avatar image uploads by their actual content payload and what image codecs we wish to support.
261 | This limits website user content site attack vectors against image codec buffer overflow and similar bugs.
262 | `You must have python-imaging library installed `_.
263 | Suggested safe setting: ``("image/png", "image/gif", "image/jpeg")``.
264 | When enabled you'll get the following error on the form upload *File content is invalid. Detected: image/tiff Allowed content types are: image/png, image/gif, image/jpg*.
265 |
266 | .. py:data:: AVATAR_STORAGE_ALIAS
267 |
268 | Default: 'default'
269 | Alias of the storage backend (from STORAGES settings) to use for storing avatars.
270 |
271 |
272 | Management Commands
273 | -------------------
274 |
275 | This application does include one management command: ``rebuild_avatars``. It
276 | takes no arguments and, when run, re-renders all of the thumbnails for all of
277 | the avatars for the pixel sizes specified in the
278 | :py:data:`AVATAR_AUTO_GENERATE_SIZES` setting.
279 |
280 |
281 | .. _pip: https://www.pip-installer.org/
282 |
283 | -----------------------------------------------
284 |
285 |
286 | API
287 | ---
288 |
289 | To use API there are relatively few things that are required.
290 |
291 | after `Installation <#installation>`_ .
292 |
293 | 1. in your ``INSTALLED_APPS`` of your settings file : ::
294 |
295 | INSTALLED_APPS = (
296 | # ...
297 | 'avatar',
298 | 'rest_framework'
299 | )
300 |
301 |
302 | 2. Add the avatar api urls to the end of your root url config : ::
303 |
304 | urlpatterns = [
305 | # ...
306 | path('api/', include('avatar.api.urls')),
307 | ]
308 |
309 | -----------------------------------------------
310 |
311 | .. toctree::
312 | :maxdepth: 1
313 |
314 | avatar
315 |
--------------------------------------------------------------------------------
/tests/tests.py:
--------------------------------------------------------------------------------
1 | import math
2 | import os.path
3 | from pathlib import Path
4 | from shutil import rmtree
5 |
6 | from django.contrib.admin.sites import AdminSite
7 | from django.core import management
8 | from django.core.cache import cache
9 | from django.test import TestCase
10 | from django.test.utils import override_settings
11 | from django.urls import reverse
12 | from PIL import Image, ImageChops
13 |
14 | from avatar.admin import AvatarAdmin
15 | from avatar.conf import settings
16 | from avatar.models import Avatar
17 | from avatar.signals import avatar_deleted
18 | from avatar.templatetags import avatar_tags
19 | from avatar.utils import (
20 | get_cache_key,
21 | get_primary_avatar,
22 | get_user_model,
23 | invalidate_cache,
24 | )
25 |
26 |
27 | class AssertSignal:
28 | def __init__(self):
29 | self.signal_sent_count = 0
30 | self.avatar = None
31 | self.user = None
32 | self.sender = None
33 | self.signal = None
34 |
35 | def __call__(self, user, avatar, sender, signal):
36 | self.user = user
37 | self.avatar = avatar
38 | self.sender = sender
39 | self.signal = signal
40 | self.signal_sent_count += 1
41 |
42 |
43 | def upload_helper(o, filename):
44 | f = open(os.path.join(o.testdatapath, filename), "rb")
45 | response = o.client.post(
46 | reverse("avatar:add"),
47 | {
48 | "avatar": f,
49 | },
50 | follow=True,
51 | )
52 | f.close()
53 | return response
54 |
55 |
56 | def root_mean_square_difference(image1, image2):
57 | "Calculate the root-mean-square difference between two images"
58 | diff = ImageChops.difference(image1, image2).convert("L")
59 | h = diff.histogram()
60 | sq = (value * (idx**2) for idx, value in enumerate(h))
61 | sum_of_squares = sum(sq)
62 | rms = math.sqrt(sum_of_squares / float(image1.size[0] * image1.size[1]))
63 | return rms
64 |
65 |
66 | class AvatarTests(TestCase):
67 | @classmethod
68 | def setUpClass(cls):
69 | cls.path = os.path.dirname(__file__)
70 | cls.testdatapath = os.path.join(cls.path, "data")
71 | cls.testmediapath = os.path.join(cls.path, "../test-media/")
72 | return super().setUpClass()
73 |
74 | def setUp(self):
75 | self.user = get_user_model().objects.create_user(
76 | "test", "lennon@thebeatles.com", "testpassword"
77 | )
78 | self.user.save()
79 | self.client.login(username="test", password="testpassword")
80 | self.site = AdminSite()
81 | Image.init()
82 |
83 | def tearDown(self):
84 | if os.path.exists(self.testmediapath):
85 | rmtree(self.testmediapath)
86 | return super().tearDown()
87 |
88 | def assertMediaFileExists(self, path):
89 | full_path = os.path.join(self.testmediapath, f".{path}")
90 | if not Path(full_path).resolve().is_file():
91 | raise AssertionError(f"File does not exist: {full_path}")
92 |
93 | def test_admin_get_avatar_returns_different_image_tags(self):
94 | self.test_normal_image_upload()
95 | self.test_normal_image_upload()
96 | primary = Avatar.objects.get(primary=True)
97 | old = Avatar.objects.get(primary=False)
98 |
99 | aa = AvatarAdmin(Avatar, self.site)
100 | primary_link = aa.get_avatar(primary)
101 | old_link = aa.get_avatar(old)
102 |
103 | self.assertNotEqual(primary_link, old_link)
104 |
105 | def test_non_image_upload(self):
106 | response = upload_helper(self, "nonimagefile")
107 | self.assertEqual(response.status_code, 200)
108 | self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
109 |
110 | def test_normal_image_upload(self):
111 | response = upload_helper(self, "test.png")
112 | self.assertEqual(response.status_code, 200)
113 | self.assertEqual(len(response.redirect_chain), 1)
114 | self.assertEqual(response.context["upload_avatar_form"].errors, {})
115 | avatar = get_primary_avatar(self.user)
116 | self.assertIsNotNone(avatar)
117 | self.assertEqual(avatar.user, self.user)
118 | self.assertTrue(avatar.primary)
119 |
120 | # We allow the .tiff file extension but not the mime type
121 | @override_settings(AVATAR_ALLOWED_FILE_EXTS=(".png", ".gif", ".jpg", ".tiff"))
122 | @override_settings(
123 | AVATAR_ALLOWED_MIMETYPES=("image/png", "image/gif", "image/jpeg")
124 | )
125 | def test_unsupported_image_format_upload(self):
126 | """Check with python-magic that we detect corrupted / unapprovd image files correctly"""
127 | response = upload_helper(self, "test.tiff")
128 | self.assertEqual(response.status_code, 200)
129 | self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
130 | self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
131 |
132 | # We allow the .tiff file extension and the mime type
133 | @override_settings(AVATAR_ALLOWED_FILE_EXTS=(".png", ".gif", ".jpg", ".tiff"))
134 | @override_settings(
135 | AVATAR_ALLOWED_MIMETYPES=("image/png", "image/gif", "image/jpeg", "image/tiff")
136 | )
137 | def test_supported_image_format_upload(self):
138 | """Check with python-magic that we detect corrupted / unapprovd image files correctly"""
139 | response = upload_helper(self, "test.tiff")
140 | self.assertEqual(response.status_code, 200)
141 | self.assertEqual(len(response.redirect_chain), 1) # Redirect only if it worked
142 | self.assertEqual(response.context["upload_avatar_form"].errors, {})
143 |
144 | @override_settings(AVATAR_ALLOWED_FILE_EXTS=(".jpg", ".png"))
145 | def test_image_without_wrong_extension(self):
146 | response = upload_helper(self, "imagefilewithoutext")
147 | self.assertEqual(response.status_code, 200)
148 | self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
149 | self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
150 |
151 | @override_settings(AVATAR_ALLOWED_FILE_EXTS=(".jpg", ".png"))
152 | def test_image_with_wrong_extension(self):
153 | response = upload_helper(self, "imagefilewithwrongext.ogg")
154 | self.assertEqual(response.status_code, 200)
155 | self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
156 | self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
157 |
158 | def test_image_too_big(self):
159 | # use with AVATAR_MAX_SIZE = 1024 * 1024
160 | response = upload_helper(self, "testbig.png")
161 | self.assertEqual(response.status_code, 200)
162 | self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
163 | self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
164 |
165 | def test_default_url(self):
166 | response = self.client.get(
167 | reverse(
168 | "avatar:render_primary",
169 | kwargs={
170 | "user": self.user.username,
171 | "width": 80,
172 | },
173 | )
174 | )
175 | loc = response["Location"]
176 | base_url = getattr(settings, "STATIC_URL", None)
177 | if not base_url:
178 | base_url = settings.MEDIA_URL
179 | self.assertTrue(base_url in loc)
180 | self.assertTrue(loc.endswith(settings.AVATAR_DEFAULT_URL))
181 |
182 | def test_non_existing_user(self):
183 | a = get_primary_avatar("nonexistinguser")
184 | self.assertEqual(a, None)
185 |
186 | def test_there_can_be_only_one_primary_avatar(self):
187 | for _ in range(1, 10):
188 | self.test_normal_image_upload()
189 | count = Avatar.objects.filter(user=self.user, primary=True).count()
190 | self.assertEqual(count, 1)
191 |
192 | def test_delete_avatar(self):
193 | self.test_normal_image_upload()
194 | avatar = Avatar.objects.filter(user=self.user)
195 | self.assertEqual(len(avatar), 1)
196 | receiver = AssertSignal()
197 | avatar_deleted.connect(receiver)
198 | response = self.client.post(
199 | reverse("avatar:delete"),
200 | {
201 | "choices": [avatar[0].id],
202 | },
203 | follow=True,
204 | )
205 | self.assertEqual(response.status_code, 200)
206 | self.assertEqual(len(response.redirect_chain), 1)
207 | count = Avatar.objects.filter(user=self.user).count()
208 | self.assertEqual(count, 0)
209 | self.assertEqual(receiver.user, self.user)
210 | self.assertEqual(receiver.avatar, avatar[0])
211 | self.assertEqual(receiver.sender, Avatar)
212 | self.assertEqual(receiver.signal_sent_count, 1)
213 |
214 | def test_delete_primary_avatar_and_new_primary(self):
215 | self.test_there_can_be_only_one_primary_avatar()
216 | primary = get_primary_avatar(self.user)
217 | oid = primary.id
218 | self.client.post(
219 | reverse("avatar:delete"),
220 | {
221 | "choices": [oid],
222 | },
223 | )
224 | primaries = Avatar.objects.filter(user=self.user, primary=True)
225 | self.assertEqual(len(primaries), 1)
226 | self.assertNotEqual(oid, primaries[0].id)
227 | avatars = Avatar.objects.filter(user=self.user)
228 | self.assertEqual(avatars[0].id, primaries[0].id)
229 |
230 | def test_change_avatar_get(self):
231 | self.test_normal_image_upload()
232 | response = self.client.get(reverse("avatar:change"))
233 |
234 | self.assertEqual(response.status_code, 200)
235 | self.assertIsNotNone(response.context["avatar"])
236 |
237 | def test_change_avatar_post_updates_primary_avatar(self):
238 | self.test_there_can_be_only_one_primary_avatar()
239 | old_primary = Avatar.objects.get(user=self.user, primary=True)
240 | choice = Avatar.objects.filter(user=self.user, primary=False)[0]
241 | response = self.client.post(
242 | reverse("avatar:change"),
243 | {
244 | "choice": choice.pk,
245 | },
246 | )
247 |
248 | self.assertEqual(response.status_code, 302)
249 | new_primary = Avatar.objects.get(user=self.user, primary=True)
250 | self.assertEqual(new_primary.pk, choice.pk)
251 | # Avatar with old primary pk exists but it is not primary anymore
252 | self.assertTrue(
253 | Avatar.objects.filter(
254 | user=self.user, pk=old_primary.pk, primary=False
255 | ).exists()
256 | )
257 |
258 | def test_too_many_avatars(self):
259 | for _ in range(0, settings.AVATAR_MAX_AVATARS_PER_USER):
260 | self.test_normal_image_upload()
261 | count_before = Avatar.objects.filter(user=self.user).count()
262 | response = upload_helper(self, "test.png")
263 | count_after = Avatar.objects.filter(user=self.user).count()
264 | self.assertEqual(response.status_code, 200)
265 | self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
266 | self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
267 | self.assertEqual(count_before, count_after)
268 |
269 | def test_automatic_thumbnail_creation_RGBA(self):
270 | upload_helper(self, "django.png")
271 | avatar = get_primary_avatar(self.user)
272 | image = Image.open(
273 | avatar.avatar.storage.open(
274 | avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb"
275 | )
276 | )
277 | self.assertEqual(image.mode, "RGBA")
278 |
279 | @override_settings(AVATAR_THUMB_FORMAT="JPEG")
280 | def test_automatic_thumbnail_creation_CMYK(self):
281 | upload_helper(self, "django_pony_cmyk.jpg")
282 | avatar = get_primary_avatar(self.user)
283 | image = Image.open(
284 | avatar.avatar.storage.open(
285 | avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb"
286 | )
287 | )
288 | self.assertEqual(image.mode, "RGB")
289 |
290 | def test_automatic_thumbnail_creation_image_type_conversion(self):
291 | upload_helper(self, "django_pony_cmyk.jpg")
292 | self.assertMediaFileExists(
293 | f"/avatars/{self.user.id}/resized/80/80/django_pony_cmyk.png"
294 | )
295 |
296 | def test_thumbnail_transpose_based_on_exif(self):
297 | upload_helper(self, "image_no_exif.jpg")
298 | avatar = get_primary_avatar(self.user)
299 | image_no_exif = Image.open(
300 | avatar.avatar.storage.open(
301 | avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb"
302 | )
303 | )
304 |
305 | upload_helper(self, "image_exif_orientation.jpg")
306 | avatar = get_primary_avatar(self.user)
307 | image_with_exif = Image.open(
308 | avatar.avatar.storage.open(
309 | avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb"
310 | )
311 | )
312 |
313 | self.assertLess(root_mean_square_difference(image_with_exif, image_no_exif), 1)
314 |
315 | def test_automatic_thumbnail_creation_nondefault_filename(self):
316 | upload_helper(self, "django #3.png")
317 | self.assertMediaFileExists(
318 | f"/avatars/{self.user.id}/resized/80/80/django_3.png"
319 | )
320 |
321 | def test_has_avatar_False_if_no_avatar(self):
322 | self.assertFalse(avatar_tags.has_avatar(self.user))
323 |
324 | def test_has_avatar_False_if_not_user_model(self):
325 | self.assertFalse(avatar_tags.has_avatar("Look, I'm a string"))
326 |
327 | def test_has_avatar_True(self):
328 | upload_helper(self, "test.png")
329 |
330 | self.assertTrue(avatar_tags.has_avatar(self.user))
331 |
332 | def test_avatar_tag_works_with_username(self):
333 | upload_helper(self, "test.png")
334 | avatar = get_primary_avatar(self.user)
335 |
336 | result = avatar_tags.avatar(self.user.username)
337 |
338 | self.assertIn('
', result)
340 |
341 | @override_settings(AVATAR_EXPOSE_USERNAMES=True)
342 | def test_avatar_tag_works_with_exposed_username(self):
343 | upload_helper(self, "test.png")
344 | avatar = get_primary_avatar(self.user)
345 |
346 | result = avatar_tags.avatar(self.user.username)
347 |
348 | self.assertIn('
', result)
350 |
351 | def test_avatar_tag_works_with_user(self):
352 | upload_helper(self, "test.png")
353 | avatar = get_primary_avatar(self.user)
354 |
355 | result = avatar_tags.avatar(self.user)
356 |
357 | self.assertIn('
', result)
359 |
360 | def test_avatar_tag_works_with_custom_size(self):
361 | upload_helper(self, "test.png")
362 | avatar = get_primary_avatar(self.user)
363 |
364 | result = avatar_tags.avatar(self.user, 100)
365 |
366 | self.assertIn('
', result)
368 |
369 | def test_avatar_tag_works_with_rectangle(self):
370 | upload_helper(self, "test.png")
371 | avatar = get_primary_avatar(self.user)
372 |
373 | result = avatar_tags.avatar(self.user, 100, 150)
374 |
375 | self.assertIn('
', result)
377 |
378 | def test_avatar_tag_works_with_kwargs(self):
379 | upload_helper(self, "test.png")
380 | avatar = get_primary_avatar(self.user)
381 |
382 | result = avatar_tags.avatar(self.user, title="Avatar")
383 | html = '
'.format(
384 | avatar.avatar_url(80)
385 | )
386 | self.assertInHTML(html, result)
387 |
388 | def test_primary_avatar_tag_works(self):
389 | upload_helper(self, "test.png")
390 |
391 | result = avatar_tags.primary_avatar(self.user)
392 |
393 | self.assertIn(f'
', result)
395 |
396 | response = self.client.get(f"/avatar/render_primary/{self.user.id}/80/")
397 | self.assertEqual(response.status_code, 302)
398 | self.assertMediaFileExists(response.url)
399 |
400 | def test_primary_avatar_tag_works_with_custom_size(self):
401 | upload_helper(self, "test.png")
402 |
403 | result = avatar_tags.primary_avatar(self.user, 90)
404 |
405 | self.assertIn(f'
', result)
407 |
408 | response = self.client.get(f"/avatar/render_primary/{self.user.id}/90/")
409 | self.assertEqual(response.status_code, 302)
410 | self.assertMediaFileExists(response.url)
411 |
412 | def test_primary_avatar_tag_works_with_rectangle(self):
413 | upload_helper(self, "test.png")
414 |
415 | result = avatar_tags.primary_avatar(self.user, 60, 110)
416 |
417 | self.assertIn(
418 | f'
', result)
421 |
422 | response = self.client.get(f"/avatar/render_primary/{self.user.id}/60/110/")
423 | self.assertEqual(response.status_code, 302)
424 | self.assertMediaFileExists(response.url)
425 |
426 | @override_settings(AVATAR_EXPOSE_USERNAMES=True)
427 | def test_primary_avatar_tag_works_with_exposed_user(self):
428 | upload_helper(self, "test.png")
429 |
430 | result = avatar_tags.primary_avatar(self.user)
431 |
432 | self.assertIn(
433 | f'
', result)
436 |
437 | response = self.client.get(f"/avatar/render_primary/{self.user.username}/80/")
438 | self.assertEqual(response.status_code, 302)
439 | self.assertMediaFileExists(response.url)
440 |
441 | def test_default_add_template(self):
442 | response = self.client.get("/avatar/add/")
443 | self.assertContains(response, "Upload New Image")
444 | self.assertNotContains(response, "ALTERNATE ADD TEMPLATE")
445 |
446 | @override_settings(AVATAR_ADD_TEMPLATE="alt/add.html")
447 | def test_custom_add_template(self):
448 | response = self.client.get("/avatar/add/")
449 | self.assertNotContains(response, "Upload New Image")
450 | self.assertContains(response, "ALTERNATE ADD TEMPLATE")
451 |
452 | def test_default_change_template(self):
453 | response = self.client.get("/avatar/change/")
454 | self.assertContains(response, "Upload New Image")
455 | self.assertNotContains(response, "ALTERNATE CHANGE TEMPLATE")
456 |
457 | @override_settings(AVATAR_CHANGE_TEMPLATE="alt/change.html")
458 | def test_custom_change_template(self):
459 | response = self.client.get("/avatar/change/")
460 | self.assertNotContains(response, "Upload New Image")
461 | self.assertContains(response, "ALTERNATE CHANGE TEMPLATE")
462 |
463 | def test_default_delete_template(self):
464 | upload_helper(self, "test.png")
465 | response = self.client.get("/avatar/delete/")
466 | self.assertContains(response, "like to delete.")
467 | self.assertNotContains(response, "ALTERNATE DELETE TEMPLATE")
468 |
469 | @override_settings(AVATAR_DELETE_TEMPLATE="alt/delete.html")
470 | def test_custom_delete_template(self):
471 | response = self.client.get("/avatar/delete/")
472 | self.assertNotContains(response, "like to delete.")
473 | self.assertContains(response, "ALTERNATE DELETE TEMPLATE")
474 |
475 | def get_media_file_mtime(self, path):
476 | full_path = os.path.join(self.testmediapath, f".{path}")
477 | return os.path.getmtime(full_path)
478 |
479 | def test_rebuild_avatars(self):
480 | upload_helper(self, "test.png")
481 | avatar_51_url = get_primary_avatar(self.user).avatar_url(51)
482 | self.assertMediaFileExists(avatar_51_url)
483 | avatar_51_mtime = self.get_media_file_mtime(avatar_51_url)
484 |
485 | avatar_62_url = get_primary_avatar(self.user).avatar_url(62)
486 | self.assertMediaFileExists(avatar_62_url)
487 | avatar_62_mtime = self.get_media_file_mtime(avatar_62_url)
488 |
489 | avatar_33_22_url = get_primary_avatar(self.user).avatar_url(33, 22)
490 | self.assertMediaFileExists(avatar_33_22_url)
491 | avatar_33_22_mtime = self.get_media_file_mtime(avatar_33_22_url)
492 |
493 | avatar_80_url = get_primary_avatar(self.user).avatar_url(80)
494 | self.assertMediaFileExists(avatar_80_url)
495 | avatar_80_mtime = self.get_media_file_mtime(avatar_80_url)
496 | # Rebuild all avatars
497 | management.call_command("rebuild_avatars", verbosity=0)
498 | # Make sure the media files all exist, but that their modification times differ
499 | self.assertMediaFileExists(avatar_51_url)
500 | self.assertNotEqual(avatar_51_mtime, self.get_media_file_mtime(avatar_51_url))
501 | self.assertMediaFileExists(avatar_62_url)
502 | self.assertNotEqual(avatar_62_mtime, self.get_media_file_mtime(avatar_62_url))
503 | self.assertMediaFileExists(avatar_33_22_url)
504 | self.assertNotEqual(
505 | avatar_33_22_mtime, self.get_media_file_mtime(avatar_33_22_url)
506 | )
507 | self.assertMediaFileExists(avatar_80_url)
508 | self.assertNotEqual(avatar_80_mtime, self.get_media_file_mtime(avatar_80_url))
509 |
510 | def test_invalidate_cache(self):
511 | upload_helper(self, "test.png")
512 | sizes_key = get_cache_key(self.user, "cached_sizes")
513 | sizes = cache.get(sizes_key, set())
514 | # Only default 80x80 thumbnail is cached
515 | self.assertEqual(len(sizes), 1)
516 | # Invalidate cache
517 | invalidate_cache(self.user)
518 | sizes = cache.get(sizes_key, set())
519 | # No thumbnail is cached.
520 | self.assertEqual(len(sizes), 0)
521 | # Create a custom 25x25 thumbnail and check that it is cached
522 | avatar_tags.avatar(self.user, 25)
523 | sizes = cache.get(sizes_key, set())
524 | self.assertEqual(len(sizes), 1)
525 | # Invalidate cache again.
526 | invalidate_cache(self.user)
527 | sizes = cache.get(sizes_key, set())
528 | # It should now be empty again
529 | self.assertEqual(len(sizes), 0)
530 |
--------------------------------------------------------------------------------