Compare commits

...

44 commits
7.0.0 ... main

Author SHA1 Message Date
Johannes Wilm
12e4745f29
9.0.0 2026-01-07 22:07:17 +01:00
Johannes Wilm
0f725788c9
Update CHANGELOG for version 9.0.0 2026-01-07 22:06:12 +01:00
satya-waylit
80a7c95583
Close files in create thumbnail (#252)
* Close the orig file in `create_thumbnail` method

* Skip python-magic tests on windows

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Johannes Wilm <mail@johanneswilm.org>
2026-01-07 22:03:47 +01:00
pre-commit-ci[bot]
7cb55334c1
[pre-commit.ci] pre-commit autoupdate (#250)
updates:
- [github.com/pre-commit/pre-commit-hooks: v5.0.0 → v6.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v5.0.0...v6.0.0)
- [github.com/pycqa/isort: 6.0.0 → 7.0.0](https://github.com/pycqa/isort/compare/6.0.0...7.0.0)
- https://github.com/psf/blackhttps://github.com/psf/black-pre-commit-mirror
- [github.com/psf/black-pre-commit-mirror: 25.1.0 → 25.12.0](https://github.com/psf/black-pre-commit-mirror/compare/25.1.0...25.12.0)
- [github.com/pycqa/flake8: 7.1.2 → 7.3.0](https://github.com/pycqa/flake8/compare/7.1.2...7.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Johannes Wilm <mail@johanneswilm.org>
2026-01-07 21:50:05 +01:00
satya-waylit
d1801edc64
Fix url in cofirm_delete (#254)
Fix #251
2026-01-07 21:48:55 +01:00
satya-waylit
4955d2d959
Update versions of Python, Django, GitHub Actions, and pre-commit hooks (#253)
* Update versions of Python, Django, GitHub Actions, and pre-commit hooks

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update CHANGELOG.rst

* Exclude unsupported combinations

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Johannes Wilm <mail@johanneswilm.org>
2026-01-07 21:48:21 +01:00
Alex Iribarren
14495e8106
Enable parameters for LibRavatar (#255)
* Enable parameters for LibRavatar

Added support for default parameters to LibRavatar

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-07 21:46:42 +01:00
Johannes Wilm
45e741a342
Update Python and Django versions in CI workflow 2026-01-07 21:45:50 +01:00
pre-commit-ci[bot]
a6cafaa7f0
[pre-commit.ci] pre-commit autoupdate (#249)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0)
- [github.com/pycqa/isort: 5.13.2 → 6.0.0](https://github.com/pycqa/isort/compare/5.13.2...6.0.0)
- [github.com/psf/black: 24.8.0 → 25.1.0](https://github.com/psf/black/compare/24.8.0...25.1.0)
- [github.com/pycqa/flake8: 7.1.1 → 7.1.2](https://github.com/pycqa/flake8/compare/7.1.1...7.1.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-20 17:11:43 +01:00
Johannes Wilm
981187a9de
8.0.1 2024-09-05 04:07:29 +02:00
Petr Dlouhý
ebeb6d5e64 update CHANGELOG.rst 2024-08-14 12:18:44 +02:00
pre-commit-ci[bot]
da3615203d
[pre-commit.ci] pre-commit autoupdate (#239)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0)
- [github.com/pycqa/isort: 5.12.0 → 5.13.2](https://github.com/pycqa/isort/compare/5.12.0...5.13.2)
- [github.com/psf/black: 23.9.1 → 24.8.0](https://github.com/psf/black/compare/23.9.1...24.8.0)
- [github.com/pycqa/flake8: 6.1.0 → 7.1.1](https://github.com/pycqa/flake8/compare/6.1.0...7.1.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Petr Dlouhý <petr.dlouhy@email.cz>
2024-08-14 12:13:21 +02:00
Petr Dlouhý
3343c51e2e
Update index.rst - Fix Error: Reverse for 'avatar_change' not found. 2024-08-14 12:12:16 +02:00
Petr Dlouhý
d81495f50c
fix #245 Django 5.1 error, test in Django 5.1 (#248) 2024-08-14 12:08:37 +02:00
Petr Dlouhý
fa54411351 fix docs conf.py 2024-08-13 10:48:48 +02:00
Petr Dlouhý
28a8173b35 add default .readthedocs.yaml 2024-08-13 10:44:16 +02:00
Johannes Wilm
a9ada9f273 8.0.0 2023-10-16 12:51:53 +02:00
Johannes Wilm
403a9283b3 unify documentation 2023-10-16 12:48:49 +02:00
Johannes Wilm
37d904c4ad allow user id as int, fixes #235 2023-10-16 12:34:48 +02:00
Johannes Wilm
c8f7ecbe54 avoid email domain missing error, fixes #229 2023-10-16 12:26:55 +02:00
Johannes Wilm
ceb7cade62 Update changelog 2023-10-16 12:16:46 +02:00
Johannes Wilm
bf7dde9337 lint 2023-10-16 11:55:32 +02:00
Johannes Wilm
7d64d5dc14 Pillow>=10.0.1 2023-10-16 11:43:53 +02:00
Petr Dlouhý
2687d6cd06
add support for Django STORAGES (#237)
* add support for Django STORAGES

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Johannes Wilm <mail@johanneswilm.org>
2023-10-16 11:36:03 +02:00
keyvan majidi
81ace968c7
Avatar API Added (#232)
* Add Avatar API support

* extend rest_framework to INSTALLED_APPS

* add api path to urls

* add requirements.txt to api app

* add self describe to assign_width_or_height function

* add django-avatar api docs

* remove unused files

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Johannes Wilm <mail@johanneswilm.org>
2023-10-16 11:34:46 +02:00
Johannes Wilm
9d43467934 fix url references 2023-10-16 11:29:33 +02:00
0xMRTT
5e5d6f9c6a
fix(urls): use path and path converters (#228)
* fix(urls): use path and path converters

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(urls): change re_path into path

* fix(urls): remove unused imports

* fix(urls): url names

* fix(tests): update reverse

* fix(tags): update reverse

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Johannes Wilm <mail@johanneswilm.org>
2023-10-16 11:27:12 +02:00
pre-commit-ci[bot]
a9bf7054fc
[pre-commit.ci] pre-commit autoupdate (#231)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0)
- [github.com/psf/black: 23.1.0 → 23.9.1](https://github.com/psf/black/compare/23.1.0...23.9.1)
- [github.com/pycqa/flake8: 6.0.0 → 6.1.0](https://github.com/pycqa/flake8/compare/6.0.0...6.1.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-16 11:23:14 +02:00
Satya Mishra
738e65a229
Pillow 10 compatibility (#233)
* Compatibility change for Pillow 10.0.0

* Update python and django versions

* Update coverage version

* Remove Django 4.0

Django 4.0 is EOL

* Fix flake8 error

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-16 11:22:18 +02:00
Johannes Wilm
8fa396f5b0 7.1.1 2023-02-24 00:11:19 +01:00
Johannes Wilm
5025be283a switch to setuptools, fixes #227 2023-02-24 00:08:07 +01:00
Johannes Wilm
1077a6bddb 7.1.0 2023-02-23 23:35:51 +01:00
0xMRTT
222cd65d85
Update RESIZE_METHOD (#222) (#226)
* fix(conf): image resize method (#222)

* doc: update because of #222

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(requirements): add packaging>=23.0 for pillow version

* fix(requirements): add packaging for pillow version check

* fix: resize method -> Image.LANCZOS

* fix(doc): update resize method: Image.LANCZOS

* fix(deps): remove packaging

* fix(deps): remove packaging

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-02-23 23:21:15 +01:00
pre-commit-ci[bot]
22b1b8346a
[pre-commit.ci] pre-commit autoupdate (#217)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/pre-commit/pre-commit-hooks: v4.3.0 → v4.4.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.3.0...v4.4.0)
- [github.com/pycqa/isort: 5.10.1 → 5.12.0](https://github.com/pycqa/isort/compare/5.10.1...5.12.0)
- [github.com/psf/black: 22.10.0 → 23.1.0](https://github.com/psf/black/compare/22.10.0...23.1.0)
- [github.com/pycqa/flake8: 5.0.4 → 6.0.0](https://github.com/pycqa/flake8/compare/5.0.4...6.0.0)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Johannes Wilm <mail@johanneswilm.org>
2023-02-22 19:42:36 +01:00
Petr Dlouhý
43e052ebd5
check if image is not corrupted during upload (#218) 2023-02-22 19:39:28 +01:00
Petr Dlouhý
dfb2cb67e7
fix admin detail too slow when there is large number of users (#219) 2023-02-22 19:38:43 +01:00
0xMRTT
8034665e6b
Add LibRavatar support (#114) (#225)
* feat: add libravatar support (#114) and format

* feat: add documentation for libravatar

* fix(gh-actions): remove support for 3.6 and add 3.11

3.6 reached EOL: https://devguide.python.org/versions/

I also refactored the matrix by removing duplicated code

* fix(deps): add missing dnspython

* feat(deps): add requirements.txt

* fix(gh-actions): install deps

* chore(deps): add pyproject.toml 

See https://github.com/pypa/pip/issues/8559

* fix(gh-actions): add fail-fast so all checks run

* fix(deps): bump coverage to 7.1.0

* fix(pre-commit): update versions

* fix(pre-commit): config error

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* style: update code for passing flake

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: 0xMRTT <0xMRTT@evta.fr>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-02-22 19:38:07 +01:00
Ihor Sychevskyi
675315b33c
update links (#224) 2023-02-06 00:18:44 +02:00
Ihor Sychevskyi
bdaebddf19
update readme links (#221) 2022-12-13 00:18:50 +02:00
Johannes Wilm
b6024d96f7
Update CHANGELOG.rst 2022-10-27 21:17:23 +02:00
Johannes Wilm
df2871ab5c __version__ = "7.0.1" 2022-10-27 21:15:20 +02:00
Johannes Wilm
41496e05bb providers: remove height-requirement 2022-10-27 21:14:02 +02:00
pre-commit-ci[bot]
c0ab427cfa
[pre-commit.ci] pre-commit autoupdate (#216)
updates:
- [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-27 21:06:15 +02:00
pre-commit-ci[bot]
d3f65b5f20
[pre-commit.ci] pre-commit autoupdate (#215)
updates:
- [github.com/psf/black: 22.6.0 → 22.8.0](https://github.com/psf/black/compare/22.6.0...22.8.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-27 17:53:01 +02:00
45 changed files with 951 additions and 156 deletions

View file

@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v6
with: with:
python-version: 3.8 python-version: 3.11
- name: Install dependencies - name: Install dependencies
run: | run: |

View file

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

2
.gitignore vendored
View file

@ -15,3 +15,5 @@ htmlcov/
test_proj/media test_proj/media
.python-version .python-version
/test-media/ /test-media/
.envrc
.direnv/

View file

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

35
.readthedocs.yaml Normal file
View file

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

View file

@ -1,5 +1,35 @@
Changelog Changelog
========= =========
* 9.0.0
* Fix files not closed in `create_thumbnail`
* Add Django 5.2 and 6.0 support
* Add Python 3.13, 3.14 support
* Drop Python 3.8, 3.9 support
* 8.0.1
* Fix Django 5.1 compatibility
* 8.0.0 (October 16, 2023)
* Add Django 4.2 support
* Remove Python 3.7 support
* Use path and path converters (changes all url names from prefix `avatar_` to `avatar:`.)
* Add support for Django STORAGES (Django 4.2)
* Add optional api app (requires djangorestframework)
* Use ``Image.Resampling.LANCZOS`` instead of ``Image.LANCZOS`` that was removed in Pillow 10.0.0
* 7.1.1 (February 23, 2023)
* Switch to setuptools for building
* 7.1.0 (February 23, 2023)
* Add LibRavatar support
* Faster admin when many users are present
* Check for corrupted image during upload
* Switch Pillow Resize method from ``Image.ANTIALIAS`` to ``Image.LANCZOS``
* Removed Python 3.6 testing
* Added Python 3.11 support
* 7.0.1 (October 27, 2022)
* Remove height requirement for providers (broke 6 to 7 upgrades)
* 7.0.0 (August 16, 2022) * 7.0.0 (August 16, 2022)
* Allowed for rectangular avatars. Custom avatar tag templates now require the specification of both a ``width`` and ``height`` attribute instead of ``size``. * Allowed for rectangular avatars. Custom avatar tag templates now require the specification of both a ``width`` and ``height`` attribute instead of ``size``.

View file

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

View file

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

View file

@ -10,20 +10,19 @@ from avatar.utils import get_user_model
class AvatarAdmin(admin.ModelAdmin): class AvatarAdmin(admin.ModelAdmin):
list_display = ("get_avatar", "user", "primary", "date_uploaded") list_display = ("get_avatar", "user", "primary", "date_uploaded")
list_filter = ("primary",) list_filter = ("primary",)
autocomplete_fields = ("user",)
search_fields = ( search_fields = (
"user__%s" % getattr(get_user_model(), "USERNAME_FIELD", "username"), "user__%s" % getattr(get_user_model(), "USERNAME_FIELD", "username"),
) )
list_per_page = 50 list_per_page = 50
def get_avatar(self, avatar_in): def get_avatar(self, avatar_in):
context = dict( context = {
{ "user": avatar_in.user,
"user": avatar_in.user, "url": avatar_in.avatar.url,
"url": avatar_in.avatar.url, "alt": str(avatar_in.user),
"alt": str(avatar_in.user), "size": 80,
"size": 80, }
}
)
return render_to_string("avatar/avatar_tag.html", context) return render_to_string("avatar/avatar_tag.html", context)
get_avatar.short_description = _("Avatar") get_avatar.short_description = _("Avatar")

0
avatar/api/__init__.py Normal file
View file

22
avatar/api/apps.py Normal file
View file

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

6
avatar/api/conf.py Normal file
View file

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

View file

View file

@ -0,0 +1 @@
djangorestframework

126
avatar/api/serializers.py Normal file
View file

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

27
avatar/api/shortcut.py Normal file
View file

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

48
avatar/api/signals.py Normal file
View file

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

8
avatar/api/urls.py Normal file
View file

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

52
avatar/api/utils.py Normal file
View file

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

134
avatar/api/views.py Normal file
View file

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

View file

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

View file

@ -5,6 +5,7 @@ from django.forms import widgets
from django.template.defaultfilters import filesizeformat from django.template.defaultfilters import filesizeformat
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageOps
from avatar.conf import settings from avatar.conf import settings
from avatar.models import Avatar from avatar.models import Avatar
@ -82,6 +83,12 @@ class UploadAvatarForm(forms.Form):
} }
) )
try:
image = Image.open(data)
ImageOps.exif_transpose(image)
except TypeError:
raise forms.ValidationError(_("Corrupted image"))
count = Avatar.objects.filter(user=self.user).count() count = Avatar.objects.filter(user=self.user).count()
if 1 < settings.AVATAR_MAX_AVATARS_PER_USER <= count: if 1 < settings.AVATAR_MAX_AVATARS_PER_USER <= count:
error = _( error = _(

View file

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

View file

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

View file

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

View file

@ -1,11 +1,11 @@
import binascii import binascii
import hashlib import hashlib
import os import os
from contextlib import closing
from io import BytesIO from io import BytesIO
from django.core.files import File from django.core.files import File
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import get_storage_class
from django.db import models from django.db import models
from django.db.models import signals from django.db.models import signals
from django.utils.encoding import force_bytes, force_str from django.utils.encoding import force_bytes, force_str
@ -17,7 +17,14 @@ from PIL import Image, ImageOps
from avatar.conf import settings from avatar.conf import settings
from avatar.utils import get_username, invalidate_cache from avatar.utils import get_username, invalidate_cache
avatar_storage = get_storage_class(settings.AVATAR_STORAGE)() try: # Django 4.2+
from django.core.files.storage import storages
avatar_storage = storages[settings.AVATAR_STORAGE_ALIAS]
except ImportError:
from django.core.files.storage import get_storage_class
avatar_storage = get_storage_class(settings.AVATAR_STORAGE)()
def avatar_path_handler( def avatar_path_handler(
@ -136,38 +143,38 @@ class Avatar(models.Model):
orig = self.avatar.storage.open(self.avatar.name, "rb") orig = self.avatar.storage.open(self.avatar.name, "rb")
except IOError: except IOError:
return # What should we do here? Render a "sorry, didn't work" img? return # What should we do here? Render a "sorry, didn't work" img?
try:
image = Image.open(orig) with closing(orig):
image = self.transpose_image(image) try:
quality = quality or settings.AVATAR_THUMB_QUALITY image = Image.open(orig)
w, h = image.size except IOError:
if w != width or h != height:
ratioReal = 1.0 * w / h
ratioWant = 1.0 * width / height
if ratioReal > ratioWant:
diff = int((w - (h * ratioWant)) / 2)
image = image.crop((diff, 0, w - diff, h))
elif ratioReal < ratioWant:
diff = int((h - (w / ratioWant)) / 2)
image = image.crop((0, diff, w, h - diff))
if settings.AVATAR_THUMB_FORMAT == "JPEG" and image.mode == "RGBA":
image = image.convert("RGB")
elif image.mode not in (settings.AVATAR_THUMB_MODES):
image = image.convert(settings.AVATAR_THUMB_MODES[0])
image = image.resize((width, height), settings.AVATAR_RESIZE_METHOD)
thumb = BytesIO()
image.save(thumb, settings.AVATAR_THUMB_FORMAT, quality=quality)
thumb_file = ContentFile(thumb.getvalue())
else:
thumb_file = File(orig) thumb_file = File(orig)
else:
image = self.transpose_image(image)
quality = quality or settings.AVATAR_THUMB_QUALITY
w, h = image.size
if w != width or h != height:
ratioReal = 1.0 * w / h
ratioWant = 1.0 * width / height
if ratioReal > ratioWant:
diff = int((w - (h * ratioWant)) / 2)
image = image.crop((diff, 0, w - diff, h))
elif ratioReal < ratioWant:
diff = int((h - (w / ratioWant)) / 2)
image = image.crop((0, diff, w, h - diff))
if settings.AVATAR_THUMB_FORMAT == "JPEG" and image.mode == "RGBA":
image = image.convert("RGB")
elif image.mode not in (settings.AVATAR_THUMB_MODES):
image = image.convert(settings.AVATAR_THUMB_MODES[0])
image = image.resize((width, height), settings.AVATAR_RESIZE_METHOD)
thumb = BytesIO()
image.save(thumb, settings.AVATAR_THUMB_FORMAT, quality=quality)
thumb_file = ContentFile(thumb.getvalue())
else:
thumb_file = File(orig)
thumb_name = self.avatar_name(width, height) thumb_name = self.avatar_name(width, height)
thumb = self.avatar.storage.save(thumb_name, thumb_file) thumb = self.avatar.storage.save(thumb_name, thumb_file)
except IOError: invalidate_cache(self.user, width, height)
thumb_file = File(orig)
thumb = self.avatar.storage.save(
self.avatar_name(width, height), thumb_file
)
invalidate_cache(self.user, width, height)
def avatar_url(self, width, height=None): def avatar_url(self, width, height=None):
return self.avatar.storage.url(self.avatar_name(width, height)) return self.avatar.storage.url(self.avatar_name(width, height))

View file

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

View file

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

View file

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

View file

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

View file

@ -94,7 +94,7 @@ def primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
else: else:
kwargs["height"] = height kwargs["height"] = height
url = reverse("avatar_render_primary", kwargs=kwargs) url = reverse("avatar:render_primary", kwargs=kwargs)
return """<img src="%s" width="%s" height="%s" alt="%s" />""" % ( return """<img src="%s" width="%s" height="%s" alt="%s" />""" % (
url, url,
width, width,

View file

@ -1,19 +1,24 @@
from django.urls import re_path from django.urls import path
from avatar import views from avatar import views
# For reversing namespaced urls
# https://docs.djangoproject.com/en/4.1/topics/http/urls/#reversing-namespaced-urls
app_name = "avatar"
urlpatterns = [ urlpatterns = [
re_path(r"^add/$", views.add, name="avatar_add"), path("add/", views.add, name="add"),
re_path(r"^change/$", views.change, name="avatar_change"), path("change/", views.change, name="change"),
re_path(r"^delete/$", views.delete, name="avatar_delete"), path("delete/", views.delete, name="delete"),
re_path( # https://docs.djangoproject.com/en/4.1/topics/http/urls/#path-converters
r"^render_primary/(?P<user>[\w\d\@\.\-_]+)/(?P<width>[\d]+)/$", path(
"render_primary/<slug:user>/<int:width>/",
views.render_primary, views.render_primary,
name="avatar_render_primary", name="render_primary",
), ),
re_path( path(
r"^render_primary/(?P<user>[\w\d\@\.\-_]+)/(?P<width>[\d]+)/(?P<height>[\d]+)/$", "render_primary/<slug:user>/<int:width>/<int:height>/",
views.render_primary, views.render_primary,
name="avatar_render_primary", name="render_primary",
), ),
] ]

View file

@ -21,7 +21,11 @@ def get_username(user):
def get_user(userdescriptor): def get_user(userdescriptor):
"""Return user from a username/ID/ish identifier""" """Return user from a username/ID/ish identifier"""
User = get_user_model() User = get_user_model()
if userdescriptor.isdigit(): if isinstance(userdescriptor, int):
user = User.objects.filter(id=userdescriptor).first()
if user:
return user
elif userdescriptor.isdigit():
user = User.objects.filter(id=int(userdescriptor)).first() user = User.objects.filter(id=int(userdescriptor)).first()
if user: if user:
return user return user

View file

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

191
docs/avatar.rst Normal file
View file

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

View file

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

View file

@ -1,3 +1,4 @@
django-avatar django-avatar
============= =============
@ -7,7 +8,7 @@ or Facebook) if no avatar is found for a certain user. Django-avatar
automatically generates thumbnails and stores them to your default file automatically generates thumbnails and stores them to your default file
storage backend for retrieval later. storage backend for retrieval later.
.. _Gravatar: http://gravatar.com .. _Gravatar: https://gravatar.com
Installation Installation
------------ ------------
@ -54,7 +55,7 @@ that are required. A minimal integration can work like this:
4. Somewhere in your template navigation scheme, link to the change avatar 4. Somewhere in your template navigation scheme, link to the change avatar
page:: page::
<a href="{% url 'avatar_change' %}">Change your avatar</a> <a href="{% url 'avatar:change' %}">Change your avatar</a>
5. Wherever you want to display an avatar for a user, first load the avatar 5. Wherever you want to display an avatar for a user, first load the avatar
template tags:: template tags::
@ -180,6 +181,7 @@ appear on the site. Listed below are those settings:
( (
'avatar.providers.PrimaryAvatarProvider', 'avatar.providers.PrimaryAvatarProvider',
'avatar.providers.LibRAvatarProvider',
'avatar.providers.GravatarAvatarProvider', 'avatar.providers.GravatarAvatarProvider',
'avatar.providers.DefaultAvatarProvider', 'avatar.providers.DefaultAvatarProvider',
) )
@ -210,14 +212,13 @@ appear on the site. Listed below are those settings:
.. py:data:: AVATAR_RESIZE_METHOD .. py:data:: AVATAR_RESIZE_METHOD
The method to use when resizing images, based on the options available in The method to use when resizing images, based on the options available in
Pillow. Defaults to ``Image.ANTIALIAS``. Pillow. Defaults to ``Image.Resampling.LANCZOS``.
.. py:data:: AVATAR_STORAGE_DIR .. py:data:: AVATAR_STORAGE_DIR
The directory under ``MEDIA_ROOT`` to store the images. If using a The directory under ``MEDIA_ROOT`` to store the images. If using a
non-filesystem storage device, this will simply be appended to the beginning non-filesystem storage device, this will simply be appended to the beginning
of the file name. Defaults to ``avatars``. of the file name. Defaults to ``avatars``.
Pillow. Defaults to ``Image.ANTIALIAS``.
.. py:data:: AVATAR_THUMB_FORMAT .. py:data:: AVATAR_THUMB_FORMAT
@ -262,6 +263,11 @@ appear on the site. Listed below are those settings:
Suggested safe setting: ``("image/png", "image/gif", "image/jpeg")``. Suggested safe setting: ``("image/png", "image/gif", "image/jpeg")``.
When enabled you'll get the following error on the form upload *File content is invalid. Detected: image/tiff Allowed content types are: image/png, image/gif, image/jpg*. When enabled you'll get the following error on the form upload *File content is invalid. Detected: image/tiff Allowed content types are: image/png, image/gif, image/jpg*.
.. py:data:: AVATAR_STORAGE_ALIAS
Default: 'default'
Alias of the storage backend (from STORAGES settings) to use for storing avatars.
Management Commands Management Commands
------------------- -------------------
@ -272,4 +278,37 @@ the avatars for the pixel sizes specified in the
:py:data:`AVATAR_AUTO_GENERATE_SIZES` setting. :py:data:`AVATAR_AUTO_GENERATE_SIZES` setting.
.. _pip: http://www.pip-installer.org/ .. _pip: https://www.pip-installer.org/
-----------------------------------------------
API
---
To use API there are relatively few things that are required.
after `Installation <#installation>`_ .
1. in your ``INSTALLED_APPS`` of your settings file : ::
INSTALLED_APPS = (
# ...
'avatar',
'rest_framework'
)
2. Add the avatar api urls to the end of your root url config : ::
urlpatterns = [
# ...
path('api/', include('avatar.api.urls')),
]
-----------------------------------------------
.. toctree::
:maxdepth: 1
avatar

View file

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

40
pyproject.toml Normal file
View file

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

3
requirements.txt Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,9 @@
import math import math
import os.path import os.path
import sys
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from unittest import skipIf
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django.core import management from django.core import management
@ -43,7 +45,7 @@ class AssertSignal:
def upload_helper(o, filename): def upload_helper(o, filename):
f = open(os.path.join(o.testdatapath, filename), "rb") f = open(os.path.join(o.testdatapath, filename), "rb")
response = o.client.post( response = o.client.post(
reverse("avatar_add"), reverse("avatar:add"),
{ {
"avatar": f, "avatar": f,
}, },
@ -118,6 +120,7 @@ class AvatarTests(TestCase):
self.assertTrue(avatar.primary) self.assertTrue(avatar.primary)
# We allow the .tiff file extension but not the mime type # We allow the .tiff file extension but not the mime type
@skipIf(sys.platform == "win32", "Skipping test on Windows platform")
@override_settings(AVATAR_ALLOWED_FILE_EXTS=(".png", ".gif", ".jpg", ".tiff")) @override_settings(AVATAR_ALLOWED_FILE_EXTS=(".png", ".gif", ".jpg", ".tiff"))
@override_settings( @override_settings(
AVATAR_ALLOWED_MIMETYPES=("image/png", "image/gif", "image/jpeg") AVATAR_ALLOWED_MIMETYPES=("image/png", "image/gif", "image/jpeg")
@ -130,6 +133,7 @@ class AvatarTests(TestCase):
self.assertNotEqual(response.context["upload_avatar_form"].errors, {}) self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
# We allow the .tiff file extension and the mime type # We allow the .tiff file extension and the mime type
@skipIf(sys.platform == "win32", "Skipping test on Windows platform")
@override_settings(AVATAR_ALLOWED_FILE_EXTS=(".png", ".gif", ".jpg", ".tiff")) @override_settings(AVATAR_ALLOWED_FILE_EXTS=(".png", ".gif", ".jpg", ".tiff"))
@override_settings( @override_settings(
AVATAR_ALLOWED_MIMETYPES=("image/png", "image/gif", "image/jpeg", "image/tiff") AVATAR_ALLOWED_MIMETYPES=("image/png", "image/gif", "image/jpeg", "image/tiff")
@ -141,6 +145,7 @@ class AvatarTests(TestCase):
self.assertEqual(len(response.redirect_chain), 1) # Redirect only if it worked self.assertEqual(len(response.redirect_chain), 1) # Redirect only if it worked
self.assertEqual(response.context["upload_avatar_form"].errors, {}) self.assertEqual(response.context["upload_avatar_form"].errors, {})
@skipIf(sys.platform == "win32", "Skipping test on Windows platform")
@override_settings(AVATAR_ALLOWED_FILE_EXTS=(".jpg", ".png")) @override_settings(AVATAR_ALLOWED_FILE_EXTS=(".jpg", ".png"))
def test_image_without_wrong_extension(self): def test_image_without_wrong_extension(self):
response = upload_helper(self, "imagefilewithoutext") response = upload_helper(self, "imagefilewithoutext")
@ -148,6 +153,7 @@ class AvatarTests(TestCase):
self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
self.assertNotEqual(response.context["upload_avatar_form"].errors, {}) self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
@skipIf(sys.platform == "win32", "Skipping test on Windows platform")
@override_settings(AVATAR_ALLOWED_FILE_EXTS=(".jpg", ".png")) @override_settings(AVATAR_ALLOWED_FILE_EXTS=(".jpg", ".png"))
def test_image_with_wrong_extension(self): def test_image_with_wrong_extension(self):
response = upload_helper(self, "imagefilewithwrongext.ogg") response = upload_helper(self, "imagefilewithwrongext.ogg")
@ -165,7 +171,7 @@ class AvatarTests(TestCase):
def test_default_url(self): def test_default_url(self):
response = self.client.get( response = self.client.get(
reverse( reverse(
"avatar_render_primary", "avatar:render_primary",
kwargs={ kwargs={
"user": self.user.username, "user": self.user.username,
"width": 80, "width": 80,
@ -196,7 +202,7 @@ class AvatarTests(TestCase):
receiver = AssertSignal() receiver = AssertSignal()
avatar_deleted.connect(receiver) avatar_deleted.connect(receiver)
response = self.client.post( response = self.client.post(
reverse("avatar_delete"), reverse("avatar:delete"),
{ {
"choices": [avatar[0].id], "choices": [avatar[0].id],
}, },
@ -216,7 +222,7 @@ class AvatarTests(TestCase):
primary = get_primary_avatar(self.user) primary = get_primary_avatar(self.user)
oid = primary.id oid = primary.id
self.client.post( self.client.post(
reverse("avatar_delete"), reverse("avatar:delete"),
{ {
"choices": [oid], "choices": [oid],
}, },
@ -229,7 +235,7 @@ class AvatarTests(TestCase):
def test_change_avatar_get(self): def test_change_avatar_get(self):
self.test_normal_image_upload() self.test_normal_image_upload()
response = self.client.get(reverse("avatar_change")) response = self.client.get(reverse("avatar:change"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIsNotNone(response.context["avatar"]) self.assertIsNotNone(response.context["avatar"])
@ -239,7 +245,7 @@ class AvatarTests(TestCase):
old_primary = Avatar.objects.get(user=self.user, primary=True) old_primary = Avatar.objects.get(user=self.user, primary=True)
choice = Avatar.objects.filter(user=self.user, primary=False)[0] choice = Avatar.objects.filter(user=self.user, primary=False)[0]
response = self.client.post( response = self.client.post(
reverse("avatar_change"), reverse("avatar:change"),
{ {
"choice": choice.pk, "choice": choice.pk,
}, },