Compare commits

...

40 commits
v7.0.1 ... 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
45 changed files with 942 additions and 151 deletions

View file

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

View file

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

View file

@ -1,24 +1,24 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v6.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/pycqa/isort
rev: "5.10.1"
rev: "7.0.0"
hooks:
- id: isort
args: ["--profile", "black"]
- repo: https://github.com/psf/black
rev: 22.10.0
rev: 25.12.0
hooks:
- id: black
args: [--target-version=py310]
- repo: https://github.com/pycqa/flake8
rev: '5.0.4'
rev: '7.3.0'
hooks:
- id: flake8
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
=========
* 9.0.0
* Fix files not closed in `create_thumbnail`
* Add Django 5.2 and 6.0 support
* Add Python 3.13, 3.14 support
* Drop Python 3.8, 3.9 support
* 8.0.1
* Fix Django 5.1 compatibility
* 8.0.0 (October 16, 2023)
* Add Django 4.2 support
* Remove Python 3.7 support
* Use path and path converters (changes all url names from prefix `avatar_` to `avatar:`.)
* Add support for Django STORAGES (Django 4.2)
* Add optional api app (requires djangorestframework)
* Use ``Image.Resampling.LANCZOS`` instead of ``Image.LANCZOS`` that was removed in Pillow 10.0.0
* 7.1.1 (February 23, 2023)
* Switch to setuptools for building
* 7.1.0 (February 23, 2023)
* Add LibRavatar support
* Faster admin when many users are present
* Check for corrupted image during upload
* Switch Pillow Resize method from ``Image.ANTIALIAS`` to ``Image.LANCZOS``
* Removed Python 3.6 testing
* Added Python 3.11 support
* 7.0.1 (October 27, 2022)
* Remove height requirement for providers (broke 6 to 7 upgrades)
* 7.0.0 (August 16, 2022)
* Allowed for rectangular avatars. Custom avatar tag templates now require the specification of both a ``width`` and ``height`` attribute instead of ``size``.

View file

@ -25,7 +25,7 @@ django-avatar
:alt: PyPI badge
.. 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
Django-avatar is a reusable application for handling user avatars. It has the
@ -33,4 +33,4 @@ ability to default to Gravatar if no avatar is found for a certain user.
Django-avatar automatically generates thumbnails and stores them to your default
file storage backend for retrieval later.
For more information see the documentation at http://django-avatar.readthedocs.org/
For more information see the documentation at https://django-avatar.readthedocs.org/

View file

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

View file

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

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):
DEFAULT_SIZE = 80
RESIZE_METHOD = Image.ANTIALIAS
RESIZE_METHOD = Image.Resampling.LANCZOS
STORAGE_DIR = "avatars"
PATH_HANDLER = "avatar.models.avatar_path_handler"
GRAVATAR_BASE_URL = "https://www.gravatar.com/avatar/"
@ -24,7 +24,9 @@ class AvatarConf(AppConf):
ALLOWED_FILE_EXTS = None
ALLOWED_MIMETYPES = None
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
AUTO_GENERATE_SIZES = (DEFAULT_SIZE,)
FACEBOOK_GET_ID = None
@ -35,6 +37,7 @@ class AvatarConf(AppConf):
DELETE_TEMPLATE = ""
PROVIDERS = (
"avatar.providers.PrimaryAvatarProvider",
"avatar.providers.LibRAvatarProvider",
"avatar.providers.GravatarAvatarProvider",
"avatar.providers.DefaultAvatarProvider",
)

View file

@ -5,6 +5,7 @@ from django.forms import widgets
from django.template.defaultfilters import filesizeformat
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageOps
from avatar.conf import settings
from avatar.models import Avatar
@ -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()
if 1 < settings.AVATAR_MAX_AVATARS_PER_USER <= count:
error = _(

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,8 @@
import hashlib
import re
from urllib.parse import urlencode, urljoin
import dns.resolver
from django.utils.module_loading import import_string
from avatar.conf import settings
@ -64,6 +66,40 @@ class GravatarAvatarProvider(object):
return urljoin(settings.AVATAR_GRAVATAR_BASE_URL, path)
class LibRAvatarProvider:
"""
Returns the url of an avatar by the LibRavatar service.
"""
@classmethod
def get_avatar_url(cls, user, width, _height=None):
email = getattr(user, settings.AVATAR_GRAVATAR_FIELD).encode("utf-8")
try:
_, domain = email.split(b"@")
answers = dns.resolver.query("_avatars._tcp." + domain, "SRV")
hostname = re.sub(r"\.$", "", str(answers[0].target))
# query returns "example.com." and while http requests are fine with this,
# https most certainly do not consider "example.com." and "example.com" to be the same.
port = str(answers[0].port)
if port == "443":
baseurl = "https://" + hostname + "/avatar/"
else:
baseurl = "http://" + hostname + ":" + port + "/avatar/"
except Exception:
baseurl = "https://seccdn.libravatar.org/avatar/"
params = {"s": str(width)}
if settings.AVATAR_GRAVATAR_DEFAULT:
params["d"] = settings.AVATAR_GRAVATAR_DEFAULT
if settings.AVATAR_GRAVATAR_FORCEDEFAULT:
params["f"] = "y"
path = "%s/?%s" % (
hashlib.md5(force_bytes(email.strip().lower())).hexdigest(),
urlencode(params),
)
return urljoin(baseurl, path)
class FacebookAvatarProvider(object):
"""
Returns the url of a Facebook profile image.

View file

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

View file

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

View file

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

View file

@ -94,7 +94,7 @@ def primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
else:
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" />""" % (
url,
width,

View file

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

View file

@ -21,7 +21,11 @@ def get_username(user):
def get_user(userdescriptor):
"""Return user from a username/ID/ish identifier"""
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()
if user:
return user

View file

@ -9,7 +9,7 @@ BUILDDIR = _build
# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from 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
# 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"]
# The suffix of source filenames.
source_suffix = ".txt"
source_suffix = ".rst"
# The encoding of source files.
# source_encoding = 'utf-8-sig'

View file

@ -1,3 +1,4 @@
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
storage backend for retrieval later.
.. _Gravatar: http://gravatar.com
.. _Gravatar: https://gravatar.com
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
page::
<a href="{% url 'avatar_change' %}">Change your avatar</a>
<a href="{% url 'avatar:change' %}">Change your avatar</a>
5. Wherever you want to display an avatar for a user, first load the avatar
template tags::
@ -180,6 +181,7 @@ appear on the site. Listed below are those settings:
(
'avatar.providers.PrimaryAvatarProvider',
'avatar.providers.LibRAvatarProvider',
'avatar.providers.GravatarAvatarProvider',
'avatar.providers.DefaultAvatarProvider',
)
@ -210,14 +212,13 @@ appear on the site. Listed below are those settings:
.. py:data:: AVATAR_RESIZE_METHOD
The method to use when resizing images, based on the options available in
Pillow. Defaults to ``Image.ANTIALIAS``.
Pillow. Defaults to ``Image.Resampling.LANCZOS``.
.. py:data:: AVATAR_STORAGE_DIR
The directory under ``MEDIA_ROOT`` to store the images. If using a
non-filesystem storage device, this will simply be appended to the beginning
of the file name. Defaults to ``avatars``.
Pillow. Defaults to ``Image.ANTIALIAS``.
.. py:data:: AVATAR_THUMB_FORMAT
@ -262,6 +263,11 @@ appear on the site. Listed below are those settings:
Suggested safe setting: ``("image/png", "image/gif", "image/jpeg")``.
When enabled you'll get the following error on the form upload *File content is invalid. Detected: image/tiff Allowed content types are: image/png, image/gif, image/jpg*.
.. py:data:: AVATAR_STORAGE_ALIAS
Default: 'default'
Alias of the storage backend (from STORAGES settings) to use for storing avatars.
Management Commands
-------------------
@ -272,4 +278,37 @@ the avatars for the pixel sizes specified in the
: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.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
echo.https://sphinx-doc.org/
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(
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"]),
package_data={
"avatar": [
@ -58,9 +29,5 @@ setup(
"media/avatar/img/default.jpg",
],
},
install_requires=[
"Pillow>=8.4.0",
"django-appconf>=1.0.5",
],
zip_safe=False,
)

View file

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

View file

@ -1,16 +1,18 @@
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.urls import re_path
from django.views.static import serve
urlpatterns = [
url(r"^admin/", admin.site.urls),
url(r"^avatar/", include("avatar.urls")),
re_path(r"^admin/", admin.site.urls),
re_path(r"^avatar/", include("avatar.urls")),
re_path(r"^api/", include("avatar.api.urls")),
]
if settings.DEBUG:
# static files (images, css, javascript, etc.)
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
python-magic

View file

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