├── 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 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](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 |
11 | {{ upload_avatar_form.as_p }} 12 |

{% csrf_token %}

13 |
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 |
11 | 14 |

{% csrf_token %}

15 |
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 |
11 |
    12 | {{ primary_avatar_form.as_ul }} 13 |
14 |

{% csrf_token %}

15 |
16 | {% endif %} 17 |
18 | {{ upload_avatar_form.as_p }} 19 |

{% csrf_token %}

20 |
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 """%s""" % ( 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 """%s""" % ( 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 | '%s' 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." 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('User Avatar', 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('test', 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('User Avatar', 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('User Avatar', 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('User Avatar', 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 = 'User Avatar'.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'User Avatar', 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'User Avatar', 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'User Avatar', 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'test', 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 | --------------------------------------------------------------------------------