mirror of
https://github.com/jazzband/django-avatar.git
synced 2026-05-05 22:24:46 +00:00
Compare commits
196 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e4745f29 | ||
|
|
0f725788c9 | ||
|
|
80a7c95583 | ||
|
|
7cb55334c1 | ||
|
|
d1801edc64 | ||
|
|
4955d2d959 | ||
|
|
14495e8106 | ||
|
|
45e741a342 | ||
|
|
a6cafaa7f0 | ||
|
|
981187a9de | ||
|
|
ebeb6d5e64 | ||
|
|
da3615203d | ||
|
|
3343c51e2e | ||
|
|
d81495f50c | ||
|
|
fa54411351 | ||
|
|
28a8173b35 | ||
|
|
a9ada9f273 | ||
|
|
403a9283b3 | ||
|
|
37d904c4ad | ||
|
|
c8f7ecbe54 | ||
|
|
ceb7cade62 | ||
|
|
bf7dde9337 | ||
|
|
7d64d5dc14 | ||
|
|
2687d6cd06 | ||
|
|
81ace968c7 | ||
|
|
9d43467934 | ||
|
|
5e5d6f9c6a | ||
|
|
a9bf7054fc | ||
|
|
738e65a229 | ||
|
|
8fa396f5b0 | ||
|
|
5025be283a | ||
|
|
1077a6bddb | ||
|
|
222cd65d85 | ||
|
|
22b1b8346a | ||
|
|
43e052ebd5 | ||
|
|
dfb2cb67e7 | ||
|
|
8034665e6b | ||
|
|
675315b33c | ||
|
|
bdaebddf19 | ||
|
|
b6024d96f7 | ||
|
|
df2871ab5c | ||
|
|
41496e05bb | ||
|
|
c0ab427cfa | ||
|
|
d3f65b5f20 | ||
|
|
4c5e6a0f52 | ||
|
|
a897f5e8e9 | ||
|
|
c089561f48 | ||
|
|
39f8681e29 | ||
|
|
210b54ca09 | ||
|
|
0e09d40bf1 | ||
|
|
8475d37c2a | ||
|
|
baf1cfb1f7 | ||
|
|
9e9679167b | ||
|
|
6f827c5312 | ||
|
|
d0ed3e3132 | ||
|
|
a89a3fad17 | ||
|
|
827244ca6c | ||
|
|
28302c5205 | ||
|
|
72c1cae345 | ||
|
|
942e08080b | ||
|
|
cee893f5e3 | ||
|
|
9c5e4f55f2 | ||
|
|
198946a8af | ||
|
|
99a979b057 | ||
|
|
8017d6fc4c | ||
|
|
1dd993358b | ||
|
|
2ced4c06e7 | ||
|
|
6d9bd34de0 | ||
|
|
49b85b12f8 | ||
|
|
b422c7bc0d | ||
|
|
47bd6f36ac | ||
|
|
5eb615f4b5 | ||
|
|
1f7b1ff204 | ||
|
|
8e086472d4 | ||
|
|
58aa1261ad | ||
|
|
6c05228531 | ||
|
|
96a4dc7e91 | ||
|
|
a6cef676f3 | ||
|
|
33afb0e8dc | ||
|
|
1bf0542a2f | ||
|
|
0f7ddf2c26 | ||
|
|
9c03396893 | ||
|
|
2fb3034505 | ||
|
|
ab7e9c687e | ||
|
|
dca3143ec2 | ||
|
|
5452d4ea34 | ||
|
|
b986082ae5 | ||
|
|
c8bcdb4a0c | ||
|
|
e8fa4747f9 | ||
|
|
4b912886ec | ||
|
|
7d276ead7a | ||
|
|
3e12122a9c | ||
|
|
c89f7043c6 | ||
|
|
862e4f7a23 | ||
|
|
bc05f97c93 | ||
|
|
114a154668 | ||
|
|
daa47442e5 | ||
|
|
a4708eca55 | ||
|
|
72a6751960 | ||
|
|
43dc264e82 | ||
|
|
e11a93e2ed | ||
|
|
af1c241e92 | ||
|
|
51a4a64b18 | ||
|
|
61a1643151 | ||
|
|
502d96f81f | ||
|
|
6ccc046386 | ||
|
|
10986d7be9 | ||
|
|
eba927b7e2 | ||
|
|
a977753b2c | ||
|
|
f40705b739 | ||
|
|
b86706fec5 | ||
|
|
a54fafabff | ||
|
|
9ffc4b60e7 | ||
|
|
16f69072fa | ||
|
|
b45d211e09 | ||
|
|
4839dccfd5 | ||
|
|
c27a4d1794 | ||
|
|
dab8741bed | ||
|
|
6ba1280a7e | ||
|
|
0a84015352 | ||
|
|
3dd349b577 | ||
|
|
b7c8485f1e | ||
|
|
ff0a5526ea | ||
|
|
1c08dd84b7 | ||
|
|
8f94d125b3 | ||
|
|
ae950c9b50 | ||
|
|
96ae04858f | ||
|
|
a131861589 | ||
|
|
03c95bc925 | ||
|
|
51cbdbd5c7 | ||
|
|
a1aae28859 | ||
|
|
288f3cacf2 | ||
|
|
a0998c4bde | ||
|
|
61f135fa71 | ||
|
|
12a6d65454 | ||
|
|
be9187fb62 | ||
|
|
346530c14c | ||
|
|
9fd68769c8 | ||
|
|
d941939441 | ||
|
|
ec6f8bbf2b | ||
|
|
623f529a0b | ||
|
|
68340e8ac6 | ||
|
|
348fade5a8 | ||
|
|
85218b0403 | ||
|
|
2917cc5690 | ||
|
|
22cb67fd23 | ||
|
|
53b7fa2265 | ||
|
|
3f4a8d284e | ||
|
|
ce56f6a104 | ||
|
|
5c4f587e6e | ||
|
|
6ce67a8709 | ||
|
|
991c8657de | ||
|
|
a10771fc47 | ||
|
|
25edc4172b | ||
|
|
805920e521 | ||
|
|
4ab2b379e7 | ||
|
|
6a2c361502 | ||
|
|
2793ff0830 | ||
|
|
15879af5ce | ||
|
|
0e66ae5de7 | ||
|
|
10ee97b902 | ||
|
|
182e7aa641 | ||
|
|
319d554873 | ||
|
|
0d761fdd88 | ||
|
|
a26a133d9d | ||
|
|
f1266ed1eb | ||
|
|
8b83871634 | ||
|
|
6d4a1b27d1 | ||
|
|
b6c9ff1d03 | ||
|
|
7fb00f634b | ||
|
|
2b767e0b85 | ||
|
|
16d721c210 | ||
|
|
2de2a60e85 | ||
|
|
f940db8dc9 | ||
|
|
ba552fbcd9 | ||
|
|
10e0853152 | ||
|
|
1511e67d66 | ||
|
|
d9c162cda9 | ||
|
|
69901569a3 | ||
|
|
6010209a1b | ||
|
|
fb195a570f | ||
|
|
f31a9ae092 | ||
|
|
4eaf6f8f1e | ||
|
|
dcc472a1e2 | ||
|
|
dcabd11e43 | ||
|
|
4abf3277a2 | ||
|
|
f8716937b0 | ||
|
|
e43a4bd725 | ||
|
|
631eac80bb | ||
|
|
341732a38c | ||
|
|
55be600311 | ||
|
|
0e87dc2f82 | ||
|
|
d7db61b275 | ||
|
|
5ad41df92c | ||
|
|
217baaa317 | ||
|
|
3fd8461d9e |
74 changed files with 2404 additions and 775 deletions
38
.github/workflows/release.yml
vendored
Normal file
38
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: github.repository == 'jazzband/django-avatar'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install -U pip
|
||||||
|
python -m pip install -U setuptools twine wheel
|
||||||
|
- name: Build package
|
||||||
|
run: |
|
||||||
|
python setup.py --version
|
||||||
|
python setup.py sdist --format=gztar bdist_wheel
|
||||||
|
twine check dist/*
|
||||||
|
- name: Upload packages to Jazzband
|
||||||
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
user: jazzband
|
||||||
|
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||||
|
repository_url: https://jazzband.co/projects/django-avatar/upload
|
||||||
45
.github/workflows/test.yml
vendored
Normal file
45
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
name: Test
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
Build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
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@v6
|
||||||
|
- name: 'Set up Python ${{ matrix.python-version }}'
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '${{ matrix.python-version }}'
|
||||||
|
cache: 'pip'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r tests/requirements.txt
|
||||||
|
pip install "Django==${{ matrix.django-version }}" .
|
||||||
|
- name: Run Tests
|
||||||
|
run: |
|
||||||
|
echo "$(python --version) / Django $(django-admin --version)"
|
||||||
|
export DJANGO_SETTINGS_MODULE=tests.settings
|
||||||
|
export PYTHONPATH=.
|
||||||
|
coverage run --source=avatar `which django-admin` test tests
|
||||||
|
coverage report
|
||||||
|
coverage xml
|
||||||
|
- name: Upload coverage reports to Codecov with GitHub Action
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
*.pyc
|
*.pyc
|
||||||
|
__pycache__
|
||||||
build/
|
build/
|
||||||
src/
|
src/
|
||||||
pip-log.txt
|
pip-log.txt
|
||||||
|
|
@ -12,3 +13,7 @@ docs/_build
|
||||||
htmlcov/
|
htmlcov/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
test_proj/media
|
test_proj/media
|
||||||
|
.python-version
|
||||||
|
/test-media/
|
||||||
|
.envrc
|
||||||
|
.direnv/
|
||||||
|
|
|
||||||
29
.pre-commit-config.yaml
Normal file
29
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v6.0.0
|
||||||
|
hooks:
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: "7.0.0"
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
args: ["--profile", "black"]
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 25.12.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
args: [--target-version=py310]
|
||||||
|
|
||||||
|
- repo: https://github.com/pycqa/flake8
|
||||||
|
rev: '7.3.0'
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
additional_dependencies:
|
||||||
|
- flake8-bugbear
|
||||||
|
- flake8-comprehensions
|
||||||
|
- flake8-tidy-imports
|
||||||
|
- flake8-print
|
||||||
|
args: [--max-line-length=120]
|
||||||
35
.readthedocs.yaml
Normal file
35
.readthedocs.yaml
Normal 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
|
||||||
25
.travis.yml
25
.travis.yml
|
|
@ -1,25 +0,0 @@
|
||||||
language: python
|
|
||||||
python:
|
|
||||||
- 2.7
|
|
||||||
- 3.4
|
|
||||||
- 3.5
|
|
||||||
- 3.6
|
|
||||||
before_install:
|
|
||||||
- pip install coveralls
|
|
||||||
install:
|
|
||||||
- pip install -e .
|
|
||||||
- pip install -r tests/requirements.txt
|
|
||||||
- pip install Django==${DJANGO}
|
|
||||||
script: make test
|
|
||||||
env:
|
|
||||||
- DJANGO=1.9.13
|
|
||||||
- DJANGO=1.10.7
|
|
||||||
- DJANGO=1.11.1
|
|
||||||
matrix:
|
|
||||||
exclude:
|
|
||||||
- python: 3.6
|
|
||||||
env: DJANGO=1.9.13
|
|
||||||
- python: 3.6
|
|
||||||
env: DJANGO=1.10.7
|
|
||||||
after_success:
|
|
||||||
- coveralls
|
|
||||||
|
|
@ -1,5 +1,69 @@
|
||||||
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)
|
||||||
|
* Allowed for rectangular avatars. Custom avatar tag templates now require the specification of both a ``width`` and ``height`` attribute instead of ``size``.
|
||||||
|
* Made ``True`` the default value of ``AVATAR_CLEANUP_DELETED``. (Set to ``False`` to obtain previous behavior).
|
||||||
|
* Fix invalidate_cache for on-the-fly created thumbnails.
|
||||||
|
* New setting ``AVATAR_ALLOWED_MIMETYPES``. If enabled, it checks mimetypes of uploaded files using ``python-magic``. Default is ``None``.
|
||||||
|
* Fix thumbnail transposing for Safari.
|
||||||
|
|
||||||
|
* 6.0.1 (August 12, 2022)
|
||||||
|
* Exclude tests folder from distribution.
|
||||||
|
|
||||||
|
* 6.0.0 (August 12, 2022)
|
||||||
|
* Added Django 3.2, 4.0 and 4.1 support.
|
||||||
|
* Removed Django 1.9, 1.10, 1.11, 2.0, 2.1, 2.2 and 3.0 support.
|
||||||
|
* Added Python 3.9 and 3.10 support.
|
||||||
|
* Removed Python 2.7, 3.4 and 3.5 support.
|
||||||
|
* Made ``"PNG"`` the default value for ``AVATAR_THUMB_FORMAT`` (Set to ``"JPEG"`` to obtain previous behavior).
|
||||||
|
* Made ``False`` the default value for ``AVATAR_EXPOSE_USERNAMES`` (Set to ``True`` to obtain previous behavior).
|
||||||
|
* Don't leak usernames through image alt-tags when ``AVATAR_EXPOSE_USERNAMES`` is `False`.
|
||||||
|
* New setting ``AVATAR_THUMB_MODES``. Default is ``['RGB', 'RGBA']``.
|
||||||
|
* Use original image as thumbnail if thumbnail creation failed but image saving succeeds.
|
||||||
|
* Add farsi translation.
|
||||||
|
* Introduce black and flake8 linting
|
||||||
|
|
||||||
|
* 5.0.0 (January 4, 2019)
|
||||||
|
* Added Django 2.1, 2.2, and 3.0 support.
|
||||||
|
* Added Python 3.7 and 3.8 support.
|
||||||
|
* Removed Python 1.9 and 1.10 support.
|
||||||
|
* Fixed bug where avatars couldn't be deleted if file was already deleted.
|
||||||
|
|
||||||
|
* 4.1.0 (December 20, 2017)
|
||||||
|
* Added Django 2.0 support.
|
||||||
|
* Added ``avatar_deleted`` signal.
|
||||||
|
* Ensure thumbnails are the correct orientation.
|
||||||
|
|
||||||
* 4.0.0 (May 27, 2017)
|
* 4.0.0 (May 27, 2017)
|
||||||
* **Backwards incompatible:** Added ``AVATAR_PROVIDERS`` setting. Avatar providers are classes that return an avatar URL for a given user.
|
* **Backwards incompatible:** Added ``AVATAR_PROVIDERS`` setting. Avatar providers are classes that return an avatar URL for a given user.
|
||||||
|
|
|
||||||
3
CONTRIBUTING.md
Normal file
3
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[](https://jazzband.co/)
|
||||||
|
|
||||||
|
This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines).
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
This application was originally written by Eric Florenzano.
|
This application was originally written by Eric Florenzano.
|
||||||
It is now maintained by Grant McConnaughey and a league of awesome contributors.
|
|
||||||
|
|
||||||
See the full list here: https://github.com/grantmcconnaughey/django-avatar/graphs/contributors
|
See the full list here: https://github.com/jazzband/django-avatar/graphs/contributors
|
||||||
|
|
|
||||||
|
|
@ -26,4 +26,3 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@ include LICENSE.txt
|
||||||
include CONTRIBUTORS.txt
|
include CONTRIBUTORS.txt
|
||||||
include avatar/media/avatar/img/default.jpg
|
include avatar/media/avatar/img/default.jpg
|
||||||
recursive-include docs *
|
recursive-include docs *
|
||||||
recursive-include tests *
|
|
||||||
recursive-include avatar/templates *.html *.txt
|
recursive-include avatar/templates *.html *.txt
|
||||||
recursive-include avatar/locale/*/LC_MESSAGES *.mo *.po
|
recursive-include avatar/locale/*/LC_MESSAGES *.mo *.po
|
||||||
|
recursive-exclude tests *
|
||||||
|
|
|
||||||
18
Makefile
18
Makefile
|
|
@ -1,18 +0,0 @@
|
||||||
export DJANGO_SETTINGS_MODULE=tests.settings
|
|
||||||
export PYTHONPATH=.
|
|
||||||
|
|
||||||
.PHONY: test
|
|
||||||
|
|
||||||
test:
|
|
||||||
flake8 avatar --ignore=E124,E501,E127,E128
|
|
||||||
coverage run --source=avatar `which django-admin.py` test tests
|
|
||||||
coverage report
|
|
||||||
|
|
||||||
publish: clean
|
|
||||||
python setup.py sdist
|
|
||||||
twine upload dist/*
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -vrf ./build ./dist ./*.egg-info
|
|
||||||
find . -name '*.pyc' -delete
|
|
||||||
find . -name '*.tgz' -delete
|
|
||||||
34
README.rst
34
README.rst
|
|
@ -2,29 +2,35 @@
|
||||||
django-avatar
|
django-avatar
|
||||||
=============
|
=============
|
||||||
|
|
||||||
|
.. image:: https://jazzband.co/static/img/badge.png
|
||||||
|
:target: https://jazzband.co/
|
||||||
|
:alt: Jazzband
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/pypi/pyversions/django-avatar.svg
|
||||||
|
:target: https://pypi.org/project/django-avatar/
|
||||||
|
:alt: Supported Python versions
|
||||||
|
|
||||||
|
.. image:: https://img.shields.io/pypi/djversions/django-avatar.svg
|
||||||
|
:target: https://pypi.org/project/django-avatar/
|
||||||
|
:alt: Supported Django versions
|
||||||
|
|
||||||
|
.. image:: https://github.com/jazzband/django-avatar/actions/workflows/test.yml/badge.svg
|
||||||
|
:target: https://github.com/jazzband/django-avatar/actions/workflows/test.yml
|
||||||
|
|
||||||
|
.. image:: https://codecov.io/gh/jazzband/django-avatar/branch/main/graph/badge.svg?token=BO1e4kkgtq
|
||||||
|
:target: https://codecov.io/gh/jazzband/django-avatar
|
||||||
|
|
||||||
.. image:: https://badge.fury.io/py/django-avatar.svg
|
.. image:: https://badge.fury.io/py/django-avatar.svg
|
||||||
:target: https://badge.fury.io/py/django-avatar
|
:target: https://badge.fury.io/py/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
|
||||||
|
|
||||||
.. image:: https://travis-ci.org/grantmcconnaughey/django-avatar.svg?branch=master
|
|
||||||
:target: https://travis-ci.org/grantmcconnaughey/django-avatar
|
|
||||||
:alt: Travis CI Build Status
|
|
||||||
|
|
||||||
.. image:: https://coveralls.io/repos/grantmcconnaughey/django-avatar/badge.svg?branch=master&service=github
|
|
||||||
:target: https://coveralls.io/github/grantmcconnaughey/django-avatar?branch=master
|
|
||||||
:alt: Coverage
|
|
||||||
|
|
||||||
.. image:: https://lintly.com/gh/grantmcconnaughey/django-avatar/badge.svg
|
|
||||||
:target: https://lintly.com/gh/grantmcconnaughey/django-avatar/
|
|
||||||
:alt: Lintly
|
|
||||||
|
|
||||||
Django-avatar is a reusable application for handling user avatars. It has the
|
Django-avatar is a reusable application for handling user avatars. It has the
|
||||||
ability to default to Gravatar if no avatar is found for a certain user.
|
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/
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__version__ = '4.0.0'
|
__version__ = "9.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.translation import ugettext_lazy as _
|
|
||||||
from django.utils import six
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from avatar.models import Avatar
|
from avatar.models import Avatar
|
||||||
from avatar.signals import avatar_updated
|
from avatar.signals import avatar_updated
|
||||||
|
|
@ -9,25 +8,28 @@ 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",)
|
||||||
search_fields = ('user__%s' % getattr(get_user_model(), 'USERNAME_FIELD', 'username'),)
|
autocomplete_fields = ("user",)
|
||||||
|
search_fields = (
|
||||||
|
"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': six.text_type(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")
|
||||||
get_avatar.allow_tags = True
|
get_avatar.allow_tags = True
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
super(AvatarAdmin, self).save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
avatar_updated.send(sender=Avatar, user=request.user, avatar=obj)
|
avatar_updated.send(sender=Avatar, user=request.user, avatar=obj)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
0
avatar/api/__init__.py
Normal file
0
avatar/api/__init__.py
Normal file
22
avatar/api/apps.py
Normal file
22
avatar/api/apps.py
Normal 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
6
avatar/api/conf.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from appconf import AppConf
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarAPIConf(AppConf):
|
||||||
|
# allow updating avatar image in put method
|
||||||
|
AVATAR_CHANGE_IMAGE = False
|
||||||
0
avatar/api/migrations/__init__.py
Normal file
0
avatar/api/migrations/__init__.py
Normal file
1
avatar/api/requirements.txt
Normal file
1
avatar/api/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
djangorestframework
|
||||||
126
avatar/api/serializers.py
Normal file
126
avatar/api/serializers.py
Normal 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
27
avatar/api/shortcut.py
Normal 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
48
avatar/api/signals.py
Normal 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
8
avatar/api/urls.py
Normal 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
52
avatar/api/utils.py
Normal 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
134
avatar/api/views.py
Normal 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)
|
||||||
6
avatar/apps.py
Normal file
6
avatar/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class Config(AppConfig):
|
||||||
|
name = "avatar"
|
||||||
|
default_auto_field = "django.db.models.AutoField"
|
||||||
|
|
@ -1,43 +1,48 @@
|
||||||
|
from appconf import AppConf
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from appconf import AppConf
|
|
||||||
|
|
||||||
|
|
||||||
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/"
|
||||||
GRAVATAR_FIELD = 'email'
|
GRAVATAR_FIELD = "email"
|
||||||
GRAVATAR_DEFAULT = None
|
GRAVATAR_DEFAULT = None
|
||||||
AVATAR_GRAVATAR_FORCEDEFAULT = False
|
AVATAR_GRAVATAR_FORCEDEFAULT = False
|
||||||
DEFAULT_URL = 'avatar/img/default.jpg'
|
DEFAULT_URL = "avatar/img/default.jpg"
|
||||||
MAX_AVATARS_PER_USER = 42
|
MAX_AVATARS_PER_USER = 42
|
||||||
MAX_SIZE = 1024 * 1024
|
MAX_SIZE = 1024 * 1024
|
||||||
THUMB_FORMAT = 'JPEG'
|
THUMB_FORMAT = "PNG"
|
||||||
THUMB_QUALITY = 85
|
THUMB_QUALITY = 85
|
||||||
|
THUMB_MODES = ("RGB", "RGBA")
|
||||||
HASH_FILENAMES = False
|
HASH_FILENAMES = False
|
||||||
HASH_USERDIRNAMES = False
|
HASH_USERDIRNAMES = False
|
||||||
EXPOSE_USERNAMES = True
|
EXPOSE_USERNAMES = False
|
||||||
ALLOWED_FILE_EXTS = None
|
ALLOWED_FILE_EXTS = None
|
||||||
|
ALLOWED_MIMETYPES = None
|
||||||
CACHE_TIMEOUT = 60 * 60
|
CACHE_TIMEOUT = 60 * 60
|
||||||
STORAGE = settings.DEFAULT_FILE_STORAGE
|
if hasattr(settings, "DEFAULT_FILE_STORAGE"):
|
||||||
CLEANUP_DELETED = False
|
STORAGE = settings.DEFAULT_FILE_STORAGE # deprecated settings
|
||||||
|
STORAGE_ALIAS = "default"
|
||||||
|
CLEANUP_DELETED = True
|
||||||
AUTO_GENERATE_SIZES = (DEFAULT_SIZE,)
|
AUTO_GENERATE_SIZES = (DEFAULT_SIZE,)
|
||||||
FACEBOOK_GET_ID = None
|
FACEBOOK_GET_ID = None
|
||||||
CACHE_ENABLED = True
|
CACHE_ENABLED = True
|
||||||
RANDOMIZE_HASHES = False
|
RANDOMIZE_HASHES = False
|
||||||
ADD_TEMPLATE = ''
|
ADD_TEMPLATE = ""
|
||||||
CHANGE_TEMPLATE = ''
|
CHANGE_TEMPLATE = ""
|
||||||
DELETE_TEMPLATE = ''
|
DELETE_TEMPLATE = ""
|
||||||
PROVIDERS = (
|
PROVIDERS = (
|
||||||
'avatar.providers.PrimaryAvatarProvider',
|
"avatar.providers.PrimaryAvatarProvider",
|
||||||
'avatar.providers.GravatarAvatarProvider',
|
"avatar.providers.LibRAvatarProvider",
|
||||||
'avatar.providers.DefaultAvatarProvider',
|
"avatar.providers.GravatarAvatarProvider",
|
||||||
|
"avatar.providers.DefaultAvatarProvider",
|
||||||
)
|
)
|
||||||
|
|
||||||
def configure_auto_generate_avatar_sizes(self, value):
|
def configure_auto_generate_avatar_sizes(self, value):
|
||||||
return value or getattr(settings, 'AVATAR_AUTO_GENERATE_SIZES',
|
return value or getattr(
|
||||||
(self.DEFAULT_SIZE,))
|
settings, "AVATAR_AUTO_GENERATE_SIZES", (self.DEFAULT_SIZE,)
|
||||||
|
)
|
||||||
|
|
|
||||||
146
avatar/forms.py
146
avatar/forms.py
|
|
@ -2,85 +2,131 @@ import os
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import widgets
|
from django.forms import widgets
|
||||||
from django.utils import six
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
|
||||||
from django.template.defaultfilters import filesizeformat
|
from django.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.conf import settings
|
||||||
from avatar.models import Avatar
|
from avatar.models import Avatar
|
||||||
|
|
||||||
|
|
||||||
def avatar_img(avatar, size):
|
def avatar_img(avatar, width, height):
|
||||||
if not avatar.thumbnail_exists(size):
|
if not avatar.thumbnail_exists(width, height):
|
||||||
avatar.create_thumbnail(size)
|
avatar.create_thumbnail(width, height)
|
||||||
return mark_safe('<img src="%s" alt="%s" width="%s" height="%s" />' %
|
return mark_safe(
|
||||||
(avatar.avatar_url(size), six.text_type(avatar),
|
'<img src="%s" alt="%s" width="%s" height="%s" />'
|
||||||
size, size))
|
% (avatar.avatar_url(width, height), str(avatar), width, height)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UploadAvatarForm(forms.Form):
|
class UploadAvatarForm(forms.Form):
|
||||||
|
|
||||||
avatar = forms.ImageField(label=_("avatar"))
|
avatar = forms.ImageField(label=_("avatar"))
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.user = kwargs.pop('user')
|
self.user = kwargs.pop("user")
|
||||||
super(UploadAvatarForm, self).__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def clean_avatar(self):
|
def clean_avatar(self):
|
||||||
data = self.cleaned_data['avatar']
|
data = self.cleaned_data["avatar"]
|
||||||
|
|
||||||
|
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 forms.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:
|
if settings.AVATAR_ALLOWED_FILE_EXTS:
|
||||||
root, ext = os.path.splitext(data.name.lower())
|
root, ext = os.path.splitext(data.name.lower())
|
||||||
if ext not in settings.AVATAR_ALLOWED_FILE_EXTS:
|
if ext not in settings.AVATAR_ALLOWED_FILE_EXTS:
|
||||||
valid_exts = ", ".join(settings.AVATAR_ALLOWED_FILE_EXTS)
|
valid_exts = ", ".join(settings.AVATAR_ALLOWED_FILE_EXTS)
|
||||||
error = _("%(ext)s is an invalid file extension. "
|
error = _(
|
||||||
"Authorized extensions are : %(valid_exts_list)s")
|
"%(ext)s is an invalid file extension. "
|
||||||
raise forms.ValidationError(error %
|
"Authorized extensions are : %(valid_exts_list)s"
|
||||||
{'ext': ext,
|
)
|
||||||
'valid_exts_list': valid_exts})
|
raise forms.ValidationError(
|
||||||
|
error % {"ext": ext, "valid_exts_list": valid_exts}
|
||||||
|
)
|
||||||
|
|
||||||
if data.size > settings.AVATAR_MAX_SIZE:
|
if data.size > settings.AVATAR_MAX_SIZE:
|
||||||
error = _("Your file is too big (%(size)s), "
|
error = _(
|
||||||
"the maximum allowed size is %(max_valid_size)s")
|
"Your file is too big (%(size)s), "
|
||||||
raise forms.ValidationError(error % {
|
"the maximum allowed size is %(max_valid_size)s"
|
||||||
'size': filesizeformat(data.size),
|
)
|
||||||
'max_valid_size': filesizeformat(settings.AVATAR_MAX_SIZE)
|
raise forms.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 forms.ValidationError(_("Corrupted image"))
|
||||||
|
|
||||||
count = Avatar.objects.filter(user=self.user).count()
|
count = Avatar.objects.filter(user=self.user).count()
|
||||||
if (settings.AVATAR_MAX_AVATARS_PER_USER > 1 and
|
if 1 < settings.AVATAR_MAX_AVATARS_PER_USER <= count:
|
||||||
count >= settings.AVATAR_MAX_AVATARS_PER_USER):
|
error = _(
|
||||||
error = _("You already have %(nb_avatars)d avatars, "
|
"You already have %(nb_avatars)d avatars, "
|
||||||
"and the maximum allowed is %(nb_max_avatars)d.")
|
"and the maximum allowed is %(nb_max_avatars)d."
|
||||||
raise forms.ValidationError(error % {
|
)
|
||||||
'nb_avatars': count,
|
raise forms.ValidationError(
|
||||||
'nb_max_avatars': settings.AVATAR_MAX_AVATARS_PER_USER,
|
error
|
||||||
})
|
% {
|
||||||
|
"nb_avatars": count,
|
||||||
|
"nb_max_avatars": settings.AVATAR_MAX_AVATARS_PER_USER,
|
||||||
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
class PrimaryAvatarForm(forms.Form):
|
class PrimaryAvatarForm(forms.Form):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs.pop('user')
|
kwargs.pop("user")
|
||||||
size = kwargs.pop('size', settings.AVATAR_DEFAULT_SIZE)
|
width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
|
||||||
avatars = kwargs.pop('avatars')
|
height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
|
||||||
super(PrimaryAvatarForm, self).__init__(*args, **kwargs)
|
avatars = kwargs.pop("avatars")
|
||||||
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['choice'] = forms.ChoiceField(label=_("Choices"),
|
self.fields["choice"] = forms.ChoiceField(
|
||||||
choices=choices,
|
choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
|
||||||
widget=widgets.RadioSelect)
|
widget=widgets.RadioSelect,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeleteAvatarForm(forms.Form):
|
class DeleteAvatarForm(forms.Form):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
kwargs.pop('user')
|
kwargs.pop("user")
|
||||||
size = kwargs.pop('size', settings.AVATAR_DEFAULT_SIZE)
|
width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
|
||||||
avatars = kwargs.pop('avatars')
|
height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
|
||||||
super(DeleteAvatarForm, self).__init__(*args, **kwargs)
|
avatars = kwargs.pop("avatars")
|
||||||
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['choices'] = forms.MultipleChoiceField(label=_("Choices"),
|
self.fields["choices"] = forms.MultipleChoiceField(
|
||||||
choices=choices,
|
label=_("Choices"),
|
||||||
widget=widgets.CheckboxSelectMultiple)
|
choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
|
||||||
|
widget=widgets.CheckboxSelectMultiple,
|
||||||
|
)
|
||||||
|
|
|
||||||
BIN
avatar/locale/fa/LC_MESSAGES/django.mo
Normal file
BIN
avatar/locale/fa/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
160
avatar/locale/fa/LC_MESSAGES/django.po
Normal file
160
avatar/locale/fa/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# SOME DESCRIPTIVE TITLE.
|
||||||
|
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||||
|
# This file is distributed under the same license as the PACKAGE package.
|
||||||
|
# Mahdi Firouzjaah<mh.firouzjah@gmai.com>, 2020.
|
||||||
|
#
|
||||||
|
#, fuzzy
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2020-12-16 20:12:00.982404\n"
|
||||||
|
"PO-Revision-Date: 2020-12-16 20:12:00.982404\n"
|
||||||
|
"Last-Translator: Mahdi Firouzjaah<mh.firouzjah@gmai.com>\n"
|
||||||
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
|
"Language: fa\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||||
|
|
||||||
|
#: admin.py:30
|
||||||
|
msgid "Avatar"
|
||||||
|
msgstr "آواتار"
|
||||||
|
|
||||||
|
#: forms.py:28 models.py:105 models.py:114
|
||||||
|
msgid "avatar"
|
||||||
|
msgstr "آواتار"
|
||||||
|
|
||||||
|
#: forms.py:41
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"%(ext)s is an invalid file extension. Authorized extensions are : "
|
||||||
|
"%(valid_exts_list)s"
|
||||||
|
msgstr ""
|
||||||
|
"%(ext)s یک فایل با پسوند نامناسب است. پسوندهای مناسب اینها هستند:"
|
||||||
|
"%(valid_exts_list)s"
|
||||||
|
|
||||||
|
#: forms.py:48
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Your file is too big (%(size)s), the maximum allowed size is "
|
||||||
|
"%(max_valid_size)s"
|
||||||
|
msgstr ""
|
||||||
|
"فایلی که فرستادید بیش از حد مجاز بزرگ است(%(size)s). حداکثر مجاز این است:"
|
||||||
|
"%(max_valid_size)s"
|
||||||
|
|
||||||
|
#: forms.py:56
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"You already have %(nb_avatars)d avatars, and the maximum allowed is "
|
||||||
|
"%(nb_max_avatars)d."
|
||||||
|
msgstr ""
|
||||||
|
"شما هماکنون %(nb_avatars)d تعداد آواتار دارید و حداکثر مجاز"
|
||||||
|
"%(nb_max_avatars)d تا است."
|
||||||
|
|
||||||
|
|
||||||
|
#: forms.py:73 forms.py:86
|
||||||
|
msgid "Choices"
|
||||||
|
msgstr "انتخابها"
|
||||||
|
|
||||||
|
#: models.py:98
|
||||||
|
msgid "user"
|
||||||
|
msgstr "کاربر"
|
||||||
|
|
||||||
|
#: models.py:101
|
||||||
|
msgid "primary"
|
||||||
|
msgstr "اصلی"
|
||||||
|
|
||||||
|
#: models.py:108
|
||||||
|
msgid "uploaded at"
|
||||||
|
msgstr "بارگزاری شده در"
|
||||||
|
|
||||||
|
#: models.py:115
|
||||||
|
msgid "avatars"
|
||||||
|
msgstr "آواتارها"
|
||||||
|
|
||||||
|
#: templates/avatar/add.html:5 templates/avatar/change.html:5
|
||||||
|
msgid "Your current avatar: "
|
||||||
|
msgstr "آواتار فعلی شما: "
|
||||||
|
|
||||||
|
#: templates/avatar/add.html:8 templates/avatar/change.html:8
|
||||||
|
msgid "You haven't uploaded an avatar yet. Please upload one now."
|
||||||
|
msgstr "شما تاکنون آواتاری بارگزاری نکردهاید، لطفا یکی بارگزاری کنید."
|
||||||
|
|
||||||
|
#: templates/avatar/add.html:12 templates/avatar/change.html:19
|
||||||
|
msgid "Upload New Image"
|
||||||
|
msgstr "بارگزاری عکس جدید"
|
||||||
|
|
||||||
|
#: templates/avatar/change.html:14
|
||||||
|
msgid "Choose new Default"
|
||||||
|
msgstr "انتخاب یکی به عنوان پیشفرض"
|
||||||
|
|
||||||
|
#: templates/avatar/confirm_delete.html:5
|
||||||
|
msgid "Please select the avatars that you would like to delete."
|
||||||
|
msgstr "لطفا آواتاری که مایلید حذف شود، انتخاب کنید."
|
||||||
|
|
||||||
|
#: templates/avatar/confirm_delete.html:8
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"You have no avatars to delete. Please <a href=\"%(avatar_change_url)s"
|
||||||
|
"\">upload one</a> now."
|
||||||
|
msgstr ""
|
||||||
|
"شما آواتاری برای حذف کردن ندارید؛"
|
||||||
|
"لطفا یکی <a href=\"%(avatar_change_url)s"
|
||||||
|
"\">بارگزاری</a> کنید."
|
||||||
|
|
||||||
|
#: templates/avatar/confirm_delete.html:14
|
||||||
|
msgid "Delete These"
|
||||||
|
msgstr "این(ها) را حذف کن"
|
||||||
|
|
||||||
|
#: templates/notification/avatar_friend_updated/full.txt:1
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"%(avatar_creator)s has updated their avatar %(avatar)s.\n"
|
||||||
|
"\n"
|
||||||
|
"http://%(current_site)s%(avatar_url)s\n"
|
||||||
|
msgstr ""
|
||||||
|
"%(avatar_creator)s آواتار خود را بروزرسانی کردند %(avatar)s.\n\n"
|
||||||
|
"http://%(current_site)s%(avatar_url)s\n"
|
||||||
|
|
||||||
|
#: templates/notification/avatar_friend_updated/notice.html:2
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"<a href=\"%(user_url)s\">%(avatar_creator)s</a> has updated their avatar <a "
|
||||||
|
"href=\"%(avatar_url)s\">%(avatar)s</a>."
|
||||||
|
msgstr ""
|
||||||
|
"<a href=\"%(user_url)s\">%(avatar_creator)s</a> آواتار خود را بروزرسانی کردند."
|
||||||
|
"<a href=\"%(avatar_url)s\">%(avatar)s</a>."
|
||||||
|
|
||||||
|
|
||||||
|
#: templates/notification/avatar_updated/full.txt:1
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"Your avatar has been updated. %(avatar)s\n"
|
||||||
|
"\n"
|
||||||
|
"http://%(current_site)s%(avatar_url)s\n"
|
||||||
|
msgstr ""
|
||||||
|
"آواتار شما بروزرسانی شد. %(avatar)s\n\n"
|
||||||
|
"http://%(current_site)s%(avatar_url)s\n"
|
||||||
|
|
||||||
|
#: templates/notification/avatar_updated/notice.html:2
|
||||||
|
#, python-format
|
||||||
|
msgid "You have updated your avatar <a href=\"%(avatar_url)s\">%(avatar)s</a>."
|
||||||
|
msgstr "شما آواتار خود را بروزرسانی کردید. <a href=\"%(avatar_url)s\">%(avatar)s</a>."
|
||||||
|
|
||||||
|
#: templatetags/avatar_tags.py:49
|
||||||
|
msgid "Default Avatar"
|
||||||
|
msgstr "آواتار پیشفرض"
|
||||||
|
|
||||||
|
#: views.py:76
|
||||||
|
msgid "Successfully uploaded a new avatar."
|
||||||
|
msgstr "آواتار جدید با موفقیت بارگزاری شد."
|
||||||
|
|
||||||
|
#: views.py:114
|
||||||
|
msgid "Successfully updated your avatar."
|
||||||
|
msgstr "آواتار شما با موفقیت بروزرسانی شد."
|
||||||
|
|
||||||
|
#: views.py:157
|
||||||
|
msgid "Successfully deleted the requested avatars."
|
||||||
|
msgstr "آواتارهای مدنظر با موفقیت حذف شدند."
|
||||||
Binary file not shown.
|
|
@ -117,8 +117,9 @@ msgid ""
|
||||||
"\n"
|
"\n"
|
||||||
"http://%(current_site)s%(avatar_url)s\n"
|
"http://%(current_site)s%(avatar_url)s\n"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"<a href=\"%(user_url)s\">%(avatar_creator)s</a> a mis à jour son avatar <a "
|
"%(avatar_creator)s a mis à jour son avatar %(avatar)s\n"
|
||||||
"href=\"%(avatar_url)s\">%(avatar)s</a>."
|
"\n"
|
||||||
|
"http://%(current_site)s%(avatar_url)s\n"
|
||||||
|
|
||||||
#: templates/notification/avatar_friend_updated/notice.html:2
|
#: templates/notification/avatar_friend_updated/notice.html:2
|
||||||
#, python-format
|
#, python-format
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -121,8 +121,9 @@ msgid ""
|
||||||
"\n"
|
"\n"
|
||||||
"http://%(current_site)s%(avatar_url)s\n"
|
"http://%(current_site)s%(avatar_url)s\n"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"<a href=\"%(user_url)s\">%(avatar_creator)s</a> обновил свои аватары <a href="
|
"%(avatar_creator)s обновил свои аватары %(avatar)s.\n"
|
||||||
"\"%(avatar_url)s\">%(avatar)s</a>."
|
"\n"
|
||||||
|
"http://%(current_site)s%(avatar_url)s\n"
|
||||||
|
|
||||||
#: templates/notification/avatar_friend_updated/notice.html:2
|
#: templates/notification/avatar_friend_updated/notice.html:2
|
||||||
#, python-format
|
#, python-format
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from avatar.conf import settings
|
from avatar.conf import settings
|
||||||
from avatar.models import Avatar
|
from avatar.models import Avatar, remove_avatar_images
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = ("Regenerates avatar thumbnails for the sizes specified in "
|
help = (
|
||||||
"settings.AVATAR_AUTO_GENERATE_SIZES.")
|
"Regenerates avatar thumbnails for the sizes specified in "
|
||||||
|
"settings.AVATAR_AUTO_GENERATE_SIZES."
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
for avatar in Avatar.objects.all():
|
for avatar in Avatar.objects.all():
|
||||||
|
if settings.AVATAR_CLEANUP_DELETED:
|
||||||
|
remove_avatar_images(avatar, delete_main_avatar=False)
|
||||||
for size in settings.AVATAR_AUTO_GENERATE_SIZES:
|
for size in settings.AVATAR_AUTO_GENERATE_SIZES:
|
||||||
if options['verbosity'] != 0:
|
if options["verbosity"] != 0:
|
||||||
print("Rebuilding Avatar id=%s at size %s." % (avatar.id, size))
|
self.stdout.write(
|
||||||
|
"Rebuilding Avatar id=%s at size %s." % (avatar.id, size)
|
||||||
avatar.create_thumbnail(size)
|
)
|
||||||
|
if isinstance(size, int):
|
||||||
|
avatar.create_thumbnail(size, size)
|
||||||
|
else:
|
||||||
|
# Size is specified with height and width.
|
||||||
|
avatar.create_thumbnail(size[0], size[1])
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,49 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db import models, migrations
|
|
||||||
import django.utils.timezone
|
|
||||||
import avatar.models
|
|
||||||
import django.core.files.storage
|
import django.core.files.storage
|
||||||
|
import django.utils.timezone
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
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),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Avatar',
|
name="Avatar",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
(
|
||||||
('primary', models.BooleanField(default=False)),
|
"id",
|
||||||
('avatar', models.ImageField(storage=django.core.files.storage.FileSystemStorage(), max_length=1024, upload_to=avatar.models.avatar_file_path, blank=True)),
|
models.AutoField(
|
||||||
('date_uploaded', models.DateTimeField(default=django.utils.timezone.now)),
|
verbose_name="ID",
|
||||||
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
|
serialize=False,
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("primary", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"avatar",
|
||||||
|
models.ImageField(
|
||||||
|
storage=django.core.files.storage.FileSystemStorage(),
|
||||||
|
max_length=1024,
|
||||||
|
upload_to=avatar.models.avatar_file_path,
|
||||||
|
blank=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"date_uploaded",
|
||||||
|
models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,52 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Generated by Django 1.10.1 on 2016-09-16 08:50
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import avatar.models
|
|
||||||
from django.conf import settings
|
|
||||||
import django.core.files.storage
|
import django.core.files.storage
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import avatar.models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('avatar', '0001_initial'),
|
("avatar", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name='avatar',
|
name="avatar",
|
||||||
options={'verbose_name': 'avatar', 'verbose_name_plural': 'avatars'},
|
options={"verbose_name": "avatar", "verbose_name_plural": "avatars"},
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='avatar',
|
model_name="avatar",
|
||||||
name='avatar',
|
name="avatar",
|
||||||
field=models.ImageField(blank=True, max_length=1024, storage=django.core.files.storage.FileSystemStorage(), upload_to=avatar.models.avatar_path_handler, verbose_name='avatar'),
|
field=models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
max_length=1024,
|
||||||
|
storage=django.core.files.storage.FileSystemStorage(),
|
||||||
|
upload_to=avatar.models.avatar_path_handler,
|
||||||
|
verbose_name="avatar",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='avatar',
|
model_name="avatar",
|
||||||
name='date_uploaded',
|
name="date_uploaded",
|
||||||
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='uploaded at'),
|
field=models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now, verbose_name="uploaded at"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='avatar',
|
model_name="avatar",
|
||||||
name='primary',
|
name="primary",
|
||||||
field=models.BooleanField(default=False, verbose_name='primary'),
|
field=models.BooleanField(default=False, verbose_name="primary"),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='avatar',
|
model_name="avatar",
|
||||||
name='user',
|
name="user",
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user'),
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="user",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
17
avatar/migrations/0003_auto_20170827_1345.py
Normal file
17
avatar/migrations/0003_auto_20170827_1345.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
import avatar.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("avatar", "0002_add_verbose_names_to_avatar_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="avatar",
|
||||||
|
name="avatar",
|
||||||
|
field=avatar.models.AvatarField(),
|
||||||
|
),
|
||||||
|
]
|
||||||
216
avatar/models.py
216
avatar/models.py
|
|
@ -1,32 +1,35 @@
|
||||||
import binascii
|
import binascii
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from PIL import Image
|
import os
|
||||||
|
from contextlib import closing
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
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.utils.module_loading import import_string
|
|
||||||
from django.utils.translation import ugettext as _
|
|
||||||
from django.utils.encoding import force_text
|
|
||||||
from django.utils import six
|
|
||||||
from django.db.models import signals
|
from django.db.models import signals
|
||||||
|
from django.utils.encoding import force_bytes, force_str
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from avatar.conf import settings
|
from avatar.conf import settings
|
||||||
from avatar.utils import get_username, force_bytes, invalidate_cache
|
from avatar.utils import get_username, invalidate_cache
|
||||||
|
|
||||||
try:
|
try: # Django 4.2+
|
||||||
from django.utils.timezone import now
|
from django.core.files.storage import storages
|
||||||
|
|
||||||
|
avatar_storage = storages[settings.AVATAR_STORAGE_ALIAS]
|
||||||
except ImportError:
|
except ImportError:
|
||||||
now = datetime.datetime.now
|
from django.core.files.storage import get_storage_class
|
||||||
|
|
||||||
|
avatar_storage = get_storage_class(settings.AVATAR_STORAGE)()
|
||||||
|
|
||||||
|
|
||||||
avatar_storage = get_storage_class(settings.AVATAR_STORAGE)()
|
def avatar_path_handler(
|
||||||
|
instance=None, filename=None, width=None, height=None, ext=None
|
||||||
|
):
|
||||||
def avatar_path_handler(instance=None, filename=None, size=None, ext=None):
|
|
||||||
tmppath = [settings.AVATAR_STORAGE_DIR]
|
tmppath = [settings.AVATAR_STORAGE_DIR]
|
||||||
if settings.AVATAR_HASH_USERDIRNAMES:
|
if settings.AVATAR_HASH_USERDIRNAMES:
|
||||||
tmp = hashlib.md5(force_bytes(get_username(instance.user))).hexdigest()
|
tmp = hashlib.md5(force_bytes(get_username(instance.user))).hexdigest()
|
||||||
|
|
@ -34,28 +37,27 @@ def avatar_path_handler(instance=None, filename=None, size=None, ext=None):
|
||||||
if settings.AVATAR_EXPOSE_USERNAMES:
|
if settings.AVATAR_EXPOSE_USERNAMES:
|
||||||
tmppath.append(get_username(instance.user))
|
tmppath.append(get_username(instance.user))
|
||||||
else:
|
else:
|
||||||
tmppath.append(force_text(instance.user.pk))
|
tmppath.append(force_str(instance.user.pk))
|
||||||
if not filename:
|
if not filename:
|
||||||
# Filename already stored in database
|
# Filename already stored in database
|
||||||
filename = instance.avatar.name
|
filename = instance.avatar.name
|
||||||
if ext and settings.AVATAR_HASH_FILENAMES:
|
if ext:
|
||||||
# An extension was provided, probably because the thumbnail
|
|
||||||
# is in a different format than the file. Use it. Because it's
|
|
||||||
# only enabled if AVATAR_HASH_FILENAMES is true, we can trust
|
|
||||||
# it won't conflict with another filename
|
|
||||||
(root, oldext) = os.path.splitext(filename)
|
(root, oldext) = os.path.splitext(filename)
|
||||||
filename = root + "." + ext
|
filename = root + "." + ext.lower()
|
||||||
else:
|
else:
|
||||||
# File doesn't exist yet
|
# File doesn't exist yet
|
||||||
|
(root, oldext) = os.path.splitext(filename)
|
||||||
if settings.AVATAR_HASH_FILENAMES:
|
if settings.AVATAR_HASH_FILENAMES:
|
||||||
(root, ext) = os.path.splitext(filename)
|
|
||||||
if settings.AVATAR_RANDOMIZE_HASHES:
|
if settings.AVATAR_RANDOMIZE_HASHES:
|
||||||
filename = binascii.hexlify(os.urandom(16)).decode('ascii')
|
root = binascii.hexlify(os.urandom(16)).decode("ascii")
|
||||||
else:
|
else:
|
||||||
filename = hashlib.md5(force_bytes(filename)).hexdigest()
|
root = hashlib.md5(force_bytes(root)).hexdigest()
|
||||||
filename = filename + ext
|
if ext:
|
||||||
if size:
|
filename = root + "." + ext.lower()
|
||||||
tmppath.extend(['resized', str(size)])
|
else:
|
||||||
|
filename = root + oldext.lower()
|
||||||
|
if width or height:
|
||||||
|
tmppath.extend(["resized", str(width), str(height)])
|
||||||
tmppath.append(os.path.basename(filename))
|
tmppath.append(os.path.basename(filename))
|
||||||
return os.path.join(*tmppath)
|
return os.path.join(*tmppath)
|
||||||
|
|
||||||
|
|
@ -66,40 +68,49 @@ avatar_file_path = import_string(settings.AVATAR_PATH_HANDLER)
|
||||||
def find_extension(format):
|
def find_extension(format):
|
||||||
format = format.lower()
|
format = format.lower()
|
||||||
|
|
||||||
if format == 'jpeg':
|
if format == "jpeg":
|
||||||
format = 'jpg'
|
format = "jpg"
|
||||||
|
|
||||||
return format
|
return format
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarField(models.ImageField):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.max_length = 1024
|
||||||
|
self.upload_to = avatar_file_path
|
||||||
|
self.storage = avatar_storage
|
||||||
|
self.blank = True
|
||||||
|
|
||||||
|
def deconstruct(self):
|
||||||
|
name, path, args, kwargs = super().deconstruct()
|
||||||
|
return name, path, (), {}
|
||||||
|
|
||||||
|
|
||||||
class Avatar(models.Model):
|
class Avatar(models.Model):
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
getattr(settings, 'AUTH_USER_MODEL', 'auth.User'),
|
getattr(settings, "AUTH_USER_MODEL", "auth.User"),
|
||||||
verbose_name=_("user"),
|
verbose_name=_("user"),
|
||||||
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
primary = models.BooleanField(
|
primary = models.BooleanField(
|
||||||
verbose_name=_("primary"),
|
verbose_name=_("primary"),
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
avatar = models.ImageField(
|
avatar = AvatarField(verbose_name=_("avatar"))
|
||||||
verbose_name=_("avatar"),
|
|
||||||
max_length=1024,
|
|
||||||
upload_to=avatar_file_path,
|
|
||||||
storage=avatar_storage,
|
|
||||||
blank=True,
|
|
||||||
)
|
|
||||||
date_uploaded = models.DateTimeField(
|
date_uploaded = models.DateTimeField(
|
||||||
verbose_name=_("uploaded at"),
|
verbose_name=_("uploaded at"),
|
||||||
default=now,
|
default=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
app_label = 'avatar'
|
app_label = "avatar"
|
||||||
verbose_name = _('avatar')
|
verbose_name = _("avatar")
|
||||||
verbose_name_plural = _('avatars')
|
verbose_name_plural = _("avatars")
|
||||||
|
|
||||||
def __unicode__(self):
|
def __str__(self):
|
||||||
return _(six.u('Avatar for %s')) % self.user
|
return _("Avatar for %s") % self.user
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
avatars = Avatar.objects.filter(user=self.user)
|
avatars = Avatar.objects.filter(user=self.user)
|
||||||
|
|
@ -111,69 +122,104 @@ class Avatar(models.Model):
|
||||||
avatars.update(primary=False)
|
avatars.update(primary=False)
|
||||||
else:
|
else:
|
||||||
avatars.delete()
|
avatars.delete()
|
||||||
super(Avatar, self).save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def thumbnail_exists(self, size):
|
def thumbnail_exists(self, width, height=None):
|
||||||
return self.avatar.storage.exists(self.avatar_name(size))
|
return self.avatar.storage.exists(self.avatar_name(width, height))
|
||||||
|
|
||||||
def create_thumbnail(self, size, quality=None):
|
def transpose_image(self, image):
|
||||||
|
EXIF_ORIENTATION = 0x0112
|
||||||
|
exif_code = image.getexif().get(EXIF_ORIENTATION, 1)
|
||||||
|
if exif_code and exif_code != 1:
|
||||||
|
image = ImageOps.exif_transpose(image)
|
||||||
|
return image
|
||||||
|
|
||||||
|
def create_thumbnail(self, width, height=None, quality=None):
|
||||||
|
if height is None:
|
||||||
|
height = width
|
||||||
# invalidate the cache of the thumbnail with the given size first
|
# invalidate the cache of the thumbnail with the given size first
|
||||||
invalidate_cache(self.user, size)
|
invalidate_cache(self.user, width, height)
|
||||||
try:
|
try:
|
||||||
orig = self.avatar.storage.open(self.avatar.name, 'rb')
|
orig = self.avatar.storage.open(self.avatar.name, "rb")
|
||||||
image = Image.open(orig)
|
|
||||||
quality = quality or settings.AVATAR_THUMB_QUALITY
|
|
||||||
w, h = image.size
|
|
||||||
if w != size or h != size:
|
|
||||||
if w > h:
|
|
||||||
diff = int((w - h) / 2)
|
|
||||||
image = image.crop((diff, 0, w - diff, h))
|
|
||||||
else:
|
|
||||||
diff = int((h - w) / 2)
|
|
||||||
image = image.crop((0, diff, w, h - diff))
|
|
||||||
if image.mode not in ("RGB", "RGBA"):
|
|
||||||
image = image.convert("RGB")
|
|
||||||
image = image.resize((size, size), settings.AVATAR_RESIZE_METHOD)
|
|
||||||
thumb = six.BytesIO()
|
|
||||||
image.save(thumb, settings.AVATAR_THUMB_FORMAT, quality=quality)
|
|
||||||
thumb_file = ContentFile(thumb.getvalue())
|
|
||||||
else:
|
|
||||||
thumb_file = File(orig)
|
|
||||||
thumb = self.avatar.storage.save(self.avatar_name(size), thumb_file)
|
|
||||||
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?
|
||||||
|
|
||||||
def avatar_url(self, size):
|
with closing(orig):
|
||||||
return self.avatar.storage.url(self.avatar_name(size))
|
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)
|
||||||
|
invalidate_cache(self.user, width, height)
|
||||||
|
|
||||||
|
def avatar_url(self, width, height=None):
|
||||||
|
return self.avatar.storage.url(self.avatar_name(width, height))
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return self.avatar_url(settings.AVATAR_DEFAULT_SIZE)
|
return self.avatar_url(settings.AVATAR_DEFAULT_SIZE)
|
||||||
|
|
||||||
def avatar_name(self, size):
|
def avatar_name(self, width, height=None):
|
||||||
|
if height is None:
|
||||||
|
height = width
|
||||||
ext = find_extension(settings.AVATAR_THUMB_FORMAT)
|
ext = find_extension(settings.AVATAR_THUMB_FORMAT)
|
||||||
return avatar_file_path(
|
return avatar_file_path(instance=self, width=width, height=height, ext=ext)
|
||||||
instance=self,
|
|
||||||
size=size,
|
|
||||||
ext=ext
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def invalidate_avatar_cache(sender, instance, **kwargs):
|
def invalidate_avatar_cache(sender, instance, **kwargs):
|
||||||
invalidate_cache(instance.user)
|
if hasattr(instance, "user"):
|
||||||
|
invalidate_cache(instance.user)
|
||||||
|
|
||||||
|
|
||||||
def create_default_thumbnails(sender, instance, created=False, **kwargs):
|
def create_default_thumbnails(sender, instance, created=False, **kwargs):
|
||||||
invalidate_avatar_cache(sender, instance)
|
invalidate_avatar_cache(sender, instance)
|
||||||
if created:
|
if created:
|
||||||
for size in settings.AVATAR_AUTO_GENERATE_SIZES:
|
for size in settings.AVATAR_AUTO_GENERATE_SIZES:
|
||||||
instance.create_thumbnail(size)
|
if isinstance(size, int):
|
||||||
|
instance.create_thumbnail(size, size)
|
||||||
|
else:
|
||||||
|
# Size is specified with height and width.
|
||||||
|
instance.create_thumbnail(size[0], size[1])
|
||||||
|
|
||||||
|
|
||||||
def remove_avatar_images(instance=None, **kwargs):
|
def remove_avatar_images(instance=None, delete_main_avatar=True, **kwargs):
|
||||||
for size in settings.AVATAR_AUTO_GENERATE_SIZES:
|
base_filepath = instance.avatar.name
|
||||||
if instance.thumbnail_exists(size):
|
path, filename = os.path.split(base_filepath)
|
||||||
instance.avatar.storage.delete(instance.avatar_name(size))
|
# iterate through resized avatars directories and delete resized avatars
|
||||||
instance.avatar.storage.delete(instance.avatar.name)
|
resized_path = os.path.join(path, "resized")
|
||||||
|
resized_widths, _ = instance.avatar.storage.listdir(resized_path)
|
||||||
|
for width in resized_widths:
|
||||||
|
resized_width_path = os.path.join(resized_path, width)
|
||||||
|
resized_heights, _ = instance.avatar.storage.listdir(resized_width_path)
|
||||||
|
for height in resized_heights:
|
||||||
|
if instance.thumbnail_exists(width, height):
|
||||||
|
instance.avatar.storage.delete(instance.avatar_name(width, height))
|
||||||
|
if delete_main_avatar:
|
||||||
|
if instance.avatar.storage.exists(instance.avatar.name):
|
||||||
|
instance.avatar.storage.delete(instance.avatar.name)
|
||||||
|
|
||||||
|
|
||||||
signals.post_save.connect(create_default_thumbnails, sender=Avatar)
|
signals.post_save.connect(create_default_thumbnails, sender=Avatar)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,19 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlencode, urljoin
|
||||||
|
|
||||||
try:
|
import dns.resolver
|
||||||
from urllib.parse import urljoin, urlencode
|
from django.utils.module_loading import import_string
|
||||||
except ImportError:
|
|
||||||
from urlparse import urljoin
|
|
||||||
from urllib import urlencode
|
|
||||||
|
|
||||||
|
|
||||||
from avatar.conf import settings
|
from avatar.conf import settings
|
||||||
from avatar.utils import (
|
from avatar.utils import force_bytes, get_default_avatar_url, get_primary_avatar
|
||||||
force_bytes,
|
|
||||||
get_default_avatar_url,
|
|
||||||
get_primary_avatar,
|
|
||||||
)
|
|
||||||
|
|
||||||
from django.utils.module_loading import import_string
|
|
||||||
|
|
||||||
# If the FacebookAvatarProvider is used, a mechanism needs to be defined on
|
# If the FacebookAvatarProvider is used, a mechanism needs to be defined on
|
||||||
# how to obtain the user's Facebook UID. This is done via
|
# how to obtain the user's Facebook UID. This is done via
|
||||||
# ``AVATAR_FACEBOOK_GET_ID``.
|
# ``AVATAR_FACEBOOK_GET_ID``.
|
||||||
get_facebook_id = None
|
get_facebook_id = None
|
||||||
|
|
||||||
if 'avatar.providers.FacebookAvatarProvider' in settings.AVATAR_PROVIDERS:
|
if "avatar.providers.FacebookAvatarProvider" in settings.AVATAR_PROVIDERS:
|
||||||
if callable(settings.AVATAR_FACEBOOK_GET_ID):
|
if callable(settings.AVATAR_FACEBOOK_GET_ID):
|
||||||
get_facebook_id = settings.AVATAR_FACEBOOK_GET_ID
|
get_facebook_id = settings.AVATAR_FACEBOOK_GET_ID
|
||||||
else:
|
else:
|
||||||
|
|
@ -34,7 +26,7 @@ class DefaultAvatarProvider(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_avatar_url(self, user, size):
|
def get_avatar_url(cls, user, width, height=None):
|
||||||
return get_default_avatar_url()
|
return get_default_avatar_url()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,10 +36,12 @@ class PrimaryAvatarProvider(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_avatar_url(self, user, size):
|
def get_avatar_url(cls, user, width, height=None):
|
||||||
avatar = get_primary_avatar(user, size)
|
if not height:
|
||||||
|
height = width
|
||||||
|
avatar = get_primary_avatar(user, width, height)
|
||||||
if avatar:
|
if avatar:
|
||||||
return avatar.avatar_url(size)
|
return avatar.avatar_url(width, height)
|
||||||
|
|
||||||
|
|
||||||
class GravatarAvatarProvider(object):
|
class GravatarAvatarProvider(object):
|
||||||
|
|
@ -56,29 +50,88 @@ class GravatarAvatarProvider(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_avatar_url(self, user, size):
|
def get_avatar_url(cls, user, width, _height=None):
|
||||||
params = {'s': str(size)}
|
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
|
||||||
if settings.AVATAR_GRAVATAR_FORCEDEFAULT:
|
if settings.AVATAR_GRAVATAR_FORCEDEFAULT:
|
||||||
params['f'] = 'y'
|
params["f"] = "y"
|
||||||
path = "%s/?%s" % (hashlib.md5(force_bytes(getattr(user,
|
path = "%s/?%s" % (
|
||||||
settings.AVATAR_GRAVATAR_FIELD))).hexdigest(), urlencode(params))
|
hashlib.md5(
|
||||||
|
force_bytes(getattr(user, settings.AVATAR_GRAVATAR_FIELD))
|
||||||
|
).hexdigest(),
|
||||||
|
urlencode(params),
|
||||||
|
)
|
||||||
|
|
||||||
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(self, user, size):
|
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={size}&height={size}'
|
url = "https://graph.facebook.com/{fb_id}/picture?type=square&width={width}&height={height}"
|
||||||
return url.format(
|
return url.format(fb_id=fb_id, width=width, height=height)
|
||||||
fb_id=fb_id,
|
|
||||||
size=size
|
|
||||||
)
|
class InitialsAvatarProvider(object):
|
||||||
|
"""
|
||||||
|
Returns a tuple with template_name and context for rendering the given user's avatar as their
|
||||||
|
initials in white against a background with random hue based on their primary key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_avatar_url(cls, user, width, _height=None):
|
||||||
|
initials = user.first_name[:1] + user.last_name[:1]
|
||||||
|
if not initials:
|
||||||
|
initials = user.username[:1]
|
||||||
|
initials = initials.upper()
|
||||||
|
context = {
|
||||||
|
"fontsize": (width * 1.1) / 2,
|
||||||
|
"initials": initials,
|
||||||
|
"hue": user.pk % 360,
|
||||||
|
"saturation": "65%",
|
||||||
|
"lightness": "60%",
|
||||||
|
}
|
||||||
|
return ("avatar/initials.html", context)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import django.dispatch
|
import django.dispatch
|
||||||
|
|
||||||
|
avatar_updated = django.dispatch.Signal()
|
||||||
avatar_updated = django.dispatch.Signal(providing_args=["user", "avatar"])
|
avatar_deleted = django.dispatch.Signal()
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
<img src="{{ url }}" width="{{ size }}" height="{{ size }}" {% for key, value in kwargs.items %}{{key}}="{{value}}" {% endfor %}/>
|
<img src="{{ url }}" width="{{ width }}" height="{{ height }}" {% for key, value in kwargs.items %}{{key}}="{{value}}" {% endfor %}/>
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,4 @@
|
||||||
<body>
|
<body>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>{% trans "Please select the avatars that you would like to delete." %}</p>
|
|
||||||
{% 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 %}
|
||||||
<form method="POST" action="{% url 'avatar_delete' %}">
|
<p>{% trans "Please select the avatars that you would like to delete." %}</p>
|
||||||
|
<form method="POST" action="{% url 'avatar:delete' %}">
|
||||||
<ul>
|
<ul>
|
||||||
{{ delete_avatar_form.as_ul }}
|
{{ delete_avatar_form.as_ul }}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
11
avatar/templates/avatar/initials.html
Normal file
11
avatar/templates/avatar/initials.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<span style='background-color: hsla({{ hue }}, {{ saturation }}, {{ lightness }}, 1);
|
||||||
|
width: {{ size }}px;
|
||||||
|
height: {{ size }}px;
|
||||||
|
font-size: {{ fontsize }}px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: {{ size }}px;
|
||||||
|
{{ kwargs.style }}'
|
||||||
|
{% for key, value in kwargs.items %}{% if key != 'style' %}{{ key }}="{{ value }}" {% endif %}{% endfor %}>
|
||||||
|
{{ initials }}</span>
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% blocktrans with user as avatar_creator and avatar.get_absolute_url as avatar_url %}You have updated your avatar <a href="{{ avatar_url }}">{{ avatar }}</a>.{% endblocktrans %}
|
{% blocktrans with user as avatar_creator and avatar.get_absolute_url as avatar_url %}You have updated your avatar <a href="{{ avatar_url }}">{{ avatar }}</a>.{% endblocktrans %}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,69 @@
|
||||||
from django import template
|
from django import template
|
||||||
from django.core.urlresolvers import reverse
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils import six
|
from django.urls import reverse
|
||||||
from django.utils.translation import ugettext as _
|
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from avatar.conf import settings
|
from avatar.conf import settings
|
||||||
from avatar.models import Avatar
|
from avatar.models import Avatar
|
||||||
from avatar.utils import (
|
from avatar.utils import cache_result, get_default_avatar_url, get_user, get_user_model
|
||||||
cache_result,
|
|
||||||
get_default_avatar_url,
|
|
||||||
get_user_model,
|
|
||||||
get_user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@cache_result()
|
@cache_result()
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def avatar_url(user, size=settings.AVATAR_DEFAULT_SIZE):
|
def avatar_url(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
|
||||||
|
if height is None:
|
||||||
|
height = width
|
||||||
for provider_path in settings.AVATAR_PROVIDERS:
|
for provider_path in settings.AVATAR_PROVIDERS:
|
||||||
provider = import_string(provider_path)
|
provider = import_string(provider_path)
|
||||||
avatar_url = provider.get_avatar_url(user, size)
|
avatar_url = provider.get_avatar_url(user, width, height)
|
||||||
if avatar_url:
|
if avatar_url:
|
||||||
return avatar_url
|
return avatar_url
|
||||||
|
return get_default_avatar_url()
|
||||||
|
|
||||||
|
|
||||||
@cache_result()
|
@cache_result()
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def avatar(user, size=settings.AVATAR_DEFAULT_SIZE, **kwargs):
|
def avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None, **kwargs):
|
||||||
|
if height is None:
|
||||||
|
height = width
|
||||||
if not isinstance(user, get_user_model()):
|
if not isinstance(user, get_user_model()):
|
||||||
try:
|
try:
|
||||||
user = get_user(user)
|
user = get_user(user)
|
||||||
alt = six.text_type(user)
|
if settings.AVATAR_EXPOSE_USERNAMES:
|
||||||
url = avatar_url(user, size)
|
alt = str(user)
|
||||||
|
else:
|
||||||
|
alt = _("User Avatar")
|
||||||
|
url = avatar_url(user, width, height)
|
||||||
except get_user_model().DoesNotExist:
|
except get_user_model().DoesNotExist:
|
||||||
url = get_default_avatar_url()
|
url = get_default_avatar_url()
|
||||||
alt = _("Default Avatar")
|
alt = _("Default Avatar")
|
||||||
else:
|
else:
|
||||||
alt = six.text_type(user)
|
if settings.AVATAR_EXPOSE_USERNAMES:
|
||||||
url = avatar_url(user, size)
|
alt = str(user)
|
||||||
kwargs.update({'alt': alt})
|
else:
|
||||||
|
alt = _("User Avatar")
|
||||||
|
url = avatar_url(user, width, height)
|
||||||
|
kwargs.update({"alt": alt})
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'user': user,
|
"user": user,
|
||||||
'url': url,
|
"alt": alt,
|
||||||
'size': size,
|
"width": width,
|
||||||
'kwargs': kwargs,
|
"height": height,
|
||||||
|
"kwargs": kwargs,
|
||||||
}
|
}
|
||||||
return render_to_string('avatar/avatar_tag.html', context)
|
template_name = "avatar/avatar_tag.html"
|
||||||
|
ext_context = None
|
||||||
|
try:
|
||||||
|
template_name, ext_context = url
|
||||||
|
except ValueError:
|
||||||
|
context["url"] = url
|
||||||
|
if ext_context:
|
||||||
|
context = dict(context, **ext_context)
|
||||||
|
return render_to_string(template_name, context)
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
|
|
@ -62,48 +75,44 @@ def has_avatar(user):
|
||||||
|
|
||||||
@cache_result()
|
@cache_result()
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE):
|
def primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
|
||||||
"""
|
"""
|
||||||
This tag tries to get the default avatar for a user without doing any db
|
This tag tries to get the default avatar for a user without doing any db
|
||||||
requests. It achieve this by linking to a special view that will do all the
|
requests. It achieve this by linking to a special view that will do all the
|
||||||
work for us. If that special view is then cached by a CDN for instance,
|
work for us. If that special view is then cached by a CDN for instance,
|
||||||
we will avoid many db calls.
|
we will avoid many db calls.
|
||||||
"""
|
"""
|
||||||
alt = six.text_type(user)
|
kwargs = {"width": width}
|
||||||
url = reverse('avatar_render_primary', kwargs={'user': user, 'size': size})
|
if settings.AVATAR_EXPOSE_USERNAMES:
|
||||||
return ("""<img src="%s" alt="%s" width="%s" height="%s" />""" %
|
alt = str(user)
|
||||||
(url, alt, size, size))
|
kwargs["user"] = user
|
||||||
|
else:
|
||||||
|
alt = _("User Avatar")
|
||||||
|
kwargs["user"] = user.id
|
||||||
|
if height is None:
|
||||||
|
height = width
|
||||||
|
else:
|
||||||
|
kwargs["height"] = height
|
||||||
|
|
||||||
|
url = reverse("avatar:render_primary", kwargs=kwargs)
|
||||||
|
return """<img src="%s" width="%s" height="%s" alt="%s" />""" % (
|
||||||
|
url,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
alt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cache_result()
|
@cache_result()
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def render_avatar(avatar, size=settings.AVATAR_DEFAULT_SIZE):
|
def render_avatar(avatar, width=settings.AVATAR_DEFAULT_SIZE, height=None):
|
||||||
if not avatar.thumbnail_exists(size):
|
if height is None:
|
||||||
avatar.create_thumbnail(size)
|
height = width
|
||||||
|
if not avatar.thumbnail_exists(width, height):
|
||||||
|
avatar.create_thumbnail(width, height)
|
||||||
return """<img src="%s" alt="%s" width="%s" height="%s" />""" % (
|
return """<img src="%s" alt="%s" width="%s" height="%s" />""" % (
|
||||||
avatar.avatar_url(size), six.text_type(avatar), size, size)
|
avatar.avatar_url(width, height),
|
||||||
|
str(avatar),
|
||||||
|
width,
|
||||||
@register.tag
|
height,
|
||||||
def primary_avatar_object(parser, token):
|
)
|
||||||
split = token.split_contents()
|
|
||||||
if len(split) == 4:
|
|
||||||
return UsersAvatarObjectNode(split[1], split[3])
|
|
||||||
raise template.TemplateSyntaxError('%r tag takes three arguments.' %
|
|
||||||
split[0])
|
|
||||||
|
|
||||||
|
|
||||||
class UsersAvatarObjectNode(template.Node):
|
|
||||||
def __init__(self, user, key):
|
|
||||||
self.user = template.Variable(user)
|
|
||||||
self.key = key
|
|
||||||
|
|
||||||
def render(self, context):
|
|
||||||
user = self.user.resolve(context)
|
|
||||||
key = self.key
|
|
||||||
avatar = Avatar.objects.filter(user=user, primary=True)
|
|
||||||
if avatar:
|
|
||||||
context[key] = avatar[0]
|
|
||||||
else:
|
|
||||||
context[key] = None
|
|
||||||
return six.text_type()
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
from django.conf.urls import url
|
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 = [
|
||||||
url(r'^add/$', views.add, name='avatar_add'),
|
path("add/", views.add, name="add"),
|
||||||
url(r'^change/$', views.change, name='avatar_change'),
|
path("change/", views.change, name="change"),
|
||||||
url(r'^delete/$', views.delete, name='avatar_delete'),
|
path("delete/", views.delete, name="delete"),
|
||||||
url(r'^render_primary/(?P<user>[\w\d\@\.\-_]+)/(?P<size>[\d]+)/$',
|
# https://docs.djangoproject.com/en/4.1/topics/http/urls/#path-converters
|
||||||
|
path(
|
||||||
|
"render_primary/<slug:user>/<int:width>/",
|
||||||
views.render_primary,
|
views.render_primary,
|
||||||
name='avatar_render_primary'),
|
name="render_primary",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"render_primary/<slug:user>/<int:width>/<int:height>/",
|
||||||
|
views.render_primary,
|
||||||
|
name="render_primary",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
103
avatar/utils.py
103
avatar/utils.py
|
|
@ -1,44 +1,52 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.utils import six
|
|
||||||
from django.template.defaultfilters import slugify
|
|
||||||
|
|
||||||
try:
|
|
||||||
from django.utils.encoding import force_bytes
|
|
||||||
except ImportError:
|
|
||||||
force_bytes = str
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.template.defaultfilters import slugify
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
|
||||||
from avatar.conf import settings
|
from avatar.conf import settings
|
||||||
|
|
||||||
|
|
||||||
cached_funcs = set()
|
cached_funcs = set()
|
||||||
|
|
||||||
|
|
||||||
def get_username(user):
|
def get_username(user):
|
||||||
""" Return username of a User instance """
|
"""Return username of a User instance"""
|
||||||
if hasattr(user, 'get_username'):
|
if hasattr(user, "get_username"):
|
||||||
return user.get_username()
|
return user.get_username()
|
||||||
else:
|
else:
|
||||||
return user.username
|
return user.username
|
||||||
|
|
||||||
|
|
||||||
def get_user(username):
|
def get_user(userdescriptor):
|
||||||
""" Return user from a username/ish identifier """
|
"""Return user from a username/ID/ish identifier"""
|
||||||
return get_user_model().objects.get_by_natural_key(username)
|
User = get_user_model()
|
||||||
|
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
|
||||||
|
return User.objects.get_by_natural_key(userdescriptor)
|
||||||
|
|
||||||
|
|
||||||
def get_cache_key(user_or_username, size, prefix):
|
def get_cache_key(user_or_username, prefix, width=None, height=None):
|
||||||
"""
|
"""
|
||||||
Returns a cache key consisten of a username and image size.
|
Returns a cache key consisten of a username and image size.
|
||||||
"""
|
"""
|
||||||
if isinstance(user_or_username, get_user_model()):
|
if isinstance(user_or_username, get_user_model()):
|
||||||
user_or_username = get_username(user_or_username)
|
user_or_username = get_username(user_or_username)
|
||||||
key = six.u('%s_%s_%s') % (prefix, user_or_username, size)
|
key = f"{prefix}_{user_or_username}"
|
||||||
return six.u('%s_%s') % (slugify(key)[:100],
|
if width:
|
||||||
hashlib.md5(force_bytes(key)).hexdigest())
|
key += f"_{width}"
|
||||||
|
if height or width:
|
||||||
|
key += f"x{height or width}"
|
||||||
|
return "%s_%s" % (
|
||||||
|
slugify(key)[:100],
|
||||||
|
hashlib.md5(force_bytes(key)).hexdigest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def cache_set(key, value):
|
def cache_set(key, value):
|
||||||
|
|
@ -48,61 +56,76 @@ def cache_set(key, value):
|
||||||
|
|
||||||
def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
|
def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
|
||||||
"""
|
"""
|
||||||
Decorator to cache the result of functions that take a ``user`` and a
|
Decorator to cache the result of functions that take a ``user``, a
|
||||||
``size`` value.
|
``width`` and a ``height`` value.
|
||||||
"""
|
"""
|
||||||
if not settings.AVATAR_CACHE_ENABLED:
|
if not settings.AVATAR_CACHE_ENABLED:
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
return func
|
return func
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
def cached_func(user, size=None, **kwargs):
|
def cached_func(user, width=None, height=None, **kwargs):
|
||||||
prefix = func.__name__
|
prefix = func.__name__
|
||||||
cached_funcs.add(prefix)
|
cached_funcs.add(prefix)
|
||||||
key = get_cache_key(user, size or default_size, prefix=prefix)
|
key = get_cache_key(user, prefix, width or default_size, height)
|
||||||
result = cache.get(key)
|
result = cache.get(key)
|
||||||
if result is None:
|
if result is None:
|
||||||
result = func(user, size or default_size, **kwargs)
|
result = func(user, width or default_size, height, **kwargs)
|
||||||
cache_set(key, result)
|
cache_set(key, result)
|
||||||
|
# add image size to set of cached sizes so we can invalidate them later
|
||||||
|
sizes_key = get_cache_key(user, "cached_sizes")
|
||||||
|
sizes = cache.get(sizes_key, set())
|
||||||
|
sizes.add((width or default_size, height or width or default_size))
|
||||||
|
cache_set(sizes_key, sizes)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return cached_func
|
return cached_func
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def invalidate_cache(user, size=None):
|
def invalidate_cache(user, width=None, height=None):
|
||||||
"""
|
"""
|
||||||
Function to be called when saving or changing an user's avatars.
|
Function to be called when saving or changing a user's avatars.
|
||||||
"""
|
"""
|
||||||
sizes = set(settings.AVATAR_AUTO_GENERATE_SIZES)
|
sizes_key = get_cache_key(user, "cached_sizes")
|
||||||
if size is not None:
|
sizes = cache.get(sizes_key, set())
|
||||||
sizes.add(size)
|
if width is not None:
|
||||||
|
sizes.add((width, height or width))
|
||||||
for prefix in cached_funcs:
|
for prefix in cached_funcs:
|
||||||
for size in sizes:
|
for size in sizes:
|
||||||
cache.delete(get_cache_key(user, size, prefix))
|
if isinstance(size, int):
|
||||||
|
cache.delete(get_cache_key(user, prefix, size))
|
||||||
|
else:
|
||||||
|
# Size is specified with height and width.
|
||||||
|
cache.delete(get_cache_key(user, prefix, size[0], size[1]))
|
||||||
|
cache.set(sizes_key, set())
|
||||||
|
|
||||||
|
|
||||||
def get_default_avatar_url():
|
def get_default_avatar_url():
|
||||||
base_url = getattr(settings, 'STATIC_URL', None)
|
base_url = getattr(settings, "STATIC_URL", None)
|
||||||
if not base_url:
|
if not base_url:
|
||||||
base_url = getattr(settings, 'MEDIA_URL', '')
|
base_url = getattr(settings, "MEDIA_URL", "")
|
||||||
|
|
||||||
# Don't use base_url if the default url starts with http:// of https://
|
# Don't use base_url if the default url starts with http:// of https://
|
||||||
if settings.AVATAR_DEFAULT_URL.startswith(('http://', 'https://')):
|
if settings.AVATAR_DEFAULT_URL.startswith(("http://", "https://")):
|
||||||
return settings.AVATAR_DEFAULT_URL
|
return settings.AVATAR_DEFAULT_URL
|
||||||
# We'll be nice and make sure there are no duplicated forward slashes
|
# We'll be nice and make sure there are no duplicated forward slashes
|
||||||
ends = base_url.endswith('/')
|
ends = base_url.endswith("/")
|
||||||
|
|
||||||
begins = settings.AVATAR_DEFAULT_URL.startswith('/')
|
begins = settings.AVATAR_DEFAULT_URL.startswith("/")
|
||||||
if ends and begins:
|
if ends and begins:
|
||||||
base_url = base_url[:-1]
|
base_url = base_url[:-1]
|
||||||
elif not ends and not begins:
|
elif not ends and not begins:
|
||||||
return '%s/%s' % (base_url, settings.AVATAR_DEFAULT_URL)
|
return "%s/%s" % (base_url, settings.AVATAR_DEFAULT_URL)
|
||||||
|
|
||||||
return '%s%s' % (base_url, settings.AVATAR_DEFAULT_URL)
|
return "%s%s" % (base_url, settings.AVATAR_DEFAULT_URL)
|
||||||
|
|
||||||
|
|
||||||
def get_primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE):
|
def get_primary_avatar(user, width=settings.AVATAR_DEFAULT_SIZE, height=None):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
if not isinstance(user, User):
|
if not isinstance(user, User):
|
||||||
try:
|
try:
|
||||||
|
|
@ -118,6 +141,6 @@ def get_primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE):
|
||||||
except IndexError:
|
except IndexError:
|
||||||
avatar = None
|
avatar = None
|
||||||
if avatar:
|
if avatar:
|
||||||
if not avatar.thumbnail_exists(size):
|
if not avatar.thumbnail_exists(width, height):
|
||||||
avatar.create_thumbnail(size)
|
avatar.create_thumbnail(width, height)
|
||||||
return avatar
|
return avatar
|
||||||
|
|
|
||||||
143
avatar/views.py
143
avatar/views.py
|
|
@ -1,16 +1,13 @@
|
||||||
from django.shortcuts import render, redirect
|
|
||||||
from django.utils import six
|
|
||||||
from django.utils.translation import ugettext as _
|
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from avatar.conf import settings
|
from avatar.conf import settings
|
||||||
from avatar.forms import PrimaryAvatarForm, DeleteAvatarForm, UploadAvatarForm
|
from avatar.forms import DeleteAvatarForm, PrimaryAvatarForm, UploadAvatarForm
|
||||||
from avatar.models import Avatar
|
from avatar.models import Avatar
|
||||||
from avatar.signals import avatar_updated
|
from avatar.signals import avatar_deleted, avatar_updated
|
||||||
from avatar.utils import (get_primary_avatar, get_default_avatar_url,
|
from avatar.utils import get_default_avatar_url, get_primary_avatar, invalidate_cache
|
||||||
invalidate_cache)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_next(request):
|
def _get_next(request):
|
||||||
|
|
@ -28,8 +25,9 @@ def _get_next(request):
|
||||||
3. If Django can determine the previous page from the HTTP headers,
|
3. If Django can determine the previous page from the HTTP headers,
|
||||||
the view will redirect to that previous page.
|
the view will redirect to that previous page.
|
||||||
"""
|
"""
|
||||||
next = request.POST.get('next', request.GET.get('next',
|
next = request.POST.get(
|
||||||
request.META.get('HTTP_REFERER', None)))
|
"next", request.GET.get("next", request.META.get("HTTP_REFERER", None))
|
||||||
|
)
|
||||||
if not next:
|
if not next:
|
||||||
next = request.path
|
next = request.path
|
||||||
return next
|
return next
|
||||||
|
|
@ -40,7 +38,7 @@ def _get_avatars(user):
|
||||||
avatars = user.avatar_set.all()
|
avatars = user.avatar_set.all()
|
||||||
|
|
||||||
# Current avatar
|
# Current avatar
|
||||||
primary_avatar = avatars.order_by('-primary')[:1]
|
primary_avatar = avatars.order_by("-primary")[:1]
|
||||||
if primary_avatar:
|
if primary_avatar:
|
||||||
avatar = primary_avatar[0]
|
avatar = primary_avatar[0]
|
||||||
else:
|
else:
|
||||||
|
|
@ -51,59 +49,70 @@ def _get_avatars(user):
|
||||||
else:
|
else:
|
||||||
# Slice the default set now that we used
|
# Slice the default set now that we used
|
||||||
# the queryset for the primary avatar
|
# the queryset for the primary avatar
|
||||||
avatars = avatars[:settings.AVATAR_MAX_AVATARS_PER_USER]
|
avatars = avatars[: settings.AVATAR_MAX_AVATARS_PER_USER]
|
||||||
return (avatar, avatars)
|
return (avatar, avatars)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add(request, extra_context=None, next_override=None,
|
def add(
|
||||||
upload_form=UploadAvatarForm, *args, **kwargs):
|
request,
|
||||||
|
extra_context=None,
|
||||||
|
next_override=None,
|
||||||
|
upload_form=UploadAvatarForm,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
if extra_context is None:
|
if extra_context is None:
|
||||||
extra_context = {}
|
extra_context = {}
|
||||||
avatar, avatars = _get_avatars(request.user)
|
avatar, avatars = _get_avatars(request.user)
|
||||||
upload_avatar_form = upload_form(request.POST or None,
|
upload_avatar_form = upload_form(
|
||||||
request.FILES or None,
|
request.POST or None, request.FILES or None, user=request.user
|
||||||
user=request.user)
|
)
|
||||||
if request.method == "POST" and 'avatar' in request.FILES:
|
if request.method == "POST" and "avatar" in request.FILES:
|
||||||
if upload_avatar_form.is_valid():
|
if upload_avatar_form.is_valid():
|
||||||
avatar = Avatar(user=request.user, primary=True)
|
avatar = Avatar(user=request.user, primary=True)
|
||||||
image_file = request.FILES['avatar']
|
image_file = request.FILES["avatar"]
|
||||||
avatar.avatar.save(image_file.name, image_file)
|
avatar.avatar.save(image_file.name, image_file)
|
||||||
avatar.save()
|
avatar.save()
|
||||||
messages.success(request, _("Successfully uploaded a new avatar."))
|
messages.success(request, _("Successfully uploaded a new avatar."))
|
||||||
avatar_updated.send(sender=Avatar, user=request.user, avatar=avatar)
|
avatar_updated.send(sender=Avatar, user=request.user, avatar=avatar)
|
||||||
return redirect(next_override or _get_next(request))
|
return redirect(next_override or _get_next(request))
|
||||||
context = {
|
context = {
|
||||||
'avatar': avatar,
|
"avatar": avatar,
|
||||||
'avatars': avatars,
|
"avatars": avatars,
|
||||||
'upload_avatar_form': upload_avatar_form,
|
"upload_avatar_form": upload_avatar_form,
|
||||||
'next': next_override or _get_next(request),
|
"next": next_override or _get_next(request),
|
||||||
}
|
}
|
||||||
context.update(extra_context)
|
context.update(extra_context)
|
||||||
template_name = settings.AVATAR_ADD_TEMPLATE or 'avatar/add.html'
|
template_name = settings.AVATAR_ADD_TEMPLATE or "avatar/add.html"
|
||||||
return render(request, template_name, context)
|
return render(request, template_name, context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def change(request, extra_context=None, next_override=None,
|
def change(
|
||||||
upload_form=UploadAvatarForm, primary_form=PrimaryAvatarForm,
|
request,
|
||||||
*args, **kwargs):
|
extra_context=None,
|
||||||
|
next_override=None,
|
||||||
|
upload_form=UploadAvatarForm,
|
||||||
|
primary_form=PrimaryAvatarForm,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
if extra_context is None:
|
if extra_context is None:
|
||||||
extra_context = {}
|
extra_context = {}
|
||||||
avatar, avatars = _get_avatars(request.user)
|
avatar, avatars = _get_avatars(request.user)
|
||||||
if avatar:
|
if avatar:
|
||||||
kwargs = {'initial': {'choice': avatar.id}}
|
kwargs = {"initial": {"choice": avatar.id}}
|
||||||
else:
|
else:
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
upload_avatar_form = upload_form(user=request.user, **kwargs)
|
upload_avatar_form = upload_form(user=request.user, **kwargs)
|
||||||
primary_avatar_form = primary_form(request.POST or None,
|
primary_avatar_form = primary_form(
|
||||||
user=request.user,
|
request.POST or None, user=request.user, avatars=avatars, **kwargs
|
||||||
avatars=avatars, **kwargs)
|
)
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
updated = False
|
updated = False
|
||||||
if 'choice' in request.POST and primary_avatar_form.is_valid():
|
if "choice" in request.POST and primary_avatar_form.is_valid():
|
||||||
avatar = Avatar.objects.get(
|
avatar = Avatar.objects.get(id=primary_avatar_form.cleaned_data["choice"])
|
||||||
id=primary_avatar_form.cleaned_data['choice'])
|
|
||||||
avatar.primary = True
|
avatar.primary = True
|
||||||
avatar.save()
|
avatar.save()
|
||||||
updated = True
|
updated = True
|
||||||
|
|
@ -114,14 +123,14 @@ def change(request, extra_context=None, next_override=None,
|
||||||
return redirect(next_override or _get_next(request))
|
return redirect(next_override or _get_next(request))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'avatar': avatar,
|
"avatar": avatar,
|
||||||
'avatars': avatars,
|
"avatars": avatars,
|
||||||
'upload_avatar_form': upload_avatar_form,
|
"upload_avatar_form": upload_avatar_form,
|
||||||
'primary_avatar_form': primary_avatar_form,
|
"primary_avatar_form": primary_avatar_form,
|
||||||
'next': next_override or _get_next(request)
|
"next": next_override or _get_next(request),
|
||||||
}
|
}
|
||||||
context.update(extra_context)
|
context.update(extra_context)
|
||||||
template_name = settings.AVATAR_CHANGE_TEMPLATE or 'avatar/change.html'
|
template_name = settings.AVATAR_CHANGE_TEMPLATE or "avatar/change.html"
|
||||||
return render(request, template_name, context)
|
return render(request, template_name, context)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -130,47 +139,61 @@ def delete(request, extra_context=None, next_override=None, *args, **kwargs):
|
||||||
if extra_context is None:
|
if extra_context is None:
|
||||||
extra_context = {}
|
extra_context = {}
|
||||||
avatar, avatars = _get_avatars(request.user)
|
avatar, avatars = _get_avatars(request.user)
|
||||||
delete_avatar_form = DeleteAvatarForm(request.POST or None,
|
delete_avatar_form = DeleteAvatarForm(
|
||||||
user=request.user,
|
request.POST or None, user=request.user, avatars=avatars
|
||||||
avatars=avatars)
|
)
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
if delete_avatar_form.is_valid():
|
if delete_avatar_form.is_valid():
|
||||||
ids = delete_avatar_form.cleaned_data['choices']
|
ids = delete_avatar_form.cleaned_data["choices"]
|
||||||
if six.text_type(avatar.id) in ids and avatars.count() > len(ids):
|
for a in avatars:
|
||||||
|
if str(a.id) in ids:
|
||||||
|
avatar_deleted.send(sender=Avatar, user=request.user, avatar=a)
|
||||||
|
if str(avatar.id) in ids and avatars.count() > len(ids):
|
||||||
# Find the next best avatar, and set it as the new primary
|
# Find the next best avatar, and set it as the new primary
|
||||||
for a in avatars:
|
for a in avatars:
|
||||||
if six.text_type(a.id) not in ids:
|
if str(a.id) not in ids:
|
||||||
a.primary = True
|
a.primary = True
|
||||||
a.save()
|
a.save()
|
||||||
avatar_updated.send(sender=Avatar, user=request.user,
|
avatar_updated.send(
|
||||||
avatar=avatar)
|
sender=Avatar, user=request.user, avatar=avatar
|
||||||
|
)
|
||||||
break
|
break
|
||||||
Avatar.objects.filter(id__in=ids).delete()
|
Avatar.objects.filter(id__in=ids).delete()
|
||||||
messages.success(request,
|
messages.success(request, _("Successfully deleted the requested avatars."))
|
||||||
_("Successfully deleted the requested avatars."))
|
|
||||||
return redirect(next_override or _get_next(request))
|
return redirect(next_override or _get_next(request))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'avatar': avatar,
|
"avatar": avatar,
|
||||||
'avatars': avatars,
|
"avatars": avatars,
|
||||||
'delete_avatar_form': delete_avatar_form,
|
"delete_avatar_form": delete_avatar_form,
|
||||||
'next': next_override or _get_next(request),
|
"next": next_override or _get_next(request),
|
||||||
}
|
}
|
||||||
context.update(extra_context)
|
context.update(extra_context)
|
||||||
template_name = settings.AVATAR_DELETE_TEMPLATE or 'avatar/confirm_delete.html'
|
template_name = settings.AVATAR_DELETE_TEMPLATE or "avatar/confirm_delete.html"
|
||||||
return render(request, template_name, context)
|
return render(request, template_name, context)
|
||||||
|
|
||||||
|
|
||||||
def render_primary(request, user=None, size=settings.AVATAR_DEFAULT_SIZE):
|
def render_primary(request, user=None, width=settings.AVATAR_DEFAULT_SIZE, height=None):
|
||||||
size = int(size)
|
if height is None:
|
||||||
avatar = get_primary_avatar(user, size=size)
|
height = width
|
||||||
|
width = int(width)
|
||||||
|
height = int(height)
|
||||||
|
avatar = get_primary_avatar(user, width=width, height=height)
|
||||||
|
if width == 0 and height == 0:
|
||||||
|
avatar = get_primary_avatar(
|
||||||
|
user,
|
||||||
|
width=settings.AVATAR_DEFAULT_SIZE,
|
||||||
|
height=settings.AVATAR_DEFAULT_SIZE,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
avatar = get_primary_avatar(user, width=width, height=height)
|
||||||
if avatar:
|
if avatar:
|
||||||
# FIXME: later, add an option to render the resized avatar dynamically
|
# FIXME: later, add an option to render the resized avatar dynamically
|
||||||
# instead of redirecting to an already created static file. This could
|
# instead of redirecting to an already created static file. This could
|
||||||
# be useful in certain situations, particulary if there is a CDN and
|
# be useful in certain situations, particulary if there is a CDN and
|
||||||
# we want to minimize the storage usage on our static server, letting
|
# we want to minimize the storage usage on our static server, letting
|
||||||
# the CDN store those files instead
|
# the CDN store those files instead
|
||||||
url = avatar.avatar_url(size)
|
url = avatar.avatar_url(width, height)
|
||||||
else:
|
else:
|
||||||
url = get_default_avatar_url()
|
url = get_default_avatar_url()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
191
docs/avatar.rst
Normal 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``.
|
||||||
153
docs/conf.py
153
docs/conf.py
|
|
@ -11,204 +11,208 @@
|
||||||
# All configuration values have a default; values that are commented out
|
# All configuration values have a default; values that are commented out
|
||||||
# serve to show the default.
|
# serve to show the default.
|
||||||
|
|
||||||
import sys, os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
# If extensions (or modules to document with autodoc) are in another directory,
|
# If extensions (or modules to document with autodoc) are in another directory,
|
||||||
# add these directories to sys.path here. If the directory is relative to the
|
# add these directories to sys.path here. If the directory is relative to the
|
||||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||||
sys.path.insert(0, os.path.abspath('.'))
|
sys.path.insert(0, os.path.abspath("."))
|
||||||
|
|
||||||
# -- General configuration -----------------------------------------------------
|
# -- General configuration -----------------------------------------------------
|
||||||
|
|
||||||
# If your documentation needs a minimal Sphinx version, state it here.
|
# If your documentation needs a minimal Sphinx version, state it here.
|
||||||
#needs_sphinx = '1.0'
|
# needs_sphinx = '1.0'
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings. They can be extensions
|
# Add any Sphinx extension module names here, as strings. They can be extensions
|
||||||
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||||
extensions = []
|
extensions = []
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
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'
|
||||||
|
|
||||||
# The master toctree document.
|
# The master toctree document.
|
||||||
master_doc = 'index'
|
master_doc = "index"
|
||||||
|
|
||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = u'django-avatar'
|
project = "django-avatar"
|
||||||
copyright = u'2013, django-avatar developers'
|
copyright = "2013, django-avatar developers"
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
# |version| and |release|, also used in various other places throughout the
|
# |version| and |release|, also used in various other places throughout the
|
||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = '2.0'
|
version = "2.0"
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = '2.0'
|
release = "2.0"
|
||||||
|
|
||||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
# for a list of supported languages.
|
# for a list of supported languages.
|
||||||
#language = None
|
# language = None
|
||||||
|
|
||||||
# There are two options for replacing |today|: either, you set today to some
|
# There are two options for replacing |today|: either, you set today to some
|
||||||
# non-false value, then it is used:
|
# non-false value, then it is used:
|
||||||
#today = ''
|
# today = ''
|
||||||
# Else, today_fmt is used as the format for a strftime call.
|
# Else, today_fmt is used as the format for a strftime call.
|
||||||
#today_fmt = '%B %d, %Y'
|
# today_fmt = '%B %d, %Y'
|
||||||
|
|
||||||
# List of patterns, relative to source directory, that match files and
|
# List of patterns, relative to source directory, that match files and
|
||||||
# directories to ignore when looking for source files.
|
# directories to ignore when looking for source files.
|
||||||
exclude_patterns = ['_build']
|
exclude_patterns = ["_build"]
|
||||||
|
|
||||||
# The reST default role (used for this markup: `text`) to use for all documents.
|
# The reST default role (used for this markup: `text`) to use for all documents.
|
||||||
#default_role = None
|
# default_role = None
|
||||||
|
|
||||||
# If true, '()' will be appended to :func: etc. cross-reference text.
|
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||||
#add_function_parentheses = True
|
# add_function_parentheses = True
|
||||||
|
|
||||||
# If true, the current module name will be prepended to all description
|
# If true, the current module name will be prepended to all description
|
||||||
# unit titles (such as .. function::).
|
# unit titles (such as .. function::).
|
||||||
#add_module_names = True
|
# add_module_names = True
|
||||||
|
|
||||||
# If true, sectionauthor and moduleauthor directives will be shown in the
|
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||||
# output. They are ignored by default.
|
# output. They are ignored by default.
|
||||||
#show_authors = False
|
# show_authors = False
|
||||||
|
|
||||||
# The name of the Pygments (syntax highlighting) style to use.
|
# The name of the Pygments (syntax highlighting) style to use.
|
||||||
pygments_style = 'sphinx'
|
pygments_style = "sphinx"
|
||||||
|
|
||||||
# A list of ignored prefixes for module index sorting.
|
# A list of ignored prefixes for module index sorting.
|
||||||
#modindex_common_prefix = []
|
# modindex_common_prefix = []
|
||||||
|
|
||||||
# If true, keep warnings as "system message" paragraphs in the built documents.
|
# If true, keep warnings as "system message" paragraphs in the built documents.
|
||||||
#keep_warnings = False
|
# keep_warnings = False
|
||||||
|
|
||||||
|
|
||||||
# -- Options for HTML output ---------------------------------------------------
|
# -- Options for HTML output ---------------------------------------------------
|
||||||
|
|
||||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||||
# a list of builtin themes.
|
# a list of builtin themes.
|
||||||
html_theme = 'default'
|
html_theme = "default"
|
||||||
|
|
||||||
# Theme options are theme-specific and customize the look and feel of a theme
|
# Theme options are theme-specific and customize the look and feel of a theme
|
||||||
# further. For a list of options available for each theme, see the
|
# further. For a list of options available for each theme, see the
|
||||||
# documentation.
|
# documentation.
|
||||||
#html_theme_options = {}
|
# html_theme_options = {}
|
||||||
|
|
||||||
# Add any paths that contain custom themes here, relative to this directory.
|
# Add any paths that contain custom themes here, relative to this directory.
|
||||||
#html_theme_path = []
|
# html_theme_path = []
|
||||||
|
|
||||||
# The name for this set of Sphinx documents. If None, it defaults to
|
# The name for this set of Sphinx documents. If None, it defaults to
|
||||||
# "<project> v<release> documentation".
|
# "<project> v<release> documentation".
|
||||||
#html_title = None
|
# html_title = None
|
||||||
|
|
||||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||||
#html_short_title = None
|
# html_short_title = None
|
||||||
|
|
||||||
# The name of an image file (relative to this directory) to place at the top
|
# The name of an image file (relative to this directory) to place at the top
|
||||||
# of the sidebar.
|
# of the sidebar.
|
||||||
#html_logo = None
|
# html_logo = None
|
||||||
|
|
||||||
# The name of an image file (within the static path) to use as favicon of the
|
# The name of an image file (within the static path) to use as favicon of the
|
||||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||||
# pixels large.
|
# pixels large.
|
||||||
#html_favicon = None
|
# html_favicon = None
|
||||||
|
|
||||||
# Add any paths that contain custom static files (such as style sheets) here,
|
# Add any paths that contain custom static files (such as style sheets) here,
|
||||||
# relative to this directory. They are copied after the builtin static files,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||||
html_static_path = ['_static']
|
html_static_path = ["_static"]
|
||||||
|
|
||||||
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||||
# using the given strftime format.
|
# using the given strftime format.
|
||||||
#html_last_updated_fmt = '%b %d, %Y'
|
# html_last_updated_fmt = '%b %d, %Y'
|
||||||
|
|
||||||
# If true, SmartyPants will be used to convert quotes and dashes to
|
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||||
# typographically correct entities.
|
# typographically correct entities.
|
||||||
#html_use_smartypants = True
|
# html_use_smartypants = True
|
||||||
|
|
||||||
# Custom sidebar templates, maps document names to template names.
|
# Custom sidebar templates, maps document names to template names.
|
||||||
#html_sidebars = {}
|
# html_sidebars = {}
|
||||||
|
|
||||||
# Additional templates that should be rendered to pages, maps page names to
|
# Additional templates that should be rendered to pages, maps page names to
|
||||||
# template names.
|
# template names.
|
||||||
#html_additional_pages = {}
|
# html_additional_pages = {}
|
||||||
|
|
||||||
# If false, no module index is generated.
|
# If false, no module index is generated.
|
||||||
#html_domain_indices = True
|
# html_domain_indices = True
|
||||||
|
|
||||||
# If false, no index is generated.
|
# If false, no index is generated.
|
||||||
#html_use_index = True
|
# html_use_index = True
|
||||||
|
|
||||||
# If true, the index is split into individual pages for each letter.
|
# If true, the index is split into individual pages for each letter.
|
||||||
#html_split_index = False
|
# html_split_index = False
|
||||||
|
|
||||||
# If true, links to the reST sources are added to the pages.
|
# If true, links to the reST sources are added to the pages.
|
||||||
#html_show_sourcelink = True
|
# html_show_sourcelink = True
|
||||||
|
|
||||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||||
#html_show_sphinx = True
|
# html_show_sphinx = True
|
||||||
|
|
||||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||||
#html_show_copyright = True
|
# html_show_copyright = True
|
||||||
|
|
||||||
# If true, an OpenSearch description file will be output, and all pages will
|
# If true, an OpenSearch description file will be output, and all pages will
|
||||||
# contain a <link> tag referring to it. The value of this option must be the
|
# contain a <link> tag referring to it. The value of this option must be the
|
||||||
# base URL from which the finished HTML is served.
|
# base URL from which the finished HTML is served.
|
||||||
#html_use_opensearch = ''
|
# html_use_opensearch = ''
|
||||||
|
|
||||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||||
#html_file_suffix = None
|
# html_file_suffix = None
|
||||||
|
|
||||||
# Output file base name for HTML help builder.
|
# Output file base name for HTML help builder.
|
||||||
htmlhelp_basename = 'django-avatardoc'
|
htmlhelp_basename = "django-avatardoc"
|
||||||
|
|
||||||
|
|
||||||
# -- Options for LaTeX output --------------------------------------------------
|
# -- Options for LaTeX output --------------------------------------------------
|
||||||
|
|
||||||
latex_elements = {
|
latex_elements = {
|
||||||
# The paper size ('letterpaper' or 'a4paper').
|
# The paper size ('letterpaper' or 'a4paper').
|
||||||
#'papersize': 'letterpaper',
|
# 'papersize': 'letterpaper',
|
||||||
|
# The font size ('10pt', '11pt' or '12pt').
|
||||||
# The font size ('10pt', '11pt' or '12pt').
|
# 'pointsize': '10pt',
|
||||||
#'pointsize': '10pt',
|
# Additional stuff for the LaTeX preamble.
|
||||||
|
# 'preamble': '',
|
||||||
# Additional stuff for the LaTeX preamble.
|
|
||||||
#'preamble': '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Grouping the document tree into LaTeX files. List of tuples
|
# Grouping the document tree into LaTeX files. List of tuples
|
||||||
# (source start file, target name, title, author, documentclass [howto/manual]).
|
# (source start file, target name, title, author, documentclass [howto/manual]).
|
||||||
latex_documents = [
|
latex_documents = [
|
||||||
('index', 'django-avatar.tex', u'django-avatar Documentation',
|
(
|
||||||
u'django-avatar developers', 'manual'),
|
"index",
|
||||||
|
"django-avatar.tex",
|
||||||
|
"django-avatar Documentation",
|
||||||
|
"django-avatar developers",
|
||||||
|
"manual",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# The name of an image file (relative to this directory) to place at the top of
|
# The name of an image file (relative to this directory) to place at the top of
|
||||||
# the title page.
|
# the title page.
|
||||||
#latex_logo = None
|
# latex_logo = None
|
||||||
|
|
||||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||||
# not chapters.
|
# not chapters.
|
||||||
#latex_use_parts = False
|
# latex_use_parts = False
|
||||||
|
|
||||||
# If true, show page references after internal links.
|
# If true, show page references after internal links.
|
||||||
#latex_show_pagerefs = False
|
# latex_show_pagerefs = False
|
||||||
|
|
||||||
# If true, show URL addresses after external links.
|
# If true, show URL addresses after external links.
|
||||||
#latex_show_urls = False
|
# latex_show_urls = False
|
||||||
|
|
||||||
# Documents to append as an appendix to all manuals.
|
# Documents to append as an appendix to all manuals.
|
||||||
#latex_appendices = []
|
# latex_appendices = []
|
||||||
|
|
||||||
# If false, no module index is generated.
|
# If false, no module index is generated.
|
||||||
#latex_domain_indices = True
|
# latex_domain_indices = True
|
||||||
|
|
||||||
|
|
||||||
# -- Options for manual page output --------------------------------------------
|
# -- Options for manual page output --------------------------------------------
|
||||||
|
|
@ -216,12 +220,17 @@ latex_documents = [
|
||||||
# One entry per manual page. List of tuples
|
# One entry per manual page. List of tuples
|
||||||
# (source start file, name, description, authors, manual section).
|
# (source start file, name, description, authors, manual section).
|
||||||
man_pages = [
|
man_pages = [
|
||||||
('index', 'django-avatar', u'django-avatar Documentation',
|
(
|
||||||
[u'django-avatar developers'], 1)
|
"index",
|
||||||
|
"django-avatar",
|
||||||
|
"django-avatar Documentation",
|
||||||
|
["django-avatar developers"],
|
||||||
|
1,
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
# If true, show URL addresses after external links.
|
# If true, show URL addresses after external links.
|
||||||
#man_show_urls = False
|
# man_show_urls = False
|
||||||
|
|
||||||
|
|
||||||
# -- Options for Texinfo output ------------------------------------------------
|
# -- Options for Texinfo output ------------------------------------------------
|
||||||
|
|
@ -230,19 +239,25 @@ man_pages = [
|
||||||
# (source start file, target name, title, author,
|
# (source start file, target name, title, author,
|
||||||
# dir menu entry, description, category)
|
# dir menu entry, description, category)
|
||||||
texinfo_documents = [
|
texinfo_documents = [
|
||||||
('index', 'django-avatar', u'django-avatar Documentation',
|
(
|
||||||
u'django-avatar developers', 'django-avatar', 'One line description of project.',
|
"index",
|
||||||
'Miscellaneous'),
|
"django-avatar",
|
||||||
|
"django-avatar Documentation",
|
||||||
|
"django-avatar developers",
|
||||||
|
"django-avatar",
|
||||||
|
"One line description of project.",
|
||||||
|
"Miscellaneous",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Documents to append as an appendix to all manuals.
|
# Documents to append as an appendix to all manuals.
|
||||||
#texinfo_appendices = []
|
# texinfo_appendices = []
|
||||||
|
|
||||||
# If false, no module index is generated.
|
# If false, no module index is generated.
|
||||||
#texinfo_domain_indices = True
|
# texinfo_domain_indices = True
|
||||||
|
|
||||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||||
#texinfo_show_urls = 'footnote'
|
# texinfo_show_urls = 'footnote'
|
||||||
|
|
||||||
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
||||||
#texinfo_no_detailmenu = False
|
# texinfo_no_detailmenu = False
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
------------
|
------------
|
||||||
|
|
@ -48,13 +49,13 @@ that are required. A minimal integration can work like this:
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# ...
|
# ...
|
||||||
(r'^avatar/', include('avatar.urls')),
|
path('avatar/', include('avatar.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
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::
|
||||||
|
|
@ -69,6 +70,10 @@ that are required. A minimal integration can work like this:
|
||||||
|
|
||||||
{% avatar user 65 %}
|
{% avatar user 65 %}
|
||||||
|
|
||||||
|
Or specify a width and height (in pixels) explicitly::
|
||||||
|
|
||||||
|
{% avatar user 65 50 %}
|
||||||
|
|
||||||
Example for customize the attribute of the HTML ``img`` tag::
|
Example for customize the attribute of the HTML ``img`` tag::
|
||||||
|
|
||||||
{% avatar user 65 class="img-circle img-responsive" id="user_avatar" %}
|
{% avatar user 65 class="img-circle img-responsive" id="user_avatar" %}
|
||||||
|
|
@ -106,9 +111,10 @@ appear on the site. Listed below are those settings:
|
||||||
|
|
||||||
.. py:data:: AVATAR_AUTO_GENERATE_SIZES
|
.. py:data:: AVATAR_AUTO_GENERATE_SIZES
|
||||||
|
|
||||||
An iterable of integers representing the sizes of avatars to generate on
|
An iterable of integers and/or sequences in the format ``(width, height)``
|
||||||
upload. This can save rendering time later on if you pre-generate the
|
representing the sizes of avatars to generate on upload. This can save
|
||||||
resized versions. Defaults to ``(80,)``
|
rendering time later on if you pre-generate the resized versions. Defaults
|
||||||
|
to ``(80,)``.
|
||||||
|
|
||||||
.. py:data:: AVATAR_CACHE_ENABLED
|
.. py:data:: AVATAR_CACHE_ENABLED
|
||||||
|
|
||||||
|
|
@ -124,7 +130,7 @@ appear on the site. Listed below are those settings:
|
||||||
|
|
||||||
Puts the User's username field in the URL path when ``True``. Set to
|
Puts the User's username field in the URL path when ``True``. Set to
|
||||||
``False`` to use the User's primary key instead, preventing their email
|
``False`` to use the User's primary key instead, preventing their email
|
||||||
from being searchable on the web. Defaults to ``True``.
|
from being searchable on the web. Defaults to ``False``.
|
||||||
|
|
||||||
.. py:data:: AVATAR_FACEBOOK_GET_ID
|
.. py:data:: AVATAR_FACEBOOK_GET_ID
|
||||||
|
|
||||||
|
|
@ -156,6 +162,11 @@ appear on the site. Listed below are those settings:
|
||||||
.. py:data:: AVATAR_MAX_SIZE
|
.. py:data:: AVATAR_MAX_SIZE
|
||||||
|
|
||||||
File size limit for avatar upload. Default is ``1024 * 1024`` (1 MB).
|
File size limit for avatar upload. Default is ``1024 * 1024`` (1 MB).
|
||||||
|
gravatar in ``user.gravatar``.
|
||||||
|
|
||||||
|
.. py:data:: AVATAR_MAX_AVATARS_PER_USER
|
||||||
|
|
||||||
|
The maximum number of avatars each user can have. Default is ``42``.
|
||||||
|
|
||||||
.. py:data:: AVATAR_PATH_HANDLER
|
.. py:data:: AVATAR_PATH_HANDLER
|
||||||
|
|
||||||
|
|
@ -170,12 +181,13 @@ 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',
|
||||||
)
|
)
|
||||||
|
|
||||||
If you want to implement your own provider, it must provide a class method
|
If you want to implement your own provider, it must provide a class method
|
||||||
``get_avatar_url(user, size)``.
|
``get_avatar_url(user, width, height)``.
|
||||||
|
|
||||||
.. py:class:: avatar.providers.PrimaryAvatarProvider
|
.. py:class:: avatar.providers.PrimaryAvatarProvider
|
||||||
|
|
||||||
|
|
@ -200,7 +212,7 @@ 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
|
||||||
PIL. Defaults to ``Image.ANTIALIAS``.
|
Pillow. Defaults to ``Image.Resampling.LANCZOS``.
|
||||||
|
|
||||||
.. py:data:: AVATAR_STORAGE_DIR
|
.. py:data:: AVATAR_STORAGE_DIR
|
||||||
|
|
||||||
|
|
@ -208,6 +220,27 @@ appear on the site. Listed below are those settings:
|
||||||
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``.
|
||||||
|
|
||||||
|
.. py:data:: AVATAR_THUMB_FORMAT
|
||||||
|
|
||||||
|
The file format of thumbnails, based on the options available in
|
||||||
|
Pillow. Defaults to `PNG`.
|
||||||
|
|
||||||
|
.. py:data:: AVATAR_THUMB_QUALITY
|
||||||
|
|
||||||
|
The quality of thumbnails, between 0 (worst) to 95 (best) or the string
|
||||||
|
"keep" (only JPEG) as provided by Pillow. Defaults to `85`.
|
||||||
|
|
||||||
|
.. py:data:: AVATAR_THUMB_MODES
|
||||||
|
|
||||||
|
A sequence of acceptable modes for thumbnails as provided by Pillow. If the mode
|
||||||
|
of the image is not in the list, the thumbnail will be converted to the
|
||||||
|
first mode in the list. Defaults to `('RGB', 'RGBA')`.
|
||||||
|
|
||||||
|
.. py:data:: AVATAR_CLEANUP_DELETED
|
||||||
|
|
||||||
|
``True`` if the avatar image files should be deleted when an avatar is
|
||||||
|
deleted from the database. Defaults to ``True``.
|
||||||
|
|
||||||
.. py:data:: AVATAR_ADD_TEMPLATE
|
.. py:data:: AVATAR_ADD_TEMPLATE
|
||||||
|
|
||||||
Path to the Django template to use for adding a new avatar. Defaults to
|
Path to the Django template to use for adding a new avatar. Defaults to
|
||||||
|
|
@ -222,6 +255,20 @@ appear on the site. Listed below are those settings:
|
||||||
Path to the Django template to use for confirming a delete of a user's
|
Path to the Django template to use for confirming a delete of a user's
|
||||||
avatar. Defaults to ``avatar/avatar/confirm_delete.html``.
|
avatar. Defaults to ``avatar/avatar/confirm_delete.html``.
|
||||||
|
|
||||||
|
.. py:data:: AVATAR_ALLOWED_MIMETYPES
|
||||||
|
|
||||||
|
Limit allowed avatar image uploads by their actual content payload and what image codecs we wish to support.
|
||||||
|
This limits website user content site attack vectors against image codec buffer overflow and similar bugs.
|
||||||
|
`You must have python-imaging library installed <https://github.com/ahupp/python-magic>`_.
|
||||||
|
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
|
Management Commands
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
|
|
@ -231,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
|
||||||
|
|
@ -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
40
pyproject.toml
Normal 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
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Pillow>=10.0.1
|
||||||
|
django-appconf>=1.0.5
|
||||||
|
dnspython>=2.3.0
|
||||||
55
setup.py
55
setup.py
|
|
@ -1,66 +1,33 @@
|
||||||
import codecs
|
import codecs
|
||||||
import re
|
import re
|
||||||
from os import path
|
from os import path
|
||||||
from setuptools import setup, find_packages
|
|
||||||
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
|
|
||||||
def read(*parts):
|
def read(*parts):
|
||||||
filename = path.join(path.dirname(__file__), *parts)
|
filename = path.join(path.dirname(__file__), *parts)
|
||||||
with codecs.open(filename, encoding='utf-8') as fp:
|
with codecs.open(filename, encoding="utf-8") as fp:
|
||||||
return fp.read()
|
return fp.read()
|
||||||
|
|
||||||
|
|
||||||
def find_version(*file_paths):
|
def find_version(*file_paths):
|
||||||
version_file = read(*file_paths)
|
version_file = read(*file_paths)
|
||||||
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]",
|
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
|
||||||
version_file, re.M)
|
|
||||||
if version_match:
|
if version_match:
|
||||||
return version_match.group(1)
|
return version_match.group(1)
|
||||||
raise RuntimeError("Unable to find version string.")
|
raise RuntimeError("Unable to find version string.")
|
||||||
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='django-avatar',
|
packages=find_packages(exclude=["tests"]),
|
||||||
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 :: 1.9',
|
|
||||||
'Framework :: Django :: 1.10',
|
|
||||||
'Framework :: Django :: 1.11',
|
|
||||||
'License :: OSI Approved :: BSD License',
|
|
||||||
'Operating System :: OS Independent',
|
|
||||||
'Programming Language :: Python',
|
|
||||||
'Programming Language :: Python :: 2',
|
|
||||||
'Programming Language :: Python :: 2.7',
|
|
||||||
'Programming Language :: Python :: 3',
|
|
||||||
'Programming Language :: Python :: 3.4',
|
|
||||||
'Programming Language :: Python :: 3.5',
|
|
||||||
'Programming Language :: Python :: 3.6',
|
|
||||||
],
|
|
||||||
keywords='avatar, django',
|
|
||||||
author='Eric Florenzano',
|
|
||||||
author_email='floguy@gmail.com',
|
|
||||||
maintainer='Grant McConnaughey',
|
|
||||||
maintainer_email='grantmcconnaughey@gmail.com',
|
|
||||||
url='http://github.com/grantmcconnaughey/django-avatar/',
|
|
||||||
license='BSD',
|
|
||||||
packages=find_packages(exclude=['tests']),
|
|
||||||
package_data={
|
package_data={
|
||||||
'avatar': [
|
"avatar": [
|
||||||
'templates/notification/*/*.*',
|
"templates/notification/*/*.*",
|
||||||
'templates/avatar/*.html',
|
"templates/avatar/*.html",
|
||||||
'locale/*/LC_MESSAGES/*',
|
"locale/*/LC_MESSAGES/*",
|
||||||
'media/avatar/img/default.jpg',
|
"media/avatar/img/default.jpg",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
install_requires=[
|
|
||||||
'Pillow>=2.0',
|
|
||||||
'django-appconf>=0.6',
|
|
||||||
],
|
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_proj.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_proj.settings")
|
||||||
|
|
||||||
# Add the django-avatar directory to the Python path. That way the
|
# Add the django-avatar directory to the Python path. That way the
|
||||||
# avatar module can be imported.
|
# avatar module can be imported.
|
||||||
sys.path.append('..')
|
sys.path.append("..")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError:
|
except ImportError as exc:
|
||||||
# The above import may fail for some other reason. Ensure that the
|
raise ImportError(
|
||||||
# issue is really that Django is missing to avoid masking other
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
# exceptions on Python 2.
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
try:
|
"forget to activate a virtual environment?"
|
||||||
import django
|
) from exc
|
||||||
except ImportError:
|
|
||||||
raise ImportError(
|
|
||||||
"Couldn't import Django. Are you sure it's installed and "
|
|
||||||
"available on your PYTHONPATH environment variable? Did you "
|
|
||||||
"forget to activate a virtual environment?"
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = '0o$jym8^hgw%vwx9hy%@ncr!29n7gik30(ln$pd$!3*4zu+9dv'
|
SECRET_KEY = "0o$jym8^hgw%vwx9hy%@ncr!29n7gik30(ln$pd$!3*4zu+9dv"
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
@ -31,54 +31,54 @@ ALLOWED_HOSTS = []
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
'django.contrib.auth',
|
"django.contrib.auth",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.sessions',
|
"django.contrib.sessions",
|
||||||
'django.contrib.messages',
|
"django.contrib.messages",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
|
"avatar",
|
||||||
'avatar',
|
"rest_framework",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'test_proj.urls'
|
ROOT_URLCONF = "test_proj.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [],
|
"DIRS": [],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": True,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.debug',
|
"django.template.context_processors.debug",
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.messages.context_processors.messages",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'test_proj.wsgi.application'
|
WSGI_APPLICATION = "test_proj.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
|
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
|
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,9 +86,9 @@ DATABASES = {
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/1.10/topics/i18n/
|
# https://docs.djangoproject.com/en/1.10/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = "UTC"
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
|
|
@ -100,7 +100,7 @@ USE_TZ = True
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/1.10/howto/static-files/
|
# https://docs.djangoproject.com/en/1.10/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = "/media/"
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls import url, include
|
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, {
|
re_path(r"^media/(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT})
|
||||||
'document_root': settings.MEDIA_ROOT})
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
BIN
tests/data/django #3.png
Normal file
BIN
tests/data/django #3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
tests/data/image_exif_orientation.jpg
Normal file
BIN
tests/data/image_exif_orientation.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
tests/data/image_no_exif.jpg
Normal file
BIN
tests/data/image_no_exif.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
tests/data/test.tiff
Normal file
BIN
tests/data/test.tiff
Normal file
Binary file not shown.
|
|
@ -1,3 +1,3 @@
|
||||||
flake8
|
coverage~=7.1.0
|
||||||
coverage==4.2
|
django
|
||||||
django-discover-runner
|
python-magic
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
import os
|
import os
|
||||||
import django
|
|
||||||
|
|
||||||
VERSION = django.VERSION
|
|
||||||
SETTINGS_DIR = os.path.dirname(__file__)
|
SETTINGS_DIR = os.path.dirname(__file__)
|
||||||
|
|
||||||
DATABASE_ENGINE = 'sqlite3'
|
DATABASE_ENGINE = "sqlite3"
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
'NAME': ':memory:',
|
"NAME": ":memory:",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.sessions',
|
"django.contrib.admin",
|
||||||
'django.contrib.auth',
|
"django.contrib.messages",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.sessions",
|
||||||
'django.contrib.sites',
|
"django.contrib.auth",
|
||||||
'avatar',
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sites",
|
||||||
|
"avatar",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE_CLASSES = (
|
MIDDLEWARE = (
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
|
@ -29,34 +29,35 @@ MIDDLEWARE_CLASSES = (
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
)
|
)
|
||||||
|
|
||||||
if VERSION[0] == 1 and VERSION[1] < 8:
|
TEMPLATES = [
|
||||||
TEMPLATE_DIRS = (
|
{
|
||||||
os.path.join(SETTINGS_DIR, 'templates'),
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
)
|
"APP_DIRS": True,
|
||||||
else:
|
"DIRS": [os.path.join(SETTINGS_DIR, "templates")],
|
||||||
TEMPLATES = [
|
"OPTIONS": {
|
||||||
{
|
"context_processors": [
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'APP_DIRS': True,
|
"django.contrib.messages.context_processors.messages",
|
||||||
'DIRS': [
|
"django.template.context_processors.request",
|
||||||
os.path.join(SETTINGS_DIR, 'templates')
|
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'tests.urls'
|
ROOT_URLCONF = "tests.urls"
|
||||||
|
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
||||||
SECRET_KEY = 'something-something'
|
SECRET_KEY = "something-something"
|
||||||
|
|
||||||
if django.VERSION[:2] < (1, 6):
|
ROOT_URLCONF = "tests.urls"
|
||||||
TEST_RUNNER = 'discover_runner.DiscoverRunner'
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'tests.urls'
|
STATIC_URL = "/site_media/static/"
|
||||||
|
|
||||||
STATIC_URL = '/site_media/static/'
|
AVATAR_ALLOWED_FILE_EXTS = (".jpg", ".png")
|
||||||
|
|
||||||
AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png')
|
|
||||||
AVATAR_MAX_SIZE = 1024 * 1024
|
AVATAR_MAX_SIZE = 1024 * 1024
|
||||||
AVATAR_MAX_AVATARS_PER_USER = 20
|
AVATAR_MAX_AVATARS_PER_USER = 20
|
||||||
|
AVATAR_AUTO_GENERATE_SIZES = [51, 62, (33, 22), 80]
|
||||||
|
|
||||||
|
|
||||||
|
MEDIA_ROOT = os.path.join(SETTINGS_DIR, "../test-media")
|
||||||
|
|
|
||||||
423
tests/tests.py
423
tests/tests.py
|
|
@ -1,37 +1,97 @@
|
||||||
|
import math
|
||||||
import os.path
|
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.contrib.admin.sites import AdminSite
|
||||||
|
from django.core import management
|
||||||
|
from django.core.cache import cache
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.core.urlresolvers import reverse
|
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
from avatar.admin import AvatarAdmin
|
from avatar.admin import AvatarAdmin
|
||||||
from avatar.conf import settings
|
from avatar.conf import settings
|
||||||
from avatar.utils import get_primary_avatar, get_user_model
|
|
||||||
from avatar.models import Avatar
|
from avatar.models import Avatar
|
||||||
|
from avatar.signals import avatar_deleted
|
||||||
from avatar.templatetags import avatar_tags
|
from avatar.templatetags import avatar_tags
|
||||||
from PIL import Image
|
from avatar.utils import (
|
||||||
|
get_cache_key,
|
||||||
|
get_primary_avatar,
|
||||||
|
get_user_model,
|
||||||
|
invalidate_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssertSignal:
|
||||||
|
def __init__(self):
|
||||||
|
self.signal_sent_count = 0
|
||||||
|
self.avatar = None
|
||||||
|
self.user = None
|
||||||
|
self.sender = None
|
||||||
|
self.signal = None
|
||||||
|
|
||||||
|
def __call__(self, user, avatar, sender, signal):
|
||||||
|
self.user = user
|
||||||
|
self.avatar = avatar
|
||||||
|
self.sender = sender
|
||||||
|
self.signal = signal
|
||||||
|
self.signal_sent_count += 1
|
||||||
|
|
||||||
|
|
||||||
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(reverse('avatar_add'), {
|
response = o.client.post(
|
||||||
'avatar': f,
|
reverse("avatar:add"),
|
||||||
}, follow=True)
|
{
|
||||||
|
"avatar": f,
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
f.close()
|
f.close()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def root_mean_square_difference(image1, image2):
|
||||||
|
"Calculate the root-mean-square difference between two images"
|
||||||
|
diff = ImageChops.difference(image1, image2).convert("L")
|
||||||
|
h = diff.histogram()
|
||||||
|
sq = (value * (idx**2) for idx, value in enumerate(h))
|
||||||
|
sum_of_squares = sum(sq)
|
||||||
|
rms = math.sqrt(sum_of_squares / float(image1.size[0] * image1.size[1]))
|
||||||
|
return rms
|
||||||
|
|
||||||
|
|
||||||
class AvatarTests(TestCase):
|
class AvatarTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.path = os.path.dirname(__file__)
|
||||||
|
cls.testdatapath = os.path.join(cls.path, "data")
|
||||||
|
cls.testmediapath = os.path.join(cls.path, "../test-media/")
|
||||||
|
return super().setUpClass()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.testdatapath = os.path.join(os.path.dirname(__file__), "data")
|
self.user = get_user_model().objects.create_user(
|
||||||
self.user = get_user_model().objects.create_user('test', 'lennon@thebeatles.com', 'testpassword')
|
"test", "lennon@thebeatles.com", "testpassword"
|
||||||
|
)
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.client.login(username='test', password='testpassword')
|
self.client.login(username="test", password="testpassword")
|
||||||
self.site = AdminSite()
|
self.site = AdminSite()
|
||||||
Image.init()
|
Image.init()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.testmediapath):
|
||||||
|
rmtree(self.testmediapath)
|
||||||
|
return super().tearDown()
|
||||||
|
|
||||||
|
def assertMediaFileExists(self, path):
|
||||||
|
full_path = os.path.join(self.testmediapath, f".{path}")
|
||||||
|
if not Path(full_path).resolve().is_file():
|
||||||
|
raise AssertionError(f"File does not exist: {full_path}")
|
||||||
|
|
||||||
def test_admin_get_avatar_returns_different_image_tags(self):
|
def test_admin_get_avatar_returns_different_image_tags(self):
|
||||||
self.test_normal_image_upload()
|
self.test_normal_image_upload()
|
||||||
self.test_normal_image_upload()
|
self.test_normal_image_upload()
|
||||||
|
|
@ -47,46 +107,79 @@ class AvatarTests(TestCase):
|
||||||
def test_non_image_upload(self):
|
def test_non_image_upload(self):
|
||||||
response = upload_helper(self, "nonimagefile")
|
response = upload_helper(self, "nonimagefile")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertNotEqual(response.context['upload_avatar_form'].errors, {})
|
self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
|
||||||
|
|
||||||
def test_normal_image_upload(self):
|
def test_normal_image_upload(self):
|
||||||
response = upload_helper(self, "test.png")
|
response = upload_helper(self, "test.png")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(len(response.redirect_chain), 1)
|
self.assertEqual(len(response.redirect_chain), 1)
|
||||||
self.assertEqual(response.context['upload_avatar_form'].errors, {})
|
self.assertEqual(response.context["upload_avatar_form"].errors, {})
|
||||||
avatar = get_primary_avatar(self.user)
|
avatar = get_primary_avatar(self.user)
|
||||||
self.assertIsNotNone(avatar)
|
self.assertIsNotNone(avatar)
|
||||||
self.assertEqual(avatar.user, self.user)
|
self.assertEqual(avatar.user, self.user)
|
||||||
self.assertTrue(avatar.primary)
|
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")
|
||||||
|
)
|
||||||
|
def test_unsupported_image_format_upload(self):
|
||||||
|
"""Check with python-magic that we detect corrupted / unapprovd image files correctly"""
|
||||||
|
response = upload_helper(self, "test.tiff")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.redirect_chain), 0) # Redirect only if it worked
|
||||||
|
self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
)
|
||||||
|
def test_supported_image_format_upload(self):
|
||||||
|
"""Check with python-magic that we detect corrupted / unapprovd image files correctly"""
|
||||||
|
response = upload_helper(self, "test.tiff")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
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):
|
def test_image_without_wrong_extension(self):
|
||||||
# use with AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png')
|
|
||||||
response = upload_helper(self, "imagefilewithoutext")
|
response = upload_helper(self, "imagefilewithoutext")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
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"))
|
||||||
def test_image_with_wrong_extension(self):
|
def test_image_with_wrong_extension(self):
|
||||||
# use with AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png')
|
|
||||||
response = upload_helper(self, "imagefilewithwrongext.ogg")
|
response = upload_helper(self, "imagefilewithwrongext.ogg")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
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, {})
|
||||||
|
|
||||||
def test_image_too_big(self):
|
def test_image_too_big(self):
|
||||||
# use with AVATAR_MAX_SIZE = 1024 * 1024
|
# use with AVATAR_MAX_SIZE = 1024 * 1024
|
||||||
response = upload_helper(self, "testbig.png")
|
response = upload_helper(self, "testbig.png")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
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, {})
|
||||||
|
|
||||||
def test_default_url(self):
|
def test_default_url(self):
|
||||||
response = self.client.get(reverse('avatar_render_primary', kwargs={
|
response = self.client.get(
|
||||||
'user': self.user.username,
|
reverse(
|
||||||
'size': 80,
|
"avatar:render_primary",
|
||||||
}))
|
kwargs={
|
||||||
loc = response['Location']
|
"user": self.user.username,
|
||||||
base_url = getattr(settings, 'STATIC_URL', None)
|
"width": 80,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
loc = response["Location"]
|
||||||
|
base_url = getattr(settings, "STATIC_URL", None)
|
||||||
if not base_url:
|
if not base_url:
|
||||||
base_url = settings.MEDIA_URL
|
base_url = settings.MEDIA_URL
|
||||||
self.assertTrue(base_url in loc)
|
self.assertTrue(base_url in loc)
|
||||||
|
|
@ -97,7 +190,7 @@ class AvatarTests(TestCase):
|
||||||
self.assertEqual(a, None)
|
self.assertEqual(a, None)
|
||||||
|
|
||||||
def test_there_can_be_only_one_primary_avatar(self):
|
def test_there_can_be_only_one_primary_avatar(self):
|
||||||
for i in range(1, 10):
|
for _ in range(1, 10):
|
||||||
self.test_normal_image_upload()
|
self.test_normal_image_upload()
|
||||||
count = Avatar.objects.filter(user=self.user, primary=True).count()
|
count = Avatar.objects.filter(user=self.user, primary=True).count()
|
||||||
self.assertEqual(count, 1)
|
self.assertEqual(count, 1)
|
||||||
|
|
@ -106,21 +199,34 @@ class AvatarTests(TestCase):
|
||||||
self.test_normal_image_upload()
|
self.test_normal_image_upload()
|
||||||
avatar = Avatar.objects.filter(user=self.user)
|
avatar = Avatar.objects.filter(user=self.user)
|
||||||
self.assertEqual(len(avatar), 1)
|
self.assertEqual(len(avatar), 1)
|
||||||
response = self.client.post(reverse('avatar_delete'), {
|
receiver = AssertSignal()
|
||||||
'choices': [avatar[0].id],
|
avatar_deleted.connect(receiver)
|
||||||
}, follow=True)
|
response = self.client.post(
|
||||||
|
reverse("avatar:delete"),
|
||||||
|
{
|
||||||
|
"choices": [avatar[0].id],
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(len(response.redirect_chain), 1)
|
self.assertEqual(len(response.redirect_chain), 1)
|
||||||
count = Avatar.objects.filter(user=self.user).count()
|
count = Avatar.objects.filter(user=self.user).count()
|
||||||
self.assertEqual(count, 0)
|
self.assertEqual(count, 0)
|
||||||
|
self.assertEqual(receiver.user, self.user)
|
||||||
|
self.assertEqual(receiver.avatar, avatar[0])
|
||||||
|
self.assertEqual(receiver.sender, Avatar)
|
||||||
|
self.assertEqual(receiver.signal_sent_count, 1)
|
||||||
|
|
||||||
def test_delete_primary_avatar_and_new_primary(self):
|
def test_delete_primary_avatar_and_new_primary(self):
|
||||||
self.test_there_can_be_only_one_primary_avatar()
|
self.test_there_can_be_only_one_primary_avatar()
|
||||||
primary = get_primary_avatar(self.user)
|
primary = get_primary_avatar(self.user)
|
||||||
oid = primary.id
|
oid = primary.id
|
||||||
self.client.post(reverse('avatar_delete'), {
|
self.client.post(
|
||||||
'choices': [oid],
|
reverse("avatar:delete"),
|
||||||
})
|
{
|
||||||
|
"choices": [oid],
|
||||||
|
},
|
||||||
|
)
|
||||||
primaries = Avatar.objects.filter(user=self.user, primary=True)
|
primaries = Avatar.objects.filter(user=self.user, primary=True)
|
||||||
self.assertEqual(len(primaries), 1)
|
self.assertEqual(len(primaries), 1)
|
||||||
self.assertNotEqual(oid, primaries[0].id)
|
self.assertNotEqual(oid, primaries[0].id)
|
||||||
|
|
@ -129,48 +235,94 @@ 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"])
|
||||||
|
|
||||||
def test_change_avatar_post_updates_primary_avatar(self):
|
def test_change_avatar_post_updates_primary_avatar(self):
|
||||||
self.test_there_can_be_only_one_primary_avatar()
|
self.test_there_can_be_only_one_primary_avatar()
|
||||||
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(reverse('avatar_change'), {
|
response = self.client.post(
|
||||||
'choice': choice.pk,
|
reverse("avatar:change"),
|
||||||
})
|
{
|
||||||
|
"choice": choice.pk,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
new_primary = Avatar.objects.get(user=self.user, primary=True)
|
new_primary = Avatar.objects.get(user=self.user, primary=True)
|
||||||
self.assertEqual(new_primary.pk, choice.pk)
|
self.assertEqual(new_primary.pk, choice.pk)
|
||||||
# Avatar with old primary pk exists but it is not primary anymore
|
# Avatar with old primary pk exists but it is not primary anymore
|
||||||
self.assertTrue(Avatar.objects.filter(user=self.user, pk=old_primary.pk, primary=False).exists())
|
self.assertTrue(
|
||||||
|
Avatar.objects.filter(
|
||||||
|
user=self.user, pk=old_primary.pk, primary=False
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
def test_too_many_avatars(self):
|
def test_too_many_avatars(self):
|
||||||
for i in range(0, settings.AVATAR_MAX_AVATARS_PER_USER):
|
for _ in range(0, settings.AVATAR_MAX_AVATARS_PER_USER):
|
||||||
self.test_normal_image_upload()
|
self.test_normal_image_upload()
|
||||||
count_before = Avatar.objects.filter(user=self.user).count()
|
count_before = Avatar.objects.filter(user=self.user).count()
|
||||||
response = upload_helper(self, "test.png")
|
response = upload_helper(self, "test.png")
|
||||||
count_after = Avatar.objects.filter(user=self.user).count()
|
count_after = Avatar.objects.filter(user=self.user).count()
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
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, {})
|
||||||
self.assertEqual(count_before, count_after)
|
self.assertEqual(count_before, count_after)
|
||||||
|
|
||||||
@override_settings(AVATAR_THUMB_FORMAT='png')
|
|
||||||
def test_automatic_thumbnail_creation_RGBA(self):
|
def test_automatic_thumbnail_creation_RGBA(self):
|
||||||
upload_helper(self, "django.png")
|
upload_helper(self, "django.png")
|
||||||
avatar = get_primary_avatar(self.user)
|
avatar = get_primary_avatar(self.user)
|
||||||
image = Image.open(avatar.avatar.storage.open(avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), 'rb'))
|
image = Image.open(
|
||||||
self.assertEqual(image.mode, 'RGBA')
|
avatar.avatar.storage.open(
|
||||||
|
avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(image.mode, "RGBA")
|
||||||
|
|
||||||
|
@override_settings(AVATAR_THUMB_FORMAT="JPEG")
|
||||||
def test_automatic_thumbnail_creation_CMYK(self):
|
def test_automatic_thumbnail_creation_CMYK(self):
|
||||||
upload_helper(self, "django_pony_cmyk.jpg")
|
upload_helper(self, "django_pony_cmyk.jpg")
|
||||||
avatar = get_primary_avatar(self.user)
|
avatar = get_primary_avatar(self.user)
|
||||||
image = Image.open(avatar.avatar.storage.open(avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), 'rb'))
|
image = Image.open(
|
||||||
self.assertEqual(image.mode, 'RGB')
|
avatar.avatar.storage.open(
|
||||||
|
avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(image.mode, "RGB")
|
||||||
|
|
||||||
|
def test_automatic_thumbnail_creation_image_type_conversion(self):
|
||||||
|
upload_helper(self, "django_pony_cmyk.jpg")
|
||||||
|
self.assertMediaFileExists(
|
||||||
|
f"/avatars/{self.user.id}/resized/80/80/django_pony_cmyk.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_thumbnail_transpose_based_on_exif(self):
|
||||||
|
upload_helper(self, "image_no_exif.jpg")
|
||||||
|
avatar = get_primary_avatar(self.user)
|
||||||
|
image_no_exif = Image.open(
|
||||||
|
avatar.avatar.storage.open(
|
||||||
|
avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
upload_helper(self, "image_exif_orientation.jpg")
|
||||||
|
avatar = get_primary_avatar(self.user)
|
||||||
|
image_with_exif = Image.open(
|
||||||
|
avatar.avatar.storage.open(
|
||||||
|
avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), "rb"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertLess(root_mean_square_difference(image_with_exif, image_no_exif), 1)
|
||||||
|
|
||||||
|
def test_automatic_thumbnail_creation_nondefault_filename(self):
|
||||||
|
upload_helper(self, "django #3.png")
|
||||||
|
self.assertMediaFileExists(
|
||||||
|
f"/avatars/{self.user.id}/resized/80/80/django_3.png"
|
||||||
|
)
|
||||||
|
|
||||||
def test_has_avatar_False_if_no_avatar(self):
|
def test_has_avatar_False_if_no_avatar(self):
|
||||||
self.assertFalse(avatar_tags.has_avatar(self.user))
|
self.assertFalse(avatar_tags.has_avatar(self.user))
|
||||||
|
|
@ -189,6 +341,16 @@ class AvatarTests(TestCase):
|
||||||
|
|
||||||
result = avatar_tags.avatar(self.user.username)
|
result = avatar_tags.avatar(self.user.username)
|
||||||
|
|
||||||
|
self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result)
|
||||||
|
self.assertIn('width="80" height="80" alt="User Avatar" />', result)
|
||||||
|
|
||||||
|
@override_settings(AVATAR_EXPOSE_USERNAMES=True)
|
||||||
|
def test_avatar_tag_works_with_exposed_username(self):
|
||||||
|
upload_helper(self, "test.png")
|
||||||
|
avatar = get_primary_avatar(self.user)
|
||||||
|
|
||||||
|
result = avatar_tags.avatar(self.user.username)
|
||||||
|
|
||||||
self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result)
|
self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result)
|
||||||
self.assertIn('width="80" height="80" alt="test" />', result)
|
self.assertIn('width="80" height="80" alt="test" />', result)
|
||||||
|
|
||||||
|
|
@ -199,7 +361,7 @@ class AvatarTests(TestCase):
|
||||||
result = avatar_tags.avatar(self.user)
|
result = avatar_tags.avatar(self.user)
|
||||||
|
|
||||||
self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result)
|
self.assertIn('<img src="{}"'.format(avatar.avatar_url(80)), result)
|
||||||
self.assertIn('width="80" height="80" alt="test" />', result)
|
self.assertIn('width="80" height="80" alt="User Avatar" />', result)
|
||||||
|
|
||||||
def test_avatar_tag_works_with_custom_size(self):
|
def test_avatar_tag_works_with_custom_size(self):
|
||||||
upload_helper(self, "test.png")
|
upload_helper(self, "test.png")
|
||||||
|
|
@ -208,53 +370,166 @@ class AvatarTests(TestCase):
|
||||||
result = avatar_tags.avatar(self.user, 100)
|
result = avatar_tags.avatar(self.user, 100)
|
||||||
|
|
||||||
self.assertIn('<img src="{}"'.format(avatar.avatar_url(100)), result)
|
self.assertIn('<img src="{}"'.format(avatar.avatar_url(100)), result)
|
||||||
self.assertIn('width="100" height="100" alt="test" />', result)
|
self.assertIn('width="100" height="100" alt="User Avatar" />', result)
|
||||||
|
|
||||||
|
def test_avatar_tag_works_with_rectangle(self):
|
||||||
|
upload_helper(self, "test.png")
|
||||||
|
avatar = get_primary_avatar(self.user)
|
||||||
|
|
||||||
|
result = avatar_tags.avatar(self.user, 100, 150)
|
||||||
|
|
||||||
|
self.assertIn('<img src="{}"'.format(avatar.avatar_url(100, 150)), result)
|
||||||
|
self.assertIn('width="100" height="150" alt="User Avatar" />', result)
|
||||||
|
|
||||||
def test_avatar_tag_works_with_kwargs(self):
|
def test_avatar_tag_works_with_kwargs(self):
|
||||||
upload_helper(self, "test.png")
|
upload_helper(self, "test.png")
|
||||||
avatar = get_primary_avatar(self.user)
|
avatar = get_primary_avatar(self.user)
|
||||||
|
|
||||||
result = avatar_tags.avatar(self.user, title="Avatar")
|
result = avatar_tags.avatar(self.user, title="Avatar")
|
||||||
html = '<img src="{}" width="80" height="80" alt="test" title="Avatar" />'.format(avatar.avatar_url(80))
|
html = '<img src="{}" width="80" height="80" alt="User Avatar" title="Avatar" />'.format(
|
||||||
|
avatar.avatar_url(80)
|
||||||
|
)
|
||||||
self.assertInHTML(html, result)
|
self.assertInHTML(html, result)
|
||||||
|
|
||||||
def test_default_add_template(self):
|
def test_primary_avatar_tag_works(self):
|
||||||
response = self.client.get('/avatar/add/')
|
upload_helper(self, "test.png")
|
||||||
self.assertContains(response, 'Upload New Image')
|
|
||||||
self.assertNotContains(response, 'ALTERNATE ADD TEMPLATE')
|
|
||||||
|
|
||||||
@override_settings(AVATAR_ADD_TEMPLATE='alt/add.html')
|
result = avatar_tags.primary_avatar(self.user)
|
||||||
|
|
||||||
|
self.assertIn(f'<img src="/avatar/render_primary/{self.user.id}/80/"', result)
|
||||||
|
self.assertIn('width="80" height="80" alt="User Avatar" />', result)
|
||||||
|
|
||||||
|
response = self.client.get(f"/avatar/render_primary/{self.user.id}/80/")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertMediaFileExists(response.url)
|
||||||
|
|
||||||
|
def test_primary_avatar_tag_works_with_custom_size(self):
|
||||||
|
upload_helper(self, "test.png")
|
||||||
|
|
||||||
|
result = avatar_tags.primary_avatar(self.user, 90)
|
||||||
|
|
||||||
|
self.assertIn(f'<img src="/avatar/render_primary/{self.user.id}/90/"', result)
|
||||||
|
self.assertIn('width="90" height="90" alt="User Avatar" />', result)
|
||||||
|
|
||||||
|
response = self.client.get(f"/avatar/render_primary/{self.user.id}/90/")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertMediaFileExists(response.url)
|
||||||
|
|
||||||
|
def test_primary_avatar_tag_works_with_rectangle(self):
|
||||||
|
upload_helper(self, "test.png")
|
||||||
|
|
||||||
|
result = avatar_tags.primary_avatar(self.user, 60, 110)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
f'<img src="/avatar/render_primary/{self.user.id}/60/110/"', result
|
||||||
|
)
|
||||||
|
self.assertIn('width="60" height="110" alt="User Avatar" />', result)
|
||||||
|
|
||||||
|
response = self.client.get(f"/avatar/render_primary/{self.user.id}/60/110/")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertMediaFileExists(response.url)
|
||||||
|
|
||||||
|
@override_settings(AVATAR_EXPOSE_USERNAMES=True)
|
||||||
|
def test_primary_avatar_tag_works_with_exposed_user(self):
|
||||||
|
upload_helper(self, "test.png")
|
||||||
|
|
||||||
|
result = avatar_tags.primary_avatar(self.user)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
f'<img src="/avatar/render_primary/{self.user.username}/80/"', result
|
||||||
|
)
|
||||||
|
self.assertIn('width="80" height="80" alt="test" />', result)
|
||||||
|
|
||||||
|
response = self.client.get(f"/avatar/render_primary/{self.user.username}/80/")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertMediaFileExists(response.url)
|
||||||
|
|
||||||
|
def test_default_add_template(self):
|
||||||
|
response = self.client.get("/avatar/add/")
|
||||||
|
self.assertContains(response, "Upload New Image")
|
||||||
|
self.assertNotContains(response, "ALTERNATE ADD TEMPLATE")
|
||||||
|
|
||||||
|
@override_settings(AVATAR_ADD_TEMPLATE="alt/add.html")
|
||||||
def test_custom_add_template(self):
|
def test_custom_add_template(self):
|
||||||
response = self.client.get('/avatar/add/')
|
response = self.client.get("/avatar/add/")
|
||||||
self.assertNotContains(response, 'Upload New Image')
|
self.assertNotContains(response, "Upload New Image")
|
||||||
self.assertContains(response, 'ALTERNATE ADD TEMPLATE')
|
self.assertContains(response, "ALTERNATE ADD TEMPLATE")
|
||||||
|
|
||||||
def test_default_change_template(self):
|
def test_default_change_template(self):
|
||||||
response = self.client.get('/avatar/change/')
|
response = self.client.get("/avatar/change/")
|
||||||
self.assertContains(response, 'Upload New Image')
|
self.assertContains(response, "Upload New Image")
|
||||||
self.assertNotContains(response, 'ALTERNATE CHANGE TEMPLATE')
|
self.assertNotContains(response, "ALTERNATE CHANGE TEMPLATE")
|
||||||
|
|
||||||
@override_settings(AVATAR_CHANGE_TEMPLATE='alt/change.html')
|
@override_settings(AVATAR_CHANGE_TEMPLATE="alt/change.html")
|
||||||
def test_custom_change_template(self):
|
def test_custom_change_template(self):
|
||||||
response = self.client.get('/avatar/change/')
|
response = self.client.get("/avatar/change/")
|
||||||
self.assertNotContains(response, 'Upload New Image')
|
self.assertNotContains(response, "Upload New Image")
|
||||||
self.assertContains(response, 'ALTERNATE CHANGE TEMPLATE')
|
self.assertContains(response, "ALTERNATE CHANGE TEMPLATE")
|
||||||
|
|
||||||
def test_default_delete_template(self):
|
def test_default_delete_template(self):
|
||||||
response = self.client.get('/avatar/delete/')
|
upload_helper(self, "test.png")
|
||||||
self.assertContains(response, 'like to delete.')
|
response = self.client.get("/avatar/delete/")
|
||||||
self.assertNotContains(response, 'ALTERNATE DELETE TEMPLATE')
|
self.assertContains(response, "like to delete.")
|
||||||
|
self.assertNotContains(response, "ALTERNATE DELETE TEMPLATE")
|
||||||
|
|
||||||
@override_settings(AVATAR_DELETE_TEMPLATE='alt/delete.html')
|
@override_settings(AVATAR_DELETE_TEMPLATE="alt/delete.html")
|
||||||
def test_custom_delete_template(self):
|
def test_custom_delete_template(self):
|
||||||
response = self.client.get('/avatar/delete/')
|
response = self.client.get("/avatar/delete/")
|
||||||
self.assertNotContains(response, 'like to delete.')
|
self.assertNotContains(response, "like to delete.")
|
||||||
self.assertContains(response, 'ALTERNATE DELETE TEMPLATE')
|
self.assertContains(response, "ALTERNATE DELETE TEMPLATE")
|
||||||
|
|
||||||
# def testAvatarOrder
|
def get_media_file_mtime(self, path):
|
||||||
# def testReplaceAvatarWhenMaxIsOne
|
full_path = os.path.join(self.testmediapath, f".{path}")
|
||||||
# def testHashFileName
|
return os.path.getmtime(full_path)
|
||||||
# def testHashUserName
|
|
||||||
# def testChangePrimaryAvatar
|
def test_rebuild_avatars(self):
|
||||||
# def testDeleteThumbnailAndRecreation
|
upload_helper(self, "test.png")
|
||||||
# def testAutomaticThumbnailCreation
|
avatar_51_url = get_primary_avatar(self.user).avatar_url(51)
|
||||||
|
self.assertMediaFileExists(avatar_51_url)
|
||||||
|
avatar_51_mtime = self.get_media_file_mtime(avatar_51_url)
|
||||||
|
|
||||||
|
avatar_62_url = get_primary_avatar(self.user).avatar_url(62)
|
||||||
|
self.assertMediaFileExists(avatar_62_url)
|
||||||
|
avatar_62_mtime = self.get_media_file_mtime(avatar_62_url)
|
||||||
|
|
||||||
|
avatar_33_22_url = get_primary_avatar(self.user).avatar_url(33, 22)
|
||||||
|
self.assertMediaFileExists(avatar_33_22_url)
|
||||||
|
avatar_33_22_mtime = self.get_media_file_mtime(avatar_33_22_url)
|
||||||
|
|
||||||
|
avatar_80_url = get_primary_avatar(self.user).avatar_url(80)
|
||||||
|
self.assertMediaFileExists(avatar_80_url)
|
||||||
|
avatar_80_mtime = self.get_media_file_mtime(avatar_80_url)
|
||||||
|
# Rebuild all avatars
|
||||||
|
management.call_command("rebuild_avatars", verbosity=0)
|
||||||
|
# Make sure the media files all exist, but that their modification times differ
|
||||||
|
self.assertMediaFileExists(avatar_51_url)
|
||||||
|
self.assertNotEqual(avatar_51_mtime, self.get_media_file_mtime(avatar_51_url))
|
||||||
|
self.assertMediaFileExists(avatar_62_url)
|
||||||
|
self.assertNotEqual(avatar_62_mtime, self.get_media_file_mtime(avatar_62_url))
|
||||||
|
self.assertMediaFileExists(avatar_33_22_url)
|
||||||
|
self.assertNotEqual(
|
||||||
|
avatar_33_22_mtime, self.get_media_file_mtime(avatar_33_22_url)
|
||||||
|
)
|
||||||
|
self.assertMediaFileExists(avatar_80_url)
|
||||||
|
self.assertNotEqual(avatar_80_mtime, self.get_media_file_mtime(avatar_80_url))
|
||||||
|
|
||||||
|
def test_invalidate_cache(self):
|
||||||
|
upload_helper(self, "test.png")
|
||||||
|
sizes_key = get_cache_key(self.user, "cached_sizes")
|
||||||
|
sizes = cache.get(sizes_key, set())
|
||||||
|
# Only default 80x80 thumbnail is cached
|
||||||
|
self.assertEqual(len(sizes), 1)
|
||||||
|
# Invalidate cache
|
||||||
|
invalidate_cache(self.user)
|
||||||
|
sizes = cache.get(sizes_key, set())
|
||||||
|
# No thumbnail is cached.
|
||||||
|
self.assertEqual(len(sizes), 0)
|
||||||
|
# Create a custom 25x25 thumbnail and check that it is cached
|
||||||
|
avatar_tags.avatar(self.user, 25)
|
||||||
|
sizes = cache.get(sizes_key, set())
|
||||||
|
self.assertEqual(len(sizes), 1)
|
||||||
|
# Invalidate cache again.
|
||||||
|
invalidate_cache(self.user)
|
||||||
|
sizes = cache.get(sizes_key, set())
|
||||||
|
# It should now be empty again
|
||||||
|
self.assertEqual(len(sizes), 0)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from django.conf.urls import include, url
|
from django.urls import include, re_path
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^avatar/', include('avatar.urls')),
|
re_path(r"^avatar/", include("avatar.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue